Prima Get an API key

Module kit / Identity and auth

Prima chat session and gateway key

identity-auth/prima-chat-session-and-gateway-key

Kind backend
Domain identity-auth
Maturity prod
Reusable extract
Stack express-4, node-22, typescript
Depends on _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout
Frontends identity-auth/ui/chat-signin-and-workspace-picker
Reference implementation
  • prima-chat/server/auth.ts
  • prima-chat/server/sessions.ts
  • prima-chat/server/identity.ts
  • prima-chat/server/env.ts
  • prima-chat/server/routes/workspaces.ts
  • prima-chat/scripts/auth-check.mjs
Measured on prima-chat w5-billing-panel 743b840 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

A person signs in to the chat surface with a Labs magic link and gets an opaque __Host- cookie; the Labs access token, the refresh token and a per-session ai-scoped gateway key stay in a 0600 file on the chat server, and every gateway call is made with that key on behalf of one subject and one workspace the person named. The invariant that makes it trustworthy is that nothing the browser holds can spend money or pick a tenant: the browser names a workspace id once over a same-origin POST, the server checks it against the membership list it fetched for that subject, Labs checks membership again at mint, and from then on the credential and the tenant are read from the server-side record only.

This recipe fixes the session record and its projection, the five auth routes, the two gates (requireUser, requireWorkspace), the mint-first-then-revoke order on a workspace switch, the parked-key discipline, and the honest sign-out report.

When to use

Use it for any Prima surface that has its own backend on its own origin and must call the metered gateway on a person's behalf without handing that person a credential: a chat client, a hosted notebook, an agent console. Trigger phrases: "the token is in sessionStorage", "which workspace is this billed to", "409 workspace_required", "the key leaked on a switch", "sign-out says 404", "can the browser see the key".

Do not use it for the Labs SPA (S2 holds the tokens in the browser and refreshes them there), for the developer portal (F7, static Astro with no backend of its own), or for the gateway door itself (B11 gateway-api-key-auth, D3: the standalone prima service is the reference). The upstream calls this recipe makes are B5 (magic link), B10 (developer keys) and B4 (surface gate); their behaviour is measured there, and here only as observed from the chat server.

Stack assumptions

Pinned as measured at 743b840 (2026-09-12): express ^4.21.2, Node 22 (@types/node ^22, run through tsx ^4.19, never compiled), TypeScript ^5.7, @prima/ui as a file: dependency for the client. No database: the session store is one JSON file. Foundations assumed: S5 for the upstream credential vocabulary (ai scope, X-Workspace-ID, prima_chat surface), S7 for the two upstream error shapes reasonOf parses (identity.ts 165 to 174: FastAPI detail, string or list, then the Labs {error, code}), S9 for the test layout.

Environment names and fail behaviour (server/env.ts):

