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 |
|
| Measured on | prima-chat w5-billing-panel 743b840 2026-09-12 |
| Also measured on |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
PRIMA_AUTH_BASE | the API host origin | where /api/auth/* and /api/developer/keys live (line 77); its own setting, not derived from the gateway base |
PRIMA_API_BASE | the gateway base under the same host | /api/prima/v1 (67 to 70); the proxy target and the entitlement probe |
PRIMA_SURFACE | prima_chat | sent 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_TTL | 43200 (12 h) | the cookie ceiling in seconds (97) |
PRIMA_COOKIE_INSECURE | unset | 1 drops Secure and with it the __Host- prefix (103; sessions.ts 70); the only configuration in which either is dropped |
DATA_DIR | .data | holds 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_USERS | unset | the 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.
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/config | none | none | 200 publicConfig() | the one route in front of the gate; the deploy liveness probe (deploy/ship.sh 121 to 125) |
POST /api/auth/magic-link | none | none | 202 {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, unknown | forwards {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/verify | none | none | 200 {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, server | redeems 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/session | none | none | 200 {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/logout | none (resolves the cookie itself) | none | 200 {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-cursor | requireUser (its own, 345) | none | 200 {cursor, source: 'prima-chat', surface} | this server's log, not Prima's (sessions.ts 199 to 215) |
GET /api/workspaces?refresh=1 | requireUser | the caller's own memberships | 200 {workspaces, selected, state, error} | cached 5 min on the record (WORKSPACES_STALE_AFTER_MS, 485); refresh=1 forces the upstream read |
POST /api/workspaces/select | requireUser | the id must be in the caller's own list | 200 {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_failed | mint 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.id | 401 from requireUser; 409 workspace_required or no_workspace; 503 workspace_directory_unavailable; 502 gateway_key_unavailable; then the gateway's own codes | the 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:
verifyMagicLink(token)posts the token and the surface; any 400, 401 or 404 is one 401link_invalid; a body withoutaccess_tokenor without a stringuser.idis 502 (identity.ts278 to 300).expires_indefaults to 1800 s when absent (283).me(accessToken): the id in the login body must equal the id/api/auth/meresolves for the token, else the session is refused ("the sign-in service contradicts itself", 273 to 279). The subject id is the isolation key forusers/<subject>/on disk (store.ts::scopeFor), so it is taken from the token, not from a body.- A 32-byte random cookie value is drawn;
sidis its SHA-256 (282 to 283). - The workspace question is settled before the record exists (285 to 344):
listWorkspacesis called; aWorkspaceDirectoryErroris recorded with its reason (ingress,not_deployed,denied,network,unknown;identity.ts66 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 withX-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. - The record is written with
expiresAtequal 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). - The cookie is set:
Path=/,HttpOnly,SameSite=Lax(a magic link is a top-level navigation from a mail client, whichStrictwould drop, 851 to 854),Max-AgetoexpiresAt,SecureunlessPRIMA_COOKIE_INSECURE=1, name__Host-prima_chat_sidorprima_chat_sid(845 to 859). The response body ispublicSession(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:
publicSessionhas no field that carriesaccessToken,refreshTokenorgatewayKey.key(877 to 921); the select response is asserted key-free byauth-check.mjs("the select response carries no usable credential"). - The cookie of record is its hash:
sessions.jsonnever 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-IDsent upstream is built fromrecord.workspaceorrecord.gatewayKey.tenantId, never from the request (119 to 137;routes/workspaces.ts24 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.ts608 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/billingrouter, engrave, the stone search and index, and tool execute (index.ts118 to 119;routes/billing.ts396;routes/conversations.ts86;routes/stone.ts298 and 334;routes/tools.ts366). 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.mjs616). - Signed-out is 401 on every
/apipath, known or unknown; no route table is exposed (index.ts162; asserted by section 1 of the auth check). - The operator's shared key never authenticates a browser request:
gateway.ts::assertNotSharedrefuses it unlessPRIMA_ALLOW_SHARED_KEY_FOR_USERS=1, and the deploy refuses an.envthat 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 freshDATA_DIRper 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 (409workspace_requiredon 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,previousKeyreported), 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 issession_revoked, no token and no key left in the record,sessions.jsonis 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.tsandserver/ratelimit.test.ts: the two extra gates on the spending routes, withinvalidateGatewayKeyinjected (entitlement.ts60 to 68).- Known-answer vectors: the
Set-Cookieline__Host-prima_chat_sid=<value>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<n>; Secure(845 to 859); the key labelchat.prima.li (<8 hex>); the refusal codes listed under API surface, which the client keys on (web/src/lib/billing.ts178 to 188). - Hermetic versus live: the auth check is hermetic and spends nothing;
scripts/smoke.mjsandscripts/live-check.mjsreach 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 isscripts/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.ts702 to 721 and 743 to 801; D8, a verified defect recorded here and not fixed in this wave):POST /api/auth/logoutwith{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'aikeys stay live on Labs, and because their access tokens are blanked in the same write, nothing can revoke them afterwards. Fix it needs: beforerevokeSubject, walk the subject's other live records andDELETEeach record'sgatewayKey.idandretiredKeys[].idwith that record's own access token, then report the counts;auth-check.mjshas noeverywherecase, so add one. - 2026-09-12 (measured at 743b840,
identity.ts15 to 24,auth.ts64 to 89,sessions.ts886 to 896): three header comments dated 2026-08-03 say Labs signs HS256, publishes no JWKS, and that Prima answers a Labs bearer with 401identity_provider_unavailable. Since ca227b9 Labs signs Ed25519 and publishes/.well-known/prima-jwks.json, and Prima's unavailable answer is 503 withRetry-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.ts729 to 771):POST /api/auth/logoutis not on the API host's ingress allowlist, so the upstream session revoke answers the edge's fixed 404 and sign-out reportsupstreamSession: {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.ts307 to 311). - 2026-09-12 (measured at 743b840,
sessions.ts232 to 250, 460 to 461): the whole store is one JSON file, read on every gated request and rewritten on every request to persistlastSeenAt, through a non-atomic read-modify-write across processes;persistprotects revocation (a revoked row always wins) and nothing else. One process per volume is the deployment shape (compose.yml46 to 56); a second replica on the same file loses bookkeeping writes. - 2026-09-12 (measured at 743b840,
index.ts104 to 116; D8, a verified defect recorded here and not fixed in this wave): the comment over the gateway loop saysrequireWorkspace"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.ts86,routes/stone.ts298 and 334,routes/tools.ts366) and the billing router carries it atrouter.use(routes/billing.ts396). 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 thatindex.tsand the three routers read, so the claim and the mounts cannot drift again. - 2026-09-12 (measured at 743b840,
index.ts12 to 15): the header comment lists the open routes asmagic-link,verify,session,logout;GET /api/auth/revocation-cursoris on the same router but carries its ownrequireUser(auth.ts345). The comment is right about the four; the fifth is gated. - 2026-09-12 (measured at 743b840,
scripts/auth-check.mjs): the printed count is100 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) andrequireWorkspacerefuses 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;listWorkspacesand the two/api/workspacesroutes replaced it, andmintGatewayKeytakes the tenant as an argument so no code path can invent one (identity.ts551 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-panelat 743b840, 2026-09-11, an i18n commit on top ofintegration/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),WorkspaceStateandworkspaceState(187 to 197),RevocationEvent(216 to 222),readandwrite(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),revocationCursorandrevocationsSince(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_ROUTESand 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), theinvalidatehook (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.envgate 106 to 116, liveness 121 to 125),deploy/estate/compose.yml(46 to 69).
alchemy_labs(d1a-token-sourceat fd8558f):backend/api/routers/auth_router.pylist_my_workspaces(733 to 823, the predicate that must stay identical to the mint's: owner or activeworkspace_usersrow, 769 to 776),verify_magic_link(1126 to 1262),backend/api/routers/developer_router.pyrevoke_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 theaiscope).
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.
npm run auth-checkprints100 passed, 0 failedand adata dir:line under the OS temp directory; nothing is written under the repo.grep -l "config.sharedKey" server/*.tsprintsserver/gateway.tsand nothing else (section 9 of the check asserts the same).sed -n '877,921p' server/sessions.ts | grep -c "accessToken\|refreshToken\|gatewayKey?.key\b"prints1, and that one line is thecredential: record.gatewayKey?.key ? 'key' : 'none'ternary (897), which yields a word and never the key; the projection nameskeyPrefix,tenantId,scopesanderrorundergatewayand nothing else.- 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"}'shows400and"code":"invalid_email"; with a well-formed address it shows202and the body is exactly{"ok":true}. curl -s -i http://127.0.0.1:$PORT/api/does-not-existshows401and"code":"not_signed_in";curl -s http://127.0.0.1:$PORT/api/auth/sessionshows{"session":null,"reason":"no_cookie"}.- After a verify, the
Set-Cookieheader startsprima_chat_sid=(or__Host-prima_chat_sid=with TLS) and containsHttpOnly,SameSite=Lax,Path=/; the JSON body contains no substringprima_pk_and noaccess_token. - For a subject with several workspaces:
GET /api/gw/modelswith the cookie answers409with"code":"workspace_required"and"workspaceState":"choose";POST /api/workspaces/selectwith an id from another subject's list answers403"code":"unknown_workspace"; with a non-UUID answers403; with one of the caller's own answers200withsession.workspaceStateequal toreadyandsession.gateway.tenantIdequal to the id sent;GET /api/gw/modelsis then200. - A second
POST /api/workspaces/selectwith a different own id answers200withpreviousKey.attemptedtrue; the stand-in's key list shows the first key inactive and the second bound to the new id. POST /api/auth/logoutwith the cookie answers200withlocalequal to1,gatewayKey.oktrue,upstreamSession.statusequal 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 answers401"code":"session_revoked".stat -f %Lp .data/sessions.json(macOS) orstat -c %a(Linux) prints600;grep -c '"sid": "[0-9a-f]\{64\}"' .data/sessions.jsonequals the number of records; the cookie value from step 6 does not appear in the file.grep -l requireWorkspace server/index.ts server/routes/*.tsprints exactlyserver/index.ts,server/routes/billing.ts,server/routes/conversations.ts,server/routes/stone.tsandserver/routes/tools.ts;grep -c "app.use('/api', requireWorkspace)" server/index.tsprints0.- On a deployment:
curl -fsS http://127.0.0.1:$CHAT_HOST_PORT/api/configprints 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/.envprints 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.