2,799 words
Status: accepted · 29 Sep 2026, amended the same day: the account routes are
pages and a mode signs in through a modal, and the guide is granted per
account; then verification is networkless over a held key set, grants are held
a minute, the account routes carry allowances, production is the default
evaluation of the config, and a connection that outlives its token
re-presents one; amended 30 Sep 2026: accounts and the guide are on in every
evaluation, and answer on one production host; amended 11 Oct 2026: whether
the deployment offers the guide is decided with the grant, in
tour/access.ts. Fills the account routes reserved by
ADR-0011, answers the account half of
the open question in hosting, and amends the
guide's authorization in ADR-0042.
Context
The account routes have existed since the shell did: /sign-in, /sign-up,
/profile and /auth/callback, reserved as dialogs that said accounts were
designed and not built. Discovery credit and sync across devices are the first
things that need a person rather than a browser, and a device-scoped token
cannot follow somebody to a second machine. The guide, meanwhile, sat behind a
shared alpha password: one principal for everybody who knew it, and a secret
that leaks by being shared.
Six forces shape how an identity provider can be added here, and most of them are the architecture saying no to the obvious integration.
- The site is static. Astro writes one HTML file per route and the Worker
serves
404.htmlfor anything else. Nothing renders on a server, so there is no middleware to read a cookie on a document request, and a provider's multi-step flow cannot invent addresses under/sign-inthat the build did not write. - The shell mounts once.
Rootholds the canvas's host outside every route (ADR-0011), and a document reload rebuilds the renderer, the catalog and the scene. A provider that navigates withwindow.location— or a provider mounted late, which changes the tree above the canvas — costs a boot per hop. - A mode is an address. The planetarium's target, a cinema frame, a flight
in progress are all the URL (ADR-0011). Anything that navigates while a mode
is up either carries the mode along in
location.stateor unmounts it, and a library that pushes its own paths knows nothing about that — the first version of this, with the account routes as dialogs over the mode, lost the planetarium the moment Clerk moved between the profile's sections with a bare#/security. - The Worker is the only backend, it holds no user table, and anything it attributes to a person — a guide session today — has to be decided there rather than taken from the browser's word.
- The guide spends money. Every minute of conversation is billed to the OpenAI project, so who may open a session is a grant, not a consequence of having signed up.
- The repository is public. A fork builds and runs without anybody's keys,
and offline solo play is the base case (
docs/design/modes.md): an account is an addition to a complete game, never a gate on it.
Decision
Clerk is the identity provider: the browser signs in against it — on pages
of the menu, or through Clerk's modal inside a mode — and the Worker verifies
the session token it is handed, which is the verdict anything attributed
uses. The guide is granted by admin: true or tour: true in the account's
private metadata, which the Worker reads.
- Optional per build, and one instance per environment. Accounts exist
where the client is built with a usable
PUBLIC_CLERK_PUBLISHABLE_KEY—PUBLIC_because Astro exposes nothing else to the browser — and the Worker hasCLERK_SECRET_KEYwithCLERK_ENABLEDon. Production signs in against Clerk's production instance and everything else — Worker Previews,pnpm dev— against the development instance: each environment's build settings and secrets in Cloudflare carry their own instance's keys under the same names.CLERK_ENABLEDis declared incloudflare.config.ts, becausecfreplaces dashboard variables on every deploy, and it is on in every evaluation. Nothing is committed. Without a usable publishable key there is no badge and the account pages say the build offers none; without the secret, or with accounts switched off, the Worker answers "not configured", which is a different sentence from "signed out", and the guide is unavailable. - One production host has accounts. Clerk's production instance serves
inertialref.appalone, and its Frontend API refuses a page on any other origin (origin_invalid). So the host decides, per request and per page: the Worker offers accounts atinertialref.app, on every Worker Preview and in development (offersAccounts,apps/server/src/origins.ts), and the browser uses apk_live_key only on that host (keyForHost,account/accounts.ts). The second production host answers as a build without accounts, and so without the guide, and its account pages point at the first. The browser decides at module load, before the first render, because the provider wraps the canvas's host and mounting it later remounts the renderer. - Two ways in, by where the visitor stands. From the menu, or by address,
/sign-in,/sign-upand/profileare pages of the menu over its own scene, and the front door links to them. Inside a mode the badge at the end of the IR menu opens Clerk's modal, and the address does not change. @clerk/react, mounted inRootinside the router, unconditionally once there is a key. Its navigations go throughaccountNavigation, which resolves them against the current address — a bare fragment is a step of the page it was asked from — and treats a modal finishing where it opened as nowhere to go.ClerkProviderforwards later prop changes for the appearance and localization only, so the router functions are stable and read the location from a ref, andafterSignOutUrlis a marker the adapter replaces with wherever the reader is: the mode, from the badge; the menu, from the profile page.- Hash routing for Clerk's components on the pages. A step is
/sign-in#/factor-one: the document is an address the build wrote, and the fragment never reaches the server, so a reload mid-step or an OAuth return lands on a page that exists. - Everything outside
account/readsAccountContext, written by one bridge inside the provider, and code that is not a component askssessionToken(). Clerk's hooks throw outside the provider, and a module that is not about accounts — the guide, the front door — should not know which vendor answers. - The token travels as
Authorization: Bearer, never as the__sessioncookie. The browser attaches a cookie to a request a hostile page starts; only this site's script can set a header.getToken()also refreshes on demand, where the cookie is refreshed on a timer a background tab throttles. verifyJwtover a key set the Worker holds, notauthenticateRequestorverifyToken. The first reads the cookie, compares it with__client_uatand answers a stale one with a redirect handshake — machinery for a server that renders documents, which this Worker does not. The second fetches the instance's key set itself, and its cache refetches for everykidit lacks — five attempts over two seconds while Clerk is failing — and empties itself before a refresh: an invented header makes every request a call to Clerk, and a Clerk outage signs out every visitor five minutes in. The Worker fetches the set with the secret key instead and holds it per isolate — fresh for five minutes, Clerk's own window; a missingkidrefetched at most once a minute; a held key served through failed refreshes for up to an hour, then refused — and checks the token withverifyJwt, the functionverifyTokenends in. The one checkverifyTokenadds, refusing a machine token signed by the same key, is a requiredsidhere. Authorized parties are the origins of the host the request arrived at (apps/server/src/origins.ts, shared with the origin check); an unrecognized host is refused outright, since Clerk skips the check on an empty list. A pending session reads as signed out.- The verdict takes a bare token.
verifySession(token, keys, parties)answers who, which session, and until when, andidentifyis that plus reading the bearer header. A caller with no request — a socket re-presenting a token — gets the same verdict. - Grants are held a minute, and the routes carry allowances. A user's
private metadata is held per isolate for sixty seconds and concurrent asks
share one call, so the capabilities check and the session request seconds
after it cost one lookup. Two rate-limit bindings bound what a script can
spend:
ACCOUNT_LIMIT, 120 a minute per address across/api/accountand/api/tour/*, before any work, because each asks Clerk who is calling and Clerk rate-limits the instance as a whole; andGUIDE_SESSION_LIMIT, six a minute per account on creating a guide session, because a granted token is the one caller every other check admits and each session is minutes of the OpenAI budget. Both answer 429 withRetry-After. - A connection outlives its token, so the token is re-presented, never
trusted past its expiry. Clerk's session tokens last a minute; a socket
to a partition authority (hosting H-2) lasts hours. The upgrade is a
request: it passes the per-address allowance, carries a token, and
verifySessionadmits it, recordingexpiresAtin the socket's attachment. After that the token rides on the client's own traffic — the first message it sends after the last token expires carries a fresh one fromgetToken()— and the authority refuses anything but a heartbeat from a socket whose token has lapsed, and asks a lapsed socket for a token before sending it state. Each check is a signature against the key set held in the authority's isolate, with no call to Clerk. The authority never wakes to ask: a hibernating connection with a lapsed token costs nothing until it speaks or is spoken to. Signing out elsewhere or ending a session in the dashboard stopsgetToken()from minting, so revocation reaches a live connection within one token lifetime of its next exchange. - The guide's grant is private metadata, decided in
tour/access.ts. The account module answers who is asking and fetches the user's private metadata with the secret key; the tour module alone says thatadminortour, strictlytrue, grants the guide. The planetarium asks/api/tour/capabilitiesonce per signed-in user and offers the Guide panel only on a yes; the Worker refuses a session to anybody else — 401 signed out, 403 without the grant — whatever the menu showed. Whether the deployment offers the guide at all is decided there too: without a provider key, a secret key at the request's host, or withTOUR_GUIDE_ENABLEDoff, the verdict isunavailablebefore any token is read: the session route answers 503 and the verdict route answers it as the verdict.guideAccessreturns the protocol's refusal codes, so the two routes map one answer rather than each deciding "configured". - A deployment fault is a 503, not a refusal. A key set that cannot be
loaded with nothing held, a held key an hour past its last confirmation, a
secret that is invalid, or Clerk's API unreachable during the grant lookup
throws. The package's exports throw for a bad token too, so anything the
token check throws that is not a
TokenVerificationErroris the deployment's. - The vendor stops at the adapters.
@clerk/reactis inapps/game,@clerk/backendinapps/server;packages/protocolknows onlyACCOUNT_PATHandAccountStatus, and nothing below the apps knows Clerk exists (H-4).
Alternatives considered
Account dialogs over the mode. The first version: the reserved routes as
routed dialogs, with the mode kept alive behind them through
location.state. It made every Clerk navigation a translation problem, and
the translation failed on the first bare fragment. A page of the menu is the
honest shape for arriving by address, and a modal with no address is the
honest shape for an interruption.
Clerk's Astro SDK. It integrates through Astro middleware and server output. This site is static, the Worker is not Astro's adapter, and the interactive shell is one React tree that already owns navigation; adopting it would mean a rendering model change to host a sign-in form.
authenticateRequest reading the session cookie. It works for a
same-origin request, and it makes every endpoint that trusts it a forgery
target, requires the publishable key on the Worker, and brings a document
handshake a JSON route has no use for.
Path routing on the pages. Clerk's own default. Every step and every OAuth
return gets an address under the page, each of which is a 404 in a static
build unless the build writes it — a list of Clerk's internal routes
maintained in astro/siteRoutes.ts, wrong at the first step Clerk adds.
Clerk Billing features for the guide. Clerk's has({ feature }) checks
features that come from a subscription plan. It needs Billing enabled, a plan
per grant, and the user subscribed to it; there is no administrator call that
puts one user on a plan, and production billing needs a payment provider. An
alpha allowlist is not a product anybody pays for.
Clerk's own key cache, through verifyToken with the secret key. It
works, and it hands the rate at which this Worker calls Clerk to whoever
writes the token's header, and a Clerk outage to every signed-in visitor five
minutes later. Holding the set is forty lines and a bound on both.
Polling Clerk for revocation on a long-lived connection. Checking each connection's session with the Backend API on a timer is a call per player per interval against the instance's shared rate limit, and it wakes a hibernating authority to make it. Token re-presentation gives the same bound — one token lifetime — out of traffic the client sends anyway.
Public metadata copied into the session token. Networkless — the grant would arrive in the verified claims — but readable by the visitor, and it needs the instance's session token customized. Private metadata costs one Backend API call per capabilities check and per session, a few an hour, and is the grant nobody but an administrator can see or change.
A device-scoped token, as the hosting open question proposed. Enough to attribute a discovery to a browser, and useless for the sync across devices the account exists to provide; it would be replaced by an account at the first sync.
Authentication built in the Worker. A user table in D1, password hashing, email verification, OAuth clients, rate limits and account recovery, all of it security-critical and none of it the product.
Mounting the provider after boot, to keep Clerk's script off the critical
path. The provider wraps the canvas's host, and inserting it later changes the
tree above GameLoader, which remounts the renderer.
Consequences
- Every visit to a build with a key fetches Clerk's script and UI bundle from
Clerk's origin during boot, whether or not the visitor ever signs in. The
cost to first light is unmeasured and recorded in
design/plans/perf.md. - Clerk is a runtime dependency of the account UI. If its script is blocked or unreachable the badge holds an empty slot and the account pages stay empty; the game is unaffected.
- Clerk's modal renders outside
.hud-layer. It is held at standard range by its element classes, and the key dispatcher refuses keys from anyaria-modaloutside the layer, which a Clerk modal is. - A Clerk production instance is configured for one primary domain. The site answers on two hosts, and signing in on the second needs Clerk's satellite domains, which require a paid plan in production. Until one is chosen, the second host has no accounts and no guide.
- The account pages are prerendered once, for the canonical host, where Clerk's form draws nothing on the server. On the second host the first render matches that empty section, and the sentence pointing at the canonical host arrives with hydration.
- Clerk's components render in this system's material only through
account/appearance.tsand aclerkcascade layer below Tailwind's utilities; its styles arrive unlayered and would otherwise outrank every utility. A Clerk upgrade that renames an element key restyles silently. - A grant is a hand edit in Clerk's dashboard, and revoking one takes effect at the next capabilities check or session after the held answer's minute runs out, in each isolate — not in a guide session already open.
- A signing key removed from the instance is still trusted for up to five minutes, and through a Clerk outage for up to an hour; past that hour an outage is a 503 for every account check.
- The allowances count per Cloudflare location and settle eventually, so a burst spread across locations gets somewhat past them. They bound scripts; a person never meets them.
/auth/callbackstays reserved and unused: Clerk's OAuth return comes back to/sign-in#/sso-callback.