Prima Get an API key

Module kit / Identity and auth

Developer portal auth pages

identity-auth/ui/developer-portal-auth-pageslive

Kind frontend
Surface portal
Maturity prod
Reusable extract
Design system prima
Stack astro-4, typescript, prima-tokens
Depends on _shared/design-system-and-tokens, _shared/astro-site-skeleton
Renders identity-auth/api-only-self-serve-signup, identity-auth/magic-link-login, identity-auth/surface-registry-and-gate
Entry prima-developer/src/pages/anmelden.astro
Reference implementation
  • prima-developer/src/pages/registrieren.astro
  • prima-developer/src/pages/signup/confirm.astro
  • prima-developer/src/pages/anmelden.astro
  • prima-developer/src/pages/anmelden/verify.astro
  • prima-developer/src/pages/auth/magic-link.astro
  • prima-developer/src/lib/auth.ts
  • prima-developer/src/lib/prima-api.ts
  • prima-developer/src/components/AppNav.astro
  • prima-developer/src/layouts/Layout.astro
  • prima-developer/src/styles/auth.css
Measured on prima-developer w6-konto c594dfc 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
  • prima.li w8b-content-source 86fe9d1 2026-09-12
Version 1

Purpose

/signup/confirm: 201, the key shown once in one node, the first curl confirm-key server rendered

Konto bestätigt

Ihr Konto für [email protected] ist angelegt.
Willkommen bei PrimaAPI.
Einmalig
Das ist das einzige Mal, dass Sie diesen Schlüssel sehen. Kopieren Sie ihn jetzt.
prima_pk_BEISPIEL_nicht_echt_00000000000000000000
Wir speichern nur einen Hash davon. Wenn Sie ihn verlieren, können wir ihn Ihnen nicht schicken: dann stellen Sie angemeldet einen neuen aus und sperren diesen. Legen Sie ihn in Ihrer Umgebung ab, etwa als PRIMA_API_KEY, nicht in Ihrem Repository.
Ihr erster Aufruf
Schlüssel oben kopieren, beides hier einsetzen, absenden. Mehr ist nicht nötig: das ist bereits ein vollständiger Aufruf gegen die Live-API.
bash
export PRIMA_API_KEY="…"   # der Schlüssel von oben

curl https://<PUBLIC_PRIMA_API_BASE>/api/prima/v1/chat/completions \
  -H "X-API-Key: $PRIMA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"prima-core","messages":[{"role":"user","content":"Guten Tag"}]}'
Wenn eine Antwort zurückkommt, funktioniert Ihr Zugang. Alles Weitere, Python, Node, Embeddings, Streaming, Fehlercodes, steht im Schnellstart. Anmelden können Sie sich jederzeit mit einem Link an Ihre E-Mail-Adresse; ein Passwort gibt es nicht.

Five static pages through which a developer gets into the portal without a password: request a sign-in link, land on the mailed link, and, for a new account, request a confirmation link, land on it, and read the first API key once. The invariant that makes them trustworthy is that the browser never learns what the server refuses to say: the two request pages render the same "if this address can be ... a link is on its way" panel whether or not the address exists, the verify page cannot tell an expired token from a used or forged one and its copy names all three, and a full key exists in exactly one DOM node on one page load and nowhere in storage, the URL or a log.

It makes three backend modules visible: B14 (POST /api/prima/v1/accounts and /accounts/verify, the two-step signup whose second step this surface owns), B5 (POST /api/auth/request-magic-link and /verify-magic-link with surface: prima_dev), and B4 (the prima_dev surface: the 403 copy on both request and verify, and /me.surfaces on the page that follows).

When to use

Trigger phrases: "magic link page", "the sign-in card", "why does the portal say the link is on its way when I typed a wrong address", "the key shown once", "the confirmation link 404s", "nicht konfiguriert", "where does the mailed link land". Surface: portal, a static Astro site (astro 4.16.19, prima-developer/package-lock.json at c594dfc) on its own origin with no server of its own, every call a cross-origin fetch to the Labs API whose base is baked at build (src/lib/auth.ts lines 79 to 91). It is static because ADR-0020 gives every Prima surface its own compile-time surface constant and its own build (astro.config.mjs lines 3 to 9); the sibling pages on prima.li share the shape and differ in SURFACE and in the two-step registration (auth.ts header, lines 10 to 16).

Do not use it for the Labs SPA's login (identity-auth/ui/auth-screens, F1: React, password and passkey, cookie mode), for the chat client's sign-in (identity-auth/ui/chat-signin-and-workspace-picker, F6: a server holds the session), or for the page after sign-in (identity-auth/ui/developer-portal-keys-page, F8: the guard, the shell, the keys). The primacode desktop hand-over that branches off /auth/magic-link (src/pages/auth/desktop.astro, 750 lines at c594dfc) is a primacode row, not this one; this recipe records only the one conditional that sends a token there.

Stack assumptions

/anmelden built without PUBLIC_PRIMA_API_BASE: the form is not rendered (the other four pages carry their own copy) unconfigured server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Nicht konfiguriert
Diese Installation von developer.prima.li wurde ohne Anmeldedienst gebaut: PUBLIC_PRIMA_API_BASE war beim Build nicht gesetzt. Das Anmeldeformular wird bewusst nicht angezeigt, statt Ihre Adresse ins Leere zu senden.
Wenn Sie Zugang benötigen, schreiben Sie an [email protected].
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.

S1 as the token vocabulary in the demo only: the portal's own global.css declares a house palette copied from prima.li's marketing site (src/styles/global.css lines 1 to 5 and 32 to 68: --purple, --sand, --sand-deep, --ink, --ink-60, --ink-30, --line, --line-strong, --swiss, --ok, --ok-bg, --r-*, --s-*, --shadow-*, --font, --font-display, --mono), not prima.li's tokens.css aliases, and the auth pages read those (auth.css, 224 lines, "Tokens only"). The demo maps them onto @prima-modules/prima-tokens (Frontend). S3 (_shared/astro-site-skeleton) for the site shape; no S2, nothing here is React.

Component library: none. The pages are hand-written markup with one stylesheet (auth.css) and one <script> each; Code.astro on the confirm page is the docs' code block with its copy button (src/components/Code.astro lines 1 to 22). Data-fetch style: auth.ts post() with credentials: "omit", Content-Type and Accept JSON, no cookie ever read or relied on (lines 161 to 173); results are an AuthResult<T> discriminated union, never a thrown error (lines 126 to 143). Copy: de-CH, Sie-Form, no ß.

Environment baked at build (astro.config.mjs lines 26 to 96, apiBaseGuard): PUBLIC_PRIMA_API_BASE unset means a loud warning and every auth page built in its "nicht konfiguriert" branch with no form; unset with PRIMA_REQUIRE_API_BASE=1 fails the build; malformed, a non-https non-local host, or a query or fragment fails the build always. There is no fallback host anywhere in the repo, on purpose (lines 11 to 25). CONTACT is one constant used by every "this is broken" branch (auth.ts line 108).

Data model

Client state, per page, held in the DOM and in module-scope variables of the page script, never in a store:

PageStateWhere
/anmelden, /registrierenform visible or the sent panel visible; the button's disabled flag and label; aria-invalid on the e-mail input; the status region's class (busy, ok, error, none)form.hidden, sent.hidden, submit.disabled, emailInput attributes, statusEl.className (anmelden.astro 125 to 178, registrieren.astro 148 to 202)
/anmelden/verify, /signup/confirmthe heading and lede text, the status region, actions.hidden, and on confirm reveal.hidden, next.hidden, the redeemed latchverify.astro 84 to 87 and 125 to 185; confirm.astro 145 to 149, 181, 183 to 255
/auth/magic-linknone; it reads the URL and leavesmagic-link.astro 72 to 94

Types the pages consume (src/lib/auth.ts):

type AuthErrorCode = 'not_configured' | 'network' | 'rate_limited' | 'invalid_email' | 'missing_token'
  | 'link_invalid' | 'email_taken' | 'surface_denied' | 'server' | 'unknown';              // lines 114 to 124
type AuthResult<T> = { ok: true; data: T } | { ok: false; code: AuthErrorCode; message: string; retryAfter?: number };  // 126 to 135
interface MagicLinkRequested { email: string }                                             // 243 to 246: the typed address echoed back
interface SessionUser { id: string; email: string; name: string | null }                   // 300 to 304
interface PrimaSession { accessToken: string; expiresAt: number; surface: string; user: SessionUser; sid: string; issuedAt?: number }  // 306 to 326
interface SignUpRequested { email: string; expiresInMinutes: number | null }               // 400 to 404
interface SignUpConfirmed { key: string | null; keyPrefix: string | null; email: string | null }  // 462 to 472

Browser storage written by these pages, all under the prima.dev.* namespace so a prima_web session stays distinguishable once a shared .prima.li cookie replaces it (auth.ts 567 to 571):

