Accepted
ADR 0026 — Admin sessions on api_tokens
An admin session is a long-lived token in an HttpOnly cookie, resolved through the same code path as a headless API token
Decision
There is one auth code path, not two. An admin session is a long-lived token stored in the api_tokens table and carried in a session cookie; a headless API token is a token in the same table carried in an Authorization header. Both are verified by the same middleware against the same table, so every future token kind is wired once rather than in two places.
The api_tokens table carries a kind column that discriminates a session token from a service token. A session-kind row binds to a user, holds session metadata, and is scoped to match every permission check the way an admin expects; a service-kind row has no user and carries explicit scopes. The column is plain text, not an enum, so a new kind (such as a preview token) is additive with no table rebuild.
Why
The architecture brief promises one auth surface for the admin and the headless API. Keeping admin cookies and headless tokens on separate tables and middlewares would force any token valid against both surfaces to be wired twice, and would leave no single principal table to attribute mutations to.
Unifying onto api_tokens collapses the middleware to one entry point and gives a single place where a principal — a user-bound session, a service token, or a future preview token — is resolved and authorised. It also gives versioned-write attribution a single, resolvable target: a mutation records the token that performed it, and resolving back to a user is one indexed join.
How it works
- Carrier — the unified middleware reads the Authorization header first, then the session cookie. When both are present the header wins: it signals explicit intent, lets an operator debug a token by curl from a logged-in browser, and keeps the auth path independent of cookie domains.
- Verification — the same flow serves both carriers: a KV hot-path cache, then a D1 lookup, then a hash verify. The session cookie and the bearer header carry the same cusp_pk token shape, so the verification primitive is shared.
- Login — verify the password, mint a session-kind row in api_tokens bound to the user with full scope and a 30-day lifetime, and set the value as the __Host-cusp_session cookie (HttpOnly, Secure, SameSite=Lax). The admin SPA never reads the cookie because JavaScript cannot; the Worker reads it.
- Logout — revokes the token row and clears the cookie.
- Password change or reset — revokes every session-kind row for that user. Service tokens are not revoked; passwords do not gate service tokens.
CSRF under the unified path
The Origin-equals-host check on admin writes stays for cookie-carried requests. A request authenticated by an Authorization header skips that check: the header is the credential, so there is no ambient cookie a cross-origin script could replay, and an HttpOnly session cookie cannot be read from JavaScript to be re-sent as a header. The rule keeps the curl-from-logged-in-browser debug flow working without weakening the cookie-borne CSRF surface.
Consequences
- One middleware module, with a hash-algorithm dispatch branch for the two carriers — a net reduction in code.
- The admin active-sessions view is a query over session-kind rows for the user, exposing user agent, IP, and last-seen, with per-row revoke.
- Versioned-write attribution gains a single principal target — the token that performed the mutation — which an audit join resolves to a user or to a service token's name.
- A separate principals table that both users and api_tokens reference was considered and rejected for v1: the kind column makes api_tokens the principal table without a third table or an extra join on every attribution lookup.