Prima Get an API key

Module kit / Identity and auth

API-only self-serve signup

identity-auth/api-only-self-serve-signup

Kind backend
Domain identity-auth
Maturity prod
Reusable extract
Stack fastapi, sqlalchemy-sync, postgres, redis, smtp
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/outbox-pattern, _shared/error-envelope, _shared/alembic-migrations, _shared/scheduler-and-background-work, _shared/test-layout
Frontends identity-auth/ui/developer-portal-auth-pages
Reference implementation
  • alchemy_labs/backend/api/routers/primaapi_router.py
  • alchemy_labs/backend/services/primaapi/identity.py
  • alchemy_labs/backend/models/prima_signup_intent_model.py
  • alchemy_labs/backend/migrations/versions/20260802_0002_surface_grants.py
  • alchemy_labs/backend/services/email_service.py
  • alchemy_labs/backend/core/surfaces.py
  • alchemy_labs/backend/core/config.py
  • prima/prima/api/v1.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • prima-developer w6-konto c594dfc 2026-09-12
  • prima feature/per-model-pricing cd2c248 2026-09-12
  • philosophers_stone main 261be0e 2026-09-12
Version 3

Purpose

A developer types an email address on the portal, follows the link that arrives, and holds a working prima_pk_ key for a workspace of their own within a minute, with no password, no Labs UI and no human in the loop. The invariant that makes it trustworthy is that nothing exists until the mailbox is proven: step one writes an intent row with a hashed token and answers the same 202 whether or not the address is taken, and step two burns that token and creates the user, the workspace, its membership and settings, the ai-scoped key, the three Prima surface grants, the welcome-credit obligation and the key-sync obligation in one transaction, so the account can never exist without its grants, its key can never exist without the row that tells Prima about it, and an unauthenticated caller can never learn who has an account or squat an address someone else owns.

When to use

Read it before touching either /accounts route, before adding a field to the signup body, before changing what an API-only account may reach, and before diagnosing "I signed up and my key 401s" or "the confirmation mail never came". Trigger phrases: "sign up on the portal", "prima_pk_ at signup", "verification_sent", "the link is valid for 30 minutes", "invite only", "allowed domains", "api_only role", "why can this account not log in to Labs", "welcome Sparks".

