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:
2026-08-19 10:26:28 +02:00
co-authored by Claude Opus 5
parent f4983c6329
commit 6c8b026590
33 changed files with 1450 additions and 35 deletions
@@ -98,6 +98,28 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.index :deleted_at
end
# Device sign-in for clients that have no browser of their own (RFC 8628).
# Short-lived rows: one exists for the minute or two between "the client asked"
# and "the person answered". Only used where access_token_owner_class is set.
create_table :device_grants do |t|
t.string :device_code, limit: 64, null: false
t.string :user_code, limit: 16, null: false
t.string :client_name, limit: 128
t.string :subject_type, limit: 128
t.bigint :subject_id
t.bigint :application_token_id
# The issued token in the clear, cleared on the poll that hands it over — see
# the model. Everything else about a token is stored as a digest.
t.string :issued_token, limit: 64
t.datetime :approved_at, precision: 3
t.datetime :denied_at, precision: 3
t.datetime :expires_at, precision: 3, null: false
t.timestamps precision: 3, null: true
t.index :device_code, unique: true
t.index :user_code, unique: true
t.index :expires_at
end
create_table :downloads do |t|
t.string :file_path, null: false
t.references :release, foreign_key: { on_delete: :nullify }, index: false
@@ -43,6 +43,26 @@ Rails.application.config.to_prepare do
# softwares got an owner (backfill)!
# c.enforce_software_ownership = true
# Who may see a title and who may download it. :open (the default) lists every
# software and serves every artifact — the behaviour of a catalog nobody sells
# from. A host that does sell supplies a policy answering three methods; see
# WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid
# title can announce itself as paid instead of failing at the download.
# c.access_policy = MyStore::AccessPolicy.new
# Client sign-in. nil (default) means there is none: /api/auth/* is inactive and
# GET /api/service reports auth: null, so a client offers no sign-in at all. Set
# the class a *client* token belongs to — usually your user model — to turn on
# the device authorization grant.
# c.access_token_owner_class = "User"
#
# Your own page where a signed-in person types the code their client displayed.
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
# against the request, so you need not know your own hostname (default "/devices").
# c.identity_verification_url = "/devices"
# c.device_code_ttl = 600 # seconds a pending code lives
# c.device_code_interval = 5 # seconds a client is told to wait between polls
# If host models also reference catalog images, register them so the
# admin Images page's orphan detection takes them into account:
# c.image_owners = [