Module kit / Identity and auth
Aurum login and enrolment
identity-auth/ui/aurum-login-and-enrolmentlive
| Kind | frontend |
|---|---|
| Surface | server-HTML |
| Maturity | prod |
| Reusable | yes |
| Design system | alchemy-ui |
| Stack | fastify-5, server-html, simplewebauthn-server-13 |
| Depends on | _shared/design-system-and-tokens |
| Renders | identity-auth/fastify-passkey-auth-ssh-enrolment |
| Entry | aurum/src/web/auth.ts |
| Reference implementation |
|
| Measured on | aurum week2-3-confirm-gate a17fce3 2026-09-12 |
| Version | 1 |
Purpose
Two server-rendered pages and two bodies around them: the login page with a passkey button first and the scrypt password folded away as break-glass, the one-time enrolment page a mailed-nowhere link opens, the uniform "Nicht gefunden." for a token that is unknown, expired or already used, and the JSON 401 every other path answers without a credential. The invariant that makes them trustworthy is that nothing on the public surface can create a credential: an enrolment link exists only because someone with a shell on the host minted it, the page that consumes it cannot tell the caller which way a bad token is bad, and every failure the browser sees is one sentence.
It makes B22 visible: registerAuth's default-deny hook, loginHtml, the /login password path and its AURUM_PASSWORD_LOGIN policy (src/web/auth.ts), enrollHtml, enrollNotFound and the two ceremonies (src/web/webauthn.ts), and the operator half in src/cli/enroll.ts.
When to use
Trigger phrases: "the Aurum login", "Mit Passkey anmelden", "break-glass password", "Passkey einrichten", "the enrolment link", "Nicht gefunden on the enrol link", "401 unauthorized in the browser", "why is there no register button". Surface: server-HTML, a Fastify 5.10.0 app (aurum/package-lock.json at a17fce3) that returns HTML strings with inline styles and one inline script per page (auth.ts 286 to 331, webauthn.ts 343 to 388); no framework, no stylesheet, no build for the pages. It is server HTML because the tool has one user and one job, and a login page that is a string in the auth module is one fewer thing that can drift from the routes it serves.
Do not use it for the Labs SPA's passkey screens (identity-auth/ui/profile-and-passkeys, F2 territory: multi-RP, many users, React), for a Prima surface (F1, F6, F7 are magic-link first), or for a page a customer sees. Copy it for another founder-only or operator-only tool where SSH is already the trust anchor.
Stack assumptions
S1: none of the source's own; the pages carry one inline style on the column (system-ui, max-width: 22rem or 24rem, centred with 4rem above) and element-level inline declarations and nothing else: six on the login page (the button, p#msg, the details, the form, the input, the form's button, auth.ts 290 to 295) and two on the enrolment page (the button and p#msg, webauthn.ts 349 to 350). The demo is built with @prima-modules/alchemy-ui and @prima-modules/tokens, the manifest default (D1b), which is what a customer's primacode builds this surface with; the rebuild is therefore a design decision the source never made, and the Frontend section says which atoms stand in for which inline rule. Component library at the source: none. Browser-side WebAuthn: hand-written, navigator.credentials.get and .create with two base64url helpers shared as the B64URL_JS string (auth.ts 334 to 337, exported and reused by webauthn.ts 353); the inventory row's simplewebauthn-browser-13 is not a dependency (package.json at a17fce3 lists @simplewebauthn/server 13.3.2 and no browser package), so this recipe's stack names the server package. Data-fetch style: inline fetch with content-type: application/json, same-origin, cookies by default. Copy: de-CH, the four sentences the two scripts write, no ß.
Env read by the pages' server (B22 Stack assumptions): AURUM_PASSWORD_LOGIN (off removes the break-glass block, but only once two passkeys exist, auth.ts 190 to 198), AURUM_PUBLIC_ORIGIN (the RP ID and the expected origin, never the request host, 72 and 278 to 280), AURUM_DASH_PASSWORD_HASH, AURUM_SESSION_SECRET, AURUM_API_TOKEN (boot refuses production without them, 57 to 60).
Data model
Client state: two element texts per page and nothing else. #msg on both pages (in the DOM from first paint with min-height: 1.2em, auth.ts 291, webauthn.ts 350; emptied on click, written on failure); on the login page the <details> open flag the browser keeps. No store, no module state beyond the TOKEN constant the enrolment page embeds (webauthn.ts 354: the path token, filtered to [A-Za-z0-9_-], as a JSON string in the script).
Props of the demo (demo.tsx): { screen: 'login' | 'enrol' | 'not-found' | 'unauthorized' | 'operator'; passwordEnabled?: boolean; breakGlassOpen?: boolean; message?: string; transcript?: string }.
Browser storage: none written by page code. What the server sets: the aurum_session cookie, HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200 (auth.ts 200 to 204), an HMAC-signed {sub, iat, exp} (101 to 106), the same cookie for a password login and a passkey login (AuthContext.issueSession, 165 to 178; webauthn.ts 246 and 326). JavaScript cannot read it. What the browser never holds: the password after the form posts, the enrolment token after the page unloads, a credential's private key (it stays in the authenticator), any session value it could copy.
Lifetimes that shape the pages: the session 12 h (SESSION_TTL_MS, 20); an enrolment token ten minutes and single-use (ENROLLMENT_TOKEN_TTL_MS in the store, enroll.ts 123); a challenge single-use, consumed before verification (webauthn.ts 196 to 202, 277 to 279).
API surface
Contracts the two pages consume, all same-origin (the routes are B22's):
| Route | Sent by | Success | Refusal and how the page renders it |
|---|---|---|---|
GET /login | the browser | 200 text/html, loginHtml(await passwordLoginEnabled()) (auth.ts 247 to 249) | none; public (allow-list 26) |
POST /api/webauthn/authenticate/options | passkeyLogin (304 to 306), body {} | 200 {challenge, rpId, timeout, userVerification, allowCredentials: []} (webauthn.ts 253 to 263) | 403 {"error":"bad origin"} (254), 429 {"error":"slow down"} (255): any non-ok is throw new Error('nicht verfuegbar'), caught, rendered as "Anmeldung fehlgeschlagen." (305, 327) |
POST /api/webauthn/authenticate/verify | the assertion as JSON (314 to 324) | 200 {ok: true} and the session cookie (326 to 327); the page sets location.href = '/' | 401 {"error":"invalid credentials"} for malformed, unknown credential, consumed challenge, counter regression, failed verification (268 to 322); 403, 429 as above; any non-ok is throw new Error('abgelehnt') and the same sentence (325, 327). The browser's own cancel of credentials.get lands in the same catch |
POST /login | the break-glass <form> (292 to 296) | 200 {ok: true} and the cookie (267 to 269); the form's own navigation shows that JSON | 401 {"error":"invalid credentials"} for a wrong password, a locked-out address (5 failures per 15 min, 133 to 134) and a disabled password path alike (251 to 266, uniform401); rendered by the browser as the raw body, since it is a form post and not a fetch. See Gotchas for the 415 the form gets at a17fce3 |
GET /enroll/:token | the browser, from the printed link | 200 text/html, enrollHtml(token) after a 40 ms floor (webauthn.ts 122 to 139) | 404 text/html <p>Nicht gefunden.</p> for a rate-limited address, an unknown, expired or used token alike (78 to 85, 127 to 136) |
POST /enroll/:token/options | the enrol script, body {} (359) | 200 the registration options: rp, user, challenge, pubKeyCredParams, attestation: 'none', authenticatorSelection with resident key and user verification required, excludeCredentials (156 to 169) | 403 bad origin; 404 {"error":"not found"} for the token cases: throw new Error('link'), rendered "Fehlgeschlagen. Neuen Link per SSH erzeugen." (360, 385) |
POST /enroll/:token/verify | the attestation as JSON (372 to 381) | 200 {ok: true, label} and the cookie; "Passkey registriert. Weiter …" then location.href = '/' (383 to 384) | 403 bad origin; 404 {"error":"not found"} for malformed, challenge mismatch, attestation failure, unverified, redeem failure (188 to 241): throw new Error('verify'), the same sentence |
| anything else without a credential | a browser typing / | 401 application/json {"error":"unauthorized"} (auth.ts 244); no redirect to /login, no page | the browser shows the body as text |
No WebSocket, no postMessage. Error codes rendered as copy: exactly two sentences, "Anmeldung fehlgeschlagen." and "Fehlgeschlagen. Neuen Link per SSH erzeugen.", whatever the cause; the page never distinguishes a network error from a refused assertion from a cancelled prompt, on purpose (B22 Invariants: unknown, expired and used are indistinguishable at every stage, test/passkey.test.ts 260 to 291).
Application logic
Login, GET /login:
- The hook lets the request through (allow-list,
auth.ts24 to 37); the handler askspasswordLoginEnabled()and renders the page with or without the break-glass block (247 to 249, 286 to 297). - Click "Mit Passkey anmelden":
#msgcleared;POST .../authenticate/options;navigator.credentials.getwith the server's challenge,rpId, timeout and user verification,allowCredentials: []so the authenticator offers its resident keys (301 to 313). - The assertion is serialised with the base64url helpers and posted to
.../authenticate/verify; on ok,location.href = '/'(314 to 326). - Any throw, including the user cancelling the prompt, writes "Anmeldung fehlgeschlagen." (327).
- Break-glass: open the
<details>, type the password, submit; the server answers{ok: true}with the cookie or a uniform 401 (251 to 270). The demo renders the form as the page ships it; Gotchas record what happens to that post at a17fce3.
Enrolment, GET /enroll/:token:
- The operator mints the link over SSH:
node dist/cli/enroll.js --label "<device>"prints${AURUM_PUBLIC_ORIGIN}/enroll/<token>once to stdout and stores only the sha256 with the label, the minting shell's identity and a ten-minute expiry, plus an audit row that carries the fact and never the token (enroll.ts105 to 127). There is no HTTP route that does this (webauthn.ts8 to 13;test/passkey.test.ts346). - The browser opens the link on the device to enrol; the server audits the attempt, checks the enrolment limiter, resolves the token to a live row (length 16 to 256, hash lookup, timing-safe compare, not used, not expired,
liveToken108 to 118), pads to the 40 ms floor and renders the page with the token embedded as a script constant (122 to 139). Every failure is the same 404 page. - Click "Passkey erstellen":
POST /enroll/<token>/options;navigator.credentials.createwith the returnedrp,user, challenge, params,attestation: 'none', resident key required,excludeCredentialsfor already-enrolled ids (356 to 371). - The attestation is posted to
/enroll/<token>/verify; the server consumes the challenge bound to this token, verifies against the canonical origin and RP ID with user verification required, then burns the token and inserts the credential in one transaction (redeemEnrollmentToken, 226 to 236), auditspasskey_enrolled, issues the session cookie and answers{ok, label}. The page writes "Passkey registriert. Weiter …" and goes to/(383 to 384). - Any failure after the page loaded writes "Fehlgeschlagen. Neuen Link per SSH erzeugen." (385): the token is single-use and may already be burnt, so the way forward is a new link, never a retry.
Invariants
- No public route mints an enrolment token; the only writer is the CLI over a shell on the host (
webauthn.ts8 to 13,enroll.ts10 to 12; asserted bytest/passkey.test.ts346 to 365). - The enrolment page renders only for a live token, and the three ways a token is dead produce one status, one body, one content type and one latency floor (
enrollNotFound77 to 85;test/passkey.test.ts260 to 291). - Each page's failure copy is one sentence for every cause; the browser learns nothing from it (
auth.ts327,webauthn.ts385). - The break-glass block exists in the HTML only when
passwordLoginEnabled()is true, and that is true whenever fewer than two passkeys are enrolled, whateverAURUM_PASSWORD_LOGINsays (auth.ts190 to 198, 292 to 296;test/passkey.test.ts301). - A passkey login and a password login mint the same cookie through the same function (
issueSession,auth.ts200 to 204;webauthn.ts246, 326). - The RP ID and the expected origin come from
AURUM_PUBLIC_ORIGIN, never fromHost(auth.ts278 to 280;webauthn.ts23 to 24, 209 to 210, 300 to 301). - The 401 for a missing credential is JSON, not a page, and there is no redirect to
/login(auth.ts244); a browser at/sees the body. - The token never leaves the page except in the two POST paths it is meant for; it is not logged, not in the audit rows, not in the session (
enroll.ts117 to 121;webauthn.ts342).
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.
login server rendered, hydrated in view Aurum
Passwort (Break-glass)
login-break-glass server rendered, hydrated in view Aurum
Passwort (Break-glass)
login-failed server rendered, hydrated in view Aurum
Anmeldung fehlgeschlagen.
Passwort (Break-glass)
login-passkey-only server rendered, hydrated in view Aurum
enrol server rendered, hydrated in view Passkey einrichten
Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.
enrol-done server rendered, hydrated in view Passkey einrichten
Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.
Passkey registriert. Weiter …
enrol-failed server rendered, hydrated in view Passkey einrichten
Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.
Fehlgeschlagen. Neuen Link per SSH erzeugen.
not-found server rendered, hydrated in view Nicht gefunden.
unauthorized server rendered, hydrated in view {"error":"unauthorized"}operator-mint server rendered, hydrated in view $ docker exec -it aurum node dist/cli/enroll.js --label "laptop-beispiel" Enrolment link (single-use, expires 2026-09-12T10:10:00.000Z, ~10 min): https://aurum.example.com/enroll/BEISPIEL-nicht-echt-0000000000000000000000000 Open it in the browser on the device you want to enrol. It is shown once. recovery: only 1 passkey(s) enrolled - the scrypt password stays ENABLED. enrol a second authenticator before setting AURUM_PASSWORD_LOGIN=off (it will be refused otherwise).
identity-auth/ui/aurum-login-and-enrolment/demo.tsx
/**
* Live demo for identity-auth/ui/aurum-login-and-enrolment.
*
* Aurum's two auth pages are Fastify-rendered HTML strings with inline
* styles and one inline script each (`loginHtml` in src/web/auth.ts lines
* 286 to 331 and `enrollHtml` in src/web/webauthn.ts lines 343 to 388, both
* at a17fce3 on week2-3-confirm-gate), plus the two bodies a browser meets
* around them: the uniform 404 page for an unknown, used or expired
* enrolment token (`enrollNotFound`, webauthn.ts 78 to 85) and the JSON 401
* the default-deny hook answers on every other path (auth.ts 244). This file
* rebuilds those four screens from the alchemy-ui atoms (design system
* `alchemy-ui`, the manifest default: the source is neither a Prima surface
* nor an Astro site, D1b) and adds the operator step the pages depend on,
* the enrolment link minted over SSH by src/cli/enroll.ts, rendered as the
* transcript that command prints (RENDERING.md section 2.2, CLI surfaces).
*
* What the browser does on the real pages happens in their inline scripts
* after a click: `navigator.credentials.get` or `.create`, two fetches, then
* `location.href = '/'`. None of that runs here. A state is the markup with
* the message line the script would have written, so every outcome is on
* the docs page at once and nothing reaches the network.
*
* Sample data only: the token in the transcript is obviously not one, the
* origin is a placeholder, the device label is generic.
*/
import React from 'react';
import { defineStates } from '@prima-modules/demo';
import { Button, Heading, Input, Text } from '@prima-modules/alchemy-ui';
/* ---- props ----------------------------------------------------------- */
export type Screen = 'login' | 'enrol' | 'not-found' | 'unauthorized' | 'operator';
export interface Props {
screen: Screen;
/** `loginHtml(passwordEnabled)`: the break-glass block exists only when true. */
passwordEnabled?: boolean;
/** The `<details>` around the password form, open or closed. */
breakGlassOpen?: boolean;
/** The `#msg` line the inline script writes after a ceremony. */
message?: string;
/** The transcript of `node dist/cli/enroll.js --label <device>`. */
transcript?: string;
}
/* ---- sample data ------------------------------------------------------ */
const ORIGIN = 'https://aurum.example.com';
/* Not a token. The real one is 32 random bytes as base64url (43 characters). */
const TOKEN = 'BEISPIEL-nicht-echt-0000000000000000000000000';
const mintTranscript = `$ docker exec -it aurum node dist/cli/enroll.js --label "laptop-beispiel"
Enrolment link (single-use, expires 2026-09-12T10:10:00.000Z, ~10 min):
${ORIGIN}/enroll/${TOKEN}
Open it in the browser on the device you want to enrol. It is shown once.
recovery: only 1 passkey(s) enrolled - the scrypt password stays ENABLED.
enrol a second authenticator before setting AURUM_PASSWORD_LOGIN=off (it will be refused otherwise).`;
export const states = defineStates<Props>({
login: { label: 'GET /login: passkey first, the break-glass password folded away', props: { screen: 'login', passwordEnabled: true }, height: 360 },
'login-break-glass': { label: 'GET /login: the details opened, the scrypt password form', props: { screen: 'login', passwordEnabled: true, breakGlassOpen: true }, height: 480 },
'login-failed': { label: 'After a refused or cancelled ceremony: one message for every cause', props: { screen: 'login', passwordEnabled: true, message: 'Anmeldung fehlgeschlagen.' }, height: 360 },
'login-passkey-only': { label: 'AURUM_PASSWORD_LOGIN=off with two or more passkeys: no password block at all', props: { screen: 'login', passwordEnabled: false }, height: 300 },
enrol: { label: 'GET /enroll/:token with a live token: the one-time enrolment page', props: { screen: 'enrol' }, height: 360 },
'enrol-done': { label: 'Registration verified, the session cookie set, about to leave for /', props: { screen: 'enrol', message: 'Passkey registriert. Weiter …' }, height: 360 },
'enrol-failed': { label: 'Any failure after the page loaded: the same sentence, the next link is minted over SSH', props: { screen: 'enrol', message: 'Fehlgeschlagen. Neuen Link per SSH erzeugen.' }, height: 360 },
'not-found': { label: 'GET /enroll/:token for an unknown, used or expired token: the uniform 404 page', props: { screen: 'not-found' }, height: 240 },
unauthorized: { label: 'Any other path without a session: the default-deny hook answers JSON, not a page', props: { screen: 'unauthorized' }, height: 240 },
'operator-mint': { label: 'The step before the enrolment page: the link minted over SSH, printed once to stdout and stored only as its sha256', props: { screen: 'operator', transcript: mintTranscript }, height: 400 },
});
/* ---- the screens ------------------------------------------------------ */
/* The source wraps each page in a 22rem (login) or 24rem (enrol) column with
4rem above it (auth.ts 288, webauthn.ts 346). */
function Column({ width, children }: { width: 'narrow' | 'wide'; children: React.ReactNode }) {
return (
<div className={`${width === 'wide' ? 'max-w-[24rem]' : 'max-w-[22rem]'} mx-auto py-10 font-sans`}>
{children}
</div>
);
}
function Message({ text, tone }: { text?: string; tone: 'error' | 'neutral' }) {
/* The source's `p#msg` is in the DOM from first paint with min-height 1.2em,
so the page does not jump when the script writes it (auth.ts 291,
webauthn.ts 350). The demo keeps the geometry and drops the id: the docs
page renders every state in one document, and seven `id="msg"` would be
invalid HTML. `role="status"` is set only when a sentence is shown, so
the empty line is not a live region. */
return (
<p data-msg className={`min-h-[1.2em] mt-3 text-sm font-body ${tone === 'error' ? 'text-red-700' : 'text-ink'}`} role={text ? 'status' : undefined}>
{text ?? ''}
</p>
);
}
function Login({ passwordEnabled, breakGlassOpen, message }: Props) {
return (
<Column width="narrow">
<Heading level={1}>Aurum</Heading>
<div className="mt-4">
<Button variant="primary" fullWidth data-pk>Mit Passkey anmelden</Button>
</div>
<Message text={message} tone="error" />
{passwordEnabled && (
<details className="mt-4 group" open={breakGlassOpen || undefined}>
<summary className="cursor-pointer text-sm font-body text-ink-60 hover:text-ink">Passwort (Break-glass)</summary>
<form method="post" action="/login" className="mt-2 flex flex-col gap-2">
<Input type="password" name="password" placeholder="Passwort" inputSize="sm" />
<div>
<Button variant="outline" size="sm" type="submit">Anmelden</Button>
</div>
</form>
</details>
)}
</Column>
);
}
function Enrol({ message }: Props) {
return (
<Column width="wide">
<Heading level={1}>Passkey einrichten</Heading>
{/* The source's lede is a plain <p> in the default colour (webauthn.ts
348), so the Text atom is used without its muted variant. */}
<Text className="mt-2">Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.</Text>
<div className="mt-4">
<Button variant="primary" fullWidth data-go>Passkey erstellen</Button>
</div>
<Message text={message} tone="neutral" />
</Column>
);
}
/* The two bodies have no column of their own: `enrollNotFound` sends a bare
`<p>Nicht gefunden.</p>` (webauthn.ts 82) and the 401 is a JSON body a
browser shows top-left as preformatted text (auth.ts 244). `p-2` stands in
for the 8px the browser puts around a bare body; nothing centres them. */
function NotFound() {
return (
<div className="font-sans p-2">
<Text>Nicht gefunden.</Text>
</div>
);
}
function Unauthorized() {
return (
<div className="font-sans p-2">
<pre className="font-mono text-sm text-ink whitespace-pre-wrap">{'{"error":"unauthorized"}'}</pre>
</div>
);
}
function Operator({ transcript }: Props) {
/* The dark surface is a plain div, not the Card atom: Card paints its own
`bg-white` (variant default) and Tailwind emits `.bg-white` after
`.bg-ink` in the bundle, so a `bg-ink` passed through `className` loses
whatever the class order (measured 2026-09-12 on the built docs page,
recipe Gotchas). The frame is the transcript and nothing else; the
commentary lives in the state label and the recipe. `break-all` on the
pre: the link is one unbreakable run of origin plus token, and the
wrapper hides overflow, so without it the token is clipped below about
26rem (measured 2026-09-12 at a 400px viewport: 50px cut off). */
return (
<div className="font-sans">
<div className="rounded-3xl overflow-hidden bg-ink text-paper" data-transcript>
<pre className="font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-all p-6 text-paper">{transcript ?? ''}</pre>
</div>
</div>
);
}
export default function Demo(props: Props) {
switch (props.screen) {
case 'login':
return <Login {...props} />;
case 'enrol':
return <Enrol {...props} />;
case 'not-found':
return <NotFound />;
case 'unauthorized':
return <Unauthorized />;
case 'operator':
return <Operator {...props} />;
default:
return null;
}
}
Component tree, from the recipe
GET /login auth.ts loginHtml(passwordEnabled) (public route, allow-list line 26)
div (system-ui, max-width 22rem, centred, 4rem above)
h1 "Aurum"
button#pk "Mit Passkey anmelden" click -> POST /api/webauthn/authenticate/options -> navigator.credentials.get -> POST .../verify -> location.href = '/'
p#msg (min-height 1.2em; "Anmeldung fehlgeschlagen." on any failure)
details > summary "Passwort (Break-glass)" only when passwordEnabled
form[method=post][action=/login] > input[type=password][name=password], button "Anmelden"
<script> B64URL_JS + passkeyLogin
GET /enroll/:token webauthn.ts enrollHtml(token) (public prefix, allow-list line 34; a live token, else the 404 page)
div (system-ui, max-width 24rem)
h1 "Passkey einrichten"
p "Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab."
button#go "Passkey erstellen" click -> POST /enroll/:token/options -> navigator.credentials.create -> POST /enroll/:token/verify -> "Passkey registriert. Weiter …" -> location.href = '/'
p#msg ("Fehlgeschlagen. Neuen Link per SSH erzeugen." on any failure)
<script> B64URL_JS + TOKEN constant + the ceremony
GET /enroll/:token (unknown | expired | used) webauthn.ts enrollNotFound: 404 text/html "<p>Nicht gefunden.</p>" after a 40 ms floor
any other path without a credential auth.ts onRequest hook: 401 application/json {"error":"unauthorized"}; no HTML page
operator step (not a page) cli/enroll.ts --label <device>: the link printed once to stdout, ten minutes, single-use The rendering is the live rebuild aurum-login-and-enrolment/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md, section 3). The source pages are strings with inline styles; the demo rebuilds their structure with the atoms (Heading level 1 for the h1, Button primary fullWidth for the one wide button, Input inputSize="sm" with type="password" and Button outline sm for the break-glass form, Text in its default colour for the lede, as the source's plain <p> with no colour of its own (webauthn.ts 348), and for the 404 sentence; for the operator transcript a plain div with bg-ink and text-paper around a pre, not the Card molecule, because every Card variant paints a background of its own that a class passed through className cannot beat, Gotchas 2026-09-12) and keeps the source's geometry in two Tailwind classes (max-w-[22rem] and max-w-[24rem] with mx-auto py-10, the centred 22rem column with 4rem above of auth.ts 288 and the 24rem of webauthn.ts 346). The message line keeps the source's min-h-[1.2em] but not its id (data-msg instead; the source's #pk and #go are data-pk and data-go for the same reason, Gotchas 2026-09-12), and carries role="status", which the source lacks, only in the three states that show a sentence. The break-glass input is as the page ships it (auth.ts 294: type, name, placeholder, no autocomplete attribute). The two bodies, not-found and unauthorized, have no column: the 404 is a bare <p> (webauthn.ts 82) and the 401 is JSON a browser shows top-left, so the demo renders them at the frame's top-left with p-2 for the 8px the browser puts around a bare body. Hydration is visible for every state: the demo has no handlers, portals or measurements, so the server-rendered markup is the state.
States, in order: login (passkey first, the details closed), login-break-glass (the details open: the password input and "Anmelden"), login-failed (the one sentence in #msg, red), login-passkey-only (passwordEnabled: false: no details at all, which is the page once two passkeys exist and the switch is off), enrol (the one-time page for a live token), enrol-done ("Passkey registriert. Weiter …", the instant before location.href), enrol-failed ("Fehlgeschlagen. Neuen Link per SSH erzeugen."), not-found (the 404 page: one sentence), unauthorized (the JSON body of the default-deny hook, as a browser shows it), operator-mint (the CLI transcript with a placeholder origin and an obviously fake token, rendered as a dark block the way the docs site's terminal block renders a transcript, RENDERING.md section 2.2 for CLI surfaces; the frame carries the transcript and nothing else, the fact that only the token's sha256 is stored is in the state label and in Application logic step 6, not printed inside the frame, since enroll.ts 121 to 126 prints only the link block and the recovery note).
Tokens used: bg-brand-purple through Button primary, text-ink, text-ink-60, text-red-700 for the failed-login sentence (the source's inline color:#a00), bg-ink and text-paper on the transcript block (the plain div, measured as rgb(27, 23, 38) on the built docs page, Acceptance check 6), font-sans and font-mono. No viewport: nothing here is shell-wrapped; the source centres a narrow column on a bare page and the frame does the same. Responsive: the columns are max-width only and shrink with the frame; below 22 rem the button and the input are full width already.
Empty, loading, error states: the pages have no loading state (a click either resolves to a navigation or to the sentence) and no empty state; error is the sentence. What the demo deliberately does not render: the inline scripts and the ceremonies (nothing here calls navigator.credentials or fetch), the base64url helpers, the navigation to / after success, and the details' native marker styling. The copy is the pages' own; the transcript's recovery note uses a hyphen where enroll.ts 139 prints an em dash (house rule).
Security & tenancy
What the browser may hold: the HttpOnly session cookie it cannot read, and, on the enrolment page, the token as a script constant and in the address bar for the life of that page. Never a password beyond the form post, never a key: the credential's private half is in the authenticator and the server stores the public key and a counter (webauthn.ts 228 to 235).
Origin and CSRF: the cookie is SameSite=Strict (auth.ts 203); a cookie-authenticated non-GET must carry Origin equal to AURUM_PUBLIC_ORIGIN (237 to 240), and the four unauthenticated ceremony routes check the same header before anything else (originOk, webauthn.ts 87 to 91, 143, 174, 254, 266; test/passkey.test.ts 216). The CSP the server sends on every HTML response is default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none' (auth.ts 213 to 214): the inline script and the inline styles are why the two 'unsafe-inline' are there.
Surface gating: one surface, one user (USER_ID is the literal aurum-founder, webauthn.ts 41), no /me, no tenant. What is hidden versus enforced: the break-glass block is not rendered when the password path is off, and the server refuses the post with the same 401 as a wrong password when it is off, so hiding and enforcing agree (auth.ts 254 to 259). Nothing is hidden by CSS.
Attacker-controlled inputs rendered: the token, filtered to [A-Za-z0-9_-] before it is embedded as a JSON string (webauthn.ts 344, 354); nothing else from a request reaches a page.
Tests
aurumat a17fce3:npm test(node --import tsx --test test/*.test.ts) includestest/passkey.test.ts(365 lines) with the cases this surface rests on: enrol then login mints the existing cookie (113), expired token rejected (142), single-use token (149), replayed registration (159), two concurrent redemptions yield one success (176), wrong-origin assertion (202), cross-origin POST refused before the ceremony (216), counter regression (227, 248), unknown, expired and used indistinguishable (260), the scrypt path (293),AURUM_PASSWORD_LOGIN=offrefused with fewer than two passkeys (301), the default-deny gate (323), the enrolment limiter (329), no HTTP handler mints a token (346). B22's Tests section records which of these it ran.- The demo's colocated test,
aurum-login-and-enrolment/demo.test.tsx(eight cases, passed 8 of 8 on 2026-09-12 withnpx vitest run --config modules/identity-auth/ui/aurum-login-and-enrolment/vitest.config.mjsfrom the repo root): every state renders non-empty markup; the password input exists only whenpasswordEnabled; the details are open only inlogin-break-glass; a failed login is one sentence and none of the script's internal reasons; the enrolment states and the 404 carry no token in their markup while the transcript does; the 401 is the JSON body; no state's markup carries an element id androle="status"is present in exactly the three states that show a sentence (the case added after the duplicate-id finding in Gotchas); the transcript'spresits in an element whose onlybg-*class isbg-ink, the state's markup carries nobg-white, and the frame's text is the transcript and nothing else (the case added after the paper-on-white rendering in Gotchas; markup cannot prove a computed colour, so the check is that no competing background class is present to win, and the computed colour is Acceptance check 6). The rootvitest.config.mjsincludespackages/*/testandtest/only, so the test has a config beside it until the root gains the demo glob (a deviation recorded for this wave). - The kit's render smoke:
node tooling/verify/render-smoke.mjs identity-auth/ui/aurum-login-and-enrolmentrenders the ten states withrenderToString(allhydrate: visible). - Playwright against a running Aurum: not part of the source's suite at a17fce3 (the passkey tests drive
app.injectwith a software authenticator,test/webauthn-authenticator.ts); a real-browser walk-through is Acceptance check 5.
Deploy & ops
Build target: none for the pages; they ship inside the app's image (B22 Deploy & ops: the container on the estate; the process binds 0.0.0.0 on PORT 8787 inside the container, app.listen({ port: PORT, host: '0.0.0.0' }) at server.ts 286, and it is the compose port mapping, deploy/compose.yml 37 to 38 ("127.0.0.1:8787:8787"), that publishes it on 127.0.0.1:8787, where the host's Caddy's reverse_proxy 127.0.0.1:8787 reaches it, deploy/Caddyfile.fragment 48, 55, 60 and 72). Env baked at build: none; everything is read at boot. CSP: the server's own onSend header (Security & tenancy), plus Caddy's Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and -Server on the vhost (deploy/Caddyfile.fragment 33 to 39). CORS: none; the pages are same-origin and no other origin calls these routes. Deploy path: S13's estate container (B200's ship.sh); rollback is the image tag; rotating AURUM_SESSION_SECRET signs everyone out. Argus checks that see these pages: GET /login 200 containing Mit Passkey anmelden, GET / without a credential 401 {"error":"unauthorized"} (B22 Deploy & ops).
Gotchas
- 2026-09-12 (measured at a17fce3,
auth.ts292 to 296; the defect is B22's first Gotcha, D8): the break-glass block is a plain<form method="post" action="/login">, which postsapplication/x-www-form-urlencoded; the server registers no parser for that type and Fastify answers 415 before the handler runs, so the form as rendered cannot sign anyone in. The demo'slogin-break-glassstate shows the form as the page ships it. Fix it needs:@fastify/formbody, or afetchposting JSON from the page, and a test that posts the urlencoded body the page sends. - 2026-09-12 (measured at a17fce3,
deploy/Caddyfile.fragment7 to 19 and 63 to 75; D8, recorded, not fixed): the tracked vhost fragment predates app-layer auth. Its header saysserver.ts"has NO authentication of any kind", and its catch-allhandlewrapsreverse_proxyinbasic_auth, with publichandleblocks only for/telegram/webhook*,/u/*and/health(40 to 60). Applied as written,/login,/enroll/*and/api/webauthn/authenticate/*sit behind Caddy's basic-auth prompt, so the passkey page is reachable only after a password. Whether the live vhost still matches the fragment was not measured (the estate is not touched from here). Fix it needs: publichandleblocks for the three paths, or droppingbasic_authnow thatauth.tsis the boundary, and deleting the stale header. - 2026-09-12 (measured at a17fce3,
auth.ts244): the default-deny hook answers JSON with no redirect, so a person who opens/in a browser sees{"error":"unauthorized"}and has to know/loginexists; nothing links to it. The demo'sunauthorizedstate is that body. A surface with more than one user redirects a browser (Accept: text/html) to/loginand keeps the JSON for everything else. - 2026-09-12 (measured at a17fce3,
auth.ts327,webauthn.ts385): the failure sentences absorb the user's own cancel of the WebAuthn prompt, which is not a failure of anything. A rebuild may keep one sentence for server refusals and say "Abgebrochen." forNotAllowedError, which reveals nothing about the server. - 2026-09-12 (measured at a17fce3,
webauthn.ts354 andenroll.ts124): the token is in the URL and in the page's script; a browser extension or a shoulder can read it while the page is open, and the ten-minute, single-use, SSH-minted design is what makes that acceptable. The demo shows a token only in the operator transcript, and its colocated test asserts the enrolment states carry none. - 2026-09-12 (rendering trap): the source has no design system, so the rebuild in alchemy-ui is not a pixel match and cannot be; the verifier's
mismatch_ratiois not a meaningful tolerance for this row (Acceptance check 6). What the rebuild must match is the structure and the copy, which the colocated test pins. - 2026-09-12 (rendering trap, measured on the docs page built from prima.li at 86fe9d1 with this kit at aab1a3f): the
operator-minttranscript rendered paper-coloured text on a white surface, invisible, because the demo passedbg-ink text-paperthroughclassNameto theCardmolecule.Cardpaintsbg-whiteforvariant="default"(packages/alchemy-ui/src/molecules/Card.tsx22 to 26, verbatim from Labs fd8558f), and the bundle emits#alchemy-ui .bg-whiteafter#alchemy-ui .bg-inkat equal specificity (packages/alchemy-ui/dist/alchemy-ui.css), so the atom's own background wins whatever order the classes have in the attribute: Tailwind orders utilities by config order, not by class order, andclassNameon an atom cannot override abg-*,text-*oroutline-*the atom sets itself. Fixed the same day by rendering the dark surface as a plaindiv(rounded-3xl overflow-hidden bg-ink text-paper) with no atom underneath and a colocated case that fails on a competingbg-*class; the siblingprofile-and-passkeys/demo.tsx(333, 338) takes the other way out, the important modifier!bg-green-50, which is what to use when the atom itself must stay. - 2026-09-12 (rendering trap, measured on the docs page built from prima.li at 86fe9d1 with this kit at ed56378): the demo had kept the source's element ids (
#msg,#pk,#go,auth.ts290 to 291,webauthn.ts349 to 350) and putrole="status" aria-live="polite"on every message line, so the one docs document carried sevenid="msg", fourid="pk", threeid="go"and seven live regions. A demo renders beside nine sibling frames, never alone: it must not carry an id the source has, and a live region belongs only on a state that shows something. Fixed the same day (data-msg,data-pk,data-go;role="status"only with a sentence) with a colocated case that fails on anyid="in any state. - 2026-09-12 (rendering trap, measured on the same build with Playwright at a 400px viewport): the
operator-minttranscript clipped the enrolment link, 50px cut off at 400 and 90px at 360, because the wrapper isoverflow-hiddenand the link is one unbreakable run of origin plus token thatwhitespace-pre-wrapcannot break. Fixed the same day withbreak-allon thepre(the class is in the committed bundle); at 768 and above the link fitted before and after. - 2026-09-12 (rendering trap):
<details open>in React is the booleanopenprop; the demo passesopen={breakGlassOpen || undefined}so the closed state emits no attribute and the server-rendered markup hydrates without a mismatch. - 2026-09-12 (measured at a17fce3): the working tree carries one uncommitted line in
auth.ts(POST /api/unsubscribeon the allow-list, line 30), so everyauth.tsline number in this recipe is the committed file's (git show a17fce3:src/web/auth.ts), one less than the working copy from line 30 on; the same note is in B22. - 2026-07-28 (0203908,
enroll.ts133 to 141; wikiplatform/aurum.md): passkeys landed with the rule that the password stays until two passkeys exist;recoveryNoteprints the posture after every CLI action, and the demo's transcript shows the one-passkey posture.
Reference implementation
Branch: aurum week2-3-confirm-gate at a17fce3 (2026-09-12). Paths relative to the repo root:
src/web/auth.ts(341 lines at a17fce3): the hook's design 1 to 15,SESSION_TTL_MSand the cookie name 20 to 21,PUBLIC_ROUTES24 to 37,loadConfig46 to 74, password hashing 76 to 98, cookie signing 100 to 129, brute-force protection 131 to 146,AuthContext165 to 178,registerAuth180 to 275 (passwordLoginEnabled190 to 198,issueSession200 to 204, security headers and CSP 207 to 217, the hook 220 to 245,GET /login247 to 249,POST /login251 to 270),rpIdFrom278 to 280,loginHtml282 to 331,B64URL_JS334 to 337,uniform401339 to 341.src/web/webauthn.ts(388 lines): the design constraint 1 to 25, constants 40 to 53,enrollNotFound77 to 85,originOk87 to 91,registerWebAuthn100 to 331 (liveToken108 to 118,GET /enroll/:token122 to 139, options 141 to 170, verify 172 to 249, authenticate options 253 to 263, authenticate verify 265 to 328),readClientChallenge333 to 340,enrollHtml342 to 388.src/cli/enroll.ts(143 lines): why the CLI is the only minter 1 to 17, usage 26 to 33,shellIdentity35 to 44, list 61 to 81, revoke 83 to 95, burn 97 to 103, mint 105 to 127,recoveryNote133 to 141.src/web/server.ts:registerAuth73 to 79,registerWebAuthn80,listen286.deploy/Caddyfile.fragment: the vhost, headers 33 to 39, public handles 40 to 60, the catch-all withbasic_auth63 to 75.test/passkey.test.ts(365 lines): the cases listed under Tests;test/webauthn-authenticator.ts, the software authenticator they use.
Deviations of the reference from this recipe, dated 2026-09-12: the urlencoded break-glass post; the stale Caddy fragment; the JSON 401 with no link to /login; no browser-level test; the stack pin simplewebauthn-browser-13 in the inventory row, which is not a dependency.
Acceptance checks
From aurum at a17fce3, or a new tool built to this recipe:
npm testpasses, andgrep -c "^test(" test/passkey.test.tsprints15(or the new tool's count).grep -n "app\.\(get\|post\|put\|delete\)\(<[^>]*>\)\?(.*enroll" src/web/webauthn.tsprints exactly three lines, 122, 141 and 172 (GET /enroll/:token,POST /enroll/:token/options,POST /enroll/:token/verify; the optional group is the Fastify route generic<{ Params: { token: string } }>that sits between the method and the paren on all three, so a pattern without it prints nothing and proves nothing); the guard that no route mints a token is the second command:grep -rn "createEnrollmentToken" src/ | grep -v "db/"printssrc/cli/enroll.tsonly (line 115 at a17fce3).grep -n "'unsafe-inline'" src/web/auth.tsprints one line, 214, carrying the directive twice (style and script), which is the price of inline pages; a rebuild that moves the script to a file drops the second.grep -c "simplewebauthn/browser" package.jsonprints0;grep -n "B64URL_JS" src/web/auth.ts src/web/webauthn.tsprints the export and the two uses.- Browser walk-through against a local Aurum (
npm run dev,AURUM_PUBLIC_ORIGIN=http://localhost:8787, an authenticator or a virtual one in DevTools): (a) open/, observe{"error":"unauthorized"}as text; (b) open/login, observe "Mit Passkey anmelden" first and "Passwort (Break-glass)" folded; click the button and cancel the prompt, observe "Anmeldung fehlgeschlagen."; (c)npm run enroll -- --label test-device, open the printed link, observe "Passkey einrichten", click "Passkey erstellen", complete the ceremony, observe "Passkey registriert. Weiter …" and the dashboard at/; (d) open the same link again, observe "Nicht gefunden." with status 404; (e)npm run enroll -- --list, observe the device and the recovery note; (f)AURUM_PASSWORD_LOGIN=offwith one passkey, restart, observe the break-glass block still on/loginand the refusal in the log; with two passkeys, observe it gone. - Demo gate:
npm run checkprints no line for this recipe;node tooling/verify/render-smoke.mjs identity-auth/ui/aurum-login-and-enrolmentprintsokfor the ten states and0 failures;npx vitest run --config modules/identity-auth/ui/aurum-login-and-enrolment/vitest.config.mjsprints8 passed; in the prima.li checkout,PRIMA_MODULES_DIR=<this repo> npm run build:indexwritesdist/docs/modules/identity-auth/ui/aurum-login-and-enrolment/index.html, and opened in Playwright the page has tenfigure[data-demo-state]elements, each with a non-empty.demo-stage,pageerrorcount 0, and in theoperator-mintfigure thepre's parent has computedbackground-colorrgb(27, 23, 38)(--color-ink) and theprecomputedcolorrgb(251, 250, 252)(--color-paper) (verified 2026-09-12 against prima.li at 86fe9d1 after the Gotcha of that date; before the fix the same probe readrgb(255, 255, 255)). - Verifier:
compare.mjsneeds an--appthat serves Aurum, which the harness does not boot (its two scripts are for Labs and for Astro sites); a run against a local Aurum onhttp://127.0.0.1:8787with--state loginis possible withrendering.verifyset toroute: /login,session: anon,selector: body, and its report is read forpage_errors: []on both sides only: the source has no design system, somismatch_ratiocarries no tolerance for this row.
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.