유대선
프로젝트로
·기술 회고·3

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:

  1. Supabase JWKScreateRemoteJWKSet against …/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.
  2. Supabase HS256 secret — fallback for projects still on the legacy shared secret (SUPABASE_JWT_SECRET).
  3. Self-issued HS256 — a zero-dep node:crypto token (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:

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.