NameDefaultBehaviour
PRIMA_AUTH_BASEthe API host originwhere /api/auth/* and /api/developer/keys live (line 77); its own setting, not derived from the gateway base
PRIMA_API_BASEthe gateway base under the same host/api/prima/v1 (67 to 70); the proxy target and the entitlement probe
PRIMA_SURFACEprima_chatsent on every auth call (83); an unregistered value is refused upstream as Unknown surface, which requestMagicLink turns into 500 surface_unknown (identity.ts 213 to 221)
PRIMA_SESSION_TTL43200 (12 h)the cookie ceiling in seconds (97)
PRIMA_COOKIE_INSECUREunset1 drops Secure and with it the __Host- prefix (103; sessions.ts 70); the only configuration in which either is dropped
DATA_DIR.dataholds sessions.json and users/<subject>/ (50 to 63); the users directory is created 0700 (160)
PRIMA_API_KEY, PRIMA_API_KEY_FILE, PRIMA_ALLOW_SHARED_KEY_FOR_USERSunsetthe operator's shared key, for npm run smoke and the boot probe only; deploy/ship.sh 106 to 116 refuses to ship an .env that sets any of the three

Data model

No table. One file, <DATA_DIR>/sessions.json, written whole through a .tmp rename with mode 0600 (sessions.ts 245 to 250), read on every request (232 to 243). Its shape, as the TypeScript declares it (75 to 228), rendered as SQL so the columns are comparable with the Labs sessions table in S5:

-- sessions.ts 224 to 228: the file
session_file (
  cursor       integer NOT NULL DEFAULT 0,      -- monotonic revocation cursor
  sessions     map<sid, session_record>,
  revocations  revocation_event[]               -- bounded to the last 1000 (673 to 680)
);

-- sessions.ts 75 to 163: one record per cookie
session_record (
  sid                 text PRIMARY KEY,          -- SHA-256 hex of the cookie value; the value is never stored (76 to 77, 252 to 254)
  subject_id          text NOT NULL,             -- Labs users.id, confirmed by /api/auth/me (272 to 279)
  email               text NOT NULL,
  name                text NULL,
  surface             text NOT NULL,             -- config.surface
  created_at          text NOT NULL, last_seen_at text NOT NULL,
  expires_at          integer NOT NULL,          -- epoch ms; the earlier of our TTL and the access token's expiry when no refresh token (359 to 361)
  upstream_expires_at integer NOT NULL,
  checked_at          integer NOT NULL,          -- last successful /api/auth/me
  revoked_at          text NULL, revoked_reason text NULL,
  upstream_revocation jsonb NULL,                -- what POST /api/auth/logout answered
  access_token        text NOT NULL,             -- never in publicSession; blanked at revoke (690)
  refresh_token       text NULL,                 -- kept on purpose, see 95 to 108; blanked at revoke (691)
  gateway_key         jsonb NULL,                -- {id, key, prefix, tenantId, scopes}; key blanked at revoke (692)
  gateway_key_error   text NULL,
  workspace           jsonb NULL,                -- {id, name, role, isOwner, chosenAt}: THE chosen tenant (119 to 137)
  workspaces          jsonb NULL,                -- the subject's own directory, cached (139 to 146)
  workspaces_at       integer NULL,
  workspaces_error    jsonb NULL,                -- {reason: ingress|not_deployed|denied|network|unknown, message}
  retired_keys        jsonb NULL                 -- keys a switch could not revoke; ids kept, raw keys blanked at revoke (149 to 162, 695)
);

-- sessions.ts 216 to 222: removals only, never grants
revocation_event (seq integer, kind text CHECK (kind IN ('jti_revoked','subject_deactivated')), sid text NULL, subject_id text NULL, at text);

What is not stored: the cookie value (only its hash, the same rule as prima_api_keys.key_hash, comment at 26 to 29); any workspace choice anywhere the browser can edit (no cookie, no localStorage key, no header the browser repeats: 119 to 137); a display label carrying the workspace name (the key label is chat.prima.li (<first 8 hex of sid>), 316 to 323, because key labels are readable by anyone who can list the account's keys).

The projection the browser may see is publicSession (877 to 921): subjectId, email, name, surface, createdAt, expiresAt, gateway: {credential: 'key' | 'none', keyPrefix, tenantId, scopes, error}, workspace | null, workspaceState, workspaces | null, workspacesError | null. No token in any field; keyPrefix is the truncated prefix Labs returns.

WorkspaceState (187 to 197) is derived, never stored: ready (a workspace and a key), key_failed (a workspace, no key; or a one-element list with no choice, which means the silent selection failed at mint), unavailable (directory error, or nothing cached), none (empty list), choose (more than one).

API surface

All routes are on the chat server's own origin. sameOriginOnly (auth.ts 210 to 221) runs in front of everything (index.ts 53): a non-GET with a present Origin whose host is not the request Host is 403 bad_origin; a missing Origin passes because SameSite=Lax already refuses the cross-site POST. Every path under /api except /api/config and /api/auth/* passes through requireUser (index.ts 66) before any handler, and the 404 catch-all is mounted after it (162), so an unauthenticated prober gets 401 on unknown paths rather than a route map.

RouteAuth depTenancyCodesNotes
GET /api/confignonenone200 publicConfig()the one route in front of the gate; the deploy liveness probe (deploy/ship.sh 121 to 125)
POST /api/auth/magic-linknonenone202 {ok: true} always; 400 invalid_email (syntax, before any upstream call); 429 rate_limited with Retry-After passed through; 500 surface_unknown; 403 surface_denied; 502 network, server, unknownforwards {email, surface} to POST /api/auth/request-magic-link (identity.ts 188 to 241); the 202 carries no hint whether the address exists
POST /api/auth/verifynonenone200 {session} and Set-Cookie; 400 missing_token; 401 link_invalid (upstream 400, 401 and 404 collapsed on purpose, 257 to 263); 403 surface_denied; 502 no_subject, unknown, serverredeems at POST /api/auth/verify-magic-link, confirms the subject at GET /api/auth/me, settles the workspace, mints the key, writes the record, sets the cookie
GET /api/auth/sessionnonenone200 {session} or 200 {session: null, reason}signed-out is a normal answer, not 401 (auth.ts 269 to 278); a dead cookie is cleared on the way out
POST /api/auth/logoutnone (resolves the cookie itself)none200 {ok, local, everywhere, gatewayKey, upstreamSession, revocationCursor}the three halves reported separately (313 to 335; the shape in signOut, 729 to 741); {everywhere: true} revokes every session of the subject locally
GET /api/auth/revocation-cursorrequireUser (its own, 345)none200 {cursor, source: 'prima-chat', surface}this server's log, not Prima's (sessions.ts 199 to 215)
GET /api/workspaces?refresh=1requireUserthe caller's own memberships200 {workspaces, selected, state, error}cached 5 min on the record (WORKSPACES_STALE_AFTER_MS, 485); refresh=1 forces the upstream read
POST /api/workspaces/selectrequireUserthe id must be in the caller's own list200 {ok, session, previousKey}; 400 missing_workspace; 403 unknown_workspace (not 404: "not yours" and "does not exist" are one answer, routes/workspaces.ts 80 to 87); 409 no_workspaces; 503 directory_unavailable; 502 mint_failedmint first, swap, then retire the old key (chooseWorkspace, 564 to 645)
ALL /api/gw/{models,usage,chat/completions,embeddings}requireUser, then requireWorkspace; the two spending routes add the per-subject limiter and the entitlement probe (index.ts 89 to 119)the record's workspace.id401 from requireUser; 409 workspace_required or no_workspace; 503 workspace_directory_unavailable; 502 gateway_key_unavailable; then the gateway's own codesthe credential is credentialOf(req), built from the record (auth.ts 91 to 96, 195 to 199); X-API-Key, never Bearer (gateway.ts 136 to 140)

requireUser refusals (auth.ts 100 to 115), keyed by resolveSession's reason: 401 not_signed_in (no cookie), 401 session_unknown, 401 session_revoked, 401 session_expired, 401 session_revoked_upstream (Labs answered 401 or 403 to a refresh or a /me, or /me named a different subject), 503 identity_provider_unavailable (the access token expired and the refresh could not be reached). Every refusal but no_cookie clears the cookie (123).

requireWorkspace refusals (154 to 192): 409 workspace_required ("First choose the workspace this usage is billed to."), 409 no_workspace, 503 workspace_directory_unavailable, 502 gateway_key_unavailable; the body carries workspaceState and, when one exists, the upstream's own message in error. It sits on the four gateway routes (index.ts 118 to 119), on the whole /api/billing router through router.use (routes/billing.ts 396), and per route on four further handlers that reach the gateway themselves: POST /api/conversations/:id/engrave (routes/conversations.ts 86), GET /api/stone/repos/:id/search and POST /api/stone/repos/:id/index (routes/stone.ts 298 and 334) and POST /api/tools/execute (routes/tools.ts 366), each of which then reads credentialOf(req) (88, 301 and 337, 369). Listing threads, reading one, deleting one, listing repositories and reading a tree or a file, and the picker itself carry no gate and keep answering while the question is open (routes/conversations.ts 19 to 65; routes/stone.ts 70, 256 and 275).

Upstream contracts consumed, as this server reads them (all in identity.ts): POST /api/auth/request-magic-link {email, surface} answering 202, 400 Unknown surface '<x>', 403, 429; POST /api/auth/verify-magic-link {token, surface} answering {access_token, refresh_token, expires_in, user: {id, email, name}} (Labs builds exactly that at auth_router.py 1242 to 1262, fd8558f); GET /api/auth/me (the authority on the subject id, 326 to 352); POST /api/auth/refresh {refresh_token} (364 to 387); GET /api/auth/workspaces answering {workspaces: [{id, name, slug, role, is_owner}]} (Labs list_my_workspaces, auth_router.py 733 to 823, mounted on the auth_router so a prima_chat token is admitted; rows not carrying a UUID id are dropped, 496 to 503); POST /api/developer/keys with X-Workspace-ID and {name, description, scopes: ['ai']} answering {id, key, key_prefix, scopes} (566 to 679); DELETE /api/developer/keys/{id} with X-Workspace-ID (689 to 717; 404 counts as done); POST /api/auth/logout (751 to 771).

Application logic

POST /api/auth/verify and createSession (auth.ts 253 to 267; sessions.ts 272 to 380), in order:

  1. verifyMagicLink(token) posts the token and the surface; any 400, 401 or 404 is one 401 link_invalid; a body without access_token or without a string user.id is 502 (identity.ts 278 to 300). expires_in defaults to 1800 s when absent (283).
  2. me(accessToken): the id in the login body must equal the id /api/auth/me resolves for the token, else the session is refused ("the sign-in service contradicts itself", 273 to 279). The subject id is the isolation key for users/<subject>/ on disk (store.ts::scopeFor), so it is taken from the token, not from a body.
  3. A 32-byte random cookie value is drawn; sid is its SHA-256 (282 to 283).
  4. The workspace question is settled before the record exists (285 to 344): listWorkspaces is called; a WorkspaceDirectoryError is recorded with its reason (ingress, not_deployed, denied, network, unknown; identity.ts 66 to 84 and 449 to 481, where the edge's fixed 404 body is told apart from a FastAPI 404). Exactly one workspace is chosen silently and a key is minted for it with X-Workspace-ID; the choice is recorded even when the mint fails, so the failure reads as "no key could be issued" rather than "pick a workspace" (324 to 344). More than one, and nothing is chosen.
  5. The record is written with expiresAt equal to now plus the TTL when a refresh token exists, else the earlier of the TTL and the access token's expiry (354 to 362); expired records are purged in the same write (purge, 814 to 819).
  6. The cookie is set: Path=/, HttpOnly, SameSite=Lax (a magic link is a top-level navigation from a mail client, which Strict would drop, 851 to 854), Max-Age to expiresAt, Secure unless PRIMA_COOKIE_INSECURE=1, name __Host-prima_chat_sid or prima_chat_sid (845 to 859). The response body is publicSession(record).

resolveSession on every gated request (406 to 464): no cookie, unknown hash, revokedAt set, expiresAt passed, in that order. Within 120 s of the access token's expiry and with a refresh token, refresh() is called; a 401 or 403 from it revokes the record (refresh_rejected) and answers upstream_rejected; any other failure is tolerated until the access token is actually dead, then upstream_unreachable (421 to 438). Every 5 min (REVALIDATE_AFTER_MS, 73) me() re-confirms the subject: a different id or a 401 or 403 revokes; an unreachable upstream serves the cached session, bounded by expiresAt (440 to 458). lastSeenAt is persisted through persist, which re-reads the file and refuses to overwrite a record that was revoked in the meantime (474 to 480).

chooseWorkspace (564 to 645), the order being the correctness argument (541 to 555): (1) refuse a non-UUID; refresh the cached directory; refuse when there is none (directory_unavailable) or it is empty (no_workspaces); refuse an id not in the caller's own list with one message for "not yours" and "not real" (585 to 597); a no-op re-selection of the current workspace with a live key returns ok without re-minting (599 to 603). (2) Mint the new key first; on failure keep the old credential, record gatewayKeyError and answer mint_failed (605 to 618). (3) Swap the key and the workspace into the record and persist before touching the old key, so a crash between the two leaves a correct session and one stale key rather than a session pointing at a revoked key (620 to 632). (4) DELETE the previous key; on failure park it on retiredKeys and report previousKey: {attempted, ok, status, retained} (634 to 644).

mintGatewayKey (identity.ts 566 to 679) refuses a non-UUID tenant before any call; sends Authorization: Bearer <access>, X-Workspace-ID: <tenant> and scopes: ['ai']; maps a 403 whose reason mentions the workspace to 403 workspace_denied (the upstream's membership check, the second of two independent checks, 608 to 629), other 401 or 403 to 403 key_denied, the rest to 502 key_failed; and refuses a key that comes back with a wildcard scope or without ai by deleting it and answering 502 key_scope (655 to 670). The result is {id, key, prefix, tenantId, scopes}.

invalidateGatewayKey(sid, reason) (661 to 669) is the entitlement layer's hook (entitlement.ts 115 to 117): when the gateway refuses the key on a probe or a real call, the key is parked on retiredKeys, removed from the request path, gatewayKeyError is set, and workspaceState becomes key_failed; the session survives.

signOut (743 to 801): read the access token and the key out before anything blanks them; DELETE the current key first, while a live access token exists to authenticate it; retry every parked key (the last chance, since the local revoke wipes the token, 764 to 777); attempt POST /api/auth/logout and record the verbatim outcome (779 to 780); then revokeSid or, with everywhere, revokeSubject (782 to 786), which blank the tokens and raw keys, keep the key ids, and bump the cursor with jti_revoked or subject_deactivated (682 to 721). The response says how far each half reached.

Invariants

  • No upstream token in the browser: publicSession has no field that carries accessToken, refreshToken or gatewayKey.key (877 to 921); the select response is asserted key-free by auth-check.mjs ("the select response carries no usable credential").
  • The cookie of record is its hash: sessions.json never contains the cookie value (hash(token) at 283 and 410; asserted by "sessions.json stores a hash, never the cookie value").
  • The chosen tenant lives in the record and nowhere the browser can edit; the X-Workspace-ID sent upstream is built from record.workspace or record.gatewayKey.tenantId, never from the request (119 to 137; routes/workspaces.ts 24 to 31).
  • A submitted workspace id is a request, not a fact: it must be in the list fetched for this subject, and Labs re-checks membership at mint (541 to 546; identity.ts 608 to 622).
  • Mint first, swap, retire last: between the mint and the retire two keys exist upstream and exactly one is reachable from the record (551 to 555).
  • A key whose id this server holds is never dropped: a failed revoke parks it, and sign-out retries the parked list; a revoked record keeps the ids and blanks the raw keys (149 to 162, 693 to 695).
  • Every route that spends carries requireWorkspace, and only those do: the four gateway routes, the whole /api/billing router, engrave, the stone search and index, and tool execute (index.ts 118 to 119; routes/billing.ts 396; routes/conversations.ts 86; routes/stone.ts 298 and 334; routes/tools.ts 366). Listing and reading threads, browsing a repository and the picker itself carry no gate, so the screen that answers the question stays reachable (asserted by "B can still list conversations while the question is open", auth-check.mjs 616).
  • Signed-out is 401 on every /api path, known or unknown; no route table is exposed (index.ts 162; asserted by section 1 of the auth check).
  • The operator's shared key never authenticates a browser request: gateway.ts::assertNotShared refuses it unless PRIMA_ALLOW_SHARED_KEY_FOR_USERS=1, and the deploy refuses an .env that carries it (asserted by "config.sharedKey is referenced in exactly one file").
  • A refusal from the identity provider is an answer and revokes; silence is not an answer and does not (396 to 404, 430 to 437, 448 to 457).
  • The revocation log carries removals only; nothing in it can grant (199 to 206).

Frontend

identity-auth/ui/chat-signin-and-workspace-picker   SignIn (email, "if an account exists" copy, the link), the verify
                                                     landing, WorkspacePicker (one silent, several searchable, zero its
                                                     own screen, directory down refused with the reason verbatim), the
                                                     sign-out report (local, gatewayKey, upstreamSession) and the
                                                     account panel's keyPrefix line; web/src/lib/auth.ts and
                                                     web/src/lib/workspaces.ts are the client half of the routes above

Minimum admin and debug surface: GET /api/auth/session (the projection, including workspaceState, workspacesError and gateway.error), GET /api/workspaces (the cached directory with error), the previousKey field on a switch, the sign-out report, GET /api/auth/revocation-cursor, and sessions.json itself read on the host (ids and states, no cookie values). The usage log written by recordLlmUsage (index.ts 129 to 141) carries subjectId, tenantId and keyId per gateway call, which is how a spend line is traced to a session.

Security & tenancy

Auth dependency: requireUser (the cookie resolved to a record), then requireWorkspace on spending routes; the upstream dependency the chat server itself satisfies is S5's CurrentUser on a prima_chat-surface token. Workspace derivation: the record's workspace.id, chosen by the person from their own directory, re-validated by Labs' WorkspaceMiddleware at mint. Fail direction, per seam: the cookie closed (unknown, revoked, expired, refused upstream); the identity provider's silence open for live sessions and closed for new ones, bounded by expiresAt (396 to 404); the workspace directory closed for spending (requireWorkspace 503) and open for everything else; a mint failure closed for spending without ending the session (key_failed).

Credentials at rest: the Labs access and refresh tokens and the raw gateway key in sessions.json, mode 0600, under DATA_DIR, alongside the per-user git credentials that already live there (the trade-off written out at sessions.ts 31 to 39 and 95 to 108: keeping the refresh token is what lets sign-out still reach DELETE /api/developer/keys/{id} an hour into a session). All three are blanked at revoke (688 to 695). The file is the only copy; the estate volume prima-chat-data holds it (deploy/estate/compose.yml 48 to 56).

Rate limits: none on /api/auth/* in this server; the upstream's auth_rate_limit (S5, B18) answers 429 and the Retry-After header is passed through (auth.ts 229). The spending routes carry per-subject limiters (PRIMA_LLM_CHAT_RPM 30, PRIMA_LLM_EMBED_RPM 300; env.ts 146 to 147) and the entitlement probe with a 60 s positive cache (entitlement.ts 50).

Attacker-controlled inputs: the cookie header (parsed by a four-line reader, first = only, 829 to 843; hashed before lookup); email (syntax-checked, then forwarded); token (forwarded, never stored); workspaceId (UUID-shaped or refused, then membership); Origin and Host (compared as parsed URL host to Host; trust proxy is off so req.ip is never a header, index.ts 43 to 50); the upstream's bodies (every field type-checked; a non-UUID workspace id is dropped, not passed to the meter, identity.ts 496 to 503). The JSON body limit is 25 MB because vision turns carry data URLs (index.ts 39 to 40).

Tenancy of the credential: one key per session per workspace, labelled with a fragment of the session hash so revoking it cannot touch the person's other clients (316 to 323); the tenant is api_keys.workspace_id on the Labs side and tenant_id in the metering hash chain, which is why it is settled before the first spend and never inferred (routes/workspaces.ts 4 to 13).

Tests

  • scripts/auth-check.mjs (npm run auth-check): the real server on an ephemeral port against a stand-in upstream on another (freePort, 49 to 58), a fresh DATA_DIR per run, a per-run surface id so a stale server cannot pass (35 to 38, 395 to 401). The stand-in implements the auth, developer-key and gateway contracts measured on 2026-08-03 and is deliberately generous (22 to 25), so every refusal observed is the chat server's own. Sections: 1 the signed-out wall (401 on every path, no gateway call), 1b the magic-link seam (202 with {"ok":true} and nothing else, the surface on the send call, the token that would be mailed opens a session), 2 two users with their own keys, 2b several workspaces (409 workspace_required on both spending routes, the picker reachable, another subject's workspace 403 with nothing minted, a non-UUID 403, the chosen non-owned workspace attributed on the key), 2c switching (re-mint, retire, previousKey reported), 2d zero workspaces, 3 attribution, 4 to 7 the store isolation, 8 sign-out (the upstream key revoke succeeds, the session revoke is "honestly reported as 404", the replayed cookie is session_revoked, no token and no key left in the record, sessions.json is 0600 and holds hashes), 9 the shared key unreachable from any browser path. Run 2026-09-12 at 743b840: 100 passed, 0 failed (the inventory row map names 112 and the README 62; the printed count is the measurement).
  • server/entitlement.test.ts and server/ratelimit.test.ts: the two extra gates on the spending routes, with invalidateGatewayKey injected (entitlement.ts 60 to 68).
  • Known-answer vectors: the Set-Cookie line __Host-prima_chat_sid=<value>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<n>; Secure (845 to 859); the key label chat.prima.li (<8 hex>); the refusal codes listed under API surface, which the client keys on (web/src/lib/billing.ts 178 to 188).
  • Hermetic versus live: the auth check is hermetic and spends nothing; scripts/smoke.mjs and scripts/live-check.mjs reach the deployed hosts with the operator's key and are not evidence for this recipe. Not verified anywhere: that a real mail arrives (the script prints the caveat itself, 525 to 527). Playwright: none; the chat client's render check is scripts/render.mjs (F32).

Deploy & ops

Config keys per app heredoc (S13, deploy/ship.sh and deploy/estate/compose.yml): PRIMA_AUTH_BASE, PRIMA_API_BASE, PRIMA_SURFACE=prima_chat, PRIMA_SESSION_TTL, DATA_DIR, CHAT_HOST_PORT (loopback-published, 4318 by default), NODE_ENV=production, and the git and engrave settings the other chat recipes own. PRIMA_COOKIE_INSECURE is never set on a host with TLS. PRIMA_API_KEY, PRIMA_API_KEY_FILE and PRIMA_ALLOW_SHARED_KEY_FOR_USERS must be absent: the ship gate refuses the .env otherwise (106 to 116), and the .env must be mode 600 (107 to 108).

Upstream prerequisites, both on Labs and on the ingress in front of it: GET /api/auth/workspaces exists on Labs since 770e53e (2026-08-04, chat side) and is in auth_router.py at fd8558f; the ingress allowlist for the API host must carry /api/auth/request-magic-link, /api/auth/verify-magic-link, /api/auth/me, /api/auth/refresh, /api/auth/workspaces and /api/developer/keys (wiki platform/primaapi.md, "an allowlist"); /api/auth/logout is not on it (Gotchas). The prima_chat surface must be registered upstream (ISSUABLE_SURFACES, S5) and the person must hold the grant.

Migrations: none. Scheduler jobs: none; expired records are purged at the next createSession (374 to 375). Argus checks: GET /api/config on the loopback port answers 200 with surface: prima_chat; GET /api/auth/session without a cookie answers {session: null, reason: 'no_cookie'}; a POST /api/auth/magic-link with an invalid address answers 400 without touching the upstream. Rollback: the image tag (ship.sh prints the command only when the previous image exists, 133 to 138); sessions.json lives on the volume and survives a rollback, and a record written by a newer build is read by an older one field by field (read, 232 to 243).

Gotchas

  • 2026-09-12 (measured at 743b840, sessions.ts 702 to 721 and 743 to 801; D8, a verified defect recorded here and not fixed in this wave): POST /api/auth/logout with {everywhere: true} revokes every session of the subject locally and blanks their access tokens and raw keys, but only the calling session's gateway key and parked keys are revoked upstream. The other sessions' ai keys stay live on Labs, and because their access tokens are blanked in the same write, nothing can revoke them afterwards. Fix it needs: before revokeSubject, walk the subject's other live records and DELETE each record's gatewayKey.id and retiredKeys[].id with that record's own access token, then report the counts; auth-check.mjs has no everywhere case, so add one.
  • 2026-09-12 (measured at 743b840, identity.ts 15 to 24, auth.ts 64 to 89, sessions.ts 886 to 896): three header comments dated 2026-08-03 say Labs signs HS256, publishes no JWKS, and that Prima answers a Labs bearer with 401 identity_provider_unavailable. Since ca227b9 Labs signs Ed25519 and publishes /.well-known/prima-jwks.json, and Prima's unavailable answer is 503 with Retry-After: 30 (S5, measured at fd8558f and cd2c248). The code path the comments justify (no bearer fallback) is still right; the reasons quoted are stale.
  • 2026-09-12 (measured at 743b840, identity.ts 729 to 771): POST /api/auth/logout is not on the API host's ingress allowlist, so the upstream session revoke answers the edge's fixed 404 and sign-out reports upstreamSession: {ok: false, status: 404}. Survivable because the browser never held the token; what an attacker with disk access holds cannot be revoked from here. The fix is an allowlist entry at the edge, not a change in this server (auth.ts 307 to 311).
  • 2026-09-12 (measured at 743b840, sessions.ts 232 to 250, 460 to 461): the whole store is one JSON file, read on every gated request and rewritten on every request to persist lastSeenAt, through a non-atomic read-modify-write across processes; persist protects revocation (a revoked row always wins) and nothing else. One process per volume is the deployment shape (compose.yml 46 to 56); a second replica on the same file loses bookkeeping writes.
  • 2026-09-12 (measured at 743b840, index.ts 104 to 116; D8, a verified defect recorded here and not fixed in this wave): the comment over the gateway loop says requireWorkspace "sits on these four and nowhere else" and that everything below it, threads and repositories included, has nothing to attribute. Four routes outside the loop carry the gate per route (routes/conversations.ts 86, routes/stone.ts 298 and 334, routes/tools.ts 366) and the billing router carries it at router.use (routes/billing.ts 396). All five places spend through the gateway, so the gate belongs on them; the comment is what is wrong, and it is the sentence a reader would trust when deciding where a new spending route needs the gate. Fix it needs: correct the comment, which changes no behaviour, or hoist the gated set into one named list that index.ts and the three routers read, so the claim and the mounts cannot drift again.
  • 2026-09-12 (measured at 743b840, index.ts 12 to 15): the header comment lists the open routes as magic-link, verify, session, logout; GET /api/auth/revocation-cursor is on the same router but carries its own requireUser (auth.ts 345). The comment is right about the four; the fifth is gated.
  • 2026-09-12 (measured at 743b840, scripts/auth-check.mjs): the printed count is 100 passed; the README's "62/62" (line 298) and the inventory's "112 assertions" are older or later counts. The count is not the contract; the sections are.
  • 2026-08-08 (5fce593, FIND-003): the spending routes forwarded a key the gateway had already refused, at line rate, until the per-subject limiter and the entitlement probe were added in front of them; a refused key is now parked (invalidateGatewayKey) and requireWorkspace refuses cheaply from then on.
  • 2026-08-04 (770e53e): the chat server used to resolve the tenant from GET /api/prima/v1/usage, which disclosed the resolved tenant rather than enumerating memberships and which stopped answering a Labs bearer after the 2026-08-03 cutover; listWorkspaces and the two /api/workspaces routes replaced it, and mintGatewayKey takes the tenant as an argument so no code path can invent one (identity.ts 551 to 564).
  • 2026-08-03 (78900dc, 2026-08-08 bdc3413): the auth check used a fixed port and once asserted against a previous run's detached server pointing at another data directory; ephemeral ports and the per-run surface check fixed it (49 to 58, 395 to 401).

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • prima-chat (w5-billing-panel at 743b840, 2026-09-11, an i18n commit on top of integration/redteam-fixes; the auth files last changed at 4a9e428 and e606308 on 2026-09-10, and substantively at 71b425c, 770e53e and 5fce593):
    • server/auth.ts (347 lines): AuthContext (42 to 52), credentialFor (91 to 96), DENIALS (100 to 115), requireUser (117 to 134), authOf (137 to 140), WORKSPACE_DENIALS (154 to 179), requireWorkspace (181 to 192), credentialOf (195 to 199), sameOriginOnly (210 to 221), identityFailure (227 to 234), the routes (236 to 347).
    • server/sessions.ts (921 lines): COOKIE_NAME (70), REVALIDATE_AFTER_MS (73), SessionRecord (75 to 163), ChosenWorkspace (166 to 172), WorkspaceState and workspaceState (187 to 197), RevocationEvent (216 to 222), read and write (232 to 250), createSession (272 to 380), resolveSession (406 to 464), persist (474 to 480), refreshWorkspaces (501 to 522), chooseWorkspace (564 to 645), invalidateGatewayKey (661 to 669), revokeSid (682 to 699), revokeSubject (702 to 721), signOut (743 to 801), revocationCursor and revocationsSince (804 to 810), purge (814 to 819), readCookie, setSessionCookie, clearSessionCookie (829 to 865), publicSession (877 to 921).
    • server/identity.ts (771 lines): call (115 to 158), reasonOf (165 to 174), requestMagicLink (188 to 241), verifyMagicLink (249 to 314), me (326 to 352), refresh (364 to 387), listWorkspaces (438 to 516), isUuid (521 to 523), mintGatewayKey (566 to 679), deleteGatewayKey (689 to 717), revokeUpstream (751 to 771).
    • server/env.ts (172 lines): the settings named under Stack assumptions (55 to 158), publicConfig (163 to 172).
    • server/routes/workspaces.ts (107 lines): GET / (48 to 58), POST /select (70 to 107).
    • server/index.ts: the mount order (40 to 66), GATEWAY_ROUTES and the gates (89 to 146), the catch-all (162).
    • server/gateway.ts: GatewayCredential (72 to 75), sharedCredential (86 to 90), assertNotShared (100 to 115), authHeaders (116 to 143).
    • server/entitlement.ts: ACTIVE_TTL_MS (50), the invalidate hook (115 to 117).
    • scripts/auth-check.mjs (1198 lines): the stand-in (78 onward), the boot and readiness (395 to 460), sections 1 to 9 (465 onward).
    • deploy/ship.sh (the .env gate 106 to 116, liveness 121 to 125), deploy/estate/compose.yml (46 to 69).
  • alchemy_labs (d1a-token-source at fd8558f): backend/api/routers/auth_router.py list_my_workspaces (733 to 823, the predicate that must stay identical to the mint's: owner or active workspace_users row, 769 to 776), verify_magic_link (1126 to 1262), backend/api/routers/developer_router.py revoke_api_key (258 onward).
  • Wiki: askedquestions/2026-08-25-prima-chat-has-no-wiki-page.md (the stack table and the repo state as of 2026-09-09), platform/primaapi.md (the allowlist paragraph), operations/decisions/0020-prima-identity-and-prepaid-credits.md (surfaces and the ai scope).

Deviations of the reference from this recipe, dated 2026-09-12: the everywhere key leak; the three stale header comments; the gate comment over the gateway loop, which claims a placement four other routes contradict; the upstream logout unreachable at the edge; the single-file store's per-request rewrite; no everywhere case in the auth check.

Acceptance checks

Run from prima-chat at the commit in measured_on, or from a new service built to this recipe. Expected observations are exact.

  1. npm run auth-check prints 100 passed, 0 failed and a data dir: line under the OS temp directory; nothing is written under the repo.
  2. grep -l "config.sharedKey" server/*.ts prints server/gateway.ts and nothing else (section 9 of the check asserts the same).
  3. sed -n '877,921p' server/sessions.ts | grep -c "accessToken\|refreshToken\|gatewayKey?.key\b" prints 1, and that one line is the credential: record.gatewayKey?.key ? 'key' : 'none' ternary (897), which yields a word and never the key; the projection names keyPrefix, tenantId, scopes and error under gateway and nothing else.
  4. With the server running locally against the stand-in (PRIMA_COOKIE_INSECURE=1): curl -s -i -X POST http://127.0.0.1:$PORT/api/auth/magic-link -H 'content-type: application/json' -d '{"email":"nope"}' shows 400 and "code":"invalid_email"; with a well-formed address it shows 202 and the body is exactly {"ok":true}.
  5. curl -s -i http://127.0.0.1:$PORT/api/does-not-exist shows 401 and "code":"not_signed_in"; curl -s http://127.0.0.1:$PORT/api/auth/session shows {"session":null,"reason":"no_cookie"}.
  6. After a verify, the Set-Cookie header starts prima_chat_sid= (or __Host-prima_chat_sid= with TLS) and contains HttpOnly, SameSite=Lax, Path=/; the JSON body contains no substring prima_pk_ and no access_token.
  7. For a subject with several workspaces: GET /api/gw/models with the cookie answers 409 with "code":"workspace_required" and "workspaceState":"choose"; POST /api/workspaces/select with an id from another subject's list answers 403 "code":"unknown_workspace"; with a non-UUID answers 403; with one of the caller's own answers 200 with session.workspaceState equal to ready and session.gateway.tenantId equal to the id sent; GET /api/gw/models is then 200.
  8. A second POST /api/workspaces/select with a different own id answers 200 with previousKey.attempted true; the stand-in's key list shows the first key inactive and the second bound to the new id.
  9. POST /api/auth/logout with the cookie answers 200 with local equal to 1, gatewayKey.ok true, upstreamSession.status equal to what the upstream answered (404 on the deployed API host as of 2026-09-12, 2xx on the stand-in); replaying the same cookie answers 401 "code":"session_revoked".
  10. stat -f %Lp .data/sessions.json (macOS) or stat -c %a (Linux) prints 600; grep -c '"sid": "[0-9a-f]\{64\}"' .data/sessions.json equals the number of records; the cookie value from step 6 does not appear in the file.
  11. grep -l requireWorkspace server/index.ts server/routes/*.ts prints exactly server/index.ts, server/routes/billing.ts, server/routes/conversations.ts, server/routes/stone.ts and server/routes/tools.ts; grep -c "app.use('/api', requireWorkspace)" server/index.ts prints 0.
  12. On a deployment: curl -fsS http://127.0.0.1:$CHAT_HOST_PORT/api/config prints JSON with "surface":"prima_chat" and no key material; grep -E '^PRIMA_API_KEY=|^PRIMA_API_KEY_FILE=|^PRIMA_ALLOW_SHARED_KEY_FOR_USERS=' /opt/alchemy/prima-chat/.env prints nothing.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.