Not this recipe: registration for the Labs SPA with a password and the verification_required flow (identity-auth/registration-with-email-verification, B1); minting more keys after signup (identity-auth/developer-api-keys-and-security-policy, B10, and the portal's keys page F8); how the minted key reaches Prima's table (identity-auth/key-sync-outbox-labs-to-prima, B12, S11); the welcome grant's own ledger (the billing-credits domain, D2); signing an API-only account in to a Prima surface afterwards (identity-auth/magic-link-login, B5, with surface: prima_chat or prima_dev). Which doors the grants open is identity-auth/surface-registry-and-gate (B4) and S5.

Stack assumptions

Labs at fd8558f: fastapi==0.118.2 with plain def routes on a synchronous Session (get_db), pydantic EmailStr on the body, Postgres, Redis for the hourly caps with an in-memory fallback (services/rate_limit_service.py), and the mail transport selected by EMAIL_TRANSPORT (services/email_service.py lines 44 to 76). Foundations assumed: S4 (the router is mounted once, app_fastapi.py line 892, on ALL_SURFACES because a Labs human may also use the gateway; the /models, /chat/completions and every other route on primaapi_router besides the two /accounts routes are the legacy in-Labs gateway copy, D3: the product gateway is standalone Prima, and only /accounts and /accounts/verify reach Labs behind the ingress), S5 (user_surface_grants decides everything; create_session is the choke point a later magic link goes through), S6 (the workspace is the tenant every gateway call bills to), S11 (two outbox rows are written inside the signup transaction), S7 (this router's refusals render in the OpenAI-shaped envelope, the codes below are HTTPException statuses).

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

NameDefaultBehaviour
PRIMA_SIGNUP_INVITE_ONLYFalseTrue closes both steps with 403; an already-mailed link stops working (line 696)
PRIMA_SIGNUP_ALLOWED_DOMAINSemptycomma-separated domain allowlist; non-empty refuses other domains with 403 at both steps (697)
PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR5hourly cap on step one per client IP, shared across workers through Redis (698)
PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR3hourly cap on step one per normalised address (699)
PRIMA_SELF_SERVE_KEY_MAX_USESNonemax_uses stamped on the minted key; None is unlimited, enforced by the key security service on every key authentication (700)
RESEND_API_KEYunsetthe gate on sending the confirmation mail at step one (primaapi_router.py line 681; Gotchas: it is the wrong gate under the SMTP transport)
ENVIRONMENTdevelopmentin development the confirmation URL is logged at INFO instead of mailed (685 to 686)
FRONTEND_URLhttp://localhost:3000the fallback origin for the link when the prima_dev surface declares none (678)
PRIMA_KEY_SYNC_ISSUERunsetread by the outbox's _issuer() (prima_key_sync.py 322 to 338) with JWT_ISSUER as the fallback, whose default is https://labs.alchemy.zuerich (core/config.py 113 to 116); the enqueue raises inside the transaction only when both are empty, so on a default configuration signup never fails here and the outbox row carries JWT_ISSUER (Gotchas: the setting's own description at 712 claims a raise the code does not perform)
PRIMA_KEY_SYNC_SECRET, PRIMA_INTERNAL_BASE_URLunset, unsetread by the outbox's deliver (372 to 384), after the commit; either unset records SyncNotConfigured on the prima_key_syncs row and leaves it pending, step two still answers 201, and the key 401s on Prima until the drain delivers (B12)
TRUSTED_PROXY_COUNT1which X-Forwarded-For hop is the client IP for the cap (utils/net.py 29 to 45)

Data model

The intent table (models/prima_signup_intent_model.py lines 27 to 41; migration 20260802_0002_surface_grants.py step 2b, lines 82 to 100, revision surface_grants_001):

prima_signup_intents (
  id          uuid PRIMARY KEY,
  email       varchar(255) NOT NULL,                -- lower-cased, stripped
  company     varchar(200) NULL,
  token_hash  varchar(64) NOT NULL UNIQUE,          -- sha256 hex of the raw token; never the token
  expires_at  timestamp NOT NULL,                   -- created_at + 30 min, naive UTC
  consumed_at timestamp NULL,                       -- set inside the step-two transaction
  created_at  timestamp NOT NULL DEFAULT now()
);
CREATE INDEX ix_prima_signup_intents_email         ON prima_signup_intents(email);
CREATE INDEX ix_prima_signup_intents_token_hash    ON prima_signup_intents(token_hash);
CREATE INDEX ix_prima_signup_intents_email_expires ON prima_signup_intents(email, expires_at);

What step two writes, in one transaction (services/primaapi/identity.py create_api_only_account, lines 239 to 398; the row shapes are owned by their own recipes and named here so the transaction is countable):

users                (password_hash NULL, is_active true, is_verified true, role 'api_only',
                      company from the intent; ck_users_role admits 'api_only')            -- lines 274 to 284
workspaces           (name '<company or username>''s Workspace', slug 'ws-' + 8 hex,
                      owner_id = the user)                                                  -- 401 to 420
workspace_users      (role 'owner', is_active true)
workspace_settings   ({"timezone": "Europe/Zurich", "currency": "CHF"})
api_keys             (key_hash sha256, key_prefix first 12 chars + '...', permissions {"scopes": ["ai"]},
                      workspace_id = the workspace, max_uses from config, is_active true)  -- 298 to 308
user_surface_grants  (one row each for prima_web, prima_chat, prima_dev; never labs)      -- 311 to 319
sparks_grants        (the welcome obligation, WELCOME_SPARKS = 5000 at core/sparks_config.py 103,
                      idempotent by a deterministic provider_event_id)                     -- 321 to 329
prima_key_syncs      (the outbox row that carries the key to Prima; enqueue_mint raises rather than
                      skip when it cannot establish the tenant or the issuer)                    -- 331 to 358

Eight tables at fd8558f. The inventory row B14 (wiki operations/prima-modules-inventory.md 261be0e, line 74) says "six tables in one transaction incl. welcome grant + key-sync row", which is internally inconsistent: six is the count of prima/api/v1.py 16 to 19 at cd2c248 (users, workspaces, workspace_users, workspace_settings, api_keys, user_surface_grants), written before the welcome grant and the key-sync row (the fix recorded at lines 331 to 346: every account created before it "held a key that 401s") were added inside the same transaction. Measured: six user, workspace, key and grant tables plus two obligation rows.

What is NOT stored: the raw token (hashed at write, _hash_signup_token, primaapi_router.py 546 to 549, and the link is the only carrier); the raw key (returned once in the 201 body, identity.py 389 to 398); a password (the column is NULL and test_password_reset_cannot_give_a_passwordless_account_a_password keeps it so, tests/test_surface_scoped_access.py line 530); a labs grant (API_ONLY_SURFACES = PRIMA_SURFACES, core/surfaces.py 174 and 189); the requesting surface (the portal sends a surface field the SignupRequest model does not declare, so it is dropped, prima-developer src/lib/auth.ts line 433 at c594dfc against primaapi_router.py 531 to 533).

API surface

Both routes live on primaapi_router (prefix="/api/prima/v1" at line 48; the mount adds none of its own), surfaces ALL_SURFACES, no auth dependency, the OpenAI-shaped envelope on refusal (S7). They are the only two routes of that router that are the product behind the ingress; the rest of the file is the legacy in-Labs gateway copy (D3):

RouteAuth depTenancyCodesNotes
POST /api/prima/v1/accountsnone; Depends(auth_rate_limit) then Depends(_signup_abuse_guard) (637 to 640)none yet202 {"status": "verification_sent", "message": ..., "expires_in_minutes": 30} always, taken or not; 422 on a body EmailStr rejects; 403 "Self-service signup is disabled on this deployment. Contact support for an account." or "Signup for this email domain is not permitted on this deployment."; 429 with Retry-After from the burst limiter (auth_rate_limit: 30 per 60 s per IP per path, key rate_limit:auth:/api/prima/v1/accounts:<ip>, detail "Too many authentication attempts. Please try again later.") or from the hourly caps (5 per hour per IP, 3 per hour per email, detail "Too many signup attempts. Please try again later."); no per-email burst limit exists on this routebody {email, company?}; the 202 is the same for a taken address on purpose (docstring 645 to 655)
POST /api/prima/v1/accounts/verifynone; Depends(auth_rate_limit) (699 to 702)the new workspace, in the response201 {user_id, surfaces: ["prima_chat", "prima_dev", "prima_web"], email, tenant_id, tenant_name, api_key, key_prefix, scopes: ["ai"]}; 400 "Invalid or expired confirmation token" for unknown, consumed or expired; 403 the two gate strings, re-checked against the intent's address; 409 "An account with this email already exists" (the ValueError from create_api_only_account, line 263); 422 on a token outside 16 to 256 charactersbody {token}; the raw key appears here once and nowhere again

Ingress (wiki platform/primaapi.md 261be0e, "Open items", not measured on a host in this pass): /api/prima/v1/* is routed to standalone Prima, with /api/prima/v1/accounts and /accounts/verify matched first and sent to Labs, because Prima has no accounts endpoint (prima/api/v1.py documents the conservative option, signup stays in Labs; B13 records the open question of a second issuer). "Getting that order wrong breaks signup silently."

Application logic

Step one, signup (primaapi_router.py 637 to 694), in order:

  1. auth_rate_limit (S5's burst limiter) refuses with 429 before anything reads the body.
  2. _signup_abuse_guard (585 to 632) reads the memoised body, lower-cases the address, and when the body carried an address runs _enforce_signup_gate(email) (the if "@" in email at 607 to 608; the gate itself is 552 to 581: PRIMA_SIGNUP_INVITE_ONLY then the domain allowlist, 403 either way), so a parseable body whose address is malformed falls through to the endpoint's 422 rather than the gate's 403. It then asks rate_limit_service.check_simple_limit for the per-IP bucket (rate_limit:prima_signup:ip:<ip>, PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR, window 3600 s) and, when the body carried an address, the per-email bucket (rate_limit:prima_signup:email:<email>, PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR); a refusal is 429 with Retry-After and a log line naming the bucket and the IP, never the address (620 to 631). A body the guard cannot parse is left to the endpoint's 422.
  3. The handler generates secrets.token_urlsafe(32), inserts the intent with token_hash = sha256(raw), expires_at = utcnow + 30 min (_SIGNUP_INTENT_TTL_MINUTES, line 540, used at 673), and commits (668 to 676).
  4. The link is <origin>/signup/confirm?token=<raw> with origin = get_surface(PRIMA_DEV).origin, falling back to FRONTEND_URL (678 to 679). With RESEND_API_KEY set the mail goes out as a BackgroundTask through email_service.send_prima_signup_verification (services/email_service.py 617 to 642: subject "Confirm your Prima API account", the 30-minute sentence, and "no account has been created" for the uninvited recipient); in development the URL is logged; otherwise nothing is sent (Gotchas).
  5. 202 with the fixed body. No users row exists (test_signup_step_one_creates_no_account).

Step two, signup_verify (698 to 748):

  1. auth_rate_limit again, so the token cannot be guessed at line rate (the comment at 702).
  2. SELECT ... FROM prima_signup_intents WHERE token_hash = sha256(body.token) AND consumed_at IS NULL AND expires_at > now() FOR UPDATE (714 to 722); no row is 400. The row lock is what makes a double submit serialise.
  3. _enforce_signup_gate(intent.email) again (735): a lockdown flipped between the two steps wins over an already-mailed link, and on this refusal the token is not burned, so a configuration flap does not strand a legitimate signup.
  4. intent.consumed_at = now() and flush() (739 to 740), before the account is created and inside the same transaction, so the second of two concurrent submits finds consumed_at set once the lock releases and answers 400 (test_prima_signup_verification_token_is_single_use).
  5. create_api_only_account(db, email, company) (identity.py 239 to 398): duplicate email is ValueError (263), rendered 409 after db.rollback() (745 to 746); username derived from the local part with . and + replaced by _ and a numeric suffix up to 100 tries (265 to 272); the user row flushed; _create_personal_workspace (401 to 420) writes the workspace, the owner membership and the settings row; the key row with exactly ["ai"] and the configured max_uses; grant_surfaces(db, user.id, API_ONLY_SURFACES) (319); sparks_grants.enqueue_welcome(db, tenant_id=ws_id) (329); prima_key_sync.enqueue_mint(db, api_key) with no issuer keyword (358; the row's issuer is _issuer(), prima_key_sync.py 202 and 322 to 338: PRIMA_KEY_SYNC_ISSUER, else JWT_ISSUER, a ValueError only when both are empty; passing a keyword replaces that and a value Prima does not federate parks the row stuck); db.commit() (360).
  6. After the commit, two inline deliveries that may fail without failing signup: prima_key_sync.deliver_for_key (368 to 375; a failure is logged and the S11 drain retries; with PRIMA_KEY_SYNC_SECRET or PRIMA_INTERNAL_BASE_URL unset, deliver records SyncNotConfigured and returns False, prima_key_sync.py 372 to 384, so the row stays pending) and sparks_grants.deliver_for_grant (381 to 387).
  7. 201 with the raw key. The portal renders it into one node with the first curl (F7).

Invariants

  • Step one creates nothing and answers alike: test_signup_step_one_creates_no_account (tests/test_primaapi_gateway.py line 286) and test_public_prima_signup_no_longer_creates_a_users_row (tests/test_surface_scoped_access.py line 579) both assert no users row after a 202 and an intent row present.
  • The token of record is its hash; the intent table has no token column (token_hash only, model line 34), and the same design is pinned for the desk sign-in intents by test_signin_intents_carry_no_token_column (tests/test_desk_domain_login_schema.py line 392).
  • One token, one account: the burn happens before the create, inside the transaction, under FOR UPDATE (test_prima_signup_verification_token_is_single_use, line 597; test_duplicate_verified_signup_is_409, gateway tests line 308).
  • The gate is enforced at both steps: test_allowlist_flip_voids_already_mailed_token_at_verify (line 400), test_invite_only_closes_signup (391), test_allowlist_blocks_other_domains_when_set (373).
  • The account holds prima_web, prima_chat, prima_dev and never labs: test_api_only_account_holds_prima_grants_and_never_labs (line 308), test_minted_key_is_ai_scope_only_never_labs (tests/test_primaapi_identity.py line 181), and downstream test_api_only_session_is_denied_on_every_labs_gated_route (354) walks the real route table.
  • The key is ai-only, hash-stored and bound to the new workspace; the raw key hashes to api_keys.key_hash and Labs' legacy gateway dependency accepts it (test_signup_key_hash_actually_authenticates, 162, exercises _authenticate_ai_scoped_key in Labs, not Prima's prima_api_keys lookup; test_signup_creates_user_workspace_and_key, 135; test_open_by_default_and_verified_key_is_ai_scoped_only, gateway 421). Prima accepts the key only after the prima_key_syncs row delivers (B12, D3).
  • The outbox rows are in the transaction and the key-sync enqueue raises rather than skipping on the two refusals it can detect: a gateway key whose workspace_id is NULL (prima_key_sync.py 155 to 162) and an issuer that is empty in both PRIMA_KEY_SYNC_ISSUER and JWT_ISSUER (332 to 337). An account therefore cannot be committed without its key-sync and welcome obligations, and a failed or unconfigured inline delivery never fails the signup.
  • A wildcard among the key's scopes is the deliberate exception to that rule: it is logged at ERROR and enqueued anyway (prima_key_sync.py 164 to 177, the comment gives the reason), so Prima refuses the row with a 400 and it parks as stuck where the stranded-key query finds it, rather than a key that silently never syncs. That is the design B12 records; signup's own key is exactly ["ai"] (test_minted_key_is_ai_scope_only_never_labs), so no route at fd8558f reaches the branch.
  • The account cannot be given a password by the reset flow (test_password_reset_cannot_give_a_passwordless_account_a_password, 530) or relabelled by Spark provisioning (test_spark_refuses_to_relabel_an_api_only_account, 622) or by the admin role endpoint (B16, admin_router.py 989 to 1000).
  • A Labs magic link for this account is refused, a Prima one works and stays on Prima (test_magic_link_for_labs_is_refused_for_api_only, 471; test_magic_link_for_prima_works_and_stays_on_prima, 480).

Frontend

The module that renders this, by id:

identity-auth/ui/developer-portal-auth-pages   F7, portal (Astro, static): /registrieren posts step one and shows
                                               "if this address can be registered, a link is on its way"
                                               (src/lib/auth.ts signUp, lines 415 to 460 at c594dfc);
                                               /signup/confirm reads ?token= (also from the hash), redeems at most
                                               once per page load, renders the key into one node with the first
                                               curl, and maps 409 to "a login instead" and 400 or 404 to
                                               "expired, used or invalid" (verifySignup, lines 487 to 542, the 409 and 400/404 branches at 509 to 520;
                                               src/pages/signup/confirm.astro 160 to 215)

Minimum admin and debug surface: SELECT email, created_at, expires_at, consumed_at FROM prima_signup_intents ORDER BY created_at DESC LIMIT 20 (a row with consumed_at NULL past expires_at is a link that was never followed; NULL inside the window with no mail is the transport gotcha); the prima_key_syncs row for the new key (S11's admin table, F30, planned) as the answer to "my key 401s"; the log line PrimaAPI signup rate limit exceeded (per-<ip|email>) for IP <ip>; in development the INFO line [DEV] PrimaAPI signup confirmation for <email>: <url>.

Security & tenancy

Auth dependency: none on either route by design; the credential is the mailbox. Workspace derivation: none inbound; the workspace is created and returned as tenant_id, and every later gateway call bills to it through the key's workspace_id (S6, "the gateway with a key").

Fail direction: the burst limiter and the hourly caps fail open on a Redis error (rate_limit_service.py 358 to 361, the in-memory fallback per process otherwise, so N workers means N times the cap); the gate fails closed (a config value it cannot read is a boot failure, not a bypass); the intent lookup fails closed (no row, 400); the outbox enqueue fails closed on the two refusals it can detect (prima_key_sync.py 155 to 162 and 322 to 338: a gateway key whose workspace_id is NULL, and an issuer that is empty in both PRIMA_KEY_SYNC_ISSUER and JWT_ISSUER, the raise at 332 to 337; either rolls the transaction back, and on a default configuration JWT_ISSUER is set so nothing raises), while a wildcard scope is the one refusal it deliberately lets through: logged at ERROR and enqueued anyway (164 to 177) so Prima's 400 parks the row as stuck instead of a key that silently never syncs (B12); the inline deliveries fail open by design because the obligation is already durable, and an unset PRIMA_KEY_SYNC_SECRET or PRIMA_INTERNAL_BASE_URL is one of those open failures (SyncNotConfigured, the row stays pending, the key 401s on Prima until it is delivered).

Credentials at rest: the token as SHA-256 (single use, 30 minutes); the key as SHA-256 in api_keys.key_hash; no password. The raw key crosses the wire once in the 201 body over TLS and is rendered once by the portal (F7, INVARIANT B: never persisted client-side).

Rate limits, stacked: 30 per 60 s per IP per path from auth_rate_limit (api/dependencies/rate_limit.py 38 to 60; per IP only, the 10 per minute per-email ceiling in that file is enforce_email_rate_limit, 63 to 90, which neither signup route calls), then 5 per hour per IP and 3 per hour per email from _signup_abuse_guard on step one (defaults, core/config.py 698 to 699; the hourly cap is the only per-email control at signup), and auth_rate_limit alone on step two, where the 256-bit token makes guessing a non-issue and the limiter is against line-rate probing of expired links.

Attacker-controlled inputs: the address (validated by EmailStr, lower-cased, never logged on refusal, used only as a bucket key and an intent column); company (200 characters, copied into users.company and the workspace name; it is display text, not an identifier); the token (16 to 256 characters, hashed before the lookup, compared by index); the X-Forwarded-For chain (TRUSTED_PROXY_COUNT decides the hop); the request body at the guard (parsed defensively, 597 to 603). What the two steps deliberately leak: step one nothing; step two, after mailbox proof, the fact that the address already has an account (409), which the mailbox owner is entitled to know.

Tests

On the test Postgres of S9, all at fd8558f, run 2026-09-12:

  • tests/test_primaapi_identity.py and tests/test_primaapi_gateway.py together: 32 passed in 6.32s. The signup cases: TestApiOnlySignup::test_signup_creates_user_workspace_and_key (135), test_signup_rejects_duplicate_email (155), test_signup_key_hash_actually_authenticates (162), test_minted_key_is_ai_scope_only_never_labs (181), test_self_serve_key_gets_default_usage_cap_when_configured (199); TestSignupEndpointAgainstRealDb::test_signup_step_one_creates_no_account (286), test_verified_signup_returns_key_once (297: api_key starts with prima_pk_, scopes == ["ai"], surfaces == ["prima_chat", "prima_dev", "prima_web"]), test_duplicate_verified_signup_is_409 (308); TestSignupAbuseControls::test_per_ip_hourly_cap_returns_429 (341), test_per_email_hourly_cap_returns_429 (357), test_allowlist_blocks_other_domains_when_set (373), test_invite_only_closes_signup (391), test_allowlist_flip_voids_already_mailed_token_at_verify (400), test_open_by_default_and_verified_key_is_ai_scoped_only (421). The abuse-control class clears the in-memory limiter singleton around every test (330 to 339), which is what makes the caps testable without Redis. Harness trait (measured 2026-09-12): a first run of this pair against a cold test database can end in errors at fixture DDL setup (IntegrityError; one such run printed 18 passed, 14 errors) and the rerun prints 32 passed; run it again before believing a red result, as magic-link-login.md says for the same fixture.
  • tests/test_surface_scoped_access.py -k "signup or api_only": 19 passed, 25 deselected in 6.02s. The api_only_account fixture is create_api_only_account itself, so every one of those 19 starts from this recipe's transaction.
  • tests/sparks/test_sparks_grants_db.py: test_welcome_grant_is_created_once (134), test_welcome_grant_called_twice_for_the_same_tenant_is_a_single_row (151), test_welcome_and_allowance_for_the_same_tenant_do_not_collide (167); skipped on this run (3 skipped, reason SPARKS_TEST_DATABASE_URL is not set (see conftest.py)): tests/sparks/conftest.py (1 to 15, 40 to 49) builds its own engine from that variable against a separately migrated scratch database and never touches the shared :5434 fixture; setting the variable is the way to run them. Measured state, not a pass.
  • Known-answer vectors: the 202 body is exactly {"status": "verification_sent", "message": "If this address can be registered, a confirmation link has been sent. The account and its API key are created when you follow it.", "expires_in_minutes": 30}; the 201 surfaces list is exactly ["prima_chat", "prima_dev", "prima_web"] (sorted); the key prefix is prima_pk_; key_prefix is the first twelve characters plus three dots; the intent's token_hash equals hashlib.sha256(raw.encode()).hexdigest() of the query parameter in the mailed link.
  • Hermetic versus live: everything above is hermetic (the mail is gated off by the unset key and the deliveries are caught). Playwright gate: none in Labs for this flow; F7's spec covers the portal walk from /registrieren to the rendered key against a stubbed API.

Deploy & ops

Config keys per app heredoc (S13): PRIMA_SIGNUP_INVITE_ONLY=true and PRIMA_SIGNUP_ALLOWED_DOMAINS on a staging sandbox, both unset on the customer host; PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR, PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR, PRIMA_SELF_SERVE_KEY_MAX_USES as decided (defaults 5, 3, unlimited); PRIMA_KEY_SYNC_ISSUER (B12; falls back to JWT_ISSUER, so unset is a row filed under the Labs origin rather than a refusal) and PRIMA_KEY_SYNC_SECRET with PRIMA_INTERNAL_BASE_URL (B12; unset means step two still answers 201 and every prima_key_syncs row stays pending as SyncNotConfigured, so every new key 401s on Prima); the mail transport keys (EMAIL_TRANSPORT, the SMTP triple or RESEND_API_KEY) with the gotcha below in mind; TRUSTED_PROXY_COUNT=1 behind the single ingress.

Migrations order: surface_grants_001 (revises primaapi_v1_001) creates user_surface_grants, prima_signup_intents, the ck_users_role CHECK admitting api_only, and backfills every existing account's grants without labs for role='api_only'; the prima_key_syncs and sparks_grants tables come from their own revisions (B12, billing-credits) and must be at head before step two is reachable, which verify_schema_at_head() guarantees at boot (S8).

Scheduler jobs: the S11 drains for prima_key_syncs and sparks_grants pick up whatever the inline deliveries did not; none owned here. Intent rows are never purged at fd8558f (Gotchas).

Argus checks: POST /api/prima/v1/accounts with a throwaway address on the staging sandbox returns 403 when PRIMA_SIGNUP_INVITE_ONLY is on (the lockdown is live), and on the customer host 202 with expires_in_minutes: 30; the count of prima_key_syncs rows in stuck stays at zero after a signup. Rollback: configuration only; the routes have no versioned behaviour. Ingress order (accounts first to Labs, the rest to Prima) is part of the deploy, not of the code.

Gotchas

  • 2026-09-12 (measured at fd8558f): signup gates the confirmation mail on _settings.RESEND_API_KEY (primaapi_router.py line 681), while the transport rule since ADR-0023 is transport_is_configured() (services/email_service.py 64 to 76, and auth_router.py 325 and 1096 say in as many words not to gate on the Resend key). On a host that sends through SMTP with no Resend key, step one writes the intent, answers 202, and sends nothing; the caller waits for a mail that was never queued and nothing logs it (the development branch logs only under ENVIRONMENT == "development"). D8: the fix is if transport_is_configured(): at line 681, with the same background task; a recipe rebuild gates on the selected transport from the start.
  • 2026-09-12 (measured at fd8558f and wiki 261be0e): the inventory row B14 says "six tables in one transaction incl. welcome grant + key-sync row", a count that is inconsistent with its own list; six is the list in prima/api/v1.py 16 to 19 (cd2c248), and the transaction is eight at this commit because the welcome grant (identity.py 321 to 329) and the key-sync outbox row (331 to 358) were added inside it after that list was written. The second one is the fix for "every account created through this function held a key that 401s" (the comment at 333 to 340); before it, the only way to a working key was a second mint through POST /api/developer/keys.
  • 2026-09-12 (measured at fd8558f): the 409 path calls db.rollback() (745), which also rolls back the consumed_at write at 739, so a token whose address is taken stays redeemable until it expires and answers 409 every time. Harmless (the answer is the same) and worth knowing when reading the table.
  • 2026-09-12 (measured at fd8558f): nothing deletes prima_signup_intents; expired and consumed rows stay. A daily S12 job deleting rows with expires_at < now() - interval '7 days' is the recipe's rule; the (email, expires_at) index is already there for it.
  • 2026-09-12 (measured at fd8558f and c594dfc): the portal posts surface: "prima_dev" in the step-one body (src/lib/auth.ts 430 to 434); SignupRequest (531 to 533) declares email and company only, and the field is dropped. The link origin comes from the surface registry, not from the body, which is the right side to trust; the portal field is dead.
  • 2026-09-12 (measured at fd8558f): the hourly caps live in rate_limit_service, Redis when present and a per-process dict otherwise (fail open on a Redis error, 358 to 361). Under four workers without Redis the effective cap is four times the configured one (B18 records the same for every per-process limiter).
  • 2026-09-12 (measured at fd8558f): expires_at and consumed_at are naive utcnow() timestamps compared against datetime.utcnow() (673, 720, 739); consistent with each other and with the intent model, and different from the desk sign-in intents, which are timezone-aware (B20). A rebuild uses timestamptz and now() on both.
  • 2026-09-12 (measured at fd8558f, D8): core/config.py line 712 describes PRIMA_KEY_SYNC_ISSUER as "Unset = every mint raises rather than queuing a key that can only ever be stuck", and _issuer() (prima_key_sync.py 322 to 338) does not do that: it falls back to JWT_ISSUER, whose default https://labs.alchemy.zuerich (113 to 116) is always set, so an unset issuer queues a row under the Labs origin and nothing raises. Harmless where Prima federates that origin (the value the description says the two must share) and a stuck row on a host where Prima federates a different one. The fix is one of two: an explicit raise in _issuer() when PRIMA_KEY_SYNC_ISSUER itself is empty, or a corrected description naming the fallback. No code changed in this wave.
  • 2026-09-12 (wiki 261be0e, platform/primaapi.md): the ingress sends /api/prima/v1/accounts and /accounts/verify to Labs and everything else under /api/prima/v1/ to standalone Prima, so a signup that "silently breaks" after an ingress edit is the first thing to check; Prima has no accounts route and answers its route-miss envelope (S7, code: null).
  • 2026-08-02 (surface_grants_001, the migration docstring): before the grants table, role="api_only" was written and read by nothing, and an API-only account could obtain a full Labs session through a magic link and became a workspace owner on the way. The grant rows, written in the same transaction as the user row, are what makes "this account cannot log in to Labs" true; the role string and the missing password are labels (identity.py docstring 247 to 256).
  • 2026-08 (models/prima_signup_intent_model.py docstring, ADR-0020 section 1 prerequisite bugfix 5): POST /accounts used to create a users row from an arbitrary unauthenticated address, so anyone could squat an address (blocking the real owner's Labs registration on the duplicate check) and hold a key for a mailbox they did not own. The intent table is the fix; the always-alike 202 is its corollary.

Reference implementation

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

  • alchemy_labs (d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • backend/api/routers/primaapi_router.py: SignupRequest and SignupVerifyRequest (531 to 537), _SIGNUP_INTENT_TTL_MINUTES (540), _SIGNUP_RATE_WINDOW_SECONDS (543), _hash_signup_token (546 to 549), _enforce_signup_gate (552 to 581), _signup_abuse_guard (585 to 632), signup (636 to 694), signup_verify (698 to 748); the mount at app_fastapi.py 892 on ALL_SURFACES. The router's other routes (/models at 62 onward, the completions and usage routes) are the legacy in-Labs gateway copy (D3); the standalone prima repo is the reference for them.
    • backend/services/primaapi/identity.py: AI_SCOPE, GATEWAY_KEY_PREFIX (58 to 59), create_api_only_account (239 to 398), _create_personal_workspace (401 to 420).
    • backend/models/prima_signup_intent_model.py (27 to 41); backend/migrations/versions/20260802_0002_surface_grants.py (the intent table with its three indexes 82 to 100, the grants table with its index 52 to 70, the role normalisation and the ck_users_role CHECK 102 to 117, the backfill from 120).
    • backend/services/email_service.py: selected_transport (48 to 63), transport_is_configured (64 to 76), send_prima_signup_verification (617 to 642).
    • backend/core/surfaces.py: PRIMA_DEV (37), the prima_dev surface with its origin (151 to 157), PRIMA_SURFACES (174), LABS_SIGNUP_SURFACES (185), API_ONLY_SURFACES (189).
    • backend/core/config.py: the five PRIMA_SIGNUP_* and PRIMA_SELF_SERVE_KEY_MAX_USES settings (696 to 700), PRIMA_KEY_SYNC_ISSUER (712), PRIMA_KEY_SYNC_SECRET (719), PRIMA_INTERNAL_BASE_URL (752), JWT_ISSUER (113 to 116), RESEND_API_KEY (437), FRONTEND_URL (43).
    • backend/services/primaapi/prima_key_sync.py: enqueue_mint (134 to 237; the workspace_id raise 155 to 162, the wildcard that is logged at ERROR and enqueued anyway 164 to 177), _issuer (322 to 338, the raise 332 to 337), deliver (360 onward; the SyncNotConfigured branch 372 to 384), _fail (451 to 466), deliver_for_key (519 to 547); backend/services/commerce/sparks_grants.py: enqueue_welcome (253 to 291); backend/core/sparks_config.py WELCOME_SPARKS (103).
    • backend/services/rate_limit_service.py: check_simple_limit (from 415), the Redis path and its fail-open (318 to 361); backend/api/dependencies/rate_limit.py: auth_rate_limit (38 to 60), enforce_email_rate_limit (63 to 90, not used here); backend/utils/net.py get_client_ip (29 onward).
    • Tests as listed under Tests.
  • prima-developer (w6-konto at c594dfc, the worktree HEAD): src/lib/auth.ts (signUp 415 to 460, the step-one body 430 to 434, verifySignup 487 to 542), src/pages/registrieren.astro, src/pages/signup/confirm.astro (token extraction 160 to 168, the once-per-load redemption 178 to 215). The two portal pages are unchanged between d5f9aa8 and c594dfc; src/lib/auth.ts gained nine import lines and a block of new exports in that range (git diff --stat d5f9aa8 c594dfc, read 2026-09-12), so its line anchors are the ones at c594dfc and not at d5f9aa8.
  • Wiki philosophers_stone (261be0e): platform/primaapi.md ("Self-serve signup mints a key Prima cannot see", the ingress note under "Open items"), operations/decisions/0020-prima-identity-and-prepaid-credits.md (section 1), askedquestions/2026-08-21-prima-user-provisioning-and-spark-grants.md, operations/prima-modules-inventory.md (rows B14, F7). prima (feature/per-model-pricing at cd2c248): prima/api/v1.py 16 to 19, the six-table list the inventory count comes from.

Deviations of the reference from this recipe, all dated 2026-09-12: the mail gated on the Resend key instead of the selected transport; no intent purge; naive timestamps; the dead surface field from the portal; the caps' per-process fallback; the PRIMA_KEY_SYNC_ISSUER description that promises a raise _issuer() does not perform.

Acceptance checks

Run from alchemy_labs/backend at fd8558f with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.

  1. python -m pytest tests/test_primaapi_identity.py tests/test_primaapi_gateway.py -q --no-cov -o addopts="" prints 32 passed; python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -k "signup or api_only" prints 19 passed, 25 deselected.
  2. grep -n "token_hash\|token = Column" models/prima_signup_intent_model.py prints the token_hash column (line 34) and no token column; grep -c "with_for_update" api/routers/primaapi_router.py prints 1.
  3. grep -n "consumed_at = datetime.utcnow()" api/routers/primaapi_router.py prints line 739, and grep -n "create_api_only_account(db" api/routers/primaapi_router.py prints line 743 (the burn precedes the create).
  4. python -c "from core.surfaces import API_ONLY_SURFACES; print(sorted(API_ONLY_SURFACES))" prints ['prima_chat', 'prima_dev', 'prima_web']; grep -c "labs" <<< "$(python -c 'from core.surfaces import API_ONLY_SURFACES; print(API_ONLY_SURFACES)')" prints 0.
  5. grep -n "^ grant_surfaces(db\|_sparks_grants.enqueue_welcome(\|_prima_key_sync.enqueue_mint(" services/primaapi/identity.py prints exactly three lines, 319, 329 and 358, all above the db.commit() at line 360 (the looser pattern enqueue_mint also matches three comment lines, 337, 344 and 350).
  6. With the app running in development: curl -s -X POST http://127.0.0.1:8000/api/prima/v1/accounts -H 'content-type: application/json' -d '{"email":"[email protected]"}' returns 202 with "status":"verification_sent" and "expires_in_minutes":30; the same call with an address that already has an account returns byte-identical JSON; SELECT count(*) FROM users WHERE email = '[email protected]' prints 0; the log carries [DEV] PrimaAPI signup confirmation for [email protected]: <url> and the URL's path is /signup/confirm?token=.
  7. Take the token from that log line: curl -s -X POST http://127.0.0.1:8000/api/prima/v1/accounts/verify -H 'content-type: application/json' -d "{\"token\":\"$T\"}" returns 201 with "scopes":["ai"], "surfaces":["prima_chat","prima_dev","prima_web"] and an api_key beginning prima_pk_; repeating the same call returns 400 with "Invalid or expired confirmation token"; SELECT surface FROM user_surface_grants WHERE user_id = '<the user_id from the body>' ORDER BY surface prints the same three rows and no labs; SELECT count(*) FROM prima_key_syncs WHERE api_key_id = (SELECT id FROM api_keys WHERE key_prefix = '<the key_prefix from the body>') prints 1.
  8. On the legacy in-Labs gateway copy in development (D3): curl -s -H "X-API-Key: $KEY" http://127.0.0.1:8000/api/prima/v1/models (the key from check 7) returns 200, which proves only that the raw key hashes to Labs' api_keys.key_hash; curl -s -H "X-API-Key: $KEY" http://127.0.0.1:8000/api/forms/templates returns 403 naming the ai agent API prefix (S5's prefix lock). The product proof is on Prima: with PRIMA_KEY_SYNC_SECRET and PRIMA_INTERNAL_BASE_URL set and a Prima at :8090, SELECT status FROM prima_key_syncs WHERE api_key_id = (SELECT id FROM api_keys WHERE key_prefix = '<the key_prefix>') prints synced and the same curl against http://127.0.0.1:8090/api/prima/v1/models returns 200; with either variable unset the row's status prints pending and last_error prints SyncNotConfigured, and Prima answers 401.
  9. Restart with PRIMA_SIGNUP_INVITE_ONLY=true: check 6's first call returns 403 with "Self-service signup is disabled on this deployment. Contact support for an account.", and a token mailed before the restart returns the same 403 at step two while its consumed_at stays NULL.
  10. Six POST /accounts calls from one IP inside an hour with six different addresses: the sixth returns 429 with a Retry-After header; four calls for the same address: the fourth returns 429.
  11. grep -n "RESEND_API_KEY" api/routers/primaapi_router.py prints line 681 at fd8558f (the gotcha is reproducible by reading); it prints nothing once the gate is transport_is_configured() and this recipe's version is bumped with the date.
  12. npm run check in this repo prints no line for this recipe.

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.