WI-013: Clerk sessions and the lazy local user
WI-013: Clerk sessions and the lazy local user
Implements ADR-0005.
Problem
Every read is public. Nothing can be yours until the API knows who is asking, and nothing can foreign-key to a user until there is a local row to point at.
Not blocked on webhooks
The backlog and WI-H06 both list CLERK_WEBHOOK_SIGNING_SECRET against this item. It is not needed, and saying so is the point of ADR-0005: “Create the local row lazily on first authenticated request. Do not write code that depends on a webhook having fired first.”
Webhooks are reconciliation, not the primary path — a later item, and one that genuinely does need a public URL.
In scope
- Session verification in the Worker, networkless.
requireUsermiddleware: verify, then find-or-create the local row.GET /me, returning our UUID.- Health reports whether authentication is configured.
Out of scope
- The client sign-in flow. Clerk’s email OTP UI belongs in
apps/mobile, needsCLERK_PUBLISHABLE_KEYin the app, and cannot be meaningfully tested without a live Clerk instance. Shipping untested sign-in UI is worse than shipping none; it is a separate item, to be built against a real instance. - Writes of any kind.
/meis a read; the first write arrives with collections (WI-030). - Roles and permissions. There is one kind of user.
Networkless verification, and why it matters here
CLERK_JWT_KEY is the PEM public key from the Clerk dashboard, so verification is a signature check against a key already in memory.
The alternative — fetching JWKS — costs a TLS round trip on every authenticated request, and the obvious fix of caching it per isolate runs straight into the cross-request I/O rule that broke WI-012. Under a 10ms CPU budget this is not an optimisation, it is the design.
The two things that are easy to get wrong
Find-or-create must be an upsert, not select-then-insert. Two requests from the same new user arriving together both see no row, both insert, and the second violates users_clerk_user_id_key. An app opening two screens at once does exactly this. Seeded: the select-then-insert version fails the concurrency test.
A rejection must not say why. “Expired” versus “bad signature” versus “malformed” is free reconnaissance. Every failure returns the same body, and there is a test comparing them.
Definition of done
- A token signed by the wrong key is rejected — verification is real, not stubbed.
- Expired, not-yet-valid, tampered and malformed tokens are all rejected.
- Every rejection is byte-identical.
- The first authenticated request creates the local row.
- The second returns the same row.
- Six concurrent first requests produce one row.
- The response exposes our UUID and never
clerk_user_id. - A token carrying only
substill yields a usable display name. - A missing
CLERK_JWT_KEYreturns 503, not 401 — misconfiguration must not look like “signed out”, or the client goes to a login screen that can never succeed. - A soft-deleted user is not silently resurrected.
- Search routes stay public.
- Each check proven able to fail.
- Gate green.
Verification
pnpm --filter @tabletop/db run db:uppnpm --filter @tabletop/api testpnpm gateTests generate a throwaway RSA keypair per run: the public half becomes CLERK_JWT_KEY exactly as Clerk’s would, and the tests sign with the private half. Nothing is stubbed, and no key is committed.
Deploying it
CLERK_JWT_KEY is environment-specific, so it is a Wrangler secret rather than a var.
Clerk’s dashboard has moved the PEM around and does not reliably expose it, so derive it from the JWKS endpoint instead. The publishable key encodes the host, so nothing secret is needed:
pnpm --filter @tabletop/api jwt-key pk_test_... # prints the PEMpnpm --filter @tabletop/api jwt-key pk_test_... | pnpm exec wrangler secret put CLERK_JWT_KEYOptionally CLERK_AUTHORIZED_PARTIES, comma-separated, to refuse tokens minted for a different application even when the signature is good.
Notes
users.display_name is NOT NULL, and a bare Clerk session token carries only sub. name and email appear only if the session token template includes them, which is a dashboard setting the API cannot depend on — so the name falls back to Player <last six of sub> and the user can change it later. Storing user_2abc... and calling it a name would be worse.