Prima Get an API key

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
  • prima-developer/src/pages/schluessel.astro
  • prima-developer/src/lib/prima-api.ts
  • prima-developer/src/lib/auth.ts
Measured on prima-developer w6-konto c594dfc 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
  • prima.li w8b-content-source 86fe9d1 2026-09-12
Version 1

Purpose

Ready: the create form, three keys (the portal session key marked), the housekeeping card default server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
developer.prima.li (a1b2c3d4)
prima_pk_S1t... · erstellt 12.09.2026 · zuletzt genutzt 12.09.2026 · läuft ab 13.09.2026 · dieses Portal, diese Sitzung: wird beim Abmelden gesperrt
Aktiv
Produktion Backend
prima_pk_Pr0... · erstellt 03.09.2026 · zuletzt genutzt 11.09.2026
Aktiv
Laptop (alt)
prima_pk_L4p... · erstellt 21.08.2026 · zuletzt genutzt 28.08.2026
Gesperrt
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.

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:

ContainerStatesSet by
#gateloading, notconfigured, denied, error, readyboot() (schluessel.astro 604 to 622)
#new-cardready, unavailable, errorfailCard after a mint refusal (341 to 348, 555) and the "minted but not returned" branch (542 to 547)
#list-cardloading, ready, empty, unavailable, errorloadKeys (441 to 458) and onRevoke (479 to 481)
#revealhidden or shown; #reveal-key holds the key while shownshowReveal and hideReveal (485 to 505)
workspaceIdstring or nullresolveWorkspace (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:

RouteSentParsedApiError.code by status (classify, prima-api.ts 268 to 280)Copy rendered (errorMessage, 282 to 322, and the page)
GET /api/auth/meBearer, X-Prima-SurfaceAccount; 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 forbiddensurfaces 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/workspacesBearerWorkspace[] 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/keysBearer, X-Workspace-ID: <tenant> (listKeys, 928 to 929)ApiKeySummary[] with key: null, newest first (B10); an empty array is the empty branch404 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 box403 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 failCardas 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/logoutBearer204; 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

Just minted: the once-only reveal in one node, the list refreshed revealed server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Einmalig
Das ist das einzige Mal, dass Sie diesen Schlüssel sehen. Kopieren Sie ihn jetzt.
prima_pk_BEISPIEL_nicht_echt_00000000000000000000
Wir speichern nur einen Hash davon. Wenn Sie ihn verlieren, können wir ihn Ihnen nicht schicken: dann stellen Sie einen neuen aus und sperren diesen. Legen Sie ihn in Ihrer Umgebung ab, etwa als PRIMA_API_KEY, nicht in Ihrem Repository.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Staging
prima_pk_BEI... · erstellt 12.09.2026 · noch nie genutzt
Aktiv
developer.prima.li (a1b2c3d4)
prima_pk_S1t... · erstellt 12.09.2026 · zuletzt genutzt 12.09.2026 · läuft ab 13.09.2026 · dieses Portal, diese Sitzung: wird beim Abmelden gesperrt
Aktiv
Produktion Backend
prima_pk_Pr0... · erstellt 03.09.2026 · zuletzt genutzt 11.09.2026
Aktiv
Laptop (alt)
prima_pk_L4p... · erstellt 21.08.2026 · zuletzt genutzt 28.08.2026
Gesperrt
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
  1. The page ships with #gate[data-state=loading], the whoami skeleton and every branch in the HTML; the module script runs boot() (schluessel.astro 604 to 622, 631).
  2. requireSession() (prima-api.ts 507 to 546): not_configured throws before any read; no local session or one past expiresAt is redirectToSignIn() (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; surfaces present without prima_dev throws 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 per sid in the background (sessionKeyHousekeeping, 882 to 908: revoke the parked ids, then list the tenant's keys and revoke this portal's orphaned rows).
  3. boot's catch (613 to 621): unauthenticated returns (the page is leaving); forbidden sets denied; not_configured sets notconfigured; everything else sets error with errorMessage(err) in #gate-err, and #gate-retry calls boot() again (624).
  4. 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-actions unhidden.
  5. resolveWorkspace() (360 to 379): workspaceId = await resolveTenantId(), which is ensureGatewayKey().tenantId (634 to 636): the record from sessionStorage when it is for this sid and not near expiry, else one mint (GET /api/auth/workspaces, chooseTenant, POST /api/developer/keys, acceptMintedKey, saveGatewayKey; 687 to 744, single-flight through the minting promise). Then, cosmetically, the workspace name from listWorkspaces(); #ws-single reads "Workspace: . Der Verbrauch dieses Schlüssels wird ihm zugerechnet." A failure of the name lookup never blocks minting (363 to 371).
  6. #gate becomes ready; wireCreate() binds the form, the reveal's copy and close buttons and the beforeunload guard (507 to 588); loadKeys() runs without being awaited (612).
  7. loadKeys (441 to 458): #list-card to loading; listKeys(workspaceId); an empty array is empty; otherwise #key-rows is emptied and one keyRow appended 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), the chip--live or chip--off chip, and "Sperren" for an active key); ready. A refusal goes through failCard.
  8. Submit (513 to 560): the field error cleared; an empty name is "Bitte geben Sie dem Schlüssel einen Namen." with focus; no workspaceId is "Kein Workspace ausgewählt."; hideReveal() first (a previous key leaves the DOM before a new one arrives); the button disabled and #create-state reads "wird ausgestellt …"; createKey(name, workspaceId); on a body with key, showReveal(key) (textContent into #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's error branch; then loadKeys(); on a thrown forbidden the server's detail under the field, otherwise failCard; the button re-enabled in finally.
  9. Copy (564 to 577): navigator.clipboard.writeText, "Kopiert" with data-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.
  10. 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.
  11. "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/logout best-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.astro 9 to 14; Acceptance check 3).
  • A customer key is rendered into exactly one node (#reveal-key) by textContent, cleared by hideReveal before 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 (createKey 949 to 954; gatewayKeyMintBody gateway-key.ts 110 to 122); a minted key that comes back with another scope is revoked before it is kept (acceptMintedKey, prima-api.ts 729 to 733).
  • Key management runs on the session Bearer, never on an API key: an ai key is prefix-locked to /api/prima/v1/ and answers 403 on /api/developer/keys (prima-api.ts 99 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 (keyRow 403, B10 Data model).
  • The guard's three denials stay apart: no session redirects, a 401 redirects after clearing, a missing prima_dev grant stays put and says so, because signing in again cannot add a grant (requireSession docstring 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-ID the page sends is the tenant Labs validated at the per-session mint (resolveTenantId 629 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: the gate skeleton before /me and the workspace settle loading server rendered
developer.prima.li

API-Schlüssel

Konto wird geladen.
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
Ready, no keys yet: the empty state with the quick-start link empty server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
Mint refused with 403: the server wording verbatim under the name field create-refused server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Keys minted from a Prima surface may carry the 'ai' scope only (requested: read, write).
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
developer.prima.li (a1b2c3d4)
prima_pk_S1t... · erstellt 12.09.2026 · zuletzt genutzt 12.09.2026 · läuft ab 13.09.2026 · dieses Portal, diese Sitzung: wird beim Abmelden gesperrt
Aktiv
Produktion Backend
prima_pk_Pr0... · erstellt 03.09.2026 · zuletzt genutzt 11.09.2026
Aktiv
Laptop (alt)
prima_pk_L4p... · erstellt 21.08.2026 · zuletzt genutzt 28.08.2026
Gesperrt
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
The list read failed: the error notice with a retry list-error server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.
Der Prima-Dienst antwortet gerade nicht. Das liegt nicht an Ihnen: bitte in einigen Minuten erneut versuchen.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
Signed in, prima_dev not in /me.surfaces: the notice, the band skeleton left in place, no sign-out denied server rendered
developer.prima.li

API-Schlüssel

Konto wird geladen.
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
The gate could not load (network or 5xx): the notice with a retry gate-error server rendered
developer.prima.li

API-Schlüssel

Konto wird geladen.
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.
Keine Verbindung zum Prima-Dienst. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
Built without PUBLIC_PRIMA_API_BASE: a configuration fault on our side unconfigured server rendered
developer.prima.li

API-Schlüssel

Konto wird geladen.
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
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
Design system
prima
Packages
@prima-modules/prima-tokens 0.1.0, from prima.li 03f4205
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild developer-portal-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-developer at 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 (parseErrorEnvelope for the four error shapes, billingErrorMessage, mayManageBilling), desktop-auth.test.ts. The page script itself has no test.
  • What a new app adds: a node --test table for classify (401, 402, 403, 404, 501, 429 with and without spend_cap_exceeded, 503 with and without credits_unavailable, 5xx) and for errorMessage on each code; a jsdom test of the shell that sets each data-state and 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 in sessionStorage, /me mocked with surfaces: ["prima_web"], assert the denied notice, #whoami still the skeleton, #kh-actions still hidden, and no redirect; (c) /me ok, /workspaces with one row, /keys mocked [], assert the empty state; (d) POST /keys mocked 200 with key, assert one element holds it, sessionStorage does not, "Ich habe ihn gesichert" empties it; (e) DELETE mocked 200 and the list re-fetched (two GET /keys calls); (f) prima.dev.gateway-key.v1 seeded for the session's sid, GET /keys mocked 404, assert the list card's quiet notice at load; submit a name with POST /keys mocked 404, assert the create card's quiet notice and no red; without the seeded record, assert the gate's error branch instead.
  • The kit's gate: npm run check prints 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

GET /keys 404 with a stored session key, then a mint refused with 404: both cards quiet, not an error unavailable server rendered
developer.prima.li

API-Schlüssel

Mira Muster · [email protected]
Guthaben
Diese Installation ist nicht mit dem Prima-Dienst verbunden. Schlüssel lassen sich deshalb weder anzeigen noch ausstellen. Das ist ein Konfigurationsfehler auf unserer Seite, nicht auf Ihrer.
Ihr Konto ist für developer.prima.li nicht freigeschaltet. Sie sind angemeldet, aber dieses Konto hat keinen Zugang zum Entwicklerportal. Erneutes Anmelden ändert daran nichts.
Wenn das nicht stimmen kann, schreiben Sie an [email protected].
Die Seite konnte nicht geladen werden.

Neuen Schlüssel ausstellen

Jeder Schlüssel trägt genau den Scope ai: er erreicht den Gateway und sonst nichts. Insbesondere kann er keine weiteren Schlüssel ausstellen.
Nur für Sie. Der Name hilft später beim Sperren des richtigen Schlüssels: er wird nicht geprüft und nicht ausgewertet.
Workspace: Muster AG. Der Verbrauch dieses Schlüssels wird ihm zugerechnet.
Das Ausstellen von Schlüsseln ist für dieses Konto noch nicht freigeschaltet.
Der Schlüssel konnte nicht ausgestellt werden.
Der Schlüssel wurde angelegt, aber nicht zurückgegeben. Bitte sperren Sie ihn unten und stellen Sie einen neuen aus.

Ihre Schlüssel

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.
Noch kein Schlüssel
Stellen Sie oben einen aus. Danach steht der erste Aufruf in der Dokumentation: er dauert weniger als eine Minute.
Die Schlüsselverwaltung ist für dieses Konto noch nicht aktiv.
Schlüssel konnten nicht geladen werden.

Umgang mit Schlüsseln

Ein Schlüssel pro Einsatzort
Produktion, Staging, Ihr Laptop. Dann sperrt ein Vorfall genau eine Sache und nicht alles.
Nicht ins Repository
Umgebungsvariable oder ein Secret-Store. Ein Schlüssel in der Git-Historie bleibt dort auch nach dem Löschen.
Nicht im Browser
Rufen Sie die API von Ihrem Server aus auf. Ein Schlüssel in JavaScript im Browser ist ein öffentlicher Schlüssel.
Verloren? Sperren, neu ausstellen
Sperren wirkt sofort. Wiederherstellen können wir nichts: wir haben den Schlüssel selbst nie.
  • 2026-09-12 (measured at c594dfc, prima-api.ts 590 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 (503 identity_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 from GET /api/auth/workspaces (the caller's own memberships, the predicate Labs uses to authorise the mint) through chooseTenant, and resolveTenantId returns the key record's tenantId with 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.astro 539 to 547 and app.css 105 to 122; D8, a verified defect recorded here and not fixed in this wave): when the mint answers 200 without a key, the page sets #new-card to error, and the state machine hides the ready branch, which holds the form. The card has no retry control (#gate-retry and [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.ts 538 to 545 and schluessel.astro 591 to 622; D8, a verified defect recorded here and not fixed in this wave): requireSession throws the 403 forbidden before it returns me, so boot never reaches renderIdentity for a denied account: #whoami keeps the loading skeleton for good and #kh-actions stays hidden, which leaves the page with an amber notice saying "Sie sind angemeldet" and no way to sign out from it (the #signout button is inside the hidden actions). The demo's denied state renders that, skeleton and all. Fix it needs: render the identity and unhide #kh-actions from the /me body before the surfaces check, or catch forbidden after renderIdentity has run.
  • 2026-09-12 (measured at c594dfc, schluessel.astro 360 to 361 and 604 to 622, prima-api.ts 634 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. resolveWorkspace calls resolveTenantId, which is ensureGatewayKey, which mints through POST /api/developer/keys when no record for this sid is in sessionStorage; that 404 is classified unavailable, is thrown outside any card's try, and lands in the gate's error branch with "Dieser Dienst ist noch nicht freigeschaltet." Only with a stored key does the page reach ready, and then the list card alone goes quiet at load (failCard 341 to 348 acts on that card's own call); the create card goes quiet after a submit returns 404 (555). The demo's unavailable state is the composite of those two branches, labelled as such.
  • 2026-09-12 (measured at c594dfc, onRevoke 476 to 481 and classify 272): a 404 on DELETE /api/developer/keys/{id} (another workspace's key, a stale row) is classified unavailable and 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 on DELETE mapped to its own code and a reload after it.
  • 2026-09-12 (measured at c594dfc, billing.ts 810 to 814): an account with no workspace reaches the gate's error branch 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.astro 551 to 554, and at fd8558f, developer_router.py 113 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's create-refused state shows the scope sentence because it is the one the branch was written for.
  • 2026-09-12 (measured at c594dfc, gateway-key.ts 125 to 129, and at fd8558f, developer_router.py 135 to 136 and 201): the POST's expires_at is a naive utcnow() ISO string with no offset, which Date.parse reads as local time; the list's expires_at is 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 own mintedAt for the portal key's expiry for the same reason.
  • 2026-09-12 (measured at fd8558f, developer_router.py 240 to 253): the list response omits scopes, 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's session: anon capture shows the navigation to /anmelden/ begun. The demo renders each branch as its own state; verify.route matches loading at best.
  • 2026-09-12 (rendering trap, demo-frame.css at 86fe9d1): p, h1, h2 and code inside a frame inherit the frame's reset, so the demo uses div, h3 and span.mono; and the skeleton's @keyframes shimmer is left out because the capture rules pause animations anyway.
  • 2026-09-12 (rendering trap, host side, prima.li/src/index-site/demo-frame.css at 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's fit() (lines 44 to 50) zooms the 1440 px box to stage.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 declares viewport:. 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-tokens at its 03f4205 pin): no state colours in tokens.css, so the demo declares the notice palette, the chip green and the refusal red locally (--dpk-*).
  • 2026-09-11 (measured then, prima-api.ts 99 to 103): an ai-scoped key answers 403 on /api/auth/me and /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.py 267 to 278 at fd8558f, dated in its comment): GET and DELETE filter to the workspace only when X-Workspace-ID is 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 (failCard 341 to 348, resolveWorkspace 360 to 379, keyRow 382 to 439, loadKeys 441 to 458, onRevoke 461 to 482, showReveal and hideReveal 485 to 505, wireCreate 507 to 588, renderIdentity 591 to 602, boot 604 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, ApiError and classify 214 to 280, errorMessage 282 to 322, request, keyRequest, send 335 to 443, Account and requireSession 464 to 546, signOut 557 to 578, displayName 580 to 583, workspaces 590 to 636, ensureGatewayKey and mintGatewayKey 687 to 744, revocation and housekeeping 756 to 908, ApiKeySummary, listKeys, isSessionKey, createKey, revokeKey 914 to 958, formatDate 1428 to 1432.
  • src/lib/gateway-key.ts: GATEWAY_KEY_TTL_DAYS 99, gatewayKeyMintBody 110 to 122, chooseTenant 204 to 208, GatewayKeyRecord 218 to 230, acceptMintedKey 251 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-current by current.
  • 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:

  1. grep -n 'scopes: \["ai"\]' src/lib/prima-api.ts prints 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) and grep -n 'GATEWAY_KEY_SCOPES' src/lib/gateway-key.ts prints its declaration and the spread in gatewayKeyMintBody; no other scopes: [ literal exists in src/ outside comments and *.test.ts (the scopes: string[] at gateway-key.ts 113 is the type of the mint body, not a value).
  2. grep -c 'innerHTML' src/pages/schluessel.astro prints 0.
  3. npx astro build with a configured base, then grep -o '[A-Za-z0-9._-]*@[A-Za-z0-9.-]*' dist/schluessel/index.html | sort -u prints the support mailbox and nothing else (no account address in the shell), grep -c 'prima_pk_' dist/schluessel/index.html prints 0, and grep -c 'data-when=' src/pages/schluessel.astro prints 13.
  4. npm test prints # pass 173 (or the new app's count) and # fail 0.
  5. 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 /me answering surfaces: ["prima_web"]: observe the amber "nicht freigeschaltet" notice, the band skeleton still in place (#whoami unchanged, #kh-actions still hidden, so no "Abmelden"), no sub-navigation, and no redirect; (c) /me ok, one workspace, /keys []: observe "Noch kein Schlüssel" and "Workspace: " under the form; (d) type a name, submit, mock 200 with api_key in key: observe "Einmalig", the key in exactly one element (document.body.innerText.split(key).length === 2), sessionStorage without it, the list re-fetched; click "Ich habe ihn gesichert", observe the panel gone and #reveal-key empty; (e) click "Sperren" on an active row, cancel the confirm, observe no request; accept it, mock 200, observe two GET /keys in the log and the row's chip "Gesperrt" after the reload; (f) with prima.dev.gateway-key.v1 seeded in sessionStorage for the session's sid (or POST /keys mocked 200 for the mint) and then GET /keys mocked 404: observe the list card's quiet notice at load; type a name and submit with POST /keys mocked 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's error branch reading "Dieser Dienst ist noch nicht freigeschaltet." and no card is shown; (g) /keys mocked 503: observe "Schlüssel konnten nicht geladen werden." and "Erneut versuchen" re-fetching.
  6. Demo gate: npm run check prints no line for this recipe; in the prima.li checkout, PRIMA_MODULES_DIR=<this repo> npm run build:index writes dist/docs/modules/identity-auth/ui/developer-portal-keys-page/index.html, and opened in Playwright the page has ten figure[data-demo-state] elements each with a .dpk child inside a .demo-viewport of width 1440 and pageerror count 0 (verified 2026-09-12 against prima.li at 86fe9d1).
  7. 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 by astro-static.sh; the session: anon capture must be taken before the guard navigates (--settle 0), the report's page_errors is empty on both sides and mismatch_ratio is 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 is mismatch_ratio below 0.02 over that region (set 2026-09-12, not yet measured: the docs capture is the .demo-stage element, compare.mjs 198 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). A ready comparison 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.