Overview
Two endpoints — POST /api/run-save and POST /api/run-draft — take a run's UUID straight from the request body and hand it to a store that upserts on the run_id primary key, stamping the caller as owner. The Supabase service key bypasses row-level security, so the run id was the only thing the database checked. A leaked run id (server logs, x-run-id telemetry, a shared device) let any player overwrite another's saved run — hijacking the public /r/:token that still pointed at it — or wipe their resume draft.
The fix splits every write into a first-writer INSERT and an owner-scoped UPDATE, so an existing row is only ever overwritten by the subject who owns it. A different subject is refused with a typed error the API maps to a clean 403. Below: the guard, both stores, the edge, and the tests that only the fix passes.
Why the database won't catch this
The stores connect with the service (secret) key, and these tables carry no RLS policy — server-only by design. That's a deliberate choice, but it means there is no database-level owner check behind the app. A new typed error names the condition the store now guards against, so the API layer can tell a forbidden action apart from a real fault.
server/utils/run-ownership.ts · 18 lines
The snapshot store: insert-if-new, else owner-scoped update
The in-memory adapter used to reassign existing.subject = subject outright — the hijack in one line. It now refuses a mismatched owner (lines 185–186). The Supabase adapter can't read-then-write safely, so it does the same guard race-safely in two statements: a first-writer INSERT (ON CONFLICT DO NOTHING) claims an unseen id; if the row already exists, an UPDATE scoped to run_id and user_id handles it — matching zero rows for anyone but the owner.
server/utils/run-snapshot-store.ts · 603 lines
⋯ 177 lines hidden (lines 1–177)
⋯ 136 lines hidden (lines 196–331)
⋯ 223 lines hidden (lines 381–603)
The draft store: the same guard
run_drafts is the per-turn autosave, keyed on run_id the same way. Same two halves: first-writer INSERT, then an owner-scoped UPDATE for the returning autosave. A different subject matches no row and is refused — their draft can be neither overwritten nor wiped.
server/utils/run-draft-store.ts · 172 lines
⋯ 50 lines hidden (lines 1–50)
⋯ 23 lines hidden (lines 65–87)
⋯ 45 lines hidden (lines 128–172)
A clean 403 at the edge
The store throws RunOwnershipError; the endpoint maps it to a 403 (a forbidden action, not a server fault) and lets every other error keep its 500. run-draft.post.ts carries the identical branch.
server/api/run-save.post.ts · 68 lines
⋯ 39 lines hidden (lines 1–39)
What only the fix passes
Each adapter gets three discriminating tests: a different subject can't overwrite another's run (and the owner's snapshot, list membership, and public share all still serve the original); the owner's re-save still lands; a first-writer insert works for a brand-new run. The in-memory hijack-rejection test is the clearest read — it asserts the victim's data survives.