Prima Get an API key

Module kit / Identity and auth

Surface registry and gate

identity-auth/surface-registry-and-gate

Kind backend
Domain identity-auth
Maturity prod
Reusable yes
Stack fastapi, sqlalchemy-sync, postgres
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/alembic-migrations, _shared/test-layout
Frontends identity-auth/ui/auth-screens, identity-auth/ui/admin-users-console, identity-auth/ui/chat-signin-and-workspace-picker, identity-auth/ui/developer-portal-auth-pages
Reference implementation
  • alchemy_labs/backend/core/surfaces.py
  • alchemy_labs/backend/api/surface_gate.py
  • alchemy_labs/backend/api/dependencies/surface.py
  • alchemy_labs/backend/services/surface_service.py
  • alchemy_labs/backend/models/user_surface_grant_model.py
  • alchemy_labs/backend/app_fastapi.py
  • alchemy_labs/backend/migrations/versions/20260802_0002_surface_grants.py
  • alchemy_labs/backend/tests/test_surface_scoped_access.py
  • alchemy_labs/backend/tests/test_public_exemption_checks_credentials.py
  • alchemy_labs/backend/tests/test_surface_rp_id.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

A surface is a front door (the Labs app, prima.li, the chat, the developer portal), and whether a person may walk through one is a row in user_surface_grants, never a role, never a claim, never the fact that a users row exists. The invariant that makes it trustworthy is structural: mount_router will not register a router without a surfaces= decision, assert_every_route_gated refuses to boot the process if one slipped past, the gate reads the grant table live on every credentialed request, and adding a surface to the registry grants nobody anything because a new surface has zero rows on the day it ships.

This recipe covers the registry (core/surfaces.py: five ids, of which four are issuable and one is listed so that nobody adds it by hand), the gate attached at mount time (api/surface_gate.py), the one module allowed to read or write the grant table (services/surface_service.py), the per-request surface resolver for sign-in ceremonies (api/dependencies/surface.py), the relying-party and cookie attributes each surface carries, and the migration that created the table and backfilled every existing account. What the token's aud claim contains is B2 and B3; the dependency that turns a credential into a User is S5.

When to use

Include it from every recipe that mounts a router, mints a session, or asks "may this account be here". Read it before adding a surface, before mounting a router, before writing surface_exempt, and when the question is "the token is valid but this route says 403", "the API key works on one host and not the other", "why is role not enough", "which RP id does this passkey bind to", "why did my public router refuse a logged-in user". Trigger phrases: "SurfaceGate", "mount_router", "surface_exempt", "LABS_SURFACES", "ALL_SURFACES", "user_surface_grants", "grant_surfaces", "rp_id", "cookie_domain", "X-Alchemy-Surface", "primapvs".

Do not use it for the workspace a request acts on (S6: X-Workspace-ID, memberships), for the one admin write that touches the table (B16's POST /api/admin/create-user, admin_router.py 1523 to 1526, which writes LABS_SIGNUP_SURFACES with granted_by = the admin for the account it creates; no route grants or revokes a single surface at fd8558f, Gotchas), for API-key scopes and the prefix lock (B10, S5), or for the audience rule the standalone PrimaAPI enforces (prima/auth/surfaces.py at cd2c248, the D3 reference for gateway auth, B11; Labs' core/surfaces.py is the Labs-side registry and the two vocabularies are kept equal by test_labs_and_prima_agree_on_the_surface_vocabulary). PrimaOS (primapvs) has its own identity system and is out (D6); it appears in the registry only as external=True.

Stack assumptions

Measured at fd8558f (2026-09-12): fastapi==0.118.2 (Depends(<instance>) on include_router, HTTPConnection so one dependency covers HTTP and WebSocket routes), sqlalchemy==2.0.43 sync with raw text() for the grant table, Postgres 15 for the CHECK constraint and ON CONFLICT DO NOTHING. Foundations assumed: S4 (every router goes through mount_router; assert_every_route_gated is the last statement of app_fastapi.py), S5 (the credential order the gate copies: Bearer, then the access_token cookie, then X-API-Key; the uniform 401 the endpoint produces), S7 (the 403 and 503 bodies), S8 (surface_grants_001), S9.

Environment names and fail behaviour (core/config.py):

NameDefaultBehaviour
FRONTEND_URLhttp://localhost:3000 (43)added to the Labs surface's WebAuthn origins and used as the Labs link base (core/surfaces.py 117 to 118, 337 to 338)
PRIMA_EXTRA_ORIGINSempty list (151)comma-separated extra origins for the three Prima surfaces (previews, localhost), read by origins and all_surface_origins (300 to 312, 353 to 364); seeds CORS
WEBAUTHN_RP_ID_BACKFILLunsetthe explicit opt-in migration 20260803_0001_webauthn_rp_id.py (B7) honours as given (17 to 21, _backfill_rp_id 70 onward); unset, the migration falls back to WEBAUTHN_RP_ID and then settings.WEBAUTHN_RP_ID and refuses an inferred placeholder (_NOT_A_REAL_RP, 67), which is why the harness sets this variable rather than the other (RENDERING.md, "Alchemy Labs")
JWT_LEGACY_NO_AUD_UNTILemptya token with no aud is read as labs only inside the window; the gate sees it through decode_token_with_surface (B2)

There is no setting that adds a surface: the registry is a module-level literal (SURFACES, 125 to 166) and reload_registry is a documented no-op (367 to 371). A new surface is a code change, a migration that widens ck_usg_surface, and grant rows.

Data model

