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 |
|
| Measured on | prima-developer w6-konto c594dfc 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
confirm-key server rendered Konto bestätigt
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"}]}' 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
unconfigured server rendered Anmelden
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:
| Page | State | Where |
|---|---|---|
/anmelden, /registrieren | form 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/confirm | the heading and lede text, the status region, actions.hidden, and on confirm reveal.hidden, next.hidden, the redeemed latch | verify.astro 84 to 87 and 125 to 185; confirm.astro 145 to 149, 181, 183 to 255 |
/auth/magic-link | none; it reads the URL and leaves | magic-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):
| Key | Store | Written by | Lifetime | Holds |
|---|---|---|---|---|
prima.dev.session.v1 | sessionStorage | saveSession 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 signOut | the 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.v1 | sessionStorage | mintGatewayKeyAtSignIn on the verify page, best-effort (verify.astro 168 to 172; prima-api.ts 746) | the tab; parked and dropped when stale, expired or refused | the 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.v1 | sessionStorage | the keys page's housekeeping, not these pages | survive clearSession on purpose (639 to 647) | row ids only, never a key |
prima.desktopAuth | localStorage | /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):
| Route | Body sent | Success parsed | Status 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 read | 429 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):
- 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_CONFIGUREDand both nodes exist (118). - Submit:
preventDefault, trim, cleararia-invalid, disable the button and relabel it "Wird gesendet …", statusbusy(137 to 146). requestMagicLink(email): the loose client check first (looksLikeEmail, 290 to 294; a typo saved from a rate-limited round trip), then the POST.- Failure: the button restored;
invalid_emailalso setsaria-invalidand focuses the input; the message in the status region aserror(153 to 160). - Success: status cleared, form hidden, the typed address written into the sent panel, panel shown and focused with
tabindex=-1so 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:
- The mailer's route reads
tokenfrom#token=first, then?token=(magic-link.astro72 to 79). No token:replace('/anmelden/'). Otherwise it forwards withreplaceto/anmelden/verify#token=..., or to/auth/desktop/#token=...whenreadDesktopAuth()returns a fresh record (81 to 94): the fragment never reaches a server or a Referer, andreplacekeeps the mailed URL out of history. /anmelden/verifyreads the token the same way, thenscrubUrl(history.replaceStateto 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).- Status
busy;verifyMagicLink(token). Failure: heading "Anmeldung fehlgeschlagen", the message, and for every code butrate_limitedthe sentence that links are single-use and short-lived; the action appears (153 to 163). - Success:
saveSession(result.data)(166), thenmintGatewayKeyAtSignIn()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", statusokwith a manual link, thenlocation.replace('/schluessel/')so the back button cannot return to a spent token (174 to 184).
Registration, /registrieren then /signup/confirm:
- Same shape as sign-in with a second, optional
companyfield (registrieren.astro39 to 75);signUp(email, company)sendsnullfor an empty company (auth.ts426 to 434). The sent panel says a link is on its way "falls für ... ein Konto angelegt werden kann", states the expiry only fromexpiresInMinutes, and says in bold that account and key exist only once the link is opened (77 to 97, 187 to 189). /signup/confirmreads the token, scrubs the URL, latchesredeemedand callsverifySignup(token)once per page load (confirm.astro178 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).- Failure: heading "Bestätigung fehlgeschlagen" and the message; for
email_takenthe action is rebuilt as "Zur Anmeldung" because sign-in is the useful next step (212 to 229). - Success: heading "Konto bestätigt", lede with the address. With a key: status
ok"Willkommen bei PrimaAPI.", the key written into#reveal-keywithtextContent, the reveal shown, the key node focused (237 to 244). Without a key: anerrorstatus 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_KEYfrom the environment rather than interpolating the key (19 to 24, 254). - Copy button:
navigator.clipboard.writeText, label "Kopiert" on success, an honest "Nicht möglich, bitte markieren und kopieren" on refusal (257 to 268).beforeunloadwarns 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.ts44 to 51, 406 to 413). - The verify copy names three causes at once (expired, used, invalid) because the server answers them alike (
auth.ts49 to 51, 353 to 358). - A raw key is rendered into exactly one node by
textContent, never withinnerHTML, never stored, never in the URL;hideRevealon 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.astro34 to 43, 240 to 244, 272 to 278;prima-api.tsINVARIANT 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.astro102 to 123). verifySignupruns at most once per page load (confirm.astro181, 189 to 190).- There is no
<input type="password">in the repository (auth.ts18 to 22; Acceptance check 2). - Every status message is escaped before
innerHTML(escapeHtml,anmelden.astro181 to 186 and the same in the other three scripts); the sent-panel address is written withtextContent. - The unconfigured build renders no form: an address typed into a form with nothing to post to would be discarded silently (
anmelden.astro6 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.
signin server rendered Anmelden
signin-busy server rendered Anmelden
signin-invalid server rendered Anmelden
signin-sent server rendered Anmelden
signin-throttled server rendered Anmelden
verify-busy server rendered Anmeldung wird geprüft
verify-failed server rendered Anmeldung fehlgeschlagen
verify-ok server rendered Angemeldet
magic-link-forward server rendered Anmeldung wird geprüft
signup server rendered Zugang anfragen
signup-sent server rendered Zugang anfragen
confirm-busy server rendered Konto wird bestätigt
confirm-taken server rendered Bestätigung fehlgeschlagen
confirm-no-key server rendered Konto bestätigt
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"}]}' 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 = 'hilfe@example.com';
const EMAIL = 'entwicklerin@example.com';
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="sie@firma.ch"
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 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
signin-denied server rendered Anmelden
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-developerhas no test for these pages at c594dfc:npm testisnode --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.tsitself has no test.- What a new app adds, as
node --teston pure functions:looksLikeEmailacceptsa@b.chand refusesa@b;detailOfreadserrorbeforedetailand the firstmsgof a list; a table of(status, body)toAuthErrorCodefor each of the four calls, with 409 only onaccounts/verifyand 403 only on the two magic-link calls;readSessionreturnsnulland clears the entry whenexpiresAthas passed. - Playwright, against
dist/served statically with the API mocked at the route level: (a) submit/anmeldenwith a malformed address and assertaria-invalidand 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=xand assert the navigation lands on/anmelden/verifywith the token in the fragment and notokenin the query; (d) mockverify-magic-link200 and assertsessionStorage['prima.dev.session.v1']exists and the page leaves for/schluessel/; (e) mockaccounts/verify201 with a key and assert exactly one element contains it andsessionStoragedoes not. - The kit's gate:
npm run checkprints 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>,:emptynever 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.astro93 to 99 is<div ...></div>, hidden byauth.css102 to 104). The fix is two branches, the empty one written as<div ...></div>with nothing between the tags; the built docs page now computesdisplay: noneand 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 noactionand 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.css210 to 216 and the built docs page): the reference card is not fluid below about 400 px..auth__unconfigured codehas nooverflow-wrap, and neither the lede nor the status region breaks a long token, soPUBLIC_PRIMA_API_BASE, the address in the confirm lede anddeveloper.prima.liin 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 isoverflow-wrap: anywhereon.auth__unconfigured code,.auth__ledeand.auth__statusinauth.css; no code is changed in this wave (D8). - 2026-09-12 (measured at c594dfc,
verify.astrolines 8 to 15): the page's header comment says the mailer builds its URL fromFRONTEND_URLrather than the surface registry. At fd8558f it does not:auth_router.pyline 1090 setsbase_url = surface.originand only thelabssurface in development falls back toFRONTEND_URL(1091 to 1092). The trap the W6 route closed was the path,/auth/magic-link(email_service.py522), not the host;magic-link.astrolines 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.ts440 to 450, and B14 API surface):POST /api/prima/v1/accountsanswers 403 with "Self-service signup is disabled on this deployment ..." or "Signup for this email domain is not permitted ...";signUphas no 403 branch, so both land inunknownand 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: asurface_denied-style branch for 403 on both that rendersdetailOf(res.body). - 2026-09-12 (measured at fd8558f,
app_fastapi.py452; c594dfc,auth.ts175 to 181):Retry-Afteris not inexpose_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.astro181 and 280): theredeemedlatch guardsrun(), 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.astro19 to 24 anddocs.ts25 to 28): the first-call block on an unconfigured build printshttps://<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
/anmeldenmatchessigninonly, andverify.routesays so. - 2026-09-12 (rendering trap,
demo-frame.cssat 86fe9d1):p,h1,h2andcodeinside a frame inherit the frame's reset, so the demo usesdiv,h3andspan.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-tokensat its 03f4205 pin): prima.li'stokens.csshas 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.ts376 to 377): a 200 withoutexpires_ingives the local session 1800 seconds; B5's 200 carriesexpires_in, so the default is a fallback and not the lifetime. - 2026-08-25 (wiki
platform/primaapi.md, recorded inmagic-link.astro7 to 18): every mailedprima_devlink landed on a 404 because the site did not serve/auth/magic-link; the workaround was to request the link asprima_chatfrom the Labs host. Serving the path (W6) closed it; the workaround stays valid. - 2026-08-03 (
prima-api.ts549 to 555, measured then):POST /api/auth/logoutanswered 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 (setStatus125 to 133, submit 137 to 170, again 172 to 178,escapeHtml181 to 186).src/pages/anmelden/verify.astro(188 lines): header 6 to 20, card 29 to 77, script 80 to 188 (REDIRECT_TO91,extractToken103 to 110,scrubUrl117 to 123,run125 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):firstCall19 to 24, the page every signup mail points at 26 to 43, reveal 69 to 85, next 87 to 104, script 142 to 281 (redeemed181,run183 to 255, copy 257 to 268,beforeunload272 to 278).src/lib/auth.ts(951 lines): header 1 to 52,SURFACE77,API_BASEandIS_CONFIGURED86 to 91,CONTACT108, result type 114 to 143,post167 to 196,detailOf229 to 237,requestMagicLink248 to 288,looksLikeEmail290 to 294,verifyMagicLink328 to 394,signUp415 to 460,verifySignup487 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_PATH201,redirectToSignIn476 to 481,requireSession507 to 546,signOut557 to 578,mintGatewayKeyAtSignIn746.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):.auth5,.auth__card13, 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:
grep -c 'surface: SURFACE' src/lib/auth.tsprints3(request, verify, signup);grep -n '"prima_dev"' src/lib/auth.tsprints line 77 and nothing else.grep -rn 'type="password"' src/pages src/components src/layoutsprints nothing, andgrep -rn 'type="password"' src/prints exactly one line, the forbidding comment atsrc/lib/auth.tsline 21 (verified 2026-09-12 at c594dfc).grep -n 'credentials: "omit"' src/lib/auth.ts src/lib/prima-api.tsprints at least three lines (the authpost, the data client'ssend, the logout fetch).PUBLIC_PRIMA_API_BASE= npx astro buildwarnsPUBLIC_PRIMA_API_BASE is not setandgrep -c 'Nicht konfiguriert' dist/anmelden/index.htmlprints1with no<formin the file;PRIMA_REQUIRE_API_BASE=1 PUBLIC_PRIMA_API_BASE= npx astro buildexits non-zero;PUBLIC_PRIMA_API_BASE=http://api.example.com npx astro buildexits non-zero (non-localhttp).- Browser walk-through against
dist/served statically (tooling/verify/astro-static.sh <site> /anmelden --serve) with the API mocked: (a)/anmelden: typea@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 thesurface_deniedsentence and the form still present; (d) open/auth/magic-link?token=abc, observe the address bar/anmelden/verifywith notokenin the query and then, after the mocked 200,sessionStorageholdingprima.dev.session.v1and the page leaving for/schluessel/; (e)/anmelden/verify#token=abcwith a mocked 400, observe "Anmeldung fehlgeschlagen", the three-cause sentence and "Neuen Link anfordern"; (f)/registrieren, mock 202 withexpires_in_minutes: 30, observe "läuft nach 30 Minuten ab"; mock 202 without it, observe "nur für kurze Zeit"; (g)/signup/confirm#token=abcwith a mocked 201 carryingapi_key, observe "Einmalig", the key in exactly one element (document.body.innerText.split(key).length === 2),sessionStoragewithout 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". - Demo gate:
npm run checkprints no line for this recipe; in the prima.li checkout,PRIMA_MODULES_DIR=<this repo> npm run build:indexwritesdist/docs/modules/identity-auth/ui/developer-portal-auth-pages/index.html, and opened in Playwright the page has seventeenfigure[data-demo-state]elements each with a.dpachild,pageerrorcount 0,.auth__statusin thesigninframe matching:emptywith computeddisplay: none, no.auth__statusin themagic-link-forwardframe, and the page URL unchanged after a click on thesigninframe's submit button (verified 2026-09-12 against prima.li at 86fe9d1). - 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 byastro-static.shand built with a configured base; the report'spage_errorsis empty on both sides andmismatch_ratiois 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 (#6844C4on the portal,#6642C1inprima-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.