WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs money. There was nothing in the response to say so, no way to sign in, and no way to be told "you do not own this" — so a store with paid titles could only hand the client a 403 at download time and let it guess why. The fix belongs here rather than in the client. A client serves more than one store, so anything it knows about a particular one has to arrive from that store's own API; a rule compiled into the client is a rule that breaks every other catalog it reads. Three seams, each following the storage adapter's shape — documented contract, default that is byte for byte the old behaviour, one config key to replace it: - **access policy** — visible_software_scope / access_for / authorize_download. Every catalog entry now carries an `access` block (gated, entitled, price, purchaseUrl, webUrl) and both /api/download and /file/* ask before serving. The vocabulary is deliberately generic: a word from one host's domain would make every client that reads it specific to that host. - **client sign-in** — the device authorization grant (RFC 8628), over the host's own user model. The approval page stays the host's, because approving needs a session and HTML. Tokens are ApplicationTokens with a `catalog` scope, so publishing and reading stay separable. - **service descriptor** — GET /api/service says what this deployment is and whether it has a sign-in at all, which is how a client stops guessing. With no policy and no subject class configured — every deployment today — the API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and the 187 pre-existing examples pass untouched. A policy that raises is treated as a refusal, not permission. An artifact served because the gatekeeper crashed is the one failure mode this must not have, so a broken policy empties the catalog and denies the download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
require "rails_helper"
|
||||
require "tmpdir"
|
||||
|
||||
# The access seam, from both sides: what the catalog says about a title, and whether an
|
||||
# artifact is handed over. The load-bearing case is the *default* one — a catalog with
|
||||
# no policy configured has to behave exactly as it did before this existed.
|
||||
RSpec.describe "The access policy" do
|
||||
let(:tmpdir) { Dir.mktmpdir }
|
||||
|
||||
# A policy that gates everything except what the subject is named after. Small enough
|
||||
# to read, and it exercises every method of the contract.
|
||||
let(:gating_policy) do
|
||||
Class.new do
|
||||
def initialize(open_name) = @open_name = open_name
|
||||
|
||||
def visible_software_scope(subject: nil)
|
||||
WarpEngine::Software.where.not(status: "development")
|
||||
end
|
||||
|
||||
def access_for(software:, subject: nil)
|
||||
return WarpEngine::Access.new if software.name == @open_name
|
||||
|
||||
WarpEngine::Access.new(
|
||||
gated: true, entitled: subject.present?, price_cents: 1490, currency: "EUR",
|
||||
purchase_url: "https://shop.example/#{software.name}",
|
||||
web_url: "https://shop.example/play/#{software.name}"
|
||||
)
|
||||
end
|
||||
|
||||
def authorize_download(asset: nil, subject: nil, request: nil)
|
||||
return WarpEngine::Access::Grant.new if asset&.release&.software&.name == @open_name
|
||||
|
||||
subject.nil? ? nil : WarpEngine::Access::Grant.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
|
||||
WarpEngine::Storage.reset!
|
||||
WarpEngine::AccessPolicy.reset!
|
||||
end
|
||||
|
||||
after do
|
||||
FileUtils.rm_rf(tmpdir)
|
||||
WarpEngine::AccessPolicy.reset!
|
||||
end
|
||||
|
||||
describe "the default (:open) policy" do
|
||||
it "lists every software, whatever its status" do
|
||||
create(:software, status: "development")
|
||||
create(:software, status: "released")
|
||||
|
||||
result = WarpEngine::SoftwareService.new.index
|
||||
|
||||
expect(result[:softwares].size).to eq(2)
|
||||
end
|
||||
|
||||
it "reports every title as open, so a client never has to guess" do
|
||||
create(:software)
|
||||
|
||||
entry = WarpEngine::SoftwareService.new.index[:softwares].first
|
||||
|
||||
expect(entry[:access]).to eq(
|
||||
gated: false, entitled: true, price: nil, purchaseUrl: nil, webUrl: nil
|
||||
)
|
||||
end
|
||||
|
||||
it "hands over an artifact with no subject at all" do
|
||||
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
|
||||
|
||||
path = WarpEngine::DownloadService.new.create(
|
||||
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||
)
|
||||
|
||||
expect(path).to eq(File.join(tmpdir, "game-1.0.zip"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "a configured policy" do
|
||||
let(:open_software) { create(:software, name: "free-game", status: "released") }
|
||||
let(:gated_software) { create(:software, name: "paid-game", status: "released") }
|
||||
let(:subject_record) { create(:test_owner) }
|
||||
|
||||
before do
|
||||
open_software
|
||||
gated_software
|
||||
create(:software, name: "draft-game", status: "development")
|
||||
allow(WarpEngine.config).to receive(:access_policy).and_return(gating_policy.new("free-game"))
|
||||
end
|
||||
|
||||
it "narrows the catalog to what the policy scope allows" do
|
||||
names = WarpEngine::SoftwareService.new.index[:softwares].map { |e| e[:software][:name] }
|
||||
|
||||
expect(names).to contain_exactly("free-game", "paid-game")
|
||||
end
|
||||
|
||||
it "describes a gated title with its price and where to buy it" do
|
||||
entry = WarpEngine::SoftwareService.new.index[:softwares]
|
||||
.find { |e| e[:software][:name] == "paid-game" }
|
||||
|
||||
expect(entry[:access]).to eq(
|
||||
gated: true, entitled: false,
|
||||
price: { amountCents: 1490, currency: "EUR" },
|
||||
purchaseUrl: "https://shop.example/paid-game",
|
||||
webUrl: "https://shop.example/play/paid-game"
|
||||
)
|
||||
end
|
||||
|
||||
it "reports entitlement against the authenticated subject" do
|
||||
entry = WarpEngine::SoftwareService.new.index(subject: subject_record)[:softwares]
|
||||
.find { |e| e[:software][:name] == "paid-game" }
|
||||
|
||||
expect(entry[:access][:entitled]).to be(true)
|
||||
end
|
||||
|
||||
it "refuses an artifact the policy will not authorise" do
|
||||
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
|
||||
release = create(:release, software: gated_software)
|
||||
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
|
||||
path: File.join(tmpdir, "paid-game-1.0.zip"))
|
||||
|
||||
expect {
|
||||
WarpEngine::DownloadService.new.create(
|
||||
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||
)
|
||||
}.to raise_error(WarpEngine::DownloadService::Denied)
|
||||
end
|
||||
|
||||
it "hands the same artifact over to a subject the policy accepts" do
|
||||
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
|
||||
release = create(:release, software: gated_software)
|
||||
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
|
||||
path: File.join(tmpdir, "paid-game-1.0.zip"))
|
||||
|
||||
path = WarpEngine::DownloadService.new.create(
|
||||
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil,
|
||||
subject: subject_record
|
||||
)
|
||||
|
||||
expect(path).to eq(File.join(tmpdir, "paid-game-1.0.zip"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "a policy that raises" do
|
||||
let(:broken_policy) do
|
||||
Class.new do
|
||||
def visible_software_scope(subject: nil) = raise("boom")
|
||||
def access_for(software:, subject: nil) = raise("boom")
|
||||
def authorize_download(asset: nil, subject: nil, request: nil) = raise("boom")
|
||||
end.new
|
||||
end
|
||||
|
||||
before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) }
|
||||
|
||||
# The direction of the failure is the point. A broken gatekeeper must not become an
|
||||
# open one: an empty catalog is recoverable, a paid title given away is not.
|
||||
it "empties the catalog rather than leaking it" do
|
||||
create(:software, status: "released")
|
||||
|
||||
expect(WarpEngine::SoftwareService.new.index[:softwares]).to be_empty
|
||||
end
|
||||
|
||||
it "refuses the download rather than serving it" do
|
||||
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
|
||||
|
||||
expect {
|
||||
WarpEngine::DownloadService.new.create(
|
||||
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||
)
|
||||
}.to raise_error(WarpEngine::DownloadService::Denied)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user