-- migrations/versions/20260802_0002_surface_grants.py 52 to 70; models/user_surface_grant_model.py 37 to 63
user_surface_grants (
  user_id    uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  surface    varchar(32) NOT NULL,
  granted_at timestamp NOT NULL DEFAULT now(),
  granted_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  PRIMARY KEY (user_id, surface),
  CONSTRAINT ck_usg_surface CHECK (surface IN ('labs', 'prima_web', 'prima_chat', 'prima_dev'))   -- 'primapvs' absent on purpose
);
CREATE INDEX ix_user_surface_grants_user_id ON user_surface_grants(user_id);

-- the same migration, 102 to 117: the label gets a domain and stays a label
ALTER TABLE users ADD CONSTRAINT ck_users_role
  CHECK (role IN ('user', 'admin', 'designer', 'delivery', 'api_only', 'medical_reviewer'));

-- 72 to 80, owned by B5: a link opens exactly one door, recorded on the row
ALTER TABLE magic_links ADD COLUMN surface varchar(32) NOT NULL DEFAULT 'labs';

The model's CHECK is built from ISSUABLE_SURFACES at import (_SURFACE_IN_LIST, user_surface_grant_model.py 32 to 34) and named with conv("ck_usg_surface") so create_all() and the migration produce the same name (56 to 61); the migration keeps its list literal (34 to 37) because a migration describes the schema at its point in history. The backfill (119 to 142): every existing user gets the three Prima surfaces; every user whose role IS DISTINCT FROM 'api_only' gets labs. Nobody lost access on the day it ran; api_only accounts gained nothing they did not have by design.

The registry, one frozen dataclass per surface (core/surfaces.py 41 to 166):

idhostswebauthn_rp_id (rp_id)rp_namecookie_domainoriginexternal
labsthe Labs hostthe Labs host (must never be shortened: an RP id change invalidates every enrolled passkey, 129 to 130)Alchemy Labsthe Labs hostthe Labs originno
prima_webprima.li, www.prima.liprima.liPrima.prima.lihttps://prima.lino
prima_chatchat.prima.liprima.li (a registrable suffix, so one credential covers all three, 145 to 147)Prima.prima.lihttps://chat.prima.lino
prima_devdeveloper.prima.liprima.liPrima.prima.lihttps://developer.prima.lino
primapvsnonenonePrimanonenoneyes

Derived sets (169 to 189): ISSUABLE_SURFACES (the four), PRIMA_SURFACES (three), LABS_SURFACES ({labs}), ALL_SURFACES (= ISSUABLE_SURFACES), LABS_SIGNUP_SURFACES (labs plus the three), API_ONLY_SURFACES (= PRIMA_SURFACES). origins (88 to 122) is the canonical origin first, then FRONTEND_URL for labs or PRIMA_EXTRA_ORIGINS for the others, trailing slashes stripped, duplicates dropped. webauthn_rp_ids() (342 to 350) is two values in production.

What is not stored: a surface on the sessions row (it is the token's aud); a surface on api_keys (a key is not surface-bound; its owner's grants decide, gate 174 to 187); a permission on users.role (a label with a CHECK, read for surface access by nothing; test_role_column_now_has_a_domain 193); a primapvs row (refused by the CHECK, by assert_issuable, and by grant_surfaces).

