Files
warp_engine/db/migrate/20260819000001_create_device_grants.rb
T
mr.zeroandClaude Opus 5 6c8b026590 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>
2026-08-19 10:26:28 +02:00

36 lines
2.0 KiB
Ruby

class CreateDeviceGrants < ActiveRecord::Migration[8.1]
def change
create_table :device_grants, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
# Both codes are secrets in the sense that guessing one grants a session, but they
# have different jobs: the device code is long and never shown to a person, the
# user code is short enough to read off a screen and type into a browser.
t.string :device_code, limit: 64, null: false
t.string :user_code, limit: 16, null: false
# What the client called itself. Shown on the approval page, so a person can tell
# which machine is asking, and kept on the issued token as its name.
t.string :client_name, limit: 128
# The approving subject comes from the host
# (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as
# application_tokens.owner_type.
t.string :subject_type, limit: 128
t.bigint :subject_id, unsigned: true
t.bigint :application_token_id, unsigned: true
# The issued token, in the clear, for the seconds between "approved" and "the
# client's next poll". It is cleared on the poll that hands it over, so this
# column holds a live secret only while somebody is waiting for it. There is no
# way around storing it: the approval happens in a browser and the poll arrives on
# a different request, so the two cannot share memory. Everything else about a
# token is stored as a digest — this is the one exception, and it is temporary.
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, name: "idx_device_grants_device_code", unique: true
t.index :user_code, name: "idx_device_grants_user_code", unique: true
t.index :expires_at, name: "idx_device_grants_expires_at"
end
end
end