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:
+19
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) do
|
||||
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -29,6 +29,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "application_token_id", unsigned: true
|
||||
t.datetime "approved_at", precision: 3
|
||||
t.string "client_name", limit: 128
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "denied_at", precision: 3
|
||||
t.string "device_code", limit: 64, null: false
|
||||
t.datetime "expires_at", precision: 3, null: false
|
||||
t.string "issued_token", limit: 64
|
||||
t.bigint "subject_id", unsigned: true
|
||||
t.string "subject_type", limit: 128
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.string "user_code", limit: 16, null: false
|
||||
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
|
||||
t.index ["expires_at"], name: "idx_device_grants_expires_at"
|
||||
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
require "rails_helper"
|
||||
|
||||
# Signing a client in, end to end: the client asks for a code, a person approves it on
|
||||
# the host's page, the client's next poll carries the token away, and the token then
|
||||
# works as a bearer credential on the read-only API.
|
||||
RSpec.describe "Device sign-in", type: :request do
|
||||
let(:owner) { create(:test_owner) }
|
||||
|
||||
# The identity seam is off by default. Configuring the subject class is what turns
|
||||
# the whole flow on — including whether it exists at all.
|
||||
def configure_identity!(verification: "/devices")
|
||||
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification)
|
||||
end
|
||||
|
||||
describe "when the host configured no client identity" do
|
||||
it "has no device endpoint at all" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "says so in the service descriptor rather than by erroring" do
|
||||
get "/api/service"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["auth"]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "the full flow" do
|
||||
before { configure_identity! }
|
||||
|
||||
it "issues a code pair a person can read off a screen" do
|
||||
post "/api/auth/device", params: { client_name: "Zsolt's laptop" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["deviceCode"]).to be_present
|
||||
# Grouped and free of I/O/0/1, because it is typed by hand into a browser.
|
||||
expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/)
|
||||
expect(json["verificationUrl"]).to eq("http://www.example.com/devices")
|
||||
expect(json["interval"]).to eq(5)
|
||||
end
|
||||
|
||||
it "keeps the client waiting until somebody approves" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
device_code = JSON.parse(response.body)["deviceCode"]
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: device_code }
|
||||
|
||||
expect(JSON.parse(response.body)).to eq("state" => "pending")
|
||||
end
|
||||
|
||||
it "hands over the token on the first poll after approval" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
body = JSON.parse(response.body)
|
||||
expect(body["state"]).to eq("approved")
|
||||
expect(body["token"]).to be_present
|
||||
end
|
||||
|
||||
# The plain token is never stored, so it cannot be handed out twice. A client that
|
||||
# loses it starts again — which is cheaper than a database full of live secrets.
|
||||
it "does not repeat the token on a second poll" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
body = JSON.parse(response.body)
|
||||
expect(body["state"]).to eq("approved")
|
||||
expect(body).not_to have_key("token")
|
||||
end
|
||||
|
||||
it "reports a denied grant as denied" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.deny(user_code: json["userCode"])
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
expect(JSON.parse(response.body)["state"]).to eq("denied")
|
||||
end
|
||||
|
||||
it "reports an expired grant as expired" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrant.last.update!(expires_at: 1.minute.ago)
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
expect(JSON.parse(response.body)["state"]).to eq("expired")
|
||||
end
|
||||
|
||||
it "404s an unknown device code" do
|
||||
post "/api/auth/device/token", params: { device_code: "nope" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "will not approve the same code twice" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
code = JSON.parse(response.body)["userCode"]
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner)
|
||||
|
||||
expect { WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner) }
|
||||
.to raise_error(WarpEngine::DeviceGrantService::UnknownCode)
|
||||
end
|
||||
|
||||
it "accepts the user code however a person typed it" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
code = JSON.parse(response.body)["userCode"]
|
||||
|
||||
grant = WarpEngine::DeviceGrantService.new.approve(
|
||||
user_code: code.downcase.delete("-"), subject: owner
|
||||
)
|
||||
|
||||
expect(grant).to be_approved
|
||||
end
|
||||
end
|
||||
|
||||
describe "the issued token" do
|
||||
before { configure_identity! }
|
||||
|
||||
let(:token) do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
JSON.parse(response.body)["token"]
|
||||
end
|
||||
|
||||
it "belongs to the subject who approved it, and may only read the catalog" do
|
||||
token
|
||||
record = WarpEngine::ApplicationToken.last
|
||||
|
||||
expect(record.owner).to eq(owner)
|
||||
expect(record.scopes).to eq([ "catalog" ])
|
||||
end
|
||||
|
||||
# A publishing token must not become a client token by accident, and vice versa:
|
||||
# the scope is what separates them, and the catalog endpoint requires its own.
|
||||
it "is not accepted as a publishing credential" do
|
||||
token
|
||||
|
||||
expect(WarpEngine::ApplicationToken.authenticate(token, required_scope: "update")).to be_nil
|
||||
end
|
||||
|
||||
it "identifies the subject on a catalog request" do
|
||||
create(:software)
|
||||
seen = nil
|
||||
policy = Class.new do
|
||||
def initialize(sink) = @sink = sink
|
||||
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||
def access_for(software:, subject: nil)
|
||||
@sink.call(subject)
|
||||
WarpEngine::Access::OPEN
|
||||
end
|
||||
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||
end.new(->(s) { seen = s })
|
||||
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||
|
||||
get "/api/software", headers: { "Authorization" => "Bearer #{token}" }
|
||||
|
||||
expect(seen).to eq(owner)
|
||||
end
|
||||
|
||||
it "is ignored when it is not a bearer credential" do
|
||||
value = token
|
||||
|
||||
get "/api/software", headers: { "Authorization" => value }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(WarpEngine::ApplicationToken.last.last_used_at).to be_nil
|
||||
end
|
||||
|
||||
it "stops working once revoked" do
|
||||
value = token
|
||||
|
||||
delete "/api/auth/token", headers: { "Authorization" => "Bearer #{value}" }
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(WarpEngine::ApplicationToken.authenticate(value, required_scope: "catalog")).to be_nil
|
||||
end
|
||||
|
||||
it "refuses to revoke without a token" do
|
||||
delete "/api/auth/token"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
require "rails_helper"
|
||||
|
||||
# The descriptor is how a client stops being built for one particular store: everything
|
||||
# it used to have compiled in — is there a sign-in, where does it live, can titles be
|
||||
# gated — is answered here instead.
|
||||
RSpec.describe "GET /api/service", type: :request do
|
||||
after { WarpEngine::AccessPolicy.reset! }
|
||||
|
||||
it "names the engine and its version" do
|
||||
get "/api/service"
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["engine"]).to eq("warp_engine")
|
||||
expect(json["version"]).to eq(WarpEngine::VERSION)
|
||||
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
|
||||
end
|
||||
|
||||
it "reports an open catalog as ungated and offering no sign-in" do
|
||||
get "/api/service"
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["catalog"]).to eq("gated" => false)
|
||||
expect(json["auth"]).to be_nil
|
||||
end
|
||||
|
||||
it "reports a configured policy as a catalog that can gate" do
|
||||
policy = Class.new do
|
||||
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||
def access_for(software:, subject: nil) = WarpEngine::Access::OPEN
|
||||
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||
end.new
|
||||
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["catalog"]).to eq("gated" => true)
|
||||
end
|
||||
|
||||
describe "with a client identity configured" do
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url).and_return("/devices")
|
||||
end
|
||||
|
||||
it "describes the device flow, so a client needs no addresses of its own" do
|
||||
get "/api/service"
|
||||
|
||||
auth = JSON.parse(response.body)["auth"]
|
||||
expect(auth["schemes"]).to eq([ "bearer" ])
|
||||
expect(auth["device"]["authorizeUrl"]).to eq("http://www.example.com/api/auth/device")
|
||||
expect(auth["device"]["tokenUrl"]).to eq("http://www.example.com/api/auth/device/token")
|
||||
expect(auth["device"]["revokeUrl"]).to eq("http://www.example.com/api/auth/token")
|
||||
expect(auth["device"]["interval"]).to eq(5)
|
||||
end
|
||||
|
||||
# A host that configured a bare path should not have to know its own hostname; one
|
||||
# that put the approval page on another domain should keep it.
|
||||
it "makes a configured path absolute against the request" do
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||
.to eq("http://www.example.com/devices")
|
||||
end
|
||||
|
||||
it "leaves an absolute verification URL alone" do
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url)
|
||||
.and_return("https://accounts.example.org/devices")
|
||||
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||
.to eq("https://accounts.example.org/devices")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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