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 |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
PRIMA_SIGNUP_INVITE_ONLY | False | True closes both steps with 403; an already-mailed link stops working (line 696) |
PRIMA_SIGNUP_ALLOWED_DOMAINS | empty | comma-separated domain allowlist; non-empty refuses other domains with 403 at both steps (697) |
PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR | 5 | hourly cap on step one per client IP, shared across workers through Redis (698) |
PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR | 3 | hourly cap on step one per normalised address (699) |
PRIMA_SELF_SERVE_KEY_MAX_USES | None | max_uses stamped on the minted key; None is unlimited, enforced by the key security service on every key authentication (700) |
RESEND_API_KEY | unset | the gate on sending the confirmation mail at step one (primaapi_router.py line 681; Gotchas: it is the wrong gate under the SMTP transport) |
ENVIRONMENT | development | in development the confirmation URL is logged at INFO instead of mailed (685 to 686) |
FRONTEND_URL | http://localhost:3000 | the fallback origin for the link when the prima_dev surface declares none (678) |
PRIMA_KEY_SYNC_ISSUER | unset | read 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_URL | unset, unset | read 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_COUNT | 1 | which 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):
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /api/prima/v1/accounts | none; Depends(auth_rate_limit) then Depends(_signup_abuse_guard) (637 to 640) | none yet | 202 {"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 route | body {email, company?}; the 202 is the same for a taken address on purpose (docstring 645 to 655) |
POST /api/prima/v1/accounts/verify | none; Depends(auth_rate_limit) (699 to 702) | the new workspace, in the response | 201 {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 characters | body {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:
auth_rate_limit(S5's burst limiter) refuses with 429 before anything reads the body._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)(theif "@" in emailat 607 to 608; the gate itself is 552 to 581:PRIMA_SIGNUP_INVITE_ONLYthen 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 asksrate_limit_service.check_simple_limitfor 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 withRetry-Afterand 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.- The handler generates
secrets.token_urlsafe(32), inserts the intent withtoken_hash = sha256(raw),expires_at = utcnow + 30 min(_SIGNUP_INTENT_TTL_MINUTES, line 540, used at 673), and commits (668 to 676). - The link is
<origin>/signup/confirm?token=<raw>withorigin = get_surface(PRIMA_DEV).origin, falling back toFRONTEND_URL(678 to 679). WithRESEND_API_KEYset the mail goes out as aBackgroundTaskthroughemail_service.send_prima_signup_verification(services/email_service.py617 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). - 202 with the fixed body. No
usersrow exists (test_signup_step_one_creates_no_account).
Step two, signup_verify (698 to 748):
auth_rate_limitagain, so the token cannot be guessed at line rate (the comment at 702).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._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.intent.consumed_at = now()andflush()(739 to 740), before the account is created and inside the same transaction, so the second of two concurrent submits findsconsumed_atset once the lock releases and answers 400 (test_prima_signup_verification_token_is_single_use).create_api_only_account(db, email, company)(identity.py239 to 398): duplicate email isValueError(263), rendered 409 afterdb.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 configuredmax_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 noissuerkeyword (358; the row's issuer is_issuer(),prima_key_sync.py202 and 322 to 338:PRIMA_KEY_SYNC_ISSUER, elseJWT_ISSUER, aValueErroronly when both are empty; passing a keyword replaces that and a value Prima does not federate parks the rowstuck);db.commit()(360).- 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; withPRIMA_KEY_SYNC_SECRETorPRIMA_INTERNAL_BASE_URLunset,deliverrecordsSyncNotConfiguredand returnsFalse,prima_key_sync.py372 to 384, so the row stayspending) andsparks_grants.deliver_for_grant(381 to 387). - 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.pyline 286) andtest_public_prima_signup_no_longer_creates_a_users_row(tests/test_surface_scoped_access.pyline 579) both assert nousersrow after a 202 and an intent row present. - The token of record is its hash; the intent table has no token column (
token_hashonly, model line 34), and the same design is pinned for the desk sign-in intents bytest_signin_intents_carry_no_token_column(tests/test_desk_domain_login_schema.pyline 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_devand neverlabs: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.pyline 181), and downstreamtest_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 toapi_keys.key_hashand Labs' legacy gateway dependency accepts it (test_signup_key_hash_actually_authenticates, 162, exercises_authenticate_ai_scoped_keyin Labs, not Prima'sprima_api_keyslookup;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 theprima_key_syncsrow 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_idisNULL(prima_key_sync.py155 to 162) and an issuer that is empty in bothPRIMA_KEY_SYNC_ISSUERandJWT_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.py164 to 177, the comment gives the reason), so Prima refuses the row with a 400 and it parks asstuckwhere 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.py989 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.pyandtests/test_primaapi_gateway.pytogether: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_keystarts withprima_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 inerrorsat fixture DDL setup (IntegrityError; one such run printed18 passed, 14 errors) and the rerun prints32 passed; run it again before believing a red result, asmagic-link-login.mdsays for the same fixture.tests/test_surface_scoped_access.py -k "signup or api_only":19 passed, 25 deselected in 6.02s. Theapi_only_accountfixture iscreate_api_only_accountitself, 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, reasonSPARKS_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:5434fixture; 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 201surfaceslist is exactly["prima_chat", "prima_dev", "prima_web"](sorted); the key prefix isprima_pk_;key_prefixis the first twelve characters plus three dots; the intent'stoken_hashequalshashlib.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
/registrierento 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):
signupgates the confirmation mail on_settings.RESEND_API_KEY(primaapi_router.pyline 681), while the transport rule since ADR-0023 istransport_is_configured()(services/email_service.py64 to 76, andauth_router.py325 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 underENVIRONMENT == "development"). D8: the fix isif 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.py16 to 19 (cd2c248), and the transaction is eight at this commit because the welcome grant (identity.py321 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 throughPOST /api/developer/keys. - 2026-09-12 (measured at fd8558f): the 409 path calls
db.rollback()(745), which also rolls back theconsumed_atwrite 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 withexpires_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.ts430 to 434);SignupRequest(531 to 533) declaresemailandcompanyonly, 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_atandconsumed_atare naiveutcnow()timestamps compared againstdatetime.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 usestimestamptzandnow()on both. - 2026-09-12 (measured at fd8558f, D8):
core/config.pyline 712 describesPRIMA_KEY_SYNC_ISSUERas "Unset = every mint raises rather than queuing a key that can only ever be stuck", and_issuer()(prima_key_sync.py322 to 338) does not do that: it falls back toJWT_ISSUER, whose defaulthttps://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 astuckrow on a host where Prima federates a different one. The fix is one of two: an explicit raise in_issuer()whenPRIMA_KEY_SYNC_ISSUERitself 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/accountsand/accounts/verifyto 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.pydocstring 247 to 256). - 2026-08 (
models/prima_signup_intent_model.pydocstring, ADR-0020 section 1 prerequisite bugfix 5):POST /accountsused to create ausersrow 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-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/routers/primaapi_router.py:SignupRequestandSignupVerifyRequest(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 atapp_fastapi.py892 onALL_SURFACES. The router's other routes (/modelsat 62 onward, the completions and usage routes) are the legacy in-Labs gateway copy (D3); the standaloneprimarepo 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 theck_users_roleCHECK 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), theprima_devsurface with its origin (151 to 157),PRIMA_SURFACES(174),LABS_SIGNUP_SURFACES(185),API_ONLY_SURFACES(189).backend/core/config.py: the fivePRIMA_SIGNUP_*andPRIMA_SELF_SERVE_KEY_MAX_USESsettings (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; theworkspace_idraise 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; theSyncNotConfiguredbranch 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.pyWELCOME_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.pyget_client_ip(29 onward).- Tests as listed under Tests.
prima-developer(w6-kontoat c594dfc, the worktree HEAD):src/lib/auth.ts(signUp415 to 460, the step-one body 430 to 434,verifySignup487 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.tsgained 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-pricingat cd2c248):prima/api/v1.py16 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.
python -m pytest tests/test_primaapi_identity.py tests/test_primaapi_gateway.py -q --no-cov -o addopts=""prints32 passed;python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -k "signup or api_only"prints19 passed, 25 deselected.grep -n "token_hash\|token = Column" models/prima_signup_intent_model.pyprints thetoken_hashcolumn (line 34) and notokencolumn;grep -c "with_for_update" api/routers/primaapi_router.pyprints1.grep -n "consumed_at = datetime.utcnow()" api/routers/primaapi_router.pyprints line 739, andgrep -n "create_api_only_account(db" api/routers/primaapi_router.pyprints line 743 (the burn precedes the create).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)')"prints0.grep -n "^ grant_surfaces(db\|_sparks_grants.enqueue_welcome(\|_prima_key_sync.enqueue_mint(" services/primaapi/identity.pyprints exactly three lines, 319, 329 and 358, all above thedb.commit()at line 360 (the looser patternenqueue_mintalso matches three comment lines, 337, 344 and 350).- 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":"someone@example.com"}'returns202with"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 = 'someone@example.com'prints0; the log carries[DEV] PrimaAPI signup confirmation for someone@example.com: <url>and the URL's path is/signup/confirm?token=. - 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\"}"returns201with"scopes":["ai"],"surfaces":["prima_chat","prima_dev","prima_web"]and anapi_keybeginningprima_pk_; repeating the same call returns400with"Invalid or expired confirmation token";SELECT surface FROM user_surface_grants WHERE user_id = '<the user_id from the body>' ORDER BY surfaceprints the same three rows and nolabs;SELECT count(*) FROM prima_key_syncs WHERE api_key_id = (SELECT id FROM api_keys WHERE key_prefix = '<the key_prefix from the body>')prints1. - 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) returns200, 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/templatesreturns403naming theaiagent API prefix (S5's prefix lock). The product proof is on Prima: withPRIMA_KEY_SYNC_SECRETandPRIMA_INTERNAL_BASE_URLset 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>')printssyncedand the samecurlagainsthttp://127.0.0.1:8090/api/prima/v1/modelsreturns200; with either variable unset the row'sstatusprintspendingandlast_errorprintsSyncNotConfigured, and Prima answers401. - Restart with
PRIMA_SIGNUP_INVITE_ONLY=true: check 6's first call returns403with"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 itsconsumed_atstaysNULL. - Six
POST /accountscalls from one IP inside an hour with six different addresses: the sixth returns429with aRetry-Afterheader; four calls for the same address: the fourth returns429. grep -n "RESEND_API_KEY" api/routers/primaapi_router.pyprints line 681 at fd8558f (the gotcha is reproducible by reading); it prints nothing once the gate istransport_is_configured()and this recipe'sversionis bumped with the date.npm run checkin 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.