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:
@@ -5,7 +5,7 @@ software catalog: catalog models, a CI-pipeline-callable release updater, a
|
||||
public read-only JSON API, and optional ActiveAdmin resources that plug into
|
||||
your app's existing admin.
|
||||
|
||||
Repository: `https://git.teletypegames.org/tools/warp_engine`
|
||||
Repository: `https://git.teletypegames.org/engines/warp_engine`
|
||||
|
||||
## Features
|
||||
|
||||
@@ -18,6 +18,12 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
|
||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
||||
a shared secret or by per-owner database tokens with expiry and scopes
|
||||
(`ApplicationToken`, managed in the admin).
|
||||
- **Pluggable access**: a host supplies a policy and the catalog gains prices,
|
||||
entitlements and gated downloads — and *says so* in its API, so clients can
|
||||
show a paid title as paid instead of failing at the download. Default `:open`
|
||||
is the catalog as it always was.
|
||||
- **Client sign-in**: an RFC 8628 device authorization grant for clients with no
|
||||
browser of their own, over the host's own user model. Off unless configured.
|
||||
- **Pluggable storage**: artifacts are served through a storage adapter
|
||||
(`:local` by default); a host can serve them from an object store without
|
||||
patching the engine.
|
||||
@@ -152,7 +158,7 @@ From the git repository:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
|
||||
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"
|
||||
```
|
||||
|
||||
Or from the Forgejo rubygems registry (tagged releases):
|
||||
@@ -206,6 +212,15 @@ Rails.application.config.to_prepare do
|
||||
# after backfilling owners — ownerless softwares are claimable by anyone.
|
||||
# c.enforce_software_ownership = true
|
||||
|
||||
# Who may see a title and who may download it (see "Access" below).
|
||||
# :open (default) lists everything and serves everything.
|
||||
# c.access_policy = MyStore::AccessPolicy.new
|
||||
|
||||
# Client sign-in (see "Client sign-in" below). nil (default) means there is
|
||||
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
|
||||
# c.access_token_owner_class = "User"
|
||||
# c.identity_verification_url = "/devices"
|
||||
|
||||
# If your app's own models reference catalog images, register them so the
|
||||
# admin Images page counts them as "in use":
|
||||
# c.image_owners = [
|
||||
@@ -369,6 +384,91 @@ signing adapter turns them into redirects without any further change.
|
||||
the admin file manager — still writes to the local disk. A remote adapter
|
||||
needs its own upload path today.
|
||||
|
||||
## Access
|
||||
|
||||
Who may see a title, and who may download it. The default answers "everyone" to
|
||||
both — every software listed, every artifact served, no prices — which is the
|
||||
catalog the engine always had:
|
||||
|
||||
```ruby
|
||||
c.access_policy = :open # default
|
||||
```
|
||||
|
||||
A host that sells supplies a policy instead. The contract is three methods:
|
||||
|
||||
```ruby
|
||||
class MyStore::AccessPolicy
|
||||
# Which titles GET /api/software lists at all.
|
||||
def visible_software_scope(subject: nil) = ... # an ActiveRecord scope
|
||||
|
||||
# What a client is told about one title.
|
||||
def access_for(software:, subject: nil)
|
||||
WarpEngine::Access.new(
|
||||
gated: true, entitled: false, # needs an entitlement; this caller has none
|
||||
price_cents: 1490, currency: "EUR",
|
||||
purchase_url: "https://shop.example/games/slug",
|
||||
web_url: "https://shop.example/play/slug" # nil keeps the engine's own /file/ path
|
||||
)
|
||||
end
|
||||
|
||||
# nil refuses the download; a Grant allows it.
|
||||
def authorize_download(asset:, subject:, request:) = WarpEngine::Access::Grant.new
|
||||
end
|
||||
|
||||
c.access_policy = MyStore::AccessPolicy.new
|
||||
```
|
||||
|
||||
`subject` is whoever the request authenticated as, or `nil` for an anonymous
|
||||
caller — deliberately untyped, because the engine has no user model and whose
|
||||
object this is belongs to the host.
|
||||
|
||||
Every catalog entry carries an `access` block, **including under the open
|
||||
policy**, so a client never has to tell "this catalog says nothing" from "this
|
||||
title is not gated":
|
||||
|
||||
```json
|
||||
"access": { "gated": false, "entitled": true, "price": null,
|
||||
"purchaseUrl": null, "webUrl": null }
|
||||
```
|
||||
|
||||
The vocabulary is generic on purpose. A client reads more than one store, and a
|
||||
word from any one host's domain would make it specific to that host.
|
||||
|
||||
**A policy that raises is treated as a refusal**: an empty catalog and a denied
|
||||
download, logged. An artifact served because the gatekeeper crashed is the one
|
||||
failure mode this engine must not have.
|
||||
|
||||
## Client sign-in
|
||||
|
||||
A desktop client has no cookie jar and no browser session, so it cannot host a
|
||||
login form without asking somebody to type a password into a window that is not
|
||||
a browser. The engine implements the device authorization grant (RFC 8628)
|
||||
instead — but only where a host has said whose tokens these are:
|
||||
|
||||
```ruby
|
||||
c.access_token_owner_class = "User" # nil (default): no sign-in at all
|
||||
c.identity_verification_url = "/devices" # your page where a person types the code
|
||||
c.device_code_ttl = 600
|
||||
c.device_code_interval = 5
|
||||
```
|
||||
|
||||
With `access_token_owner_class` unset, `/api/auth/*` answers 404 and
|
||||
`GET /api/service` reports `auth: null`, so a client offers no sign-in.
|
||||
|
||||
The flow:
|
||||
|
||||
1. the client `POST`s `/api/auth/device` and shows the `userCode` it gets back;
|
||||
2. the person opens `verificationUrl` in a browser and types that code;
|
||||
3. **your page** calls `WarpEngine::DeviceGrantService#approve(user_code:, subject:)`
|
||||
with the signed-in user — approving needs a session and HTML, neither of
|
||||
which is the engine's business;
|
||||
4. the client's next `POST /api/auth/device/token` carries the token away. It is
|
||||
handed over exactly once and never stored in the clear afterwards.
|
||||
|
||||
The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as
|
||||
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
|
||||
and the admin lists both kinds of token and the sign-ins behind them.
|
||||
|
||||
## Publish events
|
||||
|
||||
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
|
||||
@@ -387,17 +487,29 @@ end
|
||||
Hosts that must support older engine versions can feature-detect with
|
||||
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
|
||||
|
||||
Downloads emit one too — `warp_engine.download`, with `path`, `asset`,
|
||||
`release`, `software`, `subject` and the `Download` record — so a host can keep
|
||||
its own account of who fetched what without reaching into `DownloadService`.
|
||||
|
||||
## Public API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
|
||||
| `GET /api/service` | What this deployment is: version, whether the catalog gates, and how to sign in (or that you cannot) |
|
||||
| `GET /api/software` | Full catalog with releases, assets, links, download counts and an `access` block; `?owner_id=` filters to one publisher |
|
||||
| `GET /api/software/highlighted` | The currently highlighted title |
|
||||
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
||||
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
||||
| `GET /api/image/:id` | Serves catalog images |
|
||||
| `GET /api/download?path=` | Serves an artifact and logs a download record |
|
||||
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
|
||||
| `POST /api/auth/device` | Starts a device sign-in; returns the code pair (404 without a client identity) |
|
||||
| `POST /api/auth/device/token` | Polls a device sign-in for its token |
|
||||
| `DELETE /api/auth/token` | Revokes the bearer token on the request (signing out) |
|
||||
|
||||
Every read endpoint accepts an optional `Authorization: Bearer …`; none requires
|
||||
one. What it changes is what the access policy is asked about — an anonymous
|
||||
caller is a normal, supported caller.
|
||||
|
||||
### `WarpEngine-Version`
|
||||
|
||||
@@ -406,7 +518,7 @@ client can branch on the engine's age without a round trip to ask:
|
||||
|
||||
```
|
||||
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
|
||||
WarpEngine-Version: 0.4.0
|
||||
WarpEngine-Version: 0.5.0
|
||||
```
|
||||
|
||||
Set before the action runs rather than after, which means an error response carries it
|
||||
|
||||
Reference in New Issue
Block a user