KeyStoreWritten byLifetimeHolds
prima.dev.session.v1sessionStoragesaveSession on /anmelden/verify after a 200 (verify.astro 166; auth.ts 573 to 583)the tab; cleared on read when expiresAt has passed (612 to 615), by clearSession, by signOutthe access token, expiresAt, issuedAt, surface (the 200's aud or prima_dev), sid (a local random id for this sign-in, 314 to 318), the user's id, e-mail and name. The refresh token from the 200 is read and dropped (562)
prima.dev.gateway-key.v1sessionStoragemintGatewayKeyAtSignIn on the verify page, best-effort (verify.astro 168 to 172; prima-api.ts 746)the tab; parked and dropped when stale, expired or refusedthe per-session ai key the keys page reads the meter with (F8's Data model; auth.ts 662 to 702)
prima.dev.gateway-key.retired.v1, prima.dev.gateway-key.housekept.v1sessionStoragethe keys page's housekeeping, not these pagessurvive clearSession on purpose (639 to 647)row ids only, never a key
prima.desktopAuthlocalStorage/auth/desktop/ (not this recipe); read by /auth/magic-link to decide where the token goes (magic-link.astro 89 to 93; auth.ts 897 to 951)ten minutes (desktop-auth.ts 83)primacode's state, device, challenge; no credential

What the browser never holds: a password (there is no password field and the header comment forbids adding one, auth.ts 18 to 22), a refresh token, a raw API key after the confirm page unloads, the magic-link token after the page read it (scrubUrl, verify.astro 117 to 123).

API surface

Four contracts, all POST with a JSON body, no credential, surface sent where the client can (auth.ts 29 to 33; the backend side is B5 and B14):

RouteBody sentSuccess parsedStatus to AuthErrorCode (auth.ts)Copy rendered (de-CH, verbatim from auth.ts)
POST /api/auth/request-magic-link{email, surface: "prima_dev"} (260 to 263)2xx: {email} echoes the typed address (287); the body's message is not read429 rate_limited; 422 or 400 invalid_email (the server's error or detail string when present, detailOf 229 to 237); 403 surface_denied; 5xx server; anything else unknown (269 to 285); a thrown fetch is network (264 to 267)"Zu viele Versuche in kurzer Zeit. Bitte warten Sie einige Minuten ..." or, with a readable Retry-After, "... warten Sie N Sekunden/Minuten" (204 to 222); "Bitte geben Sie eine gültige E-Mail-Adresse ein." (client-side, 254 to 256) or "Diese E-Mail-Adresse wurde nicht akzeptiert."; "Dieses Konto ist für developer.prima.li nicht freigeschaltet. Ihr Zugang zu prima.li und zu Alchemy Labs bleibt davon unberührt." (278 to 280); "Beim Anmeldedienst ist ein Fehler aufgetreten ..." (213 to 217); "Unerwartete Antwort vom Anmeldedienst."; "Der Anmeldedienst ist nicht erreichbar ..." (198 to 202)
POST /api/auth/verify-magic-link{token, surface: "prima_dev"} (343 to 346); the schema has no surface field and drops it (B5 API surface)200: access_token (required, else unknown, 370 to 373), expires_in (default 1800 s, 376 to 377), aud (384), user.{id,email,name} (387 to 390)429 rate_limited; 400, 401 or 404 link_invalid; 403 surface_denied; 5xx server; else unknown (352 to 368)"Dieser Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Fordern Sie bitte einen neuen an." (356); "Dieses Konto ist für developer.prima.li nicht freigeschaltet. Erneutes Anmelden ändert daran nichts." (362); "Der Anmeldedienst hat keine gültige Sitzung zurückgegeben." (372)
POST /api/prima/v1/accounts{email, company: string or null, surface: "prima_dev"} (430 to 434)2xx: {email, expiresInMinutes} from expires_in_minutes when numeric, else null (452 to 459); the 202 is identical for a taken address (B14)429 rate_limited; 422 or 400 invalid_email; 5xx server; else unknown (440 to 450). A 403 from B14's domain gate has no branch and lands in unknown (Gotchas)as above; the sent panel states the expiry only when the server sent one (registrieren.astro 186 to 189)
POST /api/prima/v1/accounts/verify{token} (502)201: api_key or key (531 to 536), key_prefix, email (537 to 539); a missing key is a distinct UI state, never a blank box (526 to 529)429 rate_limited; 409 email_taken; 400 or 404 link_invalid; 5xx server; else unknown (508 to 524)"Für diese Adresse besteht bereits ein Konto. Melden Sie sich stattdessen an ..." (512); "Dieser Bestätigungslink ist abgelaufen, wurde bereits verwendet oder ist ungültig ..." (518)

Error bodies: the Labs handler emits {error, code, request_id, timestamp} and plain FastAPI validation emits detail; detailOf reads both so a server-worded refusal is not flattened into the generic sentence (auth.ts 35 to 42, 229 to 237; S7). Retry-After is not CORS-safelisted and Labs exposes only X-Total-Count and X-Request-ID (alchemy_labs/backend/app_fastapi.py line 452 at fd8558f), so retryAfter is undefined cross-origin and the 429 copy is the wording without a countdown (auth.ts 175 to 181).

No WebSocket, no postMessage. The mailed links: {surface.origin}/auth/magic-link?token= for a magic link (alchemy_labs/backend/api/routers/auth_router.py lines 1090 and 1119, and services/email_service.py line 522, fd8558f) and {PRIMA_DEV origin}/signup/confirm?token= for a signup confirmation (primaapi_router.py 678 to 679), which is why /auth/magic-link and /signup/confirm on this origin are not optional.

Application logic

Sign-in, /anmelden (anmelden.astro):

  1. The page renders the card with the form, the hidden sent panel and the empty status region (33 to 99); the script binds only when IS_CONFIGURED and both nodes exist (118).
  2. Submit: preventDefault, trim, clear aria-invalid, disable the button and relabel it "Wird gesendet …", status busy (137 to 146).
  3. requestMagicLink(email): the loose client check first (looksLikeEmail, 290 to 294; a typo saved from a rate-limited round trip), then the POST.
  4. Failure: the button restored; invalid_email also sets aria-invalid and focuses the input; the message in the status region as error (153 to 160).
  5. Success: status cleared, form hidden, the typed address written into the sent panel, panel shown and focused with tabindex=-1 so keyboard and screen-reader users land on the new content (162 to 169). "Andere Adresse verwenden" reverses it and clears the field (172 to 178).

Landing, /auth/magic-link then /anmelden/verify:

  1. The mailer's route reads token from #token= first, then ?token= (magic-link.astro 72 to 79). No token: replace('/anmelden/'). Otherwise it forwards with replace to /anmelden/verify#token=..., or to /auth/desktop/#token=... when readDesktopAuth() returns a fresh record (81 to 94): the fragment never reaches a server or a Referer, and replace keeps the mailed URL out of history.
  2. /anmelden/verify reads the token the same way, then scrubUrl (history.replaceState to the bare path) before any await (133 to 134). No token: heading "Kein Anmeldecode gefunden", the error explains that mail programs shorten long addresses, and the "Neuen Link anfordern" action appears (136 to 147).
  3. Status busy; verifyMagicLink(token). Failure: heading "Anmeldung fehlgeschlagen", the message, and for every code but rate_limited the sentence that links are single-use and short-lived; the action appears (153 to 163).
  4. Success: saveSession(result.data) (166), then mintGatewayKeyAtSignIn() best-effort so the next page has a balance without a second round trip; a failed mint must not undo a sign-in that succeeded (168 to 172). Heading "Angemeldet", status ok with a manual link, then location.replace('/schluessel/') so the back button cannot return to a spent token (174 to 184).

Registration, /registrieren then /signup/confirm:

  1. Same shape as sign-in with a second, optional company field (registrieren.astro 39 to 75); signUp(email, company) sends null for an empty company (auth.ts 426 to 434). The sent panel says a link is on its way "falls für ... ein Konto angelegt werden kann", states the expiry only from expiresInMinutes, and says in bold that account and key exist only once the link is opened (77 to 97, 187 to 189).
  2. /signup/confirm reads the token, scrubs the URL, latches redeemed and calls verifySignup(token) once per page load (confirm.astro 178 to 210). There is deliberately no retry button: the token is burnt server-side in the transaction that creates the account, so a retry after a transient blip would turn into "your account exists and your key is gone" (36 to 43).
  3. Failure: heading "Bestätigung fehlgeschlagen" and the message; for email_taken the action is rebuilt as "Zur Anmeldung" because sign-in is the useful next step (212 to 229).
  4. Success: heading "Konto bestätigt", lede with the address. With a key: status ok "Willkommen bei PrimaAPI.", the key written into #reveal-key with textContent, the reveal shown, the key node focused (237 to 244). Without a key: an error status saying the account exists but no key came back and to mint one under "Schlüssel" (245 to 253). In both cases the "first call" block appears with the curl that reads $PRIMA_API_KEY from the environment rather than interpolating the key (19 to 24, 254).
  5. Copy button: navigator.clipboard.writeText, label "Kopiert" on success, an honest "Nicht möglich, bitte markieren und kopieren" on refusal (257 to 268). beforeunload warns while the reveal is visible (272 to 278).

Invariants

  • No page says an e-mail was sent. Both request pages say a link is on its way if the address can be signed in or registered; the 202 is the same for an unknown or taken address on the server (B5, B14) and the client cannot detect either (auth.ts 44 to 51, 406 to 413).
  • The verify copy names three causes at once (expired, used, invalid) because the server answers them alike (auth.ts 49 to 51, 353 to 358).
  • A raw key is rendered into exactly one node by textContent, never with innerHTML, never stored, never in the URL; hideReveal on the keys page clears the node and this page has no such button, so the key lives until unload and the browser warns before that (confirm.astro 34 to 43, 240 to 244, 272 to 278; prima-api.ts INVARIANT B, 29 to 35).
  • The magic-link token is read from the fragment before the query, and the address bar is scrubbed before the first await (verify.astro 102 to 123).
  • verifySignup runs at most once per page load (confirm.astro 181, 189 to 190).
  • There is no <input type="password"> in the repository (auth.ts 18 to 22; Acceptance check 2).
  • Every status message is escaped before innerHTML (escapeHtml, anmelden.astro 181 to 186 and the same in the other three scripts); the sent-panel address is written with textContent.
  • The unconfigured build renders no form: an address typed into a form with nothing to post to would be discarded silently (anmelden.astro 6 to 10).

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

/anmelden: the form, nothing typed signin server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
/anmelden: submitting, the button disabled, the live region busy signin-busy server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.
Anmeldelink wird angefordert …
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
/anmelden: the loose client check refused the address (aria-invalid, focus) signin-invalid server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.
Bitte geben Sie eine gültige E-Mail-Adresse ein.
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
/anmelden: the 202 branch, which never says whether the address exists signin-sent server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Prüfen Sie Ihren Posteingang
Falls für [email protected] ein Prima-Konto besteht, ist jetzt ein Anmeldelink unterwegs. Der Link gilt einmalig und läuft nach kurzer Zeit ab.
Nichts erhalten? Sehen Sie im Spam-Ordner nach. Kommt weiterhin nichts an, besteht für diese Adresse vermutlich noch kein Konto: Zugang anfragen.
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
/anmelden: 429 without a readable Retry-After signin-throttled server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.
Zu viele Versuche in kurzer Zeit. Bitte warten Sie einige Minuten und versuchen Sie es dann erneut.
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
/anmelden/verify: token read from the fragment, URL scrubbed, verifying verify-busy server rendered

Anmeldung wird geprüft

Einen Moment: wir prüfen Ihren Anmeldelink.
Anmeldelink wird geprüft …
Problem mit dem Link? Zurück zur Anmeldung
/anmelden/verify: 400, expired, used and forged tokens read alike verify-failed server rendered

Anmeldung fehlgeschlagen

Der Link konnte nicht eingelöst werden.
Dieser Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Fordern Sie bitte einen neuen an. Anmeldelinks gelten einmalig und nur für kurze Zeit. Fordern Sie einfach einen neuen an.
Problem mit dem Link? Zurück zur Anmeldung
/anmelden/verify: session saved, the per-session key minted, redirecting to the keys verify-ok server rendered

Angemeldet

Sie werden weitergeleitet.
Anmeldung erfolgreich. Weiter zu Ihren Schlüsseln … Jetzt öffnen
Problem mit dem Link? Zurück zur Anmeldung
/auth/magic-link: the route the mailer targets, forwarding with the token in the fragment magic-link-forward server rendered

Anmeldung wird geprüft

Einen Moment: wir prüfen Ihren Anmeldelink.
/registrieren: e-mail and the optional company signup server rendered

Zugang anfragen

PrimaAPI ist im Early Access. Sie legen hier ein Konto an, bestätigen die Adresse über einen Link und bekommen dabei Ihren ersten Schlüssel.
An diese Adresse geht der Bestätigungslink. Sie brauchen kein Passwort, weder jetzt noch später.
Schon ein Konto? Anmelden
Ein Konto von hier gilt für die Prima-Oberflächen. Es ist kein Zugang zu den internen Team-Tools und wird auch keiner.
/registrieren: the 202 branch with the server-stated expiry signup-sent server rendered

Zugang anfragen

PrimaAPI ist im Early Access. Sie legen hier ein Konto an, bestätigen die Adresse über einen Link und bekommen dabei Ihren ersten Schlüssel.
Prüfen Sie Ihren Posteingang
Falls für [email protected] ein Konto angelegt werden kann, ist jetzt ein Bestätigungslink unterwegs. Der Link gilt einmalig und läuft nach 30 Minuten ab.
Konto und Schlüssel entstehen erst, wenn Sie den Link öffnen. Bis dahin ist nichts angelegt. Deshalb funktioniert dieses Formular auch nicht als Möglichkeit, fremde Adressen zu belegen.
Nichts erhalten? Sehen Sie im Spam-Ordner nach. Besteht für diese Adresse bereits ein Konto, kommt kein neuer Link: melden Sie sich stattdessen an.
Schon ein Konto? Anmelden
Ein Konto von hier gilt für die Prima-Oberflächen. Es ist kein Zugang zu den internen Team-Tools und wird auch keiner.
/signup/confirm: redeeming the token, once confirm-busy server rendered

Konto wird bestätigt

Einen Moment: wir lösen Ihren Bestätigungslink ein.
Konto wird angelegt …
/signup/confirm: 409, the address already has an account confirm-taken server rendered

Bestätigung fehlgeschlagen

Der Link konnte nicht eingelöst werden.
Für diese Adresse besteht bereits ein Konto. Melden Sie sich stattdessen an: Schlüssel stellen Sie danach selbst aus.
/signup/confirm: 201 without a key in the body, never an empty box confirm-no-key server rendered

Konto bestätigt

Ihr Konto für [email protected] ist angelegt.
Ihr Konto ist angelegt, ein Schlüssel wurde aber nicht zurückgegeben. Melden Sie sich an und stellen Sie unter „Schlüssel“ einen aus.
Ihr erster Aufruf
Schlüssel oben kopieren, beides hier einsetzen, absenden. Mehr ist nicht nötig: das ist bereits ein vollständiger Aufruf gegen die Live-API.
bash
export PRIMA_API_KEY="…"   # der Schlüssel von oben

curl https://<PUBLIC_PRIMA_API_BASE>/api/prima/v1/chat/completions \
  -H "X-API-Key: $PRIMA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"prima-core","messages":[{"role":"user","content":"Guten Tag"}]}'
Wenn eine Antwort zurückkommt, funktioniert Ihr Zugang. Alles Weitere, Python, Node, Embeddings, Streaming, Fehlercodes, steht im Schnellstart. Anmelden können Sie sich jederzeit mit einem Link an Ihre E-Mail-Adresse; ein Passwort gibt es nicht.
identity-auth/ui/developer-portal-auth-pages/demo.astro
---
/**
 * Live demo for identity-auth/ui/developer-portal-auth-pages.
 *
 * The five auth pages of the developer portal (prima-developer at c594dfc on
 * w6-konto: anmelden.astro, anmelden/verify.astro, auth/magic-link.astro,
 * registrieren.astro, signup/confirm.astro) rebuilt as one Astro component
 * that takes `state`. Every real page ships one card whose branches are
 * toggled by a client script after the fetch settles (`form.hidden`,
 * `sent.hidden`, `actions.hidden`, the status region's class); a state here
 * is one of those branches rendered server-side with the values the script
 * would have written, so the docs page shows every branch at once and no
 * network is touched.
 *
 * Design system `prima` (D1b): the card is drawn with @prima-modules/prima-tokens
 * aliases where the portal's auth.css reads its own house variables. The
 * mapping is in the recipe's Frontend section (sand-deep to --bg-surface,
 * purple to --fg-accent, --s-N to --space-*, and so on). Two colours the
 * token layer does not carry, the refusal red and the confirmation green,
 * are declared once on the root below and named in the recipe's Gotchas.
 *
 * Copy is the pages' own (de-CH, Sie-Form) with one change: the source
 * joins clauses with em dashes and this kit's house rule is no em dashes,
 * so those clauses are joined with a colon or a full stop. The support
 * address and the sample e-mail address are placeholders, never real ones.
 *
 * Not rendered: the site header and footer around the card (Layout.astro;
 * the auth pages sit outside the signed-in sub-navigation, so the card is
 * the screen), the noscript blocks, the beforeunload guard on the confirm
 * page, and the redirect the verify page performs after `saveSession`.
 *
 * Two docs-only differences from the source markup, both so a frame on the
 * docs page behaves: the forms carry `method="dialog"`, which makes a native
 * submit (button click or Enter in a field) a no-op outside a <dialog>
 * (HTML spec, form submission algorithm), so the typed address never lands
 * in the docs page's query string; and the aria-live region is written with
 * no whitespace children when the state has no status, because Astro emits
 * template whitespace as a text node and `.auth__status:empty` would never
 * match (2026-09-12, measured on the built docs page).
 */
export const states = {
  signin: { label: '/anmelden: the form, nothing typed', height: 560 },
  'signin-busy': { label: '/anmelden: submitting, the button disabled, the live region busy', height: 560 },
  'signin-invalid': { label: '/anmelden: the loose client check refused the address (aria-invalid, focus)', height: 560 },
  'signin-sent': { label: '/anmelden: the 202 branch, which never says whether the address exists', height: 560 },
  'signin-denied': { label: '/anmelden: 403, the account is not granted prima_dev', height: 560 },
  'signin-throttled': { label: '/anmelden: 429 without a readable Retry-After', height: 560 },
  'verify-busy': { label: '/anmelden/verify: token read from the fragment, URL scrubbed, verifying', height: 400 },
  'verify-failed': { label: '/anmelden/verify: 400, expired, used and forged tokens read alike', height: 480 },
  'verify-ok': { label: '/anmelden/verify: session saved, the per-session key minted, redirecting to the keys', height: 400 },
  'magic-link-forward': { label: '/auth/magic-link: the route the mailer targets, forwarding with the token in the fragment', height: 320 },
  signup: { label: '/registrieren: e-mail and the optional company', height: 640 },
  'signup-sent': { label: '/registrieren: the 202 branch with the server-stated expiry', height: 640 },
  'confirm-busy': { label: '/signup/confirm: redeeming the token, once', height: 400 },
  'confirm-key': { label: '/signup/confirm: 201, the key shown once in one node, the first curl', height: 900 },
  'confirm-taken': { label: '/signup/confirm: 409, the address already has an account', height: 480 },
  'confirm-no-key': { label: '/signup/confirm: 201 without a key in the body, never an empty box', height: 640 },
  unconfigured: { label: '/anmelden built without PUBLIC_PRIMA_API_BASE: the form is not rendered (the other four pages carry their own copy)', height: 560 },
};

interface Props {
  state: keyof typeof states;
}

const { state } = Astro.props;

/* Sample data: literal, nothing real. */
const CONTACT = '[email protected]';
const EMAIL = '[email protected]';
const COMPANY = 'Muster AG';
const API_ROOT = 'https://<PUBLIC_PRIMA_API_BASE>/api/prima/v1';
const AUTH_HEADER = 'X-API-Key';
/* Obviously not a key: the real prefix is prima_pk_ plus 43 url-safe characters. */
const KEY = 'prima_pk_BEISPIEL_nicht_echt_00000000000000000000';

const firstCall = `export PRIMA_API_KEY="…"   # der Schlüssel von oben

curl ${API_ROOT}/chat/completions \\
  -H "${AUTH_HEADER}: $PRIMA_API_KEY" \\
  -H "Content-Type: application/json" \\
  -d '{"model":"prima-core","messages":[{"role":"user","content":"Guten Tag"}]}'`;

type Page = 'signin' | 'verify' | 'forward' | 'signup' | 'confirm';
type StatusKind = 'busy' | 'ok' | 'error';

interface View {
  page: Page;
  title: string;
  lede: string;
  /* the form branch: shown, and in which state */
  form?: { busy?: boolean; invalid?: boolean; value?: string; company?: string };
  status?: { kind: StatusKind; strong: string; span?: string; link?: { href: string; label: string } };
  sent?: { email: string; expiry?: string };
  /* the "request a new link" or "go to sign-in" action under a failure */
  action?: { href: string; label: string };
  reveal?: boolean;
  next?: boolean;
  unconfigured?: boolean;
}

const signinBase: Pick<View, 'page' | 'title' | 'lede'> = {
  page: 'signin',
  title: 'Anmelden',
  lede: 'Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.',
};
const signupBase: Pick<View, 'page' | 'title' | 'lede'> = {
  page: 'signup',
  title: 'Zugang anfragen',
  lede: 'PrimaAPI ist im Early Access. Sie legen hier ein Konto an, bestätigen die Adresse über einen Link und bekommen dabei Ihren ersten Schlüssel.',
};

const views: Record<keyof typeof states, View> = {
  signin: { ...signinBase, form: {} },
  'signin-busy': {
    ...signinBase,
    form: { busy: true, value: EMAIL },
    status: { kind: 'busy', strong: 'Anmeldelink wird angefordert …' },
  },
  'signin-invalid': {
    ...signinBase,
    form: { invalid: true, value: 'entwicklerin@' },
    status: { kind: 'error', strong: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.' },
  },
  'signin-sent': { ...signinBase, sent: { email: EMAIL } },
  'signin-denied': {
    ...signinBase,
    form: { value: EMAIL },
    status: {
      kind: 'error',
      strong: 'Dieses Konto ist für developer.prima.li nicht freigeschaltet. Ihr Zugang zu prima.li und zu den internen Team-Tools bleibt davon unberührt.',
    },
  },
  'signin-throttled': {
    ...signinBase,
    form: { value: EMAIL },
    status: {
      kind: 'error',
      strong: 'Zu viele Versuche in kurzer Zeit. Bitte warten Sie einige Minuten und versuchen Sie es dann erneut.',
    },
  },
  'verify-busy': {
    page: 'verify',
    title: 'Anmeldung wird geprüft',
    lede: 'Einen Moment: wir prüfen Ihren Anmeldelink.',
    status: { kind: 'busy', strong: 'Anmeldelink wird geprüft …' },
  },
  'verify-failed': {
    page: 'verify',
    title: 'Anmeldung fehlgeschlagen',
    lede: 'Der Link konnte nicht eingelöst werden.',
    status: {
      kind: 'error',
      strong: 'Dieser Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Fordern Sie bitte einen neuen an.',
      span: 'Anmeldelinks gelten einmalig und nur für kurze Zeit. Fordern Sie einfach einen neuen an.',
    },
    action: { href: '/anmelden/', label: 'Neuen Link anfordern' },
  },
  'verify-ok': {
    page: 'verify',
    title: 'Angemeldet',
    lede: 'Sie werden weitergeleitet.',
    status: { kind: 'ok', strong: 'Anmeldung erfolgreich.', span: 'Weiter zu Ihren Schlüsseln …', link: { href: '/schluessel/', label: 'Jetzt öffnen' } },
  },
  'magic-link-forward': {
    page: 'forward',
    title: 'Anmeldung wird geprüft',
    lede: 'Einen Moment: wir prüfen Ihren Anmeldelink.',
  },
  signup: { ...signupBase, form: {} },
  'signup-sent': { ...signupBase, sent: { email: EMAIL, expiry: 'Der Link gilt einmalig und läuft nach 30 Minuten ab.' } },
  'confirm-busy': {
    page: 'confirm',
    title: 'Konto wird bestätigt',
    lede: 'Einen Moment: wir lösen Ihren Bestätigungslink ein.',
    status: { kind: 'busy', strong: 'Konto wird angelegt …' },
  },
  'confirm-key': {
    page: 'confirm',
    title: 'Konto bestätigt',
    lede: `Ihr Konto für ${EMAIL} ist angelegt.`,
    status: { kind: 'ok', strong: 'Willkommen bei PrimaAPI.' },
    reveal: true,
    next: true,
  },
  'confirm-taken': {
    page: 'confirm',
    title: 'Bestätigung fehlgeschlagen',
    lede: 'Der Link konnte nicht eingelöst werden.',
    status: {
      kind: 'error',
      strong: 'Für diese Adresse besteht bereits ein Konto. Melden Sie sich stattdessen an: Schlüssel stellen Sie danach selbst aus.',
    },
    action: { href: '/anmelden/', label: 'Zur Anmeldung' },
  },
  'confirm-no-key': {
    page: 'confirm',
    title: 'Konto bestätigt',
    lede: `Ihr Konto für ${EMAIL} ist angelegt.`,
    status: {
      kind: 'error',
      strong: 'Ihr Konto ist angelegt, ein Schlüssel wurde aber nicht zurückgegeben.',
      span: 'Melden Sie sich an und stellen Sie unter „Schlüssel“ einen aus.',
    },
    next: true,
  },
  unconfigured: { ...signinBase, unconfigured: true },
};

const v = views[state];
const wide = v.page === 'confirm';
const formId = v.page === 'signup' ? 'signup-form' : 'signin-form';
---

<div class="dpa" data-dpa-state={state} data-dpa-page={v.page}>
  <div class="auth">
    <div class:list={['auth__card', { 'auth__card--wide': wide }]} data-gallery-focus="true">
      <span class="auth__mark" aria-hidden="true"><i></i><i></i><i></i></span>
      <h3 class="auth__title">{v.title}</h3>
      <div class="auth__lede">{v.lede}</div>

      {v.page === 'forward' && (
        <div class="auth__forward" aria-hidden="true">
          <span class="mono">/auth/magic-link?token=…</span>
          <span class="auth__arrow">→</span>
          <span class="mono">/anmelden/verify#token=…</span>
        </div>
      )}

      {v.form && (
        <form class="auth__form" id={formId} method="dialog" novalidate>
          <div class="auth__field">
            <label for={`${state}-email`}>E-Mail-Adresse</label>
            <input
              type="email"
              id={`${state}-email`}
              name="email"
              required
              autocomplete="email"
              inputmode="email"
              spellcheck="false"
              autocapitalize="none"
              placeholder="[email protected]"
              value={v.form.value ?? ''}
              aria-invalid={v.form.invalid ? 'true' : undefined}
              aria-describedby={`${state}-email-hint`}
            />
            <div class="auth__hint" id={`${state}-email-hint`}>
              {v.page === 'signup'
                ? 'An diese Adresse geht der Bestätigungslink. Sie brauchen kein Passwort, weder jetzt noch später.'
                : 'Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.'}
            </div>
          </div>
          {v.page === 'signup' && (
            <div class="auth__field">
              <label for={`${state}-company`}>Firma <em>(optional)</em></label>
              <input
                type="text"
                id={`${state}-company`}
                name="company"
                autocomplete="organization"
                maxlength="200"
                placeholder={COMPANY}
                value={v.form.company ?? ''}
              />
            </div>
          )}
          <button type="submit" class="btn btn--primary auth__submit" disabled={v.form.busy}>
            {v.form.busy ? 'Wird gesendet …' : v.page === 'signup' ? 'Bestätigungslink senden' : 'Anmeldelink senden'}
          </button>
        </form>
      )}

      {v.sent && v.page === 'signin' && (
        <div class="auth__sent" tabindex="-1">
          <div class="auth__sentMark">Prüfen Sie Ihren Posteingang</div>
          <div class="auth__sentText">
            Falls für <strong>{v.sent.email}</strong> ein Prima-Konto besteht, ist jetzt ein Anmeldelink unterwegs.
            Der Link gilt einmalig und läuft nach kurzer Zeit ab.
          </div>
          <div class="auth__sentText">
            Nichts erhalten? Sehen Sie im Spam-Ordner nach. Kommt weiterhin nichts an, besteht für diese Adresse
            vermutlich noch kein Konto: <a href="/registrieren/">Zugang anfragen</a>.
          </div>
          <button type="button" class="auth__again">Andere Adresse verwenden</button>
        </div>
      )}

      {v.sent && v.page === 'signup' && (
        <div class="auth__sent" tabindex="-1">
          <div class="auth__sentMark">Prüfen Sie Ihren Posteingang</div>
          <div class="auth__sentText">
            Falls für <strong>{v.sent.email}</strong> ein Konto angelegt werden kann, ist jetzt ein Bestätigungslink
            unterwegs. <span>{v.sent.expiry}</span>
          </div>
          <div class="auth__sentText">
            <strong>Konto und Schlüssel entstehen erst, wenn Sie den Link öffnen.</strong> Bis dahin ist nichts
            angelegt. Deshalb funktioniert dieses Formular auch nicht als Möglichkeit, fremde Adressen zu belegen.
          </div>
          <div class="auth__sentText">
            Nichts erhalten? Sehen Sie im Spam-Ordner nach. Besteht für diese Adresse bereits ein Konto, kommt kein
            neuer Link: <a href="/anmelden/">melden Sie sich stattdessen an</a>.
          </div>
          <button type="button" class="auth__again">Andere Adresse verwenden</button>
        </div>
      )}

      {v.unconfigured && (
        <div class="auth__unconfigured">
          <div class="auth__unconfiguredTag">Nicht konfiguriert</div>
          <div>
            Diese Installation von developer.prima.li wurde ohne Anmeldedienst gebaut:
            <span class="mono mono--chip">PUBLIC_PRIMA_API_BASE</span> war beim Build nicht gesetzt. Das
            Anmeldeformular wird bewusst nicht angezeigt, statt Ihre Adresse ins Leere zu senden.
          </div>
          <div>
            Wenn Sie Zugang benötigen, schreiben Sie an <a href={`mailto:${CONTACT}`}>{CONTACT}</a>.
          </div>
        </div>
      )}

      {/* The aria-live region: present from first paint on four of the five pages (magic-link.astro has
          none), empty until the script writes it. The empty branch has no children at all, not even
          whitespace, so `.auth__status:empty` matches as it does on the real page. */}
      {v.page !== 'forward' && v.status && (
        <div class={`auth__status auth__status--${v.status.kind}`} role="status" aria-live="polite" aria-atomic="true">
          <strong>{v.status.strong}</strong>
          {v.status.span && (
            <span>
              {v.status.span}{' '}
              {v.status.link && <a href={v.status.link.href}>{v.status.link.label}</a>}
            </span>
          )}
        </div>
      )}
      {v.page !== 'forward' && !v.status && (
        <div class="auth__status" role="status" aria-live="polite" aria-atomic="true"></div>
      )}

      {v.reveal && (
        <div class="reveal">
          <div class="reveal__tag">Einmalig</div>
          <div class="reveal__warn">Das ist das einzige Mal, dass Sie diesen Schlüssel sehen. Kopieren Sie ihn jetzt.</div>
          <div class="reveal__keyrow">
            <span class="reveal__key mono" tabindex="0">{KEY}</span>
            <button type="button" class="copybtn">Kopieren</button>
          </div>
          <div class="reveal__note">
            Wir speichern nur einen Hash davon. Wenn Sie ihn verlieren, können wir ihn Ihnen nicht schicken: dann
            stellen Sie angemeldet einen neuen aus und sperren diesen. Legen Sie ihn in Ihrer Umgebung ab, etwa als
            <span class="mono">PRIMA_API_KEY</span>, nicht in Ihrem Repository.
          </div>
        </div>
      )}

      {v.next && (
        <div class="auth__sent">
          <div class="auth__sentText"><strong>Ihr erster Aufruf</strong></div>
          <div class="auth__sentText">
            Schlüssel oben kopieren, beides hier einsetzen, absenden. Mehr ist nicht nötig: das ist bereits ein
            vollständiger Aufruf gegen die Live-API.
          </div>
          <div class="code">
            <div class="code__bar">
              <span class="code__lang">bash</span>
              <button type="button" class="code__copy" aria-label="Code kopieren">Kopieren</button>
            </div>
            <pre><code>{firstCall}</code></pre>
          </div>
          <div class="auth__sentText">
            Wenn eine Antwort zurückkommt, funktioniert Ihr Zugang. Alles Weitere, Python, Node, Embeddings,
            Streaming, Fehlercodes, steht im Schnellstart. Anmelden können Sie sich jederzeit mit einem Link an
            Ihre E-Mail-Adresse; ein Passwort gibt es nicht.
          </div>
          <div class="formrow">
            <a class="btn btn--primary btn--sm" href="/docs/">Zum Schnellstart</a>
            <a class="btn btn--ghost btn--sm" href="/anmelden/">Anmelden</a>
          </div>
        </div>
      )}

      {v.action && (
        <div class="auth__sent">
          <a class="btn btn--primary auth__submit" href={v.action.href}>{v.action.label}</a>
        </div>
      )}

      {v.page === 'signin' && (
        <>
          <div class="auth__alt">Noch kein Zugang? <a href="/registrieren/">Zugang anfragen</a></div>
          <div class="auth__fine">
            Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.
          </div>
        </>
      )}
      {v.page === 'signup' && (
        <>
          <div class="auth__alt">Schon ein Konto? <a href="/anmelden/">Anmelden</a></div>
          <div class="auth__fine">
            Ein Konto von hier gilt für die Prima-Oberflächen. Es ist kein Zugang zu den internen Team-Tools und wird auch keiner.
          </div>
        </>
      )}
      {v.page === 'verify' && (
        <div class="auth__alt">Problem mit dem Link? <a href="/anmelden/">Zurück zur Anmeldung</a></div>
      )}
    </div>
  </div>
</div>

<style>
  /* prima-developer src/styles/auth.css (224 lines at c594dfc) with the
     portal's house variables replaced by the prima-tokens aliases. Text and
     headings are div and h3 (the docs host's shell rules reach p, h1 and h2
     inside a frame; prima.li src/index-site/demo-frame.css says why), and
     inline code is span.mono for the same reason. Two state colours the
     token layer lacks are declared here and nowhere else. */
  .dpa {
    --dpa-danger: #c4362f;
    --dpa-ok: #2f7d5b;
    --dpa-ok-bg: #e7f3ec;
    --dpa-ink-30: color-mix(in srgb, var(--fg-default) 45%, var(--bg-page));
    --dpa-mono: var(--work-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
    font-family: var(--font-body);
    color: var(--fg-default);
    font-size: 17px;
    line-height: 1.55;
  }

  .auth {
    display: grid;
    place-items: start center;
    background: var(--bg-surface);
    padding: var(--space-3xl) var(--space-lg) var(--space-4xl);
  }
  .auth__card {
    width: 100%;
    max-width: 460px;
    background: var(--bg-page);
    border: 1px solid var(--border-default);
    border-radius: var(--radius);
    box-shadow: var(--shadow-soft);
    padding: var(--space-2xl) var(--space-xl) var(--space-xl);
  }
  .auth__card--wide { max-width: 620px; }

  .auth__mark { display: grid; gap: 3px; width: 24px; margin-bottom: var(--space-lg); }
  .auth__mark i { display: block; height: 5px; border-radius: 2px; background: var(--fg-accent); }
  .auth__mark i:nth-child(2) { opacity: 0.72; }
  .auth__mark i:nth-child(3) { opacity: 0.45; }

  .auth__title {
    margin: 0;
    font-family: var(--font-body);
    font-weight: 500;
    font-size: clamp(28px, 4.4vw, 34px);
    letter-spacing: -0.028em;
    line-height: 1.06;
  }
  .auth__lede { color: var(--fg-muted); font-size: 16px; margin-top: var(--space-sm); }

  .auth__forward {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-sm);
    margin-top: var(--space-lg);
    font-size: 13px;
    color: var(--fg-muted);
  }
  .auth__arrow { color: var(--fg-accent); }

  .auth__form { display: grid; gap: var(--space-md); margin-top: var(--space-xl); }
  .auth__field { display: grid; gap: 6px; }
  .auth__field > label { font-size: 14px; font-weight: 500; }
  .auth__field em { color: var(--dpa-ink-30); font-style: normal; font-weight: 400; }
  .auth__field input {
    font: inherit;
    font-size: 16px;
    color: var(--fg-default);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    padding: 12px 13px;
    background: var(--bg-surface);
    width: 100%;
  }
  .auth__field input:focus-visible { outline: 2px solid var(--fg-accent); outline-offset: 1px; border-color: transparent; }
  .auth__field input[aria-invalid='true'] { border-color: var(--dpa-danger); }
  .auth__hint { font-size: 13px; color: var(--dpa-ink-30); }

  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 9px;
    padding: 13px 22px;
    border-radius: var(--radius-sm);
    font-weight: 500;
    font-size: 16px;
    border: 1px solid transparent;
    text-decoration: none;
    cursor: pointer;
  }
  .btn--primary { background: var(--fg-accent); color: var(--fg-on-dark); }
  .btn--ghost { border-color: var(--border-strong); color: var(--fg-default); }
  .btn--sm { padding: 9px 16px; font-size: 14.5px; }
  .auth__submit { width: 100%; }
  .auth__submit[disabled] { opacity: 0.55; cursor: progress; }

  .auth__status:empty { display: none; }
  .auth__status {
    display: grid;
    gap: 6px;
    margin-top: var(--space-lg);
    border-radius: var(--radius-sm);
    padding: var(--space-md);
    font-size: 14.5px;
    line-height: 1.55;
    border: 1px solid var(--border-default);
    background: var(--bg-surface);
    color: var(--fg-muted);
  }
  .auth__status strong { color: var(--fg-default); font-weight: 500; }
  .auth__status--error { border-color: color-mix(in srgb, var(--dpa-danger) 35%, transparent); background: color-mix(in srgb, var(--dpa-danger) 6%, var(--bg-page)); color: var(--fg-default); }
  .auth__status--ok { border-color: color-mix(in srgb, var(--dpa-ok) 30%, transparent); background: var(--dpa-ok-bg); color: var(--fg-default); }
  .auth__status--busy { border-color: var(--border-strong); }
  .auth__status a { color: var(--fg-accent); text-decoration: underline; text-underline-offset: 2px; }

  .auth__sent { margin-top: var(--space-xl); display: grid; gap: var(--space-md); }
  .auth__sentText { font-size: 15.5px; color: var(--fg-muted); }
  .auth__sentText strong { color: var(--fg-default); font-weight: 500; word-break: break-word; }
  .auth__sentText a { color: var(--fg-accent); text-decoration: underline; text-underline-offset: 2px; }
  .auth__sentMark { font-family: var(--dpa-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dpa-ok); }
  .auth__again {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    font-size: 14.5px;
    color: var(--fg-accent);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
    justify-self: start;
  }

  .auth__alt { margin-top: var(--space-xl); padding-top: var(--space-lg); border-top: 1px solid var(--border-default); font-size: 14.5px; color: var(--fg-muted); }
  .auth__alt a { color: var(--fg-accent); text-decoration: underline; text-underline-offset: 2px; }
  .auth__fine { margin-top: var(--space-md); font-size: 13px; color: var(--dpa-ink-30); }

  .auth__unconfigured {
    margin-top: var(--space-xl);
    border: 1px solid color-mix(in srgb, var(--dpa-danger) 35%, transparent);
    background: color-mix(in srgb, var(--dpa-danger) 6%, var(--bg-page));
    border-radius: var(--radius-sm);
    padding: var(--space-lg);
    display: grid;
    gap: var(--space-sm);
    font-size: 14.5px;
  }
  .auth__unconfigured a { color: var(--fg-accent); text-decoration: underline; }
  .auth__unconfiguredTag { font-family: var(--dpa-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dpa-danger); }

  .mono { font-family: var(--dpa-mono); font-size: 13px; }
  .mono--chip { background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px; }

  /* the once-only reveal, app.css lines 608 to 658 at c594dfc. Approximations the
     token layer forces: --r-lg (16px) has no prima-tokens radius, so --radius-sm (8px);
     --purple-50 is a 6% mix of the accent; the two violet text colours #43307E and
     #5B4A94 both read --c-violet-600. Named in the recipe's Frontend section. */
  .reveal {
    border: 2px solid var(--fg-accent);
    border-radius: var(--radius-sm);
    background: color-mix(in srgb, var(--fg-accent) 6%, var(--bg-page));
    padding: var(--space-lg);
    margin-top: var(--space-lg);
  }
  .reveal__tag { font-family: var(--dpa-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--fg-accent); }
  .reveal__warn { margin-top: 10px; font-size: 15.5px; font-weight: 500; color: var(--c-violet-600); }
  .reveal__keyrow { display: flex; flex-wrap: wrap; align-items: stretch; gap: var(--space-sm); margin-top: var(--space-md); }
  .reveal__key {
    flex: 1 1 320px;
    min-width: 0;
    font-size: 13.5px;
    line-height: 1.5;
    word-break: break-all;
    background: var(--bg-page);
    border: 1px solid color-mix(in srgb, var(--fg-accent) 20%, transparent);
    border-radius: var(--radius-sm);
    padding: 13px 15px;
    user-select: all;
  }
  .reveal__note { font-size: 13.5px; color: var(--c-violet-600); margin-top: var(--space-md); max-width: 64ch; }
  .copybtn {
    font: inherit;
    font-size: 13.5px;
    cursor: pointer;
    border: 1px solid var(--border-strong);
    background: var(--bg-page);
    color: var(--fg-default);
    border-radius: var(--radius-sm);
    padding: 9px 15px;
    white-space: nowrap;
  }

  /* Code.astro's block */
  .code { border: 1px solid var(--border-default); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-dark); color: var(--fg-on-dark); }
  .code__bar { display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.12); font-family: var(--dpa-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(255, 255, 255, 0.6); }
  .code__copy { font: inherit; background: none; border: 1px solid rgba(255, 255, 255, 0.25); color: inherit; border-radius: 6px; padding: 3px 9px; cursor: pointer; text-transform: none; letter-spacing: 0; }
  .code pre { margin: 0; padding: var(--space-lg); overflow-x: auto; font-family: var(--dpa-mono); font-size: 13px; line-height: 1.62; }
  .code pre code { font: inherit; }
  .formrow { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
</style>
Component tree, from the recipe
Layout (title, description, robots noindex, path)          src/layouts/Layout.astro: header.nav, main, footer.foot
  div.auth (sand ground, card centred)                      src/styles/auth.css
    div.auth__card (max-width 460px; 620px on /signup/confirm)
      span.auth__mark (three bars)
      h1, p.auth__lede
      /anmelden           form#signin-form (input email, button "Anmeldelink senden") | div#sent[hidden] (auth__sentMark, the "falls ... besteht" copy, button#again) | div.auth__unconfigured
      /registrieren       form#signup-form (input email, input company optional, button "Bestätigungslink senden") | div#sent[hidden] (+ span#sent-expiry) | div.auth__unconfigured
      /anmelden/verify    h1#heading, p#lede, div#actions[hidden] (a "Neuen Link anfordern"), div.auth__unconfigured, noscript
      /auth/magic-link    p#lede, noscript; forwards to /anmelden/verify#token= (or /auth/desktop/#token= when a desktop record is fresh)
      /signup/confirm     h1#heading, p#lede, div.reveal#reveal[hidden] (reveal__tag, reveal__warn, code#reveal-key, button#reveal-copy, reveal__note), div#next[hidden] (Code lang=bash, a "Zum Schnellstart", a "Anmelden"), div#actions[hidden], div.auth__unconfigured, noscript
      div.auth__status#status[role=status][aria-live=polite]   present from first paint, empty, classed --busy | --ok | --error by the script
      p.auth__alt (the other door), p.auth__fine
<script> per page: requestMagicLink | verifyMagicLink + saveSession + mintGatewayKeyAtSignIn | signUp | verifySignup   src/lib/auth.ts
Design system
prima
Packages
@prima-modules/prima-tokens 0.1.0, from prima.li 03f4205
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild developer-portal-auth-pages/demo.astro, rendered by the docs host as one frame per state (RENDERING.md, section 3). One Astro component takes state and renders the card the real page would show after its script has run: every branch that the real script toggles with hidden and a class is here a server-rendered state, so all seventeen are on the page at once and none touches the network. The real page is a static shell (the visible branch is the script's decision after the fetch, and the HTML carries no account data); that is a property of the page, not a mount mode, so the frontmatter's hydrate stays at the default visible: the docs host renders an Astro demo server-side with no directive and reads the key for React demos only (prima.li/src/index-site/DemoIsland.astro at 86fe9d1, lines 131 to 136).

States, in order: signin (the form, nothing typed), signin-busy (button disabled and relabelled, the live region busy), signin-invalid (the client check refused the address: aria-invalid, the error status), signin-sent (the 202 panel with its enumeration-safe copy and "Andere Adresse verwenden"), signin-denied (403), signin-throttled (429 without a countdown), verify-busy, verify-failed (the three-cause sentence, the "Neuen Link anfordern" action), verify-ok (status ok, the manual link), magic-link-forward (the mailer's route: the card with the lede and a one-line note of where the token goes; the real page shows the lede alone for the instant before replace), signup (e-mail and the optional company), signup-sent (the panel with the server-stated 30 minutes and the bold "erst, wenn Sie den Link öffnen"), confirm-busy, confirm-key (201: the reveal with an obviously fake key in one node, the first curl block, the two buttons; the card widens to 620 px as confirm.astro line 53 does), confirm-taken (409 with "Zur Anmeldung"), confirm-no-key (201 without a key: the error status and the first-call block, no empty box), unconfigured (/anmelden built without PUBLIC_PRIMA_API_BASE: no form, the red panel naming the variable and the sign-in footer; the other four pages carry their own unconfigured copy, registrieren.astro 100 to 111, verify.astro 47 to 59, confirm.astro 112 to 125, and the verify and confirm scripts also rewrite the heading; those variants are not states).

Design system prima (D1b): the demo draws auth.css with @prima-modules/prima-tokens aliases. The mapping, portal variable to token: --sand-deep (the ground) to --bg-surface; the white card to --bg-page; --line to --border-default; --line-strong to --border-strong; --purple to --fg-accent; --purple-dark to --c-violet-600; --ink to --fg-default; --ink-60 to --fg-muted; --ink-30 to a color-mix of --fg-default into the page; --r-xl (card) to --radius, --r-md and --r-sm to --radius-sm; --s-3 to --s-9 to --space-sm through --space-4xl (the same pixel values at 16 px root); --shadow-lg to --shadow-soft; --font-display to --font-body at weight 500 (prima.li's display face is a serif italic accent, not a heading face); --mono to --work-mono. The once-only reveal (app.css 608 to 658) and the code block (docs.css 137 to 189) bring four more: --r-lg (16 px, the reveal's corner) to --radius-sm (8 px; the token layer has no 16 px step and --radius is 20 px), --purple-50 (the reveal's ground) to a 6% color-mix of --fg-accent into the page, the two violet text colours #43307E (reveal__warn) and #5B4A94 (reveal__note) both to --c-violet-600, and the code block's --slate to --bg-dark at the docs' 13 px on --s-5 padding. Two colours the token layer does not carry, the refusal red #c4362f (--swiss) and the confirmation green #2f7d5b with its tint (--ok, --ok-bg), are declared once on the demo root and named in Gotchas as a gap in prima-tokens.

The frames flow at the column's width with no viewport: the card is max-width: 460px centred on its ground, the screen is the card, and the site header and footer around it (Layout.astro lines 51 to 131) are not rendered because the auth pages sit outside the signed-in sub-navigation; F8 renders that shell. Text and headings inside the demo are div and h3, and inline code is span.mono, because the docs host's shell rules reach p, h1, h2 and code inside a frame at a specificity an Astro scoped rule cannot beat (prima.li/src/index-site/demo-frame.css at 86fe9d1, the header comment and lines 147 to 169); the real pages use h1, p and code.

Responsive: the card is max-width: 460px (620 px on confirm) and shrinks with the column down to about 400 px of viewport; the inputs stay 16 px so iOS does not zoom on focus (auth.css line 69). Below that the unbreakable tokens set the floor, on the reference and in the demo alike: PUBLIC_PRIMA_API_BASE in the unconfigured chip (auth.css 210 to 216, no wrapping), the sample address in the confirm lede and developer.prima.li in the denied status. Measured on the built docs page on 2026-09-12: at a 400 px viewport the unconfigured card is 294 px in a 254 px ground and the two confirm cards 261 px; at 360 px eight of the seventeen cards pass their ground and the unconfigured stage scrolls sideways (Gotchas). The real site's header collapses its links below 900 px and keeps "Anmelden" at every width (Layout.astro 164 to 175). Loading is the busy status plus the disabled button; empty is the untouched form; error is the error status, and on the landing pages also the swapped heading and the action link.

What the demo deliberately does not render: the header and footer, the noscript blocks (the verify and confirm pages carry one explaining that a static site needs JavaScript to redeem a link, verify.astro 61 to 71, confirm.astro 127 to 137; magic-link.astro 58 to 63 carries the "Neuen Link anfordern" line inside its noscript and nothing under the lede otherwise, so the magic-link-forward card ends with the lede and the route note), the beforeunload guard, the redirect after verify-ok, the focus moves, and the desktop branch of /auth/magic-link (its target page is a primacode row). Two docs-only differences in the markup, named in the demo's header: the forms carry method="dialog", which the HTML form submission algorithm turns into a no-op outside a <dialog>, so a click or Enter on the docs page neither reloads it nor puts the typed address into its query string (the real forms have no method and their script calls preventDefault); and the aria-live region is written without whitespace children in the states that have no status, so .auth__status:empty matches as it does on the real page (Gotchas). The /auth/magic-link card has no status region, as magic-link.astro 53 to 65 has none. The copy is the pages' own with the em dashes the source uses replaced by a colon or a full stop (house rule); the support address and the sample address are placeholders.

Security & tenancy

/anmelden: 403, the account is not granted prima_dev signin-denied server rendered

Anmelden

Prima kennt keine Passwörter. Geben Sie Ihre E-Mail-Adresse ein: wir senden Ihnen einen Link, der Sie einmalig anmeldet.
Dieselbe Adresse wie auf prima.li, falls Sie dort schon ein Konto haben.
Dieses Konto ist für developer.prima.li nicht freigeschaltet. Ihr Zugang zu prima.li und zu den internen Team-Tools bleibt davon unberührt.
Noch kein Zugang? Zugang anfragen
Sie melden sich am Entwicklerportal an. Guthaben und Rechnungsangaben liegen getrennt davon auf prima.li.

What the browser may hold: the access token in sessionStorage (dies with the tab; the interim answer until a backend on a *.prima.li origin can set an HttpOnly cookie scoped to .prima.li, which ADR-0020 reserves and which does not exist yet, auth.ts 548 to 571), the per-session ai key beside it (F8), and the parked row ids. Never a key beyond that page load, never a refresh token, never a password, never the magic-link token after it was read.

Origin and CSRF: every call is credentials: "omit", so no ambient credential is sent and the API's Set-Cookie is ignored (161 to 166); the Labs CORS list is built from the surface registry, so https://developer.prima.li is an explicit allowed origin and never a wildcard (alchemy_labs/backend/app_fastapi.py 400 to 411 and core/surfaces.py 152 to 157 at fd8558f). The surface travels as surface: "prima_dev" in the request bodies and as X-Prima-Surface on the signed-in calls (prima-api.ts 415); the backend is the enforcement point and the constant is the claim (auth.ts 24 to 27).

Surface gating: a 403 on request or verify renders the surface_denied copy and stays on the page; /me.surfaces is read on the next page (F8), where prima_dev missing is a 403 state that does not bounce to sign-in, because signing in again cannot add a grant (prima-api.ts 492 to 500). Nothing on these pages is hidden by CSS as a security measure: the unconfigured branch is a build-time decision (the form is not in the HTML), and the sent panel hides the form after a 202 for focus, not for access.

Attacker-controlled inputs rendered: the typed address (written with textContent), server error strings (escaped before innerHTML), the key (textContent). The status region is role=status aria-live=polite from first paint so late insertions are announced (anmelden.astro 90 to 99).

Tests

  • prima-developer has no test for these pages at c594dfc: npm test is node --test --experimental-strip-types "src/**/*.test.ts" and runs the pure helpers only (gateway-key.test.ts, billing.test.ts, desktop-auth.test.ts: 173 tests, 30 suites, all passing on 2026-09-12). auth.ts itself has no test.
  • What a new app adds, as node --test on pure functions: looksLikeEmail accepts [email protected] and refuses a@b; detailOf reads error before detail and the first msg of a list; a table of (status, body) to AuthErrorCode for each of the four calls, with 409 only on accounts/verify and 403 only on the two magic-link calls; readSession returns null and clears the entry when expiresAt has passed.
  • Playwright, against dist/ served statically with the API mocked at the route level: (a) submit /anmelden with a malformed address and assert aria-invalid and the error status without a network call; (b) mock a 202 and assert the sent panel names the typed address and the form is hidden; (c) open /auth/magic-link?token=x and assert the navigation lands on /anmelden/verify with the token in the fragment and no token in the query; (d) mock verify-magic-link 200 and assert sessionStorage['prima.dev.session.v1'] exists and the page leaves for /schluessel/; (e) mock accounts/verify 201 with a key and assert exactly one element contains it and sessionStorage does not.
  • The kit's gate: npm run check prints no line for this recipe; the render smoke (node tooling/verify/render-smoke.mjs) covers React demos only and reports nothing for an Astro demo; the docs build is the render check for it (Acceptance check 6).

Deploy & ops

Build target: Astro static (astro build, output default), dist/ served as verbatim bytes. Env baked at build: PUBLIC_PRIMA_API_BASE (the only host the pages talk to) and PRIMA_REQUIRE_API_BASE=1 on a release build so an unconfigured build cannot ship (astro.config.mjs 26 to 96). The deploy path is S13's static portal: the ship script builds with both variables set, refuses an empty dist/index.html or any localhost, 127.0.0.1 or staging host left in dist/, backs up the served directory and rsyncs the new one in (S13 Application logic steps 20 to 21); rollback is the printed rsync from the backup. The comment at the top of astro.config.mjs (lines 3 to 4) still says "deployed to Cloudflare Pages"; S13 measured the rsync path on 2026-09-11.

CORS: nothing to add on the portal; the API side lists the surface origin (Security & tenancy). CSP: the portal sets none of its own at c594dfc (no <meta http-equiv> in Layout.astro, no headers file in public/). Argus: none for these pages; a broken confirmation route has no symptom but people who never arrive (confirm.astro 30 to 32), so the check that matters is the ingress one in B14 (the /accounts* routes matched to Labs before the Prima catch-all).

Gotchas

  • 2026-09-12 (rendering trap, fixed in the demo the same day): Astro emits the template whitespace around a falsy {cond && ...} expression as a text node, so a region written as <div class="auth__status">{v.status && ...}</div> across several lines serialised as <div class="auth__status"> </div>, :empty never matched and six states (signin, signin-sent, magic-link-forward, signup, signup-sent, unconfigured) painted a 34 px empty bordered box under the form that the real page never shows (anmelden.astro 93 to 99 is <div ...></div>, hidden by auth.css 102 to 104). The fix is two branches, the empty one written as <div ...></div> with nothing between the tags; the built docs page now computes display: none and height 0 for all six (Playwright against prima.li at 86fe9d1, 2026-09-12).
  • 2026-09-12 (rendering trap, fixed in the demo the same day): a <form> with no action and no script performs a native GET on submit, so a click or Enter in a docs-page frame reloaded the recipe page with ?email= in the query, the one request an Astro demo could still cause (RENDERING.md section 2, nothing reaches the network). method="dialog" on the demo's forms makes the submission a no-op outside a <dialog>; the click and the Enter key both leave the URL unchanged on the built page.
  • 2026-09-12 (measured at c594dfc, auth.css 210 to 216 and the built docs page): the reference card is not fluid below about 400 px. .auth__unconfigured code has no overflow-wrap, and neither the lede nor the status region breaks a long token, so PUBLIC_PRIMA_API_BASE, the address in the confirm lede and developer.prima.li in the denied copy push the card past its ground (294 px in 254 px at a 400 px viewport on the unconfigured page; eight of seventeen states at 360 px). The demo keeps the rules so the frame is faithful. The fix it needs is overflow-wrap: anywhere on .auth__unconfigured code, .auth__lede and .auth__status in auth.css; no code is changed in this wave (D8).
  • 2026-09-12 (measured at c594dfc, verify.astro lines 8 to 15): the page's header comment says the mailer builds its URL from FRONTEND_URL rather than the surface registry. At fd8558f it does not: auth_router.py line 1090 sets base_url = surface.origin and only the labs surface in development falls back to FRONTEND_URL (1091 to 1092). The trap the W6 route closed was the path, /auth/magic-link (email_service.py 522), not the host; magic-link.astro lines 7 to 26 record it correctly. A stale comment, no behaviour to fix; the fix it needs is deleting lines 8 to 15.
  • 2026-09-12 (measured at c594dfc, auth.ts 440 to 450, and B14 API surface): POST /api/prima/v1/accounts answers 403 with "Self-service signup is disabled on this deployment ..." or "Signup for this email domain is not permitted ..."; signUp has no 403 branch, so both land in unknown and render "Unerwartete Antwort vom Anmeldedienst." with the server's sentence dropped. verifySignup (508 to 524) has the same gap for the re-check at redemption. Fix it needs: a surface_denied-style branch for 403 on both that renders detailOf(res.body).
  • 2026-09-12 (measured at fd8558f, app_fastapi.py 452; c594dfc, auth.ts 175 to 181): Retry-After is not in expose_headers, so the 429 copy never carries the countdown the client is written to render. The fix is one entry on the Labs side (expose_headers=[..., "Retry-After"]), then no change here.
  • 2026-09-12 (measured at c594dfc, confirm.astro 181 and 280): the redeemed latch guards run(), which is called exactly once and has no caller that could run it again; the latch is a belt for a retry button that was deliberately not built. Harmless.
  • 2026-09-12 (measured at c594dfc, confirm.astro 19 to 24 and docs.ts 25 to 28): the first-call block on an unconfigured build prints https://<PUBLIC_PRIMA_API_BASE>/api/prima/v1, which is the placeholder the demo shows too; on a configured build it is the baked host plus /api/prima/v1.
  • 2026-09-12 (rendering trap): the real script decides the visible branch after the fetch, so a screenshot of the page shows the form and nothing else; the demo renders each branch as its own state. A verifier comparison against /anmelden matches signin only, and verify.route says so.
  • 2026-09-12 (rendering trap, demo-frame.css at 86fe9d1): p, h1, h2 and code inside a frame inherit the frame's reset, so the demo uses div, h3 and span.mono; a rebuild that keeps the source's elements renders its lede and title unstyled on the docs host.
  • 2026-09-12 (@prima-modules/prima-tokens at its 03f4205 pin): prima.li's tokens.css has no state colours (no danger, no success); the demo declares the portal's two values locally (--dpa-danger, --dpa-ok, --dpa-ok-bg). When the token layer gains them, the demo reads them and drops its own.
  • 2026-09-12 (measured at c594dfc, auth.ts 376 to 377): a 200 without expires_in gives the local session 1800 seconds; B5's 200 carries expires_in, so the default is a fallback and not the lifetime.
  • 2026-08-25 (wiki platform/primaapi.md, recorded in magic-link.astro 7 to 18): every mailed prima_dev link landed on a 404 because the site did not serve /auth/magic-link; the workaround was to request the link as prima_chat from the Labs host. Serving the path (W6) closed it; the workaround stays valid.
  • 2026-08-03 (prima-api.ts 549 to 555, measured then): POST /api/auth/logout answered 404 through the API host's ingress allow-list, so sign-out is local until the path is allowed; the request stays in place because it is correct.

Reference implementation

Branch: prima-developer w6-konto at c594dfc (2026-09-12), one commit past the d5f9aa8 the inventory row names (the desktop hand-over, which touches magic-link.astro 35 to 44 and 87 to 93). Paths relative to the repo root:

  • src/pages/anmelden.astro (187 lines): guard comment 6 to 13, card 22 to 109 (form 33 to 55, sent panel 57 to 72, unconfigured 75 to 87, status region 93 to 99, alt and fine print 101 to 107), script 112 to 187 (setStatus 125 to 133, submit 137 to 170, again 172 to 178, escapeHtml 181 to 186).
  • src/pages/anmelden/verify.astro (188 lines): header 6 to 20, card 29 to 77, script 80 to 188 (REDIRECT_TO 91, extractToken 103 to 110, scrubUrl 117 to 123, run 125 to 185).
  • src/pages/auth/magic-link.astro (95 lines): why it exists 5 to 44, script 68 to 95.
  • src/pages/registrieren.astro (211 lines): what the page can promise 6 to 19, form 39 to 75, sent panel 77 to 97, script 133 to 211 (expiry sentence 186 to 189).
  • src/pages/signup/confirm.astro (281 lines): firstCall 19 to 24, the page every signup mail points at 26 to 43, reveal 69 to 85, next 87 to 104, script 142 to 281 (redeemed 181, run 183 to 255, copy 257 to 268, beforeunload 272 to 278).
  • src/lib/auth.ts (951 lines): header 1 to 52, SURFACE 77, API_BASE and IS_CONFIGURED 86 to 91, CONTACT 108, result type 114 to 143, post 167 to 196, detailOf 229 to 237, requestMagicLink 248 to 288, looksLikeEmail 290 to 294, verifyMagicLink 328 to 394, signUp 415 to 460, verifySignup 487 to 542, session storage 548 to 659, gateway key storage 662 to 866, desktop record 869 to 951.
  • src/lib/prima-api.ts (1442 lines): SIGNIN_PATH 201, redirectToSignIn 476 to 481, requireSession 507 to 546, signOut 557 to 578, mintGatewayKeyAtSignIn 746.
  • src/layouts/Layout.astro (207 lines): header 51 to 77, footer 83 to 131, nav styles 135 to 175. src/components/AppNav.astro (26 lines) is the signed-in sub-navigation and is not on these pages.
  • src/styles/auth.css (224 lines): .auth 5, .auth__card 13, form 49 to 99, status 102 to 138, sent 141 to 174, footer of the card 177 to 193, unconfigured 196 to 224.

Deviations of the reference from this recipe, dated 2026-09-12: the stale mailer comment in verify.astro; no 403 branch on the two signup calls; no test of auth.ts; the astro.config.mjs comment naming Cloudflare Pages where S13 measured rsync; the card overflowing its ground below about 400 px on the pages that carry an unbreakable token (Gotchas).

Acceptance checks

From prima-developer at c594dfc, or a new portal built to this recipe:

  1. grep -c 'surface: SURFACE' src/lib/auth.ts prints 3 (request, verify, signup); grep -n '"prima_dev"' src/lib/auth.ts prints line 77 and nothing else.
  2. grep -rn 'type="password"' src/pages src/components src/layouts prints nothing, and grep -rn 'type="password"' src/ prints exactly one line, the forbidding comment at src/lib/auth.ts line 21 (verified 2026-09-12 at c594dfc).
  3. grep -n 'credentials: "omit"' src/lib/auth.ts src/lib/prima-api.ts prints at least three lines (the auth post, the data client's send, the logout fetch).
  4. PUBLIC_PRIMA_API_BASE= npx astro build warns PUBLIC_PRIMA_API_BASE is not set and grep -c 'Nicht konfiguriert' dist/anmelden/index.html prints 1 with no <form in the file; PRIMA_REQUIRE_API_BASE=1 PUBLIC_PRIMA_API_BASE= npx astro build exits non-zero; PUBLIC_PRIMA_API_BASE=http://api.example.com npx astro build exits non-zero (non-local http).
  5. Browser walk-through against dist/ served statically (tooling/verify/astro-static.sh <site> /anmelden --serve) with the API mocked: (a) /anmelden: type a@b, submit, observe the red outline and "Bitte geben Sie eine gültige E-Mail-Adresse ein." with no request sent; (b) type any address, mock 202, observe "Prüfen Sie Ihren Posteingang", the address in bold, the form gone, focus on the panel; click "Andere Adresse verwenden", observe the empty form back; (c) mock 403, observe the surface_denied sentence and the form still present; (d) open /auth/magic-link?token=abc, observe the address bar /anmelden/verify with no token in the query and then, after the mocked 200, sessionStorage holding prima.dev.session.v1 and the page leaving for /schluessel/; (e) /anmelden/verify#token=abc with a mocked 400, observe "Anmeldung fehlgeschlagen", the three-cause sentence and "Neuen Link anfordern"; (f) /registrieren, mock 202 with expires_in_minutes: 30, observe "läuft nach 30 Minuten ab"; mock 202 without it, observe "nur für kurze Zeit"; (g) /signup/confirm#token=abc with a mocked 201 carrying api_key, observe "Einmalig", the key in exactly one element (document.body.innerText.split(key).length === 2), sessionStorage without it, the curl block reading $PRIMA_API_KEY; reload, observe the key gone and "Bestätigung fehlgeschlagen" for the now-spent mock; (h) mocked 409, observe "Zur Anmeldung".
  6. Demo gate: npm run check prints no line for this recipe; in the prima.li checkout, PRIMA_MODULES_DIR=<this repo> npm run build:index writes dist/docs/modules/identity-auth/ui/developer-portal-auth-pages/index.html, and opened in Playwright the page has seventeen figure[data-demo-state] elements each with a .dpa child, pageerror count 0, .auth__status in the signin frame matching :empty with computed display: none, no .auth__status in the magic-link-forward frame, and the page URL unchanged after a click on the signin frame's submit button (verified 2026-09-12 against prima.li at 86fe9d1).
  7. Verifier: node tooling/verify/compare.mjs identity-auth/ui/developer-portal-auth-pages --state signin --docs http://127.0.0.1:4321 --app http://127.0.0.1:4320 --app-repo <prima-developer> with the portal served by astro-static.sh and built with a configured base; the report's page_errors is empty on both sides and mismatch_ratio is read for the card only, excluding by name the site header and footer (not in the demo), the font (the portal self-hosts Helvetica Now, the docs host renders the token stack) and the purple (#6844C4 on the portal, #6642C1 in prima-tokens; RENDERING.md section 7).

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.