ADR-0048: Accounts are Clerk's, and the Worker decides who is asking

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.html for 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-in that the build did not write.
  • The shell mounts once. Root holds 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 with window.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.state or 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 has CLERK_SECRET_KEY with CLERK_ENABLED on. 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_ENABLED is declared in cloudflare.config.ts, because cf replaces 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.app alone, 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 at inertialref.app, on every Worker Preview and in development (offersAccounts, apps/server/src/origins.ts), and the browser uses a pk_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-up and /profile are 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 in Root inside the router, unconditionally once there is a key. Its navigations go through accountNavigation, 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. ClerkProvider forwards later prop changes for the appearance and localization only, so the router functions are stable and read the location from a ref, and afterSignOutUrl is 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/ reads AccountContext, written by one bridge inside the provider, and code that is not a component asks sessionToken(). 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 __session cookie. 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.
  • verifyJwt over a key set the Worker holds, not authenticateRequest or verifyToken. The first reads the cookie, compares it with __client_uat and 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 every kid it 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 missing kid refetched at most once a minute; a held key served through failed refreshes for up to an hour, then refused — and checks the token with verifyJwt, the function verifyToken ends in. The one check verifyToken adds, refusing a machine token signed by the same key, is a required sid here. 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, and identify is 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/account and /api/tour/*, before any work, because each asks Clerk who is calling and Clerk rate-limits the instance as a whole; and GUIDE_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 with Retry-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 verifySession admits it, recording expiresAt in 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 from getToken() — 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 stops getToken() 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 that admin or tour, strictly true, grants the guide. The planetarium asks /api/tour/capabilities once 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 with TOUR_GUIDE_ENABLED off, the verdict is unavailable before any token is read: the session route answers 503 and the verdict route answers it as the verdict. guideAccess returns 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 TokenVerificationError is the deployment's.
  • The vendor stops at the adapters. @clerk/react is in apps/game, @clerk/backend in apps/server; packages/protocol knows only ACCOUNT_PATH and AccountStatus, 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 any aria-modal outside 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.ts and a clerk cascade 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/callback stays reserved and unused: Clerk's OAuth return comes back to /sign-in#/sso-callback.