Real identity on the web seam: Supabase JWT verify, a slash-in-id bug, and Fly deploy prep
Wired Supabase login on the web and JWKS token verification on the master server so a ticket's holder comes from a verified token, not a request body. Found and killed a slash-in-id bug that only the production (sqlite) store hit, and prepped Dockerfile + fly.toml for deploy.
The web seam could already read the master server and claim tickets, but the
holder of a lock was whatever userId the request body said. Anyone could claim
as anyone. This pass closes that: the web logs in with Supabase, and the server
decides identity from a verified token, never the body.
Server: three-layer token verification
supabaseAuth.ts resolves Authorization: Bearer <token> → userId:
- Supabase JWKS —
createRemoteJWKSetagainst…/auth/v1/.well-known/jwks.json. The project's current signing key is ECC (P-256) (asymmetric), so the server verifies the signature with the public key and holds no secret. - Supabase HS256 secret — fallback for projects still on the legacy shared
secret (
SUPABASE_JWT_SECRET). - Self-issued HS256 — a zero-dep
node:cryptotoken (auth.ts) for local/dev where there is no Supabase.
resolveUserId(header, bodyUserId) returns the token's sub when present;
otherwise, only when REQUIRE_AUTH is off, it falls back to the body (so the
local demo still works). The deployed server runs REQUIRE_AUTH=1 — token or
401. The three ticket mutations (claim / release / status) now take their
actor from this, not from the body.
The bug the tests couldn't see
app.test.ts was green, but POST /tickets/<id>/claim 404'd against the real
server. The id was s1/ticket/t-… — it had slashes. Two TicketStore
implementations had drifted: the memory store (every test) minted slash-free
tk-… ids; the sqlite store (what serve.ts actually runs) built
`${startupId}/ticket/${…}`. A slashed id turns /tickets/:id/claim into a
path that never matches → 404. The tests proved the lock on the one store
production doesn't use.
Fix: slash-free tk-… in sqlite too, plus sqliteStore.test.ts that runs the
HTTP API over createSqliteStores(":memory:") and asserts both "id has no /"
and the 409 lock. Lesson logged: an interface with two implementations needs the
production one in the HTTP test, and anything that becomes a URL segment must
be path-safe by construction.
Verified over real HTTP (what this machine could prove)
Restarted the server with REQUIRE_AUTH=1 and self-issued tokens:
- ticket id
tk-mqh23sma-9— no slash - claim with no token → HTTP 401
- claim with an alice token while the body says
bob→ 200, claimedBy=alice (token identity wins; you can't act as someone else) - second claim with a carol token → 409 ALREADY_CLAIMED (lock holds)
What this machine could not prove: the live Supabase ECC path end-to-end —
this environment can't resolve supabase.co (no outbound DNS, sandbox on or
off), so there's no way to mint a real ECC token here. That path is wired and
unit-covered but its live verification waits for Fly (which can reach Supabase)
and a browser login.
Deploy prep
apps/server/Dockerfile (Node 24, node:sqlite via --experimental-sqlite,
runs the workspace through tsx), fly.toml (region iad ≈ Supabase
us-east-1, SQLite on a /data volume, REQUIRE_AUTH=1, SUPABASE_URL baked
in), and a root .dockerignore. The web carries @supabase/supabase-js with the
publishable (anon) key in a gitignored .env.local. Remaining: fly auth login
(browser, mine to wait on), then volume + fly deploy, then the first real
browser login → live claim.