Accepted

ADR 0023 — Headless API tokens and dynamic REST shape

Bearer tokens with scopes, a REST surface generated from your collections, and one middleware that verifies every headless request

Decision

The headless API at /api/v1 is a public REST surface authenticated by bearer tokens. A collection added in the admin is queryable over REST without a deploy, because the route shape is generated at request time from the collections and field definitions that already drive the rest of the system.

Tokens live in the api_tokens table as hashes, never plaintext. Each token carries a list of scopes, a per-token rate limit, optional allowed origins for cross-origin use, and an optional expiry. Creating, editing, and revoking a token are all versioned writes, so the token history is part of the same audit and time-travel surface as every other entity.

Token format

A plaintext token has the shape cusp_pk_<ulid>_<secret>: an eight-character prefix, a 26-character ULID, an underscore, and a 32-character alphanumeric secret. The prefix is leak-detectable — secret scanners recognise it, so a token committed to a public repo trips an automated alert. The embedded ULID is the row's primary key, so verification is an O(1) lookup rather than a scan across every active token.

Only the secret is hashed, with Argon2id at a deliberately low cost. The threat model is a database leak, not an offline brute force of a low-entropy human password: the 32-character random secret is infeasible to brute-force regardless of hash speed, so the heavy password parameters would only add latency to the hot path. The low-cost parameter set is a separate constant from the password hash parameters precisely because the two serve different threat models.

Scopes

A scope is <target>:<action>. The target is a collection slug or the wildcard *, and the action is one of read, write, or read-drafts. The standalone scope * is full admin. Matching is exact-string with two wildcard cases — the literal *, and *:<action> for an action across every collection. There is no <slug>:* glob; a token that needs both read and write on a collection lists both scopes explicitly.

  • recipes:read — read published documents in the recipes collection.
  • recipes:write — create, update, and delete recipes.
  • recipes:read-drafts — also see drafts, scheduled, and archived recipes. This is a separate scope from read, not a higher privilege; a preview-deploy pipeline can hold read-drafts alone.
  • *:read — read every collection's published documents.
  • * — full admin; reserved for admin sessions, not minted on editor tokens.

Asking for a draft you cannot see returns an empty result on a listing, and a 404 on a direct fetch by id — never a 403 that would confirm the document exists. Authentication failures return a generic 401 that never reveals which step failed.

The middleware

Every /api/v1 request passes through the token middleware. It reads the Authorization header, parses and validates the token shape, looks the row up first in a KV hot-path cache and then in D1 on a miss, verifies the secret, checks the token is neither revoked nor expired, and on success caches the validated record in KV for an hour. Editing or revoking a token deletes its KV entry immediately, so a revoked token stops working within sub-second KV propagation rather than waiting out the cache TTL.

After the token validates and the scope check passes, the middleware enforces the per-token rate limit through the RateLimit Durable Object, keyed by the token id. Auth runs before rate limiting on purpose: a request that fails authentication never consumes the legitimate token's quota, so an attacker probing token ids cannot exhaust someone else's budget. A separate per-IP limit runs ahead of all of this to bound anonymous probing.

Generated REST and self-documentation

Routes are derived from the collections and their fields, read through the schema cache. A collection with slug recipes exposes a listing, create, fetch-by-id, fetch-by-slug, update, and delete. Per-field values are emitted flat on the document object, not under a data envelope, mirroring the storage shape. Rich text travels as Portable Text JSON on the wire.

GET /api/v1/_meta returns an OpenAPI 3.1 document generated at request time from the same schema cache, so the API documents itself the instant a collection is added. The endpoint is bearer-authed like the rest of the surface and the document is scope-filtered to the collections the presenting token can read, so two tokens see different documents. Because the body is token-specific it is served private, no-store and never shared at the edge — there is no openapi surrogate key and no purge on schema change (that machinery has been removed).

CORS

Same-origin is the default. A token with no allowed origins serves same-origin and header-less backend clients; a token can list explicit origins or opt into a wildcard for browser use. Preflight requests are answered permissively because browsers omit the Authorization header on a preflight, so the token cannot be resolved at that point; the actual request is still gated on the token's origin policy and returns 403 on a mismatch.

Consequences

  • A new content type is live on REST and in the OpenAPI doc with no deploy — the promise the whole modelling spine exists to keep.
  • Token create, edit, and revoke are versioned writes, so the token audit trail rides the same time-travel surface as everything else. Revocation is a soft-delete; the row stays for audit and there is no un-revoke.
  • last_used_at is updated fire-and-forget on each successful auth, bounded by the per-token rate limit, so the admin's recently-used indicator stays useful without versioning every request.
  • Considered and rejected: a normalised scopes table (scopes are read every request and fit in one cached value), storing the SHA-256 of the plaintext as the lookup key (forecloses fast metadata-shaped errors), and a second KV namespace for token caching (the single KV namespace with an apiToken prefix is enough).
Read the admin sessions decision