Request state written by the gate (surface_gate.py 197 to 198): request.state.surface (the token's aud, or for an API key the lowest-sorted granted surface the router opens) and request.state.surface_grants (the set as read). S5's dependency writes request.state.surface again from the token (api/dependencies.py 214, and again at 404 for OptionalUser), so a handler sees the same value on the JWT path and the gate's choice on the key path.

API surface

This recipe owns no route. It owns a dependency on every route, a boot assertion, and the sentences a caller can key on:

WhereAuth depTenancyCodesDetail strings
every router mounted with surfaces=<set>SurfaceGate(<set>) before the endpoint's own dependenciesnone (the gate reads the user, not the workspace)pass (anonymous, or an undecodable token, or an unknown key: the endpoint answers); 403; 503; on a WebSocket handshake a close with code 1008 and the same sentence (_deny, 82 to 91)403 "This session belongs to the '<surface>' surface and cannot be used here." (142 to 152); 403 "This account is not permitted on this surface." (177 to 186 for a key owner with no usable grant, 188 to 195 for a token whose aud is not granted); 503 "Access control is temporarily unavailable." (163 to 172)
every router mounted surface_exempt("public", note)a SurfaceGate over all four issuable surfaces, delegated (240 to 252)nonethe same as above for a presented credential; pass without onethe same sentences
every router mounted surface_exempt("own_auth", note)none (a true no-op, 247 to 249)the router's own mechanismnone from this layernone
GET /api/auth/me (B2)ActiveUsernone200 with surfaces: sorted(get_user_surfaces_standalone(user.id))the frontend's authorisation contract (auth_router.py 697 to 715)
sign-in routes that take a surface (B5 request-magic-link, B7 webauthn/*)nonenone400 on an unknown, external or conflicting surface"Unknown surface '<s>'" (auth_router._requested_surface, 155 to 173); "Unknown surface. Expected one of: labs, prima_web, prima_chat, prima_dev", "That surface is external; Labs does not issue sessions for it.", "Requested surface does not match the request origin." (api/dependencies/surface.py 66 to 83)

The mount table at fd8558f (app_fastapi.py 819 to 1078; counted 2026-09-12 in the source): 93 mount_router(...) calls, of which 74 LABS_SURFACES, 6 ALL_SURFACES (auth_router 820, auth_infomaniak_router 821, webauthn_router 822, developer_router 827, and the legacy in-Labs gateway copy primaapi_router 892 and primalayered_router 907, which a rebuild replaces with the standalone service, D3), 10 surface_exempt("public", ...) (health 819, webhooks 882, Prima inbound 887, form protection 899, public forms 944, the two tracking beacons 951 to 952, published one-pagers 1006 to 1008, JWKS 1052 to 1060, clean URLs 1077) and 3 surface_exempt("own_auth", ...) (WebSocket 880, Airiel 1022, Aurum 1034); plus 4 routes declared directly on app with dependencies=[Depends(surface_exempt("public", ...))] (1081 to 1154: outreach redirect, service banner, two probes). Some mounts sit under feature flags, so the live table is smaller: importing the app with the development defaults on 2026-09-12 gave 981 API routes, 920 gated (872 labs only, 48 all four surfaces), 59 exempt public, 2 exempt own_auth, 0 ungated; the wiki counted 891 routes on 2026-08-02.

Application logic

SurfaceGate.__call__(conn) (surface_gate.py 108 to 199), in order:

  1. Read the credential the way S5 does: Authorization: Bearer <t>, else the access_token cookie (_extract_token, 74 to 79); only when neither is present, X-API-Key (114). No credential: return (117 to 118). The gate is authorisation, not authentication; the endpoint's dependency answers the 401.
  2. With a token: auth_service.decode_token_with_surface(token, token_type="access") (B2). Any JWTError returns without a decision (128 to 133), so an unreadable or expired token produces the endpoint's 401 and never a 403 that would reveal which routers exist. A missing or non-UUID sub returns the same way (134 to 140). A surface outside self.allowed is the first 403 with a WARNING naming path, surface and the router's set (142 to 152).
  3. Open a database session. With a key and no token: _api_key_user_id(db, value) hashes the value with SHA-256 and selects user_id from api_keys WHERE key_hash = :h AND is_active = TRUE (94 to 104); None returns without a decision (158 to 161). Then get_user_surfaces(db, user_id); SurfaceGrantError is the 503 (163 to 172).
  4. Key path: granted & self.allowed empty is the second 403; otherwise sorted(usable)[0] becomes the surface (174 to 187). Token path: surface not in granted is the same 403 (188 to 195).
  5. conn.state.surface and conn.state.surface_grants are set (197 to 198).

SurfaceExempt(reason, note) (202 to 252): reason must be public or own_auth, else ValueError at import; public builds a delegate SurfaceGate((LABS, PRIMA_WEB, PRIMA_CHAT, PRIMA_DEV)) and calls it, so "no session required" never means "any session accepted" (the comment at 226 to 239 records the 12 mounts that were no-ops before the 2026-08-02 audit); own_auth is a true no-op for routers with their own prefix-bound mechanism.

mount_router(app, router, *, surfaces, **kwargs) (265 to 274): surfaces is a required keyword; a SurfaceExempt is used as is, anything else becomes SurfaceGate(surfaces) (which runs validate_surfaces, 223 to 235: the empty set and any external or unknown id raise at import); the marker is appended to the router's dependencies and app.include_router is called. ungated_routes(app) (286 to 297) walks every APIRoute and APIWebSocketRoute outside _FRAMEWORK_PATHS (54 to 60: OpenAPI, docs, redoc, metrics) and lists those with no marker; assert_every_route_gated(app) (300 to 316) raises RuntimeError naming them, or logs ADR-0020 surface gate: all N routes carry a surface decision. It is the last statement of app_fastapi.py (1226 to 1234).

surface_service (33 to 132): get_user_surfaces(db, user_id) is SELECT surface FROM user_surface_grants WHERE user_id = :uid, filtered through ISSUABLE_SURFACES so a row for a retired surface re-opens nothing (48 to 50), with every exception re-raised as SurfaceGrantError (40 to 47); user_has_surface (53 to 57); grant_surfaces(db, user_id, surfaces, *, granted_by=None, commit=False) asserts each surface issuable, inserts with ON CONFLICT (user_id, surface) DO NOTHING in sorted order, commits only when asked (60 to 92); revoke_surfaces deletes by ANY(:surfaces) (95 to 111); the two _standalone variants open their own session (114 to 132). Callers of grant_surfaces outside tests/ at fd8558f, counted 2026-09-12 (grep -rn grant_surfaces --include='*.py' backend), ten in all: auth_service.create_user (B1, 647: LABS_SIGNUP_SURFACES, or API_ONLY_SURFACES when the row says role == "api_only"); the API-only signup (B14, services/primaapi/identity.py 319, API_ONLY_SURFACES, in the user row's own transaction); POST /api/admin/create-user (B16, admin_router.py 1523 to 1526, LABS_SIGNUP_SURFACES, the only caller that passes granted_by); the Infomaniak OIDC callback when it provisions a new account (B8, auth_infomaniak_router.py 101, the surface the login resolved to); the reviewer invite (content_review_service.py 127, {labs} only); the two client-onboarding completions (client_onboarding_service.py 935 and 1066, LABS_SIGNUP_SURFACES); the desk domain sign-in and its account provisioner (services/service_desk/signin.py 364, services/service_desk/access.py 659, {labs}); heal_shared_account (access.py 363, {labs}), which re-grants on a login request to an account that already exists; and the repair script (scripts/issue_magic_links.py 129, --repair-grants, API_ONLY_SURFACES). Plus the migration's backfill. revoke_surfaces has no caller outside tests/ at fd8558f: nothing in production takes a surface away (Gotchas).

resolve_surface(request, explicit=None) (api/dependencies/surface.py 57 to 89), for ceremonies that must know which door they serve before a token exists: an explicit id from the argument, the X-Alchemy-Surface header or ?surface= wins whenever one is present (61 to 84: unknown is 400, external is 400, and an explicit id that disagrees with a recognised Origin or Referer host is 400 with a WARNING); else the surface the browser's Origin resolves to (exact match against each surface's origin, then its host) or the Referer host (46 to 54); else labs (DEFAULT_SURFACE, core/surfaces.py 262). auth_router._requested_surface (155 to 173) is the older sibling for magic links: explicit id, then surface_for_host(Host), then labs. Both produce a request, never a grant: every caller re-checks the answer against the grant table inside create_session or create_magic_link.

Invariants

  • Every product route carries a surface decision or the process does not start (assert_every_route_gated; test_every_route_carries_a_surface_decision 680; test_a_router_mounted_without_a_gate_fails_the_boot_assertion 688; test_mount_router_makes_the_surface_decision_unforgettable 707, the TypeError on a missing surfaces=; test_no_router_is_mounted_with_raw_include_router 762).
  • No row, no access: test_a_user_with_no_grants_is_allowed_nowhere (181); the gate, create_session, refresh_session and create_magic_link all read the table and none reads users.role for the question.
  • user_surface_grants is read in exactly one module (test_the_grants_table_is_read_in_exactly_one_module 716) and every read failure is a deny, never an empty set (surface_service.py 34 to 38).
  • primapvs is never issuable, never grantable, never gated: assert_issuable raises (211 to 220), grant_surfaces raises (74), the CHECK refuses the row (test_database_refuses_an_unknown_surface_row 165), validate_surfaces refuses it in a gate (test_primapvs_is_never_issuable 144, test_grant_writer_refuses_primapvs 157, test_public_gate_admits_every_issuable_surface_and_not_primapvs).
  • The gate only removes access: an anonymous request, an undecodable token and an unknown key pass through to the endpoint's own 401 (test_beacon_with_no_credential_is_allowed); the gate never mints, never grants, never writes.
  • A public exemption checks a presented credential against all four surfaces; own_auth checks nothing (test_public_delegates_to_a_real_gate, test_own_auth_stays_a_true_noop); no third reason exists (test_exempt_reasons_remain_closed).
  • An API-only session (aud in the Prima surfaces, no labs row) is 403 on every LABS_SURFACES route and admitted on the gateway (test_api_only_session_is_denied_on_every_labs_gated_route 354, which walks the real route table; test_api_only_session_403s_over_real_http 400; test_an_api_only_session_is_accepted_on_the_prima_gateway 663); a Labs user may still use the gateway (test_a_labs_user_may_still_use_the_prima_gateway 638).
  • Grants are read live: deleting a row takes effect on the next gated request, restoring it takes effect without a new token (S5 acceptance check 5, repeated below).
  • rp_id, rp_name and origins are readable on every surface without raising, the three Prima surfaces share one RP id and one RP name, and the Labs RP id equals the enrolled Labs host (test_surface_rp_id.py, 40 cases).
  • The Host and Origin headers choose defaults (which origin a link points at, which RP id a ceremony binds to) and never decide access (surface_for_host docstring 239 to 243).

Frontend

identity-auth/ui/auth-screens          PrivateRoute renders only when /me.surfaces contains the build's surface;
                                       the 403 "not permitted to sign in" at login is the missing labs row;
                                       "This account can't be used here" is hasSurface false (S2)
identity-auth/ui/admin-users-console   no grants column at fd8558f: users-list returns no surfaces and no admin
                                       route reads or writes one. The only grant write behind the page is
                                       POST /create-user, which writes LABS_SIGNUP_SURFACES with granted_by =
                                       the admin for the account it creates (Gotchas)
identity-auth/ui/chat-signin-and-workspace-picker
                                       the chat server asks for a prima_chat magic link (surface in the body)
                                       and the gate admits the resulting session on ALL_SURFACES routers only
identity-auth/ui/developer-portal-auth-pages
                                       the portal asks for prima_dev; its session mints ai-only keys on
                                       developer_router and is 403 on every LABS_SURFACES route

Minimum admin and debug surface: GET /api/auth/me (surfaces); the three gate WARNING lines (surface gate: <surface> session denied on <path> (router allows [...]), surface gate: api key owner <id> denied on <path> (grants=[...], allows=[...]), surface gate: user <id> has no grant for <surface> (grants=[...]), 143 to 148, 179 to 185, 189 to 194); the boot line ADR-0020 surface gate: all N routes carry a surface decision (316); SELECT surface, granted_at, granted_by FROM user_surface_grants WHERE user_id = :u; the repair script scripts/issue_magic_links.py --check --email <addr> --surface labs and --repair-grants (wiki platform/architecture.md "silent lockout", 6cce08b; not re-measured here).

Security & tenancy

Auth dependency: the gate runs before the endpoint's dependency on every mounted router; the endpoint's dependency (S5) then authenticates. Workspace derivation: none here; S6 reads request.state this layer writes. Fail direction, per seam: the grant read closed (503 when the table cannot be read, in the gate; SurfaceAccessDenied at mint); the boot assertion closed (no decision, no process); the surface resolver closed on conflict (400); an undecodable credential deliberately undecided (the endpoint's 401, so the gate leaks no router inventory); own_auth open by declaration, for two routers with their own prefix lock.

Credentials at rest: none owned here; granted_by is an audit column, nullable, SET NULL when the admin is deleted. Rate limits: none on the gate (one indexed SELECT per credentialed request, plus the token decode S5 repeats); the sign-in routes that take a surface carry B18's limiter.

Attacker-controlled inputs: the token's aud (a string, checked against ISSUABLE_SURFACES in resolve_token_surface, a list refused, B2), X-API-Key (hashed before the lookup, is_active in the query), Host (a default for links and RP ids, spoofable and harmless because the grant decides), Origin and Referer (exact origin match, then host; used to refuse a conflicting explicit id), X-Alchemy-Surface, ?surface= and the body's surface (validated against the registry, external refused). None of them can add a surface to a session; the only thing that can is a grant row, written by one of the ten callers listed in Application logic, each behind its own authentication. Seven of them write at account creation; the other three write labs onto an account that already exists (the desk sign-in and heal_shared_account, after the domain rule matched the caller's mailbox; the repair script, from an operator's shell).

Tenancy of the decision: per user and per surface, global across workspaces (a grant is not a membership); a token belongs to one surface for its life; an API key belongs to its owner's grants and the router's set, and request.state.surface on the key path is the lowest-sorted intersection, which is labs whenever the owner holds it (Gotchas). The standalone PrimaAPI, the D3 reference for gateway auth (prima/auth/surfaces.py at cd2c248: ISSUABLE_SURFACES 75, PRIMA_SURFACES 80, GATEWAY_SURFACES 83 every issuable surface, MANAGEMENT_SURFACES 86 prima_dev only, enforce_audience 93), narrows aud on the key-management routes and nowhere else; the in-Labs mount at app_fastapi.py 892 is the legacy copy of that gateway and is not what a rebuild follows.

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f with python -m pytest tests/test_surface_scoped_access.py tests/test_public_exemption_checks_credentials.py tests/test_surface_rp_id.py -q --no-cov -o addopts="" -p no:cacheprovider): 89 passed in 7.33s (44, 5 and 40 collected).

  • test_surface_scoped_access.py: the registry (144 to 205), the mint and decode rules (209 to 305), the API-only account (308 to 346), the route-table walk and the real-HTTP parametrisation (354 to 436), the self-service key rule (440), magic links and password reset against the grant (471 to 540), login and session (544 to 577), the Prima signup (579 to 620), Spark's relabel refusal (622), the gateway from both sides (638 to 678), the boot assertion and the one-module rule (680 to 771). test_the_grants_table_is_read_in_exactly_one_module (716) is the authoritative form of the invariant and greps the tree.
  • test_public_exemption_checks_credentials.py (5 tests, no database): test_public_delegates_to_a_real_gate (24), test_own_auth_stays_a_true_noop (28), test_beacon_with_no_credential_is_allowed (36), test_public_gate_admits_every_issuable_surface_and_not_primapvs (41), test_exempt_reasons_remain_closed (49).
  • test_surface_rp_id.py (40 tests, no database, parametrised over the four issuable surfaces): rp_id equals webauthn_rp_id and is readable (28, 34), the Labs RP id is the enrolled host (39), the alias tracks the field and survives None (48, 60), rp_name readable, non-empty, the product name, shared across Prima (81 to 100), origins a tuple of strings with no trailing slash starting with the canonical origin and including FRONTEND_URL for Labs (103 to 121).
  • test_prima_ed25519_interop.py::test_labs_and_prima_agree_on_the_surface_vocabulary (698): the two registries name the same ids.
  • Known-answer expectations for a rebuilt service: sorted(ISSUABLE_SURFACES) == ['labs', 'prima_chat', 'prima_dev', 'prima_web']; SurfaceGate([]) raises ValueError("A surface gate must allow at least one surface."); SurfaceGate(["primapvs"]) raises SurfaceNotIssuable; surface_exempt("because") raises ValueError; mount_router(app, router) without surfaces= raises TypeError; INSERT INTO user_surface_grants VALUES (<uid>, 'primapvs', now(), NULL) violates ck_usg_surface.

Hermetic versus live: all hermetic; the route-table walk uses the real app object with the test database. Playwright gate: the F1 spec's "not permitted" screen (a session for a surface the build does not serve) is the browser-visible form of the second 403.

Deploy & ops

Config keys per app heredoc (S13): FRONTEND_URL, PRIMA_EXTRA_ORIGINS (each preview origin that must complete a Prima passkey ceremony or pass CORS), WEBAUTHN_RP_ID_BACKFILL once for the B7 migration. Nothing else; the registry is code.

Migrations order: surface_grants_001 (20260802_0002_surface_grants.py, revises primaapi_v1_001) creates the table, the role CHECK, magic_links.surface, prima_signup_intents, and runs the backfill in the same upgrade; it must run before any build that calls grant_surfaces at account creation (B1, B14) and before the gate is mounted, because a missing table is a SurfaceGrantError on every credentialed request, which the gate turns into 503 everywhere. The create_all() path (pytest only, init_db() checks for pytest) builds the table from the model with the same constraint name (conv("ck_usg_surface")). Adding a surface: a code change to SURFACES, a new migration that drops and recreates ck_usg_surface with the widened list (the model's CHECK widens itself from ISSUABLE_SURFACES; the migration's literal does not), a deploy, and then grant rows; the surface is closed until the rows exist.

Scheduler jobs: none. Argus checks: the boot log carries ADR-0020 surface gate: all N routes carry a surface decision with N equal to the previous deploy's plus the routes the release added; GET /api/auth/me for the synthetic owner returns surfaces containing labs; a request with a prima_web token on any LABS_SURFACES route returns 403 with the first sentence; SELECT count(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM user_surface_grants g WHERE g.user_id = u.id) is 0 outside a signup in flight (a non-zero count is the B1 two-commit gap or a failed backfill). Rollback: the migration's downgrade (145 to 164) drops the table and the CHECKs; a code rollback to before the gate leaves the table in place and unread, which is safe; a rollback of the code without the table is the 503-everywhere case above.

Gotchas

  • 2026-09-12 (measured at fd8558f; recorded under D8, no code changed in this wave): nothing in the API grants or revokes a single surface for another account. revoke_surfaces (services/surface_service.py 95 to 111) has no caller outside tests/, and the only admin write is POST /api/admin/create-user, which writes LABS_SIGNUP_SURFACES for the account it is creating (admin_router.py 1523 to 1526). The refusal on PATCH /users/{id}/role even names the missing tool: an api_only account cannot be relabelled and the sentence is "To give this person Labs access, grant the 'labs' surface." (995 to 1002), which no route can do. Today that leaves SQL, or scripts/issue_magic_links.py --repair-grants (129), which writes API_ONLY_SURFACES and nothing else. The F4 console therefore shows no grants column and has no field to render (ui/admin-users-console.md and admin-user-management-and-impersonation.md Gotchas, both measured at fd8558f). Fix: a grant route and a revoke route under AdminUser, each writing granted_by and its own audit type, surfaces on the users-list row from get_user_surfaces_standalone, and the column on F4.
  • 2026-09-12 (measured at fd8558f): api/surface_gate.py deliberately has no from __future__ import annotations (31 to 37). SurfaceGate is used as Depends(<instance>), and FastAPI resolves a callable's annotations against call.__globals__, which an instance lacks; with postponed evaluation the HTTPConnection annotation arrives as a string, FastAPI treats it as a query parameter, and every gated route answers 422. A formatter that adds the import to every file breaks every route at once. Fix: keep the comment, and a test that greps the module for the import; test_api_only_session_403s_over_real_http (400 to 421) is the one that would turn red today, as a 422 instead of a 403.
  • 2026-09-12 (measured at fd8558f): on the API-key path the gate sets request.state.surface = sorted(granted & allowed)[0] (187). labs sorts before prima_chat, prima_dev and prima_web, so a key whose owner holds a labs grant is stamped labs on an ALL_SURFACES router (the gateway, developer_router) even when the call comes from a Prima host. One handler reads that label and decides on it: POST /api/developer/keys forces a mint down to the ai scope when request.state.surface is in PRIMA_SURFACES (developer_router.py 113 to 127), and the route is reachable with an X-API-Key because get_current_user accepts one (api/dependencies.py 244 to 264) and developer_router is mounted ALL_SURFACES (app_fastapi.py 827). A mint made with a key therefore takes the unrestricted branch whenever the owner holds labs, whatever host called. That is the same outcome the same owner gets from a labs session, so the defect is in the label and in the scope rule that trusts it, not in access; an ai-scoped key is held to /api/prima/v1/ by the prefix lock either way (api/dependencies.py 81 to 101). Fix: prefer the surface the Origin resolves to when it is in the intersection, else the sorted first.
  • 2026-09-12 (measured at fd8558f): surface_exempt("public") did no checking on 12 mounts until the 2026-08-02 audit (the comment at 226 to 239; wiki operations/security-audit-2026-08-02.md B1). It now delegates to a four-surface gate, so a public router can refuse a credential: a token minted for a surface its owner is not granted is 403 on a beacon path. That is correct; it surprises the client that sends a stale cookie with a tracking beacon. Fix on the client: never send credentials to a beacon.
  • 2026-09-12 (measured at fd8558f): the grant read is one SELECT per credentialed request in a session the gate opens itself (get_db_context(), 154), in addition to the request's own session; get_current_user then does not read grants again, but GET /api/auth/me, /login and register each read them once more through get_user_surfaces_standalone. Per request that is two checkouts from the sync QueuePool (core/database.py 50 to 58: pool_size=2, max_overflow=3, pool_recycle=300; only the async engine still uses NullPool, 48 to 49), so every credentialed request doubles its pool pressure. Fix: read once in the gate and let S5 and the handlers use request.state.surface_grants.
  • 2026-09-12 (measured at fd8558f; the aliases landed in 25d08bd 2026-08-29 rp_id, e803698 2026-09-04 rp_name, 20d2e46 2026-09-04 origins): Surface declared webauthn_rp_id and the WebAuthn service read rp_id, rp_name and origins; each alias was added after a live 500 (53 to 122: login repaired 2026-08-29, registration 500 until 2026-09-04, then origins). test_surface_rp_id.py now covers all three on every surface. When a fix adds one attribute, grep every attribute the caller reads.
  • 2026-09-12 (measured at fd8558f): the migration's surface list is literal (_GRANTABLE_SURFACES, 34 to 37) and the model's CHECK is built from ISSUABLE_SURFACES (user_surface_grant_model.py 32 to 34). Adding a surface to the registry without a migration makes a create_all() test database accept a row the production database refuses; the tests pass and the first grant in production fails on ck_usg_surface. Fix: the migration that widens the CHECK ships in the same change as the registry entry; test_database_refuses_an_unknown_surface_row (165) is the test to extend with the new id.
  • 2026-09-12 (measured at fd8558f): _requested_surface in auth_router.py (155 to 173) and resolve_surface in api/dependencies/surface.py (57 to 89) are two resolvers with two precedence orders (explicit, then Host, then labs; explicit, then Origin or Referer, then labs, with a conflict check only in the second, which refuses an explicit id that disagrees with a recognised Origin). A magic link asked for with an explicit surface that disagrees with the browser's Origin is accepted by the first and refused by the second. Neither grants anything, so the difference is which door a link points at. Fix: one resolver, the second one, called by both.
  • 2026-09-12 (measured at fd8558f): the gate's key lookup (_api_key_user_id, 94 to 104) checks is_active and nothing else; expiry, suspension, IP allow-list and use count are S5's check_api_key_security in the endpoint's dependency. A suspended key therefore passes the gate and fails the dependency, which is the intended order (the gate never grants), but a reader of the gate's WARNING lines will not see suspended keys there.
  • 2026-09-12 (measured at fd8558f): the mount table counts differ by environment because several mounts are flag-gated (SPARK_ENABLED 985 to 997, AIRIEL_ENABLED 1022, AURUM_ENABLED 1034, the optional feature imports at 919 to 980). The boot line's N is therefore per environment, and the wiki's 891 (2026-08-02) is neither the source count nor today's development count (981 API routes on 2026-09-12). N is also not the route count this recipe measures: assert_every_route_gated logs len(app.routes) (316), which counts every registered route object, the framework paths and any static mount included, while the 981 above counts APIRoute and APIWebSocketRoute only. Compare N against the previous deploy of the same environment, never against a count taken another way.
  • 2026-09-12 (measured at fd8558f): users.role now has a CHECK (ck_users_role, six values including medical_reviewer, which the ADR omitted and content_review_service.py had been writing; user_model.py 28 to 42). The migration normalises stray values to user before adding the constraint (102 to 111). A new service copies the CHECK and the rule that the column is never read for surface access.
  • 2026-09-12 (measured at fd8558f, code wins over the wiki): platform/architecture.md (6cce08b) "Surfaces" is accurate about the model; its "All 891 routes" is a 2026-08-02 count. The scout's note that "gate is authz-only (anonymous passes, endpoint 401s)" is what the code does (116 to 118, 128 to 133).

Reference implementation

Paths are relative to the repo root; the commit is measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f):
    • backend/core/surfaces.py: the docstring (1 to 27), the ids (34 to 38), Surface with rp_id, rp_name, origins (41 to 122), SURFACES (125 to 166), the derived sets (169 to 189), UnknownSurface, SurfaceNotIssuable (192 to 197), get_surface, is_issuable, assert_issuable, validate_surfaces (200 to 235), surface_for_host (238 to 250), the compatibility accessors (253 to 297), _extra_origins (300 to 312), surface_for_origin (315 to 324), link_base_for (327 to 339), webauthn_rp_ids (342 to 350), all_surface_origins (353 to 364), reload_registry (367 to 371).
    • backend/api/surface_gate.py: the docstring and the annotations note (1 to 37), _FRAMEWORK_PATHS (54 to 60), SurfaceGate (63 to 199), SurfaceExempt (202 to 252), surface_gate, surface_exempt (255 to 260), mount_router (265 to 274), route_surface_marker (277 to 283), ungated_routes (286 to 297), assert_every_route_gated (300 to 316).
    • backend/api/dependencies/surface.py: SURFACE_HEADER (43), surface_from_origin_headers (46 to 54), resolve_surface (57 to 89), request_surface (92 to 94).
    • backend/services/surface_service.py: SurfaceGrantError (29 to 30), get_user_surfaces (33 to 50), user_has_surface (53 to 57), grant_surfaces (60 to 92), revoke_surfaces (95 to 111), the standalone pair (114 to 132).
    • backend/models/user_surface_grant_model.py (1 to 66); backend/models/user_model.py USER_ROLES and the role CHECK (28 to 42, 137 to 155); backend/models/__init__.py (80 to 85).
    • backend/app_fastapi.py: the imports (131 to 137), the mount table (819 to 1078), the four direct routes (1081 to 1154), the boot assertion (1226 to 1234).
    • backend/api/routers/auth_router.py: _require_labs_grant (140 to 152), _requested_surface (155 to 173), /me (697 to 715).
    • backend/migrations/versions/20260802_0002_surface_grants.py (1 to 164).
    • backend/tests/test_surface_scoped_access.py, backend/tests/test_public_exemption_checks_credentials.py, backend/tests/test_surface_rp_id.py, backend/tests/test_prima_ed25519_interop.py (698).
  • prima (feature/per-model-pricing, cd2c248): prima/auth/surfaces.py (ISSUABLE_SURFACES 75, PRIMA_SURFACES 80, GATEWAY_SURFACES 83, MANAGEMENT_SURFACES 86, enforce_audience 93 onward): the D3 reference for gateway audience enforcement, and the registry Labs' own core/surfaces.py is kept equal to.
  • Wiki philosophers_stone (6cce08b): platform/architecture.md ("Surfaces", the passkey saga, "silent lockout"), operations/security-audit-2026-08-02.md (B1), operations/decisions/0020-prima-identity-and-prepaid-credits.md (section 1), operations/prima-modules-inventory.md (row B4).

Deviations of the reference from this recipe, all dated 2026-09-12: no route that grants or revokes a single surface, so revoke_surfaces has no production caller and F4 has no grants column; the key path's labs-first surface label, which POST /api/developer/keys reads; two grant reads per request and a third in the auth handlers; two surface resolvers with different precedence; the literal migration list beside the derived model CHECK; the flag-dependent route count; the alias trio that arrived one live 500 at a time.

Acceptance checks

Run from alchemy_labs/backend at the commit in measured_on with the :5434 Postgres up and the app on $B, or from a new service built to this recipe.

  1. python -m pytest tests/test_surface_scoped_access.py tests/test_public_exemption_checks_credentials.py tests/test_surface_rp_id.py -q --no-cov -o addopts="" -p no:cacheprovider prints 89 passed.
  2. python -c "from core.surfaces import *; print(sorted(ISSUABLE_SURFACES), sorted(LABS_SURFACES), sorted(LABS_SIGNUP_SURFACES), sorted(API_ONLY_SURFACES), SURFACES['primapvs'].external, webauthn_rp_ids())" prints ['labs', 'prima_chat', 'prima_dev', 'prima_web'] ['labs'] ['labs', 'prima_chat', 'prima_dev', 'prima_web'] ['prima_chat', 'prima_dev', 'prima_web'] True ('<the Labs host>', 'prima.li').
  3. python -c "from api.surface_gate import SurfaceGate; SurfaceGate([])" raises ValueError with A surface gate must allow at least one surface.; python -c "from api.surface_gate import SurfaceGate; SurfaceGate(['primapvs'])" raises SurfaceNotIssuable; python -c "from api.surface_gate import surface_exempt; surface_exempt('because')" raises ValueError.
  4. grep -n "include_router(" app_fastapi.py | grep -v "^[0-9]*:#" | wc -l prints 0 (the one match at fd8558f, line 831, is a commented-out mount; the only live app.include_router is inside mount_router, api/surface_gate.py line 274); grep -c "mount_router(" app_fastapi.py prints 93; tail -n 13 app_fastapi.py | grep -c assert_every_route_gated prints 1 (the assertion is line 1234 of 1246; a new service prints its own mount count and the same 1).
  5. grep -rln "user_surface_grants" --include='*.py' . | grep -v "tests/\|migrations/" prints nine files at fd8558f, and every hit outside services/surface_service.py (line 26, _TABLE) and models/user_surface_grant_model.py (line 40, __tablename__) is a comment or a docstring; grep -rn "FROM {_TABLE}\|INTO {_TABLE}" --include='*.py' . | grep -v tests/ | wc -l prints 3, all in services/surface_service.py (test_the_grants_table_is_read_in_exactly_one_module is the authoritative form).
  6. Boot with a router mounted through raw app.include_router (or run test_a_router_mounted_without_a_gate_fails_the_boot_assertion): the process exits with RuntimeError beginning ADR-0020 §1: these routes were registered without a surface decision. and listing the route; a normal boot logs ADR-0020 surface gate: all <N> routes carry a surface decision.
  7. With a labs session $T: curl -s -H "Authorization: Bearer $T" $B/api/auth/me returns "surfaces":[...] containing "labs"; DELETE FROM user_surface_grants WHERE user_id = <id> AND surface = 'labs' and repeat: 403 "This account is not permitted on this surface."; restore the row and the same token is 200 again with no new login.
  8. With a prima_web session (create_session(..., surface="prima_web") in a shell for a user with that grant): curl -s -i -H "Authorization: Bearer $T" $B/api/workspaces/ is 403 "This session belongs to the 'prima_web' surface and cannot be used here."; the same token on $B/api/auth/me is 200 (an ALL_SURFACES router); the same token on a public mount such as $B/health (health_router.py 16, mounted at app_fastapi.py 819) is 200 (the credential is checked and accepted, because prima_web is granted).
  9. Delete that user's prima_web grant and repeat the public call: 403 "This account is not permitted on this surface." (the audit B1 behaviour: a public router still checks a presented credential). With no Authorization header at all the same path is 200.
  10. curl -s -i -H "Authorization: Bearer not-a-token" $B/api/workspaces/ is 401 "Could not validate credentials", not 403 (an undecodable token is the endpoint's decision, and the gate leaks nothing); curl -s -i -H "X-API-Key: not-a-key" $B/api/workspaces/ is the same 401.
  11. Stop the database (or REVOKE SELECT ON user_surface_grants FROM <app role>) and repeat step 7's first call: 503 "Access control is temporarily unavailable."; restore and it is 200.
  12. On a route that uses resolve_surface (B7's POST /api/auth/webauthn/login/begin-discoverable, webauthn_router.py 232 to 242, which takes an optional body): curl -s -i -X POST $B/api/auth/webauthn/login/begin-discoverable -H 'Origin: https://prima.li' -H 'X-Alchemy-Surface: prima_chat' is 400 "Requested surface does not match the request origin."; with X-Alchemy-Surface: primapvs and no Origin it is 400 "That surface is external; Labs does not issue sessions for it."; with X-Alchemy-Surface: nowhere it is 400 beginning "Unknown surface. Expected one of: labs, prima_web, prima_chat, prima_dev". The magic link uses _requested_surface and answers 400 "Unknown surface 'nowhere'" for the explicit case and 202 for a conflicting Origin (the two-resolver deviation).
  13. INSERT INTO user_surface_grants (user_id, surface, granted_at) VALUES (<uid>, 'primapvs', now()) fails on ck_usg_surface; INSERT ... VALUES (<uid>, 'labs', now()) twice is one row (the second is a primary-key violation at the SQL level, ON CONFLICT DO NOTHING through grant_surfaces).
  14. python -c "from core.surfaces import SURFACES; [print(s.id, s.rp_id, s.rp_name, s.origins) for s in SURFACES.values() if not s.external]" prints four lines with no AttributeError, the three Prima lines sharing prima.li and Prima, and each origins tuple starting with the surface's own origin.

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.