Module kit / Identity and auth
Developer portal keys page
identity-auth/ui/developer-portal-keys-pagelive
| 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/developer-api-keys-and-security-policy, identity-auth/gateway-api-key-auth |
| Entry | prima-developer/src/pages/schluessel.astro |
| Reference implementation |
|
| Measured on | prima-developer w6-konto c594dfc 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
default server rendered The signed-in page where a developer mints, sees and revokes the API keys that reach the gateway: a name field that produces one ai-scoped key shown once, a list that shows prefixes only, and a "Sperren" that takes effect at once. The invariant that makes it trustworthy is that the HTML carries no account data and the browser never holds a customer key: the page ships as a shell whose branches are hidden by CSS until the server has answered, a freshly minted key is written into exactly one node and cleared by the button that dismisses it, and the list can only ever show what the server stores, which is the first twelve characters and a hash.
It makes two backend modules visible: B10 (GET, POST and DELETE /api/developer/keys, the ai-only rule for a Prima surface, the once-only key in the POST response) and B11 (the key a customer copies here is the credential the gateway's local door accepts as X-API-Key; the page's own per-session key is the same kind of credential, minted for the meter and revoked at sign-out).
When to use
Trigger phrases: "the keys page", "Schlüssel ausstellen", "why does the list show only a prefix", "the key is gone after reload", "Sperren", "the portal's own session key in my list", "noch nicht freigeschaltet", "which workspace does the key bill". Surface: portal, the static Astro site of F7 after sign-in; the same origin, the same sessionStorage session, the shell chrome of Layout.astro and the signed-in sub-navigation of AppNav.astro (src/components/AppNav.astro lines 9 to 15: Schlüssel, Verbrauch, Guthaben, Ausgabenlimit, Playground). It is a static shell rather than a server-rendered page because the site has no server: the guard is a convenience, and the authorisation runs on every call (schluessel.astro lines 9 to 14).
Do not use it for the Labs SPA's key settings (identity-auth/ui/api-keys-settings-and-developer-dashboard, F3: React, every self-service scope, the developer dashboard), for the pages that show money (/verbrauch, /konto/guthaben/, /limit: billing rows; the "Guthaben" button here only links there, and the owner-or-admin rule for changing a billing instruction, A-1, lives in billingWriteAccess, prima-api.ts 655 to 665, not on this page), or for the sign-in pages that precede it (F7).
Stack assumptions
S1 in the demo only: the page reads the portal's house variables through app.css (src/styles/app.css, 867 lines, "No new colours, no new fonts", lines 1 to 4) and the demo maps them onto @prima-modules/prima-tokens (Frontend). S3 for the site shape. No S2: nothing is React; the page is markup plus one module script of 318 lines (schluessel.astro 314 to 632).
Component library: none; AppNav.astro is the one shared component. Data-fetch style: prima-api.ts request() on the session (Authorization: Bearer from authHeader(), X-Prima-Surface: prima_dev, X-Workspace-ID when the page has one, credentials: "omit"; lines 335 to 346 and 401 to 443); every failure is an ApiError with status, code, detail and the raw gatewayCode (214 to 257) and every rendered sentence comes from errorMessage (282 to 322). Copy: de-CH, Sie-Form, no ß; dates through Intl.DateTimeFormat("de-CH") (formatDate, 1428 to 1432). Env: PUBLIC_PRIMA_API_BASE baked at build as in F7; unset, the page builds and its script lands in the notconfigured branch (prima-api.ts 336 to 338, schluessel.astro 617).
Data model
Client state, in the DOM's data-state attributes and one module variable:
| Container | States | Set by |
|---|---|---|
#gate | loading, notconfigured, denied, error, ready | boot() (schluessel.astro 604 to 622) |
#new-card | ready, unavailable, error | failCard after a mint refusal (341 to 348, 555) and the "minted but not returned" branch (542 to 547) |
#list-card | loading, ready, empty, unavailable, error | loadKeys (441 to 458) and onRevoke (479 to 481) |
#reveal | hidden or shown; #reveal-key holds the key while shown | showReveal and hideReveal (485 to 505) |
workspaceId | string or null | resolveWorkspace (358 to 379): the tenant the per-session key is bound to |
The convention is app.css lines 105 to 122: [data-when] { display: none } and one rule per state that shows the matching child, so exactly one branch of a container is visible and a state the script never sets shows nothing.
Types consumed (prima-api.ts):
interface Account { id: string; email: string; first_name?: string | null; last_name?: string | null; company?: string | null; role?: string | null; surfaces?: string[] } // 464 to 474
interface Workspace { id: string; name: string; slug: string | null; role: string; isOwner: boolean } // 617 to 623
interface ApiKeySummary { id: string; key?: string | null; key_prefix: string; name: string; description?: string | null;
created_at: string; expires_at?: string | null; last_used_at?: string | null; is_active: boolean; scopes?: string[] | null } // 914 to 926; `key` only in the POST response
Browser storage read or written on this page (all sessionStorage, F7's Data model has the full table): prima.dev.session.v1 (read by requireSession; cleared and the page left for /anmelden/ when missing, expired or refused with 401); prima.dev.gateway-key.v1 (the per-session ai key: {id, key, prefix, tenantId, sid, mintedAt}, gateway-key.ts 218 to 230, minted on first need by ensureGatewayKey, prima-api.ts 687 to 708, or already present from the verify page); prima.dev.gateway-key.retired.v1 (row ids parked for the next revoke, never a key); prima.dev.gateway-key.housekept.v1 (the sid whose sign-in housekeeping ran, auth.ts 847 to 866). A customer's key minted on this page is never stored anywhere in the browser: it lives in #reveal-key's text until "Ich habe ihn gesichert", a new mint or unload (INVARIANT B, prima-api.ts 29 to 35).
API surface
All on the session Bearer; the backend side is B10, with B15 for the workspace listing:
| Route | Sent | Parsed | ApiError.code by status (classify, prima-api.ts 268 to 280) | Copy rendered (errorMessage, 282 to 322, and the page) |
|---|---|---|---|---|
GET /api/auth/me | Bearer, X-Prima-Surface | Account; surfaces absent means "not asserted" and the page proceeds (requireSession 538 to 545) | 401 unauthenticated (the guard clears the session and leaves for /anmelden/, never rendered); 403 forbidden | surfaces present without prima_dev is thrown as 403 forbidden and rendered as the denied branch: "Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber ... Erneutes Anmelden ändert daran nichts." (schluessel.astro 87 to 101) |
GET /api/auth/workspaces | Bearer | Workspace[] through parseWorkspaces (625 to 627); used twice: at the mint to choose the one tenant (chooseTenant, gateway-key.ts 204 to 208: exactly one membership, else refuse) and cosmetically for the name next to the id (resolveWorkspace 360 to 379, a failure there costs the label only) | zero memberships: 409 no_workspace (mintGatewayKey 715 to 717); more than one: 400 workspace_context_required (718 to 723); both surface as the error gate | "Ihrem Konto ist noch kein Workspace zugeordnet, und ohne Workspace gibt es kein Guthaben ..." and "Ihr Konto gehört zu mehreren Workspaces, und developer.prima.li kann noch nicht auswählen ..." (billing.ts 810 to 824, through the seam errorMessage consults first) |
POST /api/developer/keys (the page's own key) | {name: "developer.prima.li (<sid[:8]>)", description, scopes: ["ai"], expires_in_days: 1} with X-Workspace-ID (gatewayKeyMintBody, gateway-key.ts 110 to 122) | accepted only when the body carries key, id, key_prefix and no scope but ai (acceptMintedKey, 251 to 280); a wrong-scope key is revoked at once and the mint fails (prima-api.ts 729 to 740) | as the customer mint below | "Der ausgestellte Sitzungsschlüssel trägt nicht den erwarteten Umfang und wurde verworfen." or "Der Anmeldedienst hat keinen vollständigen Sitzungsschlüssel zurückgegeben." (736 to 738) |
GET /api/developer/keys | Bearer, X-Workspace-ID: <tenant> (listKeys, 928 to 929) | ApiKeySummary[] with key: null, newest first (B10); an empty array is the empty branch | 404 or 501 unavailable; 403 forbidden; 5xx server; 429 rate_limited; a thrown fetch network (0) | unavailable: "Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv." (the quiet notice, not an error, failCard 341 to 348); otherwise the error branch with "Schlüssel konnten nicht geladen werden." plus the sentence: server "Der Prima-Dienst antwortet gerade nicht. Das liegt nicht an Ihnen ..."; network "Keine Verbindung zum Prima-Dienst ..."; rate_limited "Zu viele Anfragen in kurzer Zeit. Bitte warten Sie einen Moment."; forbidden the server's detail or "Für diesen Bereich fehlt Ihrem Konto die Freigabe." |
POST /api/developer/keys (the customer's key) | {name, scopes: ["ai"]} with X-Workspace-ID (createKey, 949 to 954; scopes explicit because the server default ["read"] is useless at the gateway and an absent permissions means unlimited, 935 to 947) | 200 ApiKeySummary with key (B10: 200, not 201); a 200 without key is the #new-card error branch "Der Schlüssel wurde angelegt, aber nicht zurückgegeben ..." (537 to 547), never an empty box | 403 forbidden is rendered verbatim under the name field (err.detail, 551 to 554: the server explains scope refusals precisely, B10's "Keys minted from a Prima surface may carry the 'ai' scope only ..."); 404 unavailable "Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet."; anything else the card's error branch through failCard | as listed; the field error is #key-name-err[aria-live] |
DELETE /api/developer/keys/{id} | X-Workspace-ID (revokeKey, 957 to 958) | 200 {message}; the list is reloaded (onRevoke 473 to 475) | 404 for another user's or another workspace's key (B10) renders as the list error branch with "Dieser Dienst ist noch nicht freigeschaltet." (Gotchas) | the button reads "Wird gesperrt …" while pending, then "Sperren" again on failure (471 to 478) |
POST /api/auth/logout | Bearer | 204; 404 through the ingress today (F7 Gotchas) | best-effort; the local session is cleared first | "Abmelden" (signOut, 557 to 578: the per-session key is revoked first, while the Bearer that can revoke it still exists) |
Error bodies: four shapes reach this client (the OpenAI envelope from Prima and from Labs' gateway handler, Labs' plain handler, FastAPI's default) and parseErrorEnvelope in billing.ts reads all of them so a test can pin it (send, 426 to 439). No WebSocket, no postMessage. The page reads nothing from the meter: the tenant comes from the key record, not from /usage (Gotchas).
Application logic
revealed server rendered - The page ships with
#gate[data-state=loading], the whoami skeleton and every branch in the HTML; the module script runsboot()(schluessel.astro604 to 622, 631). requireSession()(prima-api.ts507 to 546):not_configuredthrows before any read; no local session or one pastexpiresAtisredirectToSignIn()(clears the session,location.replace('/anmelden/'), throws so nothing runs behind the navigation, 476 to 481);GET /api/auth/me; a 401 clears the stale token and redirects the same way;surfacespresent withoutprima_devthrows 403. On a good answer the per-session key's lifecycle is armed (armSessionEndRevoke, 837 to 855: a revoke scheduled before the Bearer dies) and the sign-in housekeeping runs once persidin the background (sessionKeyHousekeeping, 882 to 908: revoke the parked ids, then list the tenant's keys and revoke this portal's orphaned rows).boot's catch (613 to 621):unauthenticatedreturns (the page is leaving);forbiddensetsdenied;not_configuredsetsnotconfigured; everything else setserrorwitherrorMessage(err)in#gate-err, and#gate-retrycallsboot()again (624).renderIdentity(me)(591 to 602):displayName(first and last name, else the e-mail, 580 to 583) in bold, the e-mail after a middle dot when it differs, and#kh-actionsunhidden.resolveWorkspace()(360 to 379):workspaceId = await resolveTenantId(), which isensureGatewayKey().tenantId(634 to 636): the record fromsessionStoragewhen it is for thissidand not near expiry, else one mint (GET /api/auth/workspaces,chooseTenant,POST /api/developer/keys,acceptMintedKey,saveGatewayKey; 687 to 744, single-flight through themintingpromise). Then, cosmetically, the workspace name fromlistWorkspaces();#ws-singlereads "Workspace:. Der Verbrauch dieses Schlüssels wird ihm zugerechnet." A failure of the name lookup never blocks minting (363 to 371). #gatebecomesready;wireCreate()binds the form, the reveal's copy and close buttons and thebeforeunloadguard (507 to 588);loadKeys()runs without being awaited (612).loadKeys(441 to 458):#list-cardtoloading;listKeys(workspaceId); an empty array isempty; otherwise#key-rowsis emptied and onekeyRowappended per key (382 to 439: title, the meta line "prefix · erstellt· zuletzt genutzt or noch nie genutzt · läuft ab · dieses Portal, diese Sitzung: wird beim Abmelden gesperrt" when isSessionKey(k), thechip--liveorchip--offchip, and "Sperren" for an active key);ready. A refusal goes throughfailCard.- Submit (513 to 560): the field error cleared; an empty name is "Bitte geben Sie dem Schlüssel einen Namen." with focus; no
workspaceIdis "Kein Workspace ausgewählt.";hideReveal()first (a previous key leaves the DOM before a new one arrives); the button disabled and#create-statereads "wird ausgestellt …";createKey(name, workspaceId); on a body withkey,showReveal(key)(textContentinto#reveal-key, panel shown, the key node focused so a keyboard user lands on the thing to copy); on a body without one, the card'serrorbranch; thenloadKeys(); on a thrownforbiddenthe server'sdetailunder the field, otherwisefailCard; the button re-enabled infinally. - Copy (564 to 577):
navigator.clipboard.writeText, "Kopiert" withdata-copied, or the honest "Nicht möglich, bitte markieren und kopieren". "Ich habe ihn gesichert" (hideReveal, 495 to 505) clears#reveal-key, hides the panel and resets the copy button.beforeunload(581 to 587) warns while the panel is visible. - Revoke (461 to 482): a native
confirm()naming the key and saying it stops at once, everything using it gets 401, and it cannot be undone ("a custom dialog here would be more design and less certainty", 462 to 463); the button reads "Wird gesperrt …";revokeKey(id, workspaceId);loadKeys(); on failure the button is restored and the list card shows the error. - "Abmelden" (
signOut, 557 to 578): revoke the per-session key and the parked ids (revokeSessionKeys, 802 to 835), clear the local session,POST /api/auth/logoutbest-effort,location.replace('/anmelden/').
Invariants
- No account data in the HTML: the shell's branches are hidden by CSS and the HTML contains no name, e-mail, prefix or id until the script writes them (
schluessel.astro9 to 14; Acceptance check 3). - A customer key is rendered into exactly one node (
#reveal-key) bytextContent, cleared byhideRevealbefore any new mint and on "Ich habe ihn gesichert", never stored, never in the URL (16 to 21, 485 to 505, 528). - The page never issues a key without
scopes: ["ai"]and never with any other scope (createKey949 to 954;gatewayKeyMintBodygateway-key.ts110 to 122); a minted key that comes back with another scope is revoked before it is kept (acceptMintedKey,prima-api.ts729 to 733). - Key management runs on the session Bearer, never on an API key: an
aikey is prefix-locked to/api/prima/v1/and answers 403 on/api/developer/keys(prima-api.ts99 to 103; S5 prefix lock), so a key that could mint keys does not exist. - The list shows prefixes only because the server stores prefixes only (
keyRow403, B10 Data model). - The guard's three denials stay apart: no session redirects, a 401 redirects after clearing, a missing
prima_devgrant stays put and says so, because signing in again cannot add a grant (requireSessiondocstring 490 to 500). - Revocation is confirmed in words that name the consequence, is instant and irreversible on the server (soft delete, B10), and the list is re-read from the server afterwards rather than patched locally (473 to 475).
- Every
X-Workspace-IDthe page sends is the tenant Labs validated at the per-session mint (resolveTenantId629 to 636); the browser never picks a tenant from a list when there is more than one (chooseTenant).
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.
loading server rendered empty server rendered create-refused server rendered list-error server rendered denied server rendered gate-error server rendered unconfigured server rendered identity-auth/ui/developer-portal-keys-page/demo.astro
---
/**
* Live demo for identity-auth/ui/developer-portal-keys-page.
*
* `/schluessel` of the developer portal (prima-developer src/pages/schluessel.astro,
* 632 lines at c594dfc on w6-konto) rebuilt as one Astro component that takes
* `state`. The real page ships as a shell: every branch is in the HTML, hidden
* by the `[data-state]` / `[data-when]` convention of app.css, and the module
* script sets the state attributes after `requireSession()` and the two reads
* settle. A state here is the shell with those attributes set and the list
* rows the script would have appended, rendered server-side with literal
* sample data, so the docs page shows every branch and touches no network.
*
* The page is shell-wrapped (the site header from Layout.astro, the slate
* header band, the signed-in sub-navigation from AppNav.astro), so every
* state declares `viewport: 1440`: the docs host lays the frame out at the
* portal's desktop width and scales it into the column.
*
* Design system `prima` (D1b): app.css and the layout's chrome redrawn with
* @prima-modules/prima-tokens aliases; the mapping is the recipe's Frontend
* section. State colours the token layer lacks (refusal red, confirmation
* green, the warning amber of the notices) are declared once on the root.
*
* Copy is the page's own (de-CH, Sie-Form); the source's em dashes are
* replaced by a colon or a full stop (house rule). Every id, prefix, name and
* date is a placeholder. The revealed key is obviously not a key.
*
* Not rendered: the footer, the noscript block, the native confirm() before
* a revoke, the beforeunload guard while a key is on screen, and the sign-out
* navigation.
*
* Two states are read from the script's control flow rather than from a
* screen the author saw: `denied` keeps the band skeleton and no actions,
* because requireSession() throws the 403 before boot() reaches
* renderIdentity(); `unavailable` is the list card quiet at load plus the
* create card quiet after a refused submit, two branches that never appear
* at once in one load (the recipe's Frontend section says why).
*/
export const states = {
default: { label: 'Ready: the create form, three keys (the portal session key marked), the housekeeping card', height: 1500, viewport: 1440 },
loading: { label: 'Loading: the gate skeleton before /me and the workspace settle', height: 700, viewport: 1440 },
empty: { label: 'Ready, no keys yet: the empty state with the quick-start link', height: 1250, viewport: 1440 },
revealed: { label: 'Just minted: the once-only reveal in one node, the list refreshed', height: 1700, viewport: 1440 },
'create-refused': { label: 'Mint refused with 403: the server wording verbatim under the name field', height: 1500, viewport: 1440 },
'list-error': { label: 'The list read failed: the error notice with a retry', height: 1250, viewport: 1440 },
unavailable: { label: 'GET /keys 404 with a stored session key, then a mint refused with 404: both cards quiet, not an error', height: 1150, viewport: 1440 },
denied: { label: 'Signed in, prima_dev not in /me.surfaces: the notice, the band skeleton left in place, no sign-out', height: 700, viewport: 1440 },
'gate-error': { label: 'The gate could not load (network or 5xx): the notice with a retry', height: 700, viewport: 1440 },
unconfigured: { label: 'Built without PUBLIC_PRIMA_API_BASE: a configuration fault on our side', height: 700, viewport: 1440 },
};
interface Props {
state: keyof typeof states;
}
const { state } = Astro.props;
/* Sample data: literal, nothing real. */
const CONTACT = '[email protected]';
const me = { name: 'Mira Muster', email: '[email protected]' };
const workspaceLabel = 'Muster AG';
const sessionSid8 = 'a1b2c3d4';
/* Obviously not a key. The real one is prima_pk_ plus 43 url-safe characters;
the list shows the first twelve characters and an ellipsis, as Labs stores them. */
const KEY = 'prima_pk_BEISPIEL_nicht_echt_00000000000000000000';
interface KeyRow {
id: string;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
session?: boolean;
}
const keys: KeyRow[] = [
{ id: 'k-1', name: `developer.prima.li (${sessionSid8})`, key_prefix: 'prima_pk_S1t...', created_at: '12.09.2026', last_used_at: '12.09.2026', expires_at: '13.09.2026', is_active: true, session: true },
{ id: 'k-2', name: 'Produktion Backend', key_prefix: 'prima_pk_Pr0...', created_at: '03.09.2026', last_used_at: '11.09.2026', expires_at: null, is_active: true },
{ id: 'k-3', name: 'Laptop (alt)', key_prefix: 'prima_pk_L4p...', created_at: '21.08.2026', last_used_at: '28.08.2026', expires_at: null, is_active: false },
];
const minted: KeyRow = { id: 'k-4', name: 'Staging', key_prefix: 'prima_pk_BEI...', created_at: '12.09.2026', last_used_at: null, expires_at: null, is_active: true };
type Gate = 'loading' | 'ready' | 'denied' | 'error' | 'notconfigured';
type ListState = 'loading' | 'ready' | 'empty' | 'error' | 'unavailable';
type NewState = 'ready' | 'unavailable' | 'error';
interface View {
gate: Gate;
identity: boolean;
list: ListState;
rows: KeyRow[];
create: NewState;
reveal: boolean;
nameErr?: string;
listErr?: string;
gateErr?: string;
}
const views: Record<keyof typeof states, View> = {
default: { gate: 'ready', identity: true, list: 'ready', rows: keys, create: 'ready', reveal: false },
loading: { gate: 'loading', identity: false, list: 'loading', rows: [], create: 'ready', reveal: false },
empty: { gate: 'ready', identity: true, list: 'empty', rows: [], create: 'ready', reveal: false },
revealed: { gate: 'ready', identity: true, list: 'ready', rows: [minted, ...keys], create: 'ready', reveal: true },
'create-refused': {
gate: 'ready', identity: true, list: 'ready', rows: keys, create: 'ready', reveal: false,
nameErr: "Keys minted from a Prima surface may carry the 'ai' scope only (requested: read, write).",
},
'list-error': {
gate: 'ready', identity: true, list: 'error', rows: [], create: 'ready', reveal: false,
listErr: 'Der Prima-Dienst antwortet gerade nicht. Das liegt nicht an Ihnen: bitte in einigen Minuten erneut versuchen.',
},
unavailable: { gate: 'ready', identity: true, list: 'unavailable', rows: [], create: 'unavailable', reveal: false },
denied: { gate: 'denied', identity: false, list: 'loading', rows: [], create: 'ready', reveal: false },
'gate-error': {
gate: 'error', identity: false, list: 'loading', rows: [], create: 'ready', reveal: false,
gateErr: 'Keine Verbindung zum Prima-Dienst. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.',
},
unconfigured: { gate: 'notconfigured', identity: false, list: 'loading', rows: [], create: 'ready', reveal: false },
};
const v = views[state];
const navLinks = [
{ href: '/docs/', label: 'Dokumentation' },
{ href: '/docs/modelle/', label: 'Modelle' },
{ href: '/playground/', label: 'Playground' },
{ href: '/schluessel/', label: 'Schlüssel', current: true },
];
const appNav = [
{ href: '/schluessel/', label: 'Schlüssel', current: true },
{ href: '/verbrauch/', label: 'Verbrauch' },
{ href: '/konto/guthaben/', label: 'Guthaben' },
{ href: '/limit/', label: 'Ausgabenlimit' },
{ href: '/playground/', label: 'Playground' },
];
const meta = (k: KeyRow) =>
[
k.key_prefix,
`erstellt ${k.created_at}`,
k.last_used_at ? `zuletzt genutzt ${k.last_used_at}` : 'noch nie genutzt',
k.expires_at ? `läuft ab ${k.expires_at}` : null,
k.session ? 'dieses Portal, diese Sitzung: wird beim Abmelden gesperrt' : null,
]
.filter(Boolean)
.join(' · ');
---
<div class="dpk" data-dpk-state={state}>
<header class="nav">
<div class="wrap nav__inner">
<a class="nav__brand" href="/" aria-label="PrimaAPI Entwicklerportal, Startseite">
<span class="nav__mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="nav__words"><span class="nav__name">Prima</span><span class="nav__li">.li</span></span>
<span class="nav__tag">developer</span>
</a>
<nav class="nav__links" aria-label="Hauptnavigation">
{navLinks.map((l) => <a href={l.href} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}
<a class="nav__signin" href="/anmelden/">Anmelden</a>
</nav>
<a class="btn btn--primary nav__cta" href="/registrieren/">Zugang anfragen</a>
</div>
</header>
<section class="kh">
<div class="wrap">
<div class="eyebrow">developer.prima.li</div>
<div class="kh__row">
<div>
<h3 class="kh__title">API-Schlüssel</h3>
<div class="kh__sub" aria-live="polite">
{v.identity ? (
<>
<strong>{me.name}</strong> · {me.email}
</>
) : (
<>
<span class="vh">Konto wird geladen.</span>
<span class="skel" style="width:220px" aria-hidden="true"></span>
</>
)}
</div>
</div>
{v.identity && (
<div class="kh__actions">
<a class="btn btn--ghost btn--sm btn--danger-ghost" href="/konto/guthaben/">Guthaben</a>
<button type="button" class="btn btn--ghost btn--sm btn--danger-ghost">Abmelden</button>
</div>
)}
</div>
</div>
</section>
<section class="kbody">
<div class="wrap">
<div class="gate" data-state={v.gate}>
<div data-when="loading">
<div class="card">
<div class="skel skel--lg"></div>
<div class="skel skel--w70" style="margin-top:22px"></div>
<div class="skel skel--w40"></div>
</div>
</div>
<div data-when="notconfigured">
<div class="note note--warn">
<div>
<div class="note__p">
<strong>Diese Installation ist nicht mit dem Prima-Dienst verbunden.</strong> Schlüssel lassen sich
deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf
Ihrer.
</div>
<div class="note__act"><a href={`mailto:${CONTACT}`}>{CONTACT}</a></div>
</div>
</div>
</div>
<div data-when="denied">
<div class="note note--warn">
<div>
<div class="note__p">
<strong>Ihr Konto ist für developer.prima.li nicht freigeschaltet.</strong> Sie sind angemeldet, aber
dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
</div>
<div class="note__act">Wenn das nicht stimmen kann, schreiben Sie an <a href={`mailto:${CONTACT}`}>{CONTACT}</a>.</div>
</div>
</div>
</div>
<div data-when="error">
<div class="note note--err">
<div>
<div class="note__p"><strong>Die Seite konnte nicht geladen werden.</strong></div>
<div class="note__p">{v.gateErr ?? ''}</div>
<div class="note__act"><button type="button" class="btn btn--ghost btn--sm">Erneut versuchen</button></div>
</div>
</div>
</div>
<div data-when="ready">
<nav class="knav" aria-label="Kontobereich">
{appNav.map((l) => <a href={l.href} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}
</nav>
<section class="card" data-state={v.create} aria-labelledby={`${state}-new-h`}>
<div class="card__head">
<div>
<h3 class="card__title" id={`${state}-new-h`}>Neuen Schlüssel ausstellen</h3>
<div class="card__hint">
Jeder Schlüssel trägt genau den Scope <span class="mono">ai</span>: er erreicht den Gateway und
sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
</div>
</div>
</div>
<div data-when="ready">
<form novalidate>
<div class="field">
<label for={`${state}-key-name`}>Name: <em>wofür ist dieser Schlüssel?</em></label>
<input type="text" id={`${state}-key-name`} name="name" required maxlength="255" autocomplete="off" placeholder="Produktion Backend" value={v.nameErr ? 'Staging' : ''} />
<div class="field__hint">
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft
und nicht ausgewertet.
</div>
<div class="field__err" aria-live="polite">{v.nameErr ?? ''}</div>
</div>
<div class="field__hint">Workspace: {workspaceLabel}. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.</div>
<div class="formrow" style="margin-top:var(--space-lg)">
<button type="submit" class="btn btn--primary btn--sm">Schlüssel ausstellen</button>
<span class="mono" aria-live="polite"></span>
</div>
</form>
{v.reveal && (
<div class="reveal" data-gallery-focus="true">
<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 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 class="reveal__done"><button type="button" class="btn btn--ghost btn--sm">Ich habe ihn gesichert</button></div>
</div>
)}
</div>
<div data-when="unavailable">
<div class="note note--quiet"><div class="note__p">Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.</div></div>
</div>
<div data-when="error">
<div class="note note--err">
<div>
<div class="note__p"><strong>Der Schlüssel konnte nicht ausgestellt werden.</strong></div>
<div class="note__p">Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.</div>
</div>
</div>
</div>
</section>
<section class="card" data-state={v.list} aria-labelledby={`${state}-list-h`}>
<div class="card__head">
<div>
<h3 class="card__title" id={`${state}-list-h`}>Ihre Schlüssel</h3>
<div class="card__hint">
Nach dem Ausstellen sehen Sie hier nur noch das Präfix. Das ist keine Einschränkung der Oberfläche:
mehr ist bei uns nicht gespeichert.
</div>
</div>
</div>
<div data-when="loading">
<div class="skel skel--w70"></div>
<div class="skel skel--w40"></div>
</div>
<div data-when="ready">
<div class="rows">
{v.rows.map((k) => (
<div class="row">
<div class="row__main">
<div class="row__title">{k.name}</div>
<div class="row__meta">{meta(k)}</div>
</div>
<div class="row__tail">
<span class:list={['chip', k.is_active ? 'chip--live' : 'chip--off']}>{k.is_active ? 'Aktiv' : 'Gesperrt'}</span>
{k.is_active && <button type="button" class="row__act">Sperren</button>}
</div>
</div>
))}
</div>
</div>
<div data-when="empty">
<div class="empty">
<div class="empty__title">Noch kein Schlüssel</div>
<div class="empty__text">Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.</div>
<div class="empty__act"><a class="btn btn--ghost btn--sm" href="/docs/">Zum Schnellstart</a></div>
</div>
</div>
<div data-when="unavailable">
<div class="note note--quiet"><div class="note__p">Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.</div></div>
</div>
<div data-when="error">
<div class="note note--err">
<div>
<div class="note__p"><strong>Schlüssel konnten nicht geladen werden.</strong></div>
<div class="note__p">{v.listErr ?? ''}</div>
<div class="note__act"><button type="button" class="btn btn--ghost btn--sm">Erneut versuchen</button></div>
</div>
</div>
</div>
</section>
<section class="card" aria-labelledby={`${state}-hk-h`}>
<div class="card__head"><h3 class="card__title" id={`${state}-hk-h`}>Umgang mit Schlüsseln</h3></div>
<div class="rows">
<div class="row"><div class="row__main"><div class="row__title">Ein Schlüssel pro Einsatzort</div><div class="row__meta">Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.</div></div></div>
<div class="row"><div class="row__main"><div class="row__title">Nicht ins Repository</div><div class="row__meta">Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.</div></div></div>
<div class="row"><div class="row__main"><div class="row__title">Nicht im Browser</div><div class="row__meta">Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.</div></div></div>
<div class="row"><div class="row__main"><div class="row__title">Verloren? Sperren, neu ausstellen</div><div class="row__meta">Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.</div></div></div>
</div>
</section>
</div>
</div>
</div>
</section>
</div>
<style>
/* prima-developer src/styles/app.css and Layout.astro's nav (c594dfc) with
the portal's house variables replaced by the prima-tokens aliases. Text
and headings are div and h3, inline code is span.mono (the docs host's
shell rules reach p, h1, h2 and code inside a frame; prima.li
src/index-site/demo-frame.css says why). The state colours the token
layer lacks are declared here and nowhere else. */
.dpk {
--dpk-danger: #c4362f;
--dpk-ok: #2f7d5b;
--dpk-ok-bg: #e7f3ec;
--dpk-warn-bg: #fbf0dc;
--dpk-warn-line: #efdfbe;
--dpk-warn-fg: #6e4e12;
--dpk-err-bg: #fdf0ef;
--dpk-err-line: #f0d6d3;
--dpk-err-fg: #7c2b26;
--dpk-ink-30: color-mix(in srgb, var(--fg-default) 45%, var(--bg-page));
--dpk-on-dark-60: rgba(255, 255, 255, 0.6);
--dpk-dark-line: rgba(255, 255, 255, 0.14);
--dpk-mono: var(--work-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-family: var(--font-body);
color: var(--fg-default);
background: var(--bg-page);
font-size: 17px;
line-height: 1.55;
min-height: 100%;
}
.wrap { width: 100%; max-width: 1120px; margin: 0 auto; padding: 0 var(--space-lg); }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.mono { font-family: var(--dpk-mono); font-size: 13px; }
/* ---- Layout.astro nav ---- */
.nav { background: var(--bg-surface); border-bottom: 1px solid var(--border-default); }
.nav__inner { display: flex; align-items: center; gap: var(--space-xl); min-height: 68px; }
.nav__brand { display: flex; align-items: center; gap: 11px; margin-right: auto; text-decoration: none; color: inherit; }
.nav__mark { display: grid; gap: 2.5px; width: 20px; }
.nav__mark i { display: block; height: 4px; border-radius: 1.5px; background: var(--fg-accent); }
.nav__mark i:nth-child(2) { opacity: 0.72; }
.nav__mark i:nth-child(3) { opacity: 0.45; }
.nav__words { font-weight: 500; font-size: 22px; letter-spacing: -0.035em; }
.nav__li { color: var(--fg-accent); }
.nav__tag { font-family: var(--dpk-mono); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--fg-muted); padding-left: 11px; margin-left: 3px; border-left: 1px solid var(--border-strong); }
.nav__links { display: flex; gap: var(--space-lg); font-size: 15.5px; color: var(--fg-muted); }
.nav__links a { color: inherit; text-decoration: none; }
.nav__links a[aria-current='page'] { color: var(--fg-default); font-weight: 500; }
.nav__signin { font-weight: 500; color: var(--fg-default) !important; }
.nav__cta { padding: 9px 17px; font-size: 15px; }
.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; }
.btn--danger-ghost { border-color: var(--dpk-dark-line); color: var(--dpk-on-dark-60); }
/* ---- the slate header band ---- */
.kh { background: var(--bg-dark); color: var(--fg-on-dark); padding: var(--space-2xl) 0 var(--space-3xl); }
.eyebrow { font-family: var(--dpk-mono); font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--dpk-on-dark-60); margin-bottom: var(--space-md); }
.kh__row { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--space-lg); }
.kh__title { margin: 0; color: var(--fg-on-dark); font-family: var(--font-body); font-weight: 500; font-size: clamp(30px, 4.4vw, 44px); letter-spacing: -0.028em; line-height: 1.06; }
.kh__sub { color: var(--dpk-on-dark-60); font-size: 15.5px; margin-top: var(--space-sm); }
.kh__sub strong { color: var(--fg-on-dark); font-weight: 500; }
.kh__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); }
.kbody { padding: var(--space-2xl) 0 var(--space-4xl); }
/* ---- the state machine: exactly one branch visible per container ---- */
[data-when] { display: none; }
[data-state='loading'] > [data-when='loading'],
[data-state='ready'] > [data-when='ready'],
[data-state='empty'] > [data-when='empty'],
[data-state='error'] > [data-when='error'],
[data-state='unavailable'] > [data-when='unavailable'],
[data-state='denied'] > [data-when='denied'],
[data-state='notconfigured'] > [data-when='notconfigured'] { display: block; }
/* ---- cards ---- */
.card { background: var(--bg-page); border: 1px solid var(--border-default); border-radius: var(--radius); padding: var(--space-xl); }
.card + .card { margin-top: var(--space-md); }
.card__head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-sm); margin-bottom: var(--space-lg); }
.card__title { margin: 0; font-family: var(--font-body); font-weight: 500; font-size: 20px; letter-spacing: -0.02em; line-height: 1.2; }
.card__hint { font-size: 14.5px; color: var(--fg-muted); margin-top: 6px; max-width: 68ch; }
/* skeletons: a shape, never a number */
.skel { display: block; background: linear-gradient(90deg, var(--bg-surface), color-mix(in srgb, var(--bg-surface) 50%, var(--bg-page)), var(--bg-surface)); background-size: 220% 100%; border-radius: var(--radius-sm); height: 14px; }
.skel--lg { height: 38px; max-width: 220px; }
.skel + .skel { margin-top: 10px; }
.skel--w70 { max-width: 70%; }
.skel--w40 { max-width: 40%; }
/* notices */
.note { display: flex; gap: var(--space-sm); align-items: flex-start; border-radius: var(--radius-sm); padding: var(--space-md) var(--space-lg); font-size: 15px; line-height: 1.5; }
.note strong { font-weight: 500; }
.note__p { max-width: 62ch; }
.note__act { margin-top: var(--space-sm); }
.note a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
.note--err { background: var(--dpk-err-bg); border: 1px solid var(--dpk-err-line); color: var(--dpk-err-fg); }
.note--warn { background: var(--dpk-warn-bg); border: 1px solid var(--dpk-warn-line); color: var(--dpk-warn-fg); }
.note--quiet { background: var(--bg-surface); border: 1px solid var(--border-default); color: var(--fg-muted); }
.empty { text-align: center; padding: var(--space-xl) var(--space-md); border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); background: var(--bg-surface); }
.empty__title { font-weight: 500; }
.empty__text { color: var(--fg-muted); font-size: 15px; margin: 8px auto 0; max-width: 46ch; }
.empty__act { margin-top: var(--space-lg); }
/* the sub-navigation (AppNav.astro) */
.knav { display: flex; flex-wrap: wrap; gap: var(--space-md); font-size: 14.5px; border-bottom: 1px solid var(--border-default); padding-bottom: var(--space-md); margin-bottom: var(--space-xl); }
.knav a { color: var(--fg-muted); padding-bottom: 2px; text-decoration: none; }
.knav a[aria-current='page'] { color: var(--fg-default); font-weight: 500; box-shadow: inset 0 -2px 0 var(--fg-accent); }
/* forms inside cards */
.field { display: grid; gap: 6px; margin-bottom: var(--space-md); }
.field > label { font-size: 14px; font-weight: 500; }
.field em { color: var(--dpk-ink-30); font-style: normal; font-weight: 400; }
.field input[type='text'] { font: inherit; font-size: 16px; color: var(--fg-default); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 13px; background: var(--bg-surface); width: 100%; }
.field__hint { font-size: 13px; color: var(--dpk-ink-30); }
.field__err { font-size: 13.5px; color: var(--dpk-danger); min-height: 1.2em; }
.formrow { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
/* the once-only reveal */
.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(--dpk-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; }
.reveal__done { margin-top: var(--space-md); }
.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; }
/* key rows */
.rows { border-top: 1px solid var(--border-default); }
.row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-sm); padding: 13px 0; border-bottom: 1px solid var(--border-default); }
.row__main { flex: 1 1 220px; min-width: 0; }
.row__title { font-weight: 500; font-size: 15.5px; }
.row__meta { font-size: 13px; color: var(--dpk-ink-30); margin-top: 3px; }
.row__tail { display: flex; align-items: center; gap: var(--space-md); }
.row__act { font: inherit; font-size: 13.5px; background: none; border: 0; padding: 4px 2px; color: var(--dpk-danger); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; white-space: nowrap; }
.chip { font-family: var(--dpk-mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 4px 10px; border-radius: 20px; white-space: nowrap; }
.chip--live { background: var(--dpk-ok-bg); color: var(--dpk-ok); }
.chip--off { background: var(--bg-surface); color: var(--fg-muted); }
</style>
Component tree, from the recipe
Layout (title "API-Schlüssel · PrimaAPI", robots noindex nofollow, path /schluessel) header.nav (Dokumentation, Modelle, Playground, Schlüssel, Anmelden, CTA), main, footer.foot
section.kh (slate band) p.eyebrow "developer.prima.li"; h1 "API-Schlüssel"; p#whoami[aria-live] (skeleton, then name · e-mail); div#kh-actions[hidden] (a Guthaben, button#signout Abmelden)
section.kbody > div.wrap
noscript.note--warn
div#gate[data-state=loading|notconfigured|denied|error|ready] app.css: exactly one [data-when] child visible
[loading] div.card > .skel x3
[notconfigured] .note--warn (PUBLIC_PRIMA_API_BASE unset; a fault on our side)
[denied] .note--warn (signed in, prima_dev not granted; signing in again changes nothing)
[error] .note--err (p#gate-err, button#gate-retry)
[ready]
AppNav current=/schluessel/ nav.knav: Schlüssel, Verbrauch, Guthaben, Ausgabenlimit, Playground
section.card#new-card[data-state=ready|unavailable|error]
[ready] form#new-form (input#key-name, p#key-name-err[aria-live], p#ws-single "Workspace: <name>", button#create, span#create-state)
div.reveal#reveal[hidden] (reveal__tag Einmalig, reveal__warn, code#reveal-key, button#reveal-copy, reveal__note, button#reveal-close)
[unavailable] .note--quiet [error] .note--err (p#new-err)
section.card#list-card[data-state=loading|ready|empty|unavailable|error]
[loading] .skel x2 [ready] div.rows#key-rows > div.row x n (row__title, row__meta prefix · erstellt · zuletzt genutzt · läuft ab · session marker; row__tail: span.chip Aktiv|Gesperrt, button.row__act Sperren)
[empty] .empty (Noch kein Schlüssel, a Zum Schnellstart) [unavailable] .note--quiet [error] .note--err (p#list-err, button[data-retry=keys])
section.card (Umgang mit Schlüsseln: four static rows)
<script> boot: requireSession -> renderIdentity -> resolveWorkspace -> wireCreate -> loadKeys src/lib/prima-api.ts The rendering is the live rebuild developer-portal-keys-page/demo.astro, rendered by the docs host as one frame per state (RENDERING.md, section 3). One Astro component takes state and renders the shell with the data-state attributes the module script would have set and the rows it would have appended, from literal sample data: the docs page shows every branch at once and nothing reaches the network. hydrate: only describes the real page (a shell; the visible branch is the script's decision after the guard); the docs host renders an Astro demo server-side and reads no hydrate key for it (prima.li/src/index-site/DemoIsland.astro at 86fe9d1, lines 131 and 136).
Every state declares viewport: 1440: the page is shell-wrapped (the site header from Layout.astro 51 to 77, the slate band, the signed-in sub-navigation from AppNav.astro), so the docs host lays the frame out at the portal's desktop width and scales it into the column (DemoFrame.astro at 86fe9d1, the demo-stage--viewport branch). Heights are minimum heights per state, from 700 px for the gate states to 1700 px for the reveal: the host writes height as min-height on .demo-viewport (DemoFrame.astro line 28) and the stage grows with the content, so a ready state taller than its floor is not clipped. Until the host's cascade is fixed (Gotchas, demo-frame.css lines 79 and 81), every one of these frames is inset by the stage's 24 px left padding and its right 24 px sit behind a sideways scroll on the docs page.
States, in order: default (gate ready: the identity line, the create form with the workspace sentence, three keys in the list with the portal's own session key first and marked "dieses Portal, diese Sitzung: wird beim Abmelden gesperrt", one revoked key with the Gesperrt chip and no "Sperren", and the housekeeping card), loading (the whoami skeleton in the band, the gate skeleton card, no sub-navigation), empty (no keys yet: the dashed empty state with "Zum Schnellstart"), revealed (a mint just succeeded: the reveal with an obviously fake key in one node, "Kopieren" and "Ich habe ihn gesichert", and the list with the new row on top), create-refused (a 403 on the mint: the server's own sentence under the name field, the typed name still in the field), list-error (the list card's error notice with the server sentence and "Erneut versuchen", the create form intact), unavailable (GET /keys answering 404 with a session key already in sessionStorage, then a mint refused with 404: both cards show the quiet "noch nicht freigeschaltet" notice, no error styling; a composite, because the list card goes quiet at load and the create card only after a submit, and without a stored key a 404 on the mint never reaches ready at all, Gotchas), denied (signed in, prima_dev not granted: the amber notice, the band skeleton still in place, no "Abmelden", no sub-navigation, no redirect, because requireSession throws before boot reaches renderIdentity), gate-error (the gate could not load: the red notice with the network sentence and a retry), unconfigured (built without the API base: the amber notice naming a configuration fault on our side).
Design system prima (D1b): the demo redraws app.css and the layout's nav with @prima-modules/prima-tokens aliases. The mapping, portal variable to token: --sand (page) to --bg-page; --sand-deep (skeletons, quiet notice, chips off) to --bg-surface; --slate (the band) to --bg-dark; --on-slate to --fg-on-dark; --on-slate-60 and --slate-line to white at 60 % and 14 % (no token); --line to --border-default; --line-strong to --border-strong; --purple to --fg-accent; --purple-50 and --purple-100 to a color-mix of the accent; --ink to --fg-default; --ink-60 to --fg-muted; --ink-30 to a color-mix of --fg-default into the page; --r-xl (cards) to --radius, --r-md, --r-lg and --r-sm to --radius-sm; --s-3 to --s-9 to --space-sm through --space-4xl; --font-display to --font-body at weight 500; --mono to --work-mono. The notice palette (.note--err #FDF0EF on #7C2B26, .note--warn #FBF0DC on #6E4E12, app.css 164 to 173), the chip green (--ok, --ok-bg) and the refusal red (--swiss) have no token; the demo declares them once on its root (Gotchas). The skeleton shimmer animation is not reproduced: the demo's skeleton is the static gradient.
Text and headings inside the demo are div and h3, 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, lines 147 to 169); the real page uses h1, h2, p and code.
Responsive: the band's row wraps its actions under the title, the card heads wrap, a key row wraps its tail under the meta line below about 500 px (.row is flex-wrap: wrap, app.css 301 to 309), and the site header collapses its links below 900 px keeping "Anmelden" (Layout.astro 168 to 175). Loading is the two skeletons (band and card), empty is the dashed card, error is the red notice with a retry per card, and unavailable is deliberately not an error.
What the demo deliberately does not render: the footer, the noscript block, the native confirm(), the beforeunload guard, the "Wird gesperrt …" and "wird ausgestellt …" pending labels, the focus moves, the sign-out navigation, and the skeleton animation. The copy is the page's own with the source's em dashes replaced by a colon or a full stop (house rule); names, ids, prefixes and dates are placeholders.
Security & tenancy
What the browser may hold: the session token and the portal's own ai key in sessionStorage (F7 Security & tenancy; auth.ts 665 to 702 says why the key is a smaller exposure than the token beside it: ai only, so it cannot list, mint or revoke keys, and it is revocable from this very list), plus parked row ids. Never a customer key beyond the reveal, never a refresh token, never a password. The portal's key is bounded on the server too: expires_in_days: 1 at the mint (gateway-key.ts 99 to 100), revoked at sign-out, at session end while the Bearer is still valid, and by the next sign-in's housekeeping for a tab that closed without either (prima-api.ts 127 to 136).
Origin and CSRF: credentials: "omit" on every call, the Labs CORS list carries the surface origin explicitly (F7). Nothing is a cookie, so there is no CSRF surface; the Bearer is attached by the script only.
Surface gating: X-Prima-Surface: prima_dev on every call; /me.surfaces decides the denied branch on the client, and the router mount decides on the server (B4). What is hidden by CSS: every branch of the shell, as a rendering convention, never as access control; the data a branch shows arrives only after the server has said yes. What is enforced: the Bearer on every read and write, the workspace membership at the mint (Labs re-checks it, 403), the ai-only rule for a Prima surface (B10, 113 to 126), the prefix lock that keeps an ai key off this page's routes (S5).
Attacker-controlled inputs rendered: key names (textContent in keyRow), server detail strings (textContent through setText), the workspace name (textContent). The page writes no innerHTML.
Tests
prima-developerat c594dfc:npm test(node --test, 173 tests, 30 suites, all passing on 2026-09-12) covers the pure decisions this page relies on:gateway-key.test.ts(chooseTenant,gatewayKeyMintBody,acceptMintedKey,keyRefusalPolicy,orphanedPortalKeys,readGatewayKeyRecord),billing.test.ts(parseErrorEnvelopefor the four error shapes,billingErrorMessage,mayManageBilling),desktop-auth.test.ts. The page script itself has no test.- What a new app adds: a
node --testtable forclassify(401, 402, 403, 404, 501, 429 with and withoutspend_cap_exceeded, 503 with and withoutcredits_unavailable, 5xx) and forerrorMessageon each code; a jsdom test of the shell that sets eachdata-stateand asserts exactly one[data-when]child is visible per container. - Playwright, against
dist/served statically with the API mocked: (a) no session, open/schluessel/, assert the navigation to/anmelden/; (b) a session insessionStorage,/memocked withsurfaces: ["prima_web"], assert thedeniednotice,#whoamistill the skeleton,#kh-actionsstillhidden, and no redirect; (c)/meok,/workspaceswith one row,/keysmocked[], assert the empty state; (d)POST /keysmocked 200 withkey, assert one element holds it,sessionStoragedoes not, "Ich habe ihn gesichert" empties it; (e)DELETEmocked 200 and the list re-fetched (twoGET /keyscalls); (f)prima.dev.gateway-key.v1seeded for the session'ssid,GET /keysmocked 404, assert the list card's quiet notice at load; submit a name withPOST /keysmocked 404, assert the create card's quiet notice and no red; without the seeded record, assert the gate'serrorbranch instead. - The kit's gate:
npm run checkprints no line for this recipe; the render smoke covers React demos only; the docs build is the render check for an Astro demo (Acceptance check 6).
Deploy & ops
Build target and deploy path as F7: Astro static, PUBLIC_PRIMA_API_BASE baked at build with PRIMA_REQUIRE_API_BASE=1 on a release build, S13's static portal ship (build, refuse a localhost or staging host in dist/, back up, rsync), rollback the printed rsync. CORS: the API side lists the surface origin. CSP: none of the portal's own at c594dfc. Ingress: GET, POST and DELETE /api/developer/keys* and GET /api/auth/workspaces must pass the API host's allow-list to Labs (measured 2026-09-11 in prima-api.ts 604 to 609 for the workspaces route; /api/auth/logout does not pass it, F7 Gotchas). Argus: none for this page; the check that matters is B12's outbox (a key minted here that Prima never learns about answers 401 at the gateway with no symptom on this page).
Gotchas
unavailable server rendered - 2026-09-12 (measured at c594dfc,
prima-api.ts590 to 636; corrects the inventory row F8, which says the tenant is "resolved from the meter (X-Workspace-ID from /usage)"): the tenant is no longer read back from/usage. Prima refuses a Labs session Bearer on/usage(503identity_provider_unavailable, 600 to 603) and the meter now runs on the per-session key, whose tenant must be known before the mint; so the tenant comes fromGET /api/auth/workspaces(the caller's own memberships, the predicate Labs uses to authorise the mint) throughchooseTenant, andresolveTenantIdreturns the key record'stenantIdwith no network call once the key exists. The row's description was true of the build before A-2 (a), decided 2026-09-11. - 2026-09-12 (measured at c594dfc,
schluessel.astro539 to 547 andapp.css105 to 122; D8, a verified defect recorded here and not fixed in this wave): when the mint answers 200 without akey, the page sets#new-cardtoerror, and the state machine hides thereadybranch, which holds the form. The card has no retry control (#gate-retryand[data-retry=keys]serve the other two containers), so the only way back to the form is a reload. The sentence tells the customer to revoke the key below and mint a new one, which they cannot do from this state. Fix it needs: render that failure into#key-name-err(as the 403 branch does) or add a retry to the create card's error branch. - 2026-09-12 (measured at c594dfc,
prima-api.ts538 to 545 andschluessel.astro591 to 622; D8, a verified defect recorded here and not fixed in this wave):requireSessionthrows the 403forbiddenbefore it returnsme, sobootnever reachesrenderIdentityfor a denied account:#whoamikeeps the loading skeleton for good and#kh-actionsstayshidden, which leaves the page with an amber notice saying "Sie sind angemeldet" and no way to sign out from it (the#signoutbutton is inside the hidden actions). The demo'sdeniedstate renders that, skeleton and all. Fix it needs: render the identity and unhide#kh-actionsfrom the/mebody before the surfaces check, or catchforbiddenafterrenderIdentityhas run. - 2026-09-12 (measured at c594dfc,
schluessel.astro360 to 361 and 604 to 622,prima-api.ts634 to 636, 687 to 708, 710 to 744, 268 to 280): a 404 on the keys routes at load does not produce a page with two quiet notices.resolveWorkspacecallsresolveTenantId, which isensureGatewayKey, which mints throughPOST /api/developer/keyswhen no record for thissidis insessionStorage; that 404 is classifiedunavailable, is thrown outside any card's try, and lands in the gate'serrorbranch with "Dieser Dienst ist noch nicht freigeschaltet." Only with a stored key does the page reachready, and then the list card alone goes quiet at load (failCard341 to 348 acts on that card's own call); the create card goes quiet after a submit returns 404 (555). The demo'sunavailablestate is the composite of those two branches, labelled as such. - 2026-09-12 (measured at c594dfc,
onRevoke476 to 481 andclassify272): a 404 onDELETE /api/developer/keys/{id}(another workspace's key, a stale row) is classifiedunavailableand rendered as "Dieser Dienst ist noch nicht freigeschaltet." in the list card's error branch, which is the wrong sentence for "this key is not yours or no longer exists". The list is not reloaded on that path (475 runs only after a successful revoke), so the stale row stays until "Erneut versuchen". Fix it needs: a 404 onDELETEmapped to its own code and a reload after it. - 2026-09-12 (measured at c594dfc,
billing.ts810 to 814): an account with no workspace reaches the gate'serrorbranch with the money page's sentence ("... kein Guthaben, das aufgeladen werden könnte"), because the seam's tokens are shared across the signed-in pages. True, but odd on a page about keys. Copy, not behaviour. - 2026-09-12 (measured at c594dfc,
schluessel.astro551 to 554, and at fd8558f,developer_router.py113 to 126): the 403 branch under the name field exists for the server's scope refusal, and this page always sends["ai"], so at c594dfc it can fire only for a membership or surface refusal. The demo'screate-refusedstate shows the scope sentence because it is the one the branch was written for. - 2026-09-12 (measured at c594dfc,
gateway-key.ts125 to 129, and at fd8558f,developer_router.py135 to 136 and 201): the POST'sexpires_atis a naiveutcnow()ISO string with no offset, whichDate.parsereads as local time; the list'sexpires_atis the column value with its offset. The page's list reads the list response only, so "läuft ab" is right; the client keeps its ownmintedAtfor the portal key's expiry for the same reason. - 2026-09-12 (measured at fd8558f,
developer_router.py240 to 253): the list response omitsscopes, so this page cannot show a key's scope and does not try; the "genau den Scope ai" sentence in the create card is the rule, not a read. - 2026-09-12 (rendering trap): the shell's branches are all in the HTML, hidden by
[data-when]; a screenshot of the served page before the script runs shows the loading skeleton and nothing else, and the verifier'ssession: anoncapture shows the navigation to/anmelden/begun. The demo renders each branch as its own state;verify.routematchesloadingat best. - 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; and the skeleton's@keyframesshimmer is left out because the capture rules pause animations anyway. - 2026-09-12 (rendering trap, host side,
prima.li/src/index-site/demo-frame.cssat 86fe9d1, lines 79 and 81):.demo-frame .demo-stage--viewport { padding: 0; overflow: hidden }is written before.demo-frame .demo-stage { padding: var(--space-lg); overflow-x: auto }at the same specificity, so the generic rule wins and a viewport stage keeps its 24 px padding and its sideways scroll.DemoFrame.astro'sfit()(lines 44 to 50) zooms the 1440 px box tostage.clientWidth, the box then sits behind the 24 px left padding, its right 24 px overflow the stage, and the frame scrolls 48 px sideways; the right edge of every frame of this demo is clipped on the docs page until scrolled. Not a defect of the demo; it affects every recipe that declaresviewport:. Fix it needs (prima.li): move line 79 below the generic rule or raise it to.demo-frame .demo-stage.demo-stage--viewport. - 2026-09-12 (
@prima-modules/prima-tokensat its 03f4205 pin): no state colours intokens.css, so the demo declares the notice palette, the chip green and the refusal red locally (--dpk-*). - 2026-09-11 (measured then,
prima-api.ts99 to 103): anai-scoped key answers 403 on/api/auth/meand/api/developer/keys(the prefix lock); key management therefore runs on the session, and "a key that could mint more keys would be a privilege-escalation primitive". - 2026-08-04 (
developer_router.py267 to 278 at fd8558f, dated in its comment):GETandDELETEfilter to the workspace only whenX-Workspace-IDis present; this page always sends it, so a key of the same user in another workspace is not listed here.
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; schluessel.astro is unchanged between the two). Paths relative to the repo root:
src/pages/schluessel.astro(632 lines): guard and invariant comments 7 to 21, band 30 to 51, gate branches 65 to 116, sub-navigation 118, create card 121 to 207 (form 134 to 163, reveal 166 to 187), list card 210 to 262, housekeeping card 265 to 307, script 314 to 632 (failCard341 to 348,resolveWorkspace360 to 379,keyRow382 to 439,loadKeys441 to 458,onRevoke461 to 482,showRevealandhideReveal485 to 505,wireCreate507 to 588,renderIdentity591 to 602,boot604 to 622).src/lib/prima-api.ts(1442 lines): header (invariants A, B, C, the backend contract, the error envelope, the two credentials) 1 to 139,ApiErrorandclassify214 to 280,errorMessage282 to 322,request,keyRequest,send335 to 443,AccountandrequireSession464 to 546,signOut557 to 578,displayName580 to 583, workspaces 590 to 636,ensureGatewayKeyandmintGatewayKey687 to 744, revocation and housekeeping 756 to 908,ApiKeySummary,listKeys,isSessionKey,createKey,revokeKey914 to 958,formatDate1428 to 1432.src/lib/gateway-key.ts:GATEWAY_KEY_TTL_DAYS99,gatewayKeyMintBody110 to 122,chooseTenant204 to 208,GatewayKeyRecord218 to 230,acceptMintedKey251 onward.src/lib/auth.ts(951 lines): session storage 548 to 659, the per-session key storage 662 to 866.src/components/AppNav.astro(26 lines): the five links,aria-currentbycurrent.src/styles/app.css(867 lines): band 7 to 44, cards 68 to 103, state machine 105 to 122, skeletons 123 to 150, notices 152 to 199, empty 201 to 219, rows 293 to 326, chips 384 to 394, sub-navigation 560 to 574, reveal 608 to 659, fields 661 to 715, row actions 717 to 733, copy button 823 to 834.src/layouts/Layout.astro(207 lines): header 51 to 77, nav styles 135 to 175.
Deviations of the reference from this recipe, dated 2026-09-12: the create card's dead end after a keyless 200; the denied account's permanent skeleton and hidden sign-out; the 404-on-revoke sentence; the money sentence for no_workspace on a keys page; no test of the page script; the inventory row's stale tenant description.
Acceptance checks
From prima-developer at c594dfc, or a new portal built to this recipe:
grep -n 'scopes: \["ai"\]' src/lib/prima-api.tsprints line 953 as its only match outside a comment (at c594dfc the other four matches, lines 128, 676, 939 and 1024, are*-prefixed comment lines) andgrep -n 'GATEWAY_KEY_SCOPES' src/lib/gateway-key.tsprints its declaration and the spread ingatewayKeyMintBody; no otherscopes: [literal exists insrc/outside comments and*.test.ts(thescopes: string[]atgateway-key.ts113 is the type of the mint body, not a value).grep -c 'innerHTML' src/pages/schluessel.astroprints0.npx astro buildwith a configured base, thengrep -o '[A-Za-z0-9._-]*@[A-Za-z0-9.-]*' dist/schluessel/index.html | sort -uprints the support mailbox and nothing else (no account address in the shell),grep -c 'prima_pk_' dist/schluessel/index.htmlprints0, andgrep -c 'data-when=' src/pages/schluessel.astroprints13.npm testprints# pass 173(or the new app's count) and# fail 0.- Browser walk-through against
dist/served statically (tooling/verify/astro-static.sh <site> /schluessel --serve) with the API mocked: (a) no session: open/schluessel/, observe the address bar at/anmelden/; (b) with a session and/meansweringsurfaces: ["prima_web"]: observe the amber "nicht freigeschaltet" notice, the band skeleton still in place (#whoamiunchanged,#kh-actionsstillhidden, so no "Abmelden"), no sub-navigation, and no redirect; (c)/meok, one workspace,/keys[]: observe "Noch kein Schlüssel" and "Workspace:" under the form; (d) type a name, submit, mock 200 with api_keyinkey: observe "Einmalig", the key in exactly one element (document.body.innerText.split(key).length === 2),sessionStoragewithout it, the list re-fetched; click "Ich habe ihn gesichert", observe the panel gone and#reveal-keyempty; (e) click "Sperren" on an active row, cancel the confirm, observe no request; accept it, mock 200, observe twoGET /keysin the log and the row's chip "Gesperrt" after the reload; (f) withprima.dev.gateway-key.v1seeded insessionStoragefor the session'ssid(orPOST /keysmocked 200 for the mint) and thenGET /keysmocked 404: observe the list card's quiet notice at load; type a name and submit withPOST /keysmocked 404: observe the create card's quiet notice and no red border on either card; without the stored record a 404 on the mint lands in the gate'serrorbranch reading "Dieser Dienst ist noch nicht freigeschaltet." and no card is shown; (g)/keysmocked 503: observe "Schlüssel konnten nicht geladen werden." and "Erneut versuchen" re-fetching. - 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-keys-page/index.html, and opened in Playwright the page has tenfigure[data-demo-state]elements each with a.dpkchild inside a.demo-viewportof width 1440 andpageerrorcount 0 (verified 2026-09-12 against prima.li at 86fe9d1). - Verifier:
node tooling/verify/compare.mjs identity-auth/ui/developer-portal-keys-page --state loading --docs http://127.0.0.1:4321 --app http://127.0.0.1:4320 --app-repo <prima-developer>with the portal served byastro-static.sh; thesession: anoncapture must be taken before the guard navigates (--settle 0), the report'spage_errorsis empty on both sides andmismatch_ratiois read for the band and the skeleton card only, excluding by name the footer (not in the demo), the font and the purple (RENDERING.md section 7); the threshold ismismatch_ratiobelow 0.02 over that region (set 2026-09-12, not yet measured: the docs capture is the.demo-stageelement,compare.mjs198 to 203, so until prima.li fixes the stage padding, Gotchas, the demo side carries a 24 px inset the app side does not, and a run before that fix is read for the two named parts only, not for the frame's edges). Areadycomparison needs a signed-in session against a mocked API and is not a harness feature at 86fe9d1.
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.