What changed, and why
This PR lets a player make a finished run public and post it — the rewritten history, the verdict, the epilogue — to social feeds and to friends. Two jobs: a link anyone can open with no account, and a share artifact that looks good when that link is pasted into X / Discord / iMessage (a rich unfurl plus a per-run image). It works for any objective, curated or AI-composed, because the objective already travels inside the saved snapshot.
It builds on two merged features: #83 (a saved run is a durable, owned RunSnapshot) and #84 (a read-only render of that snapshot via RunView). Sharing is the public, sanitized projection of that snapshot.
The whole design turns on one constraint: sharing exposes run content, never the player's identity. The sections below follow the data outward — from the projection that strips identity, through the store and API that gate ownership, to the public page and the share-card image — and end at the migration and the tests that pin the no-leak guarantee.
The public projection — no identity leak
A RunSnapshot is identity-free by design: the owner lives only in the run_snapshots row's user_id column, never in the JSON blob. So the public shape is small. PublicRun is the game record plus an optional display name; ShareState is the owner-only view (the live token + name). The run id is deliberately not part of the public contract — the share token is the only handle a viewer ever gets.
The public contract: PublicRun (game record + opt-in name), ShareState (owner-only), and one shared brag-line builder.
utils/run-snapshot.ts · 136 lines
⋯ 109 lines hidden (lines 1–109)
toPublicRun is the sanitization boundary. It blanks the run id to '' (the render never reads it) and surfaces only a trimmed display name — null when shared anonymously. Everything else in the snapshot is already safe to show.
The one projection the public endpoint serves — never the raw row.
server/utils/run-snapshot-store.ts · 277 lines
⋯ 56 lines hidden (lines 1–56)
⋯ 206 lines hidden (lines 72–277)
The API surface
Flat routes mirror the existing run-save / run-commit convention. The owner acts on a run via /api/run-share (POST to share/update, GET for state, DELETE to revoke); the public reads via /api/share/:token. The subject is always derived server-side, never trusted from the client.
On the POST, the optional display name is the only identity a sharer can attach. It's the only untrusted, public-facing string here, so it's length-capped and run through the hard-block moderation detector before it can go live.
uuid-gate the run id, moderate the name, then share — 404 if the subject doesn't own it.
server/api/run-share.post.ts · 47 lines
⋯ 21 lines hidden (lines 1–21)
The public read, no auth. Same 404 for malformed / unknown / revoked — it reveals nothing about what exists.
server/api/share/[token].get.ts · 23 lines
The public page
/r/:token is server-rendered and reuses RunView — the exact component the owner's saved-run page uses — so a shared run reads identically to the real end screen. It fetches the sanitized projection (a 404 surfaces as a friendly "not available"), and sets the unfurl meta with useServerSeoMeta so the tags are present in the SSR HTML crawlers read, the og:image pointed at the per-run card.
Fetch the projection, build absolute URLs from the request origin, compose the brag line from the shared helper.
pages/r/[token].vue · 134 lines
⋯ 71 lines hidden (lines 1–71)
⋯ 39 lines hidden (lines 96–134)
Server-rendered Open Graph / Twitter meta; title kept in useHead so the two don't both write <title>.
pages/r/[token].vue · 134 lines
⋯ 112 lines hidden (lines 1–112)
Owner affordances and the save-race guard
ShareControls is the owner's state machine: on mount it reads the run's share state (so a reload shows "here's your link" vs "share this run"), offers the opt-in name at share time, and toggles public/private. The copy / Web-Share / post-to-X row lives in a small presentational ShareLinks it reuses.
Read current state on mount; makePublic / stopSharing flip it; a 400 moderation block surfaces its reason.
components/ShareControls.vue · 150 lines
⋯ 97 lines hidden (lines 1–97)
⋯ 6 lines hidden (lines 145–150)
It's wired into the saved-run page and the end screen. The end screen needs to know the run is actually saved before offering to share it, so the store gains a runSnapshotSaved flag — set only when /api/run-save confirms a save, reset per run — and the affordance is gated on it. That closes the race where a player could try to share before the snapshot row exists.
The flag flips true only on a confirmed, still-current save (epoch-guarded).
stores/game.ts · 1389 lines
⋯ 1329 lines hidden (lines 1–1329)
⋯ 51 lines hidden (lines 1339–1389)
Migration and tests
The schema change is two nullable columns: share_token (unique, the public handle) and share_name (the opt-in attribution). Unsharing nulls both. Postgres lets a unique column hold many nulls, so unshared runs coexist freely.
Opt-in, per-run, revocable — token never derived from the run id or subject.
supabase/migrations/0006_run_share.sql · 15 lines
The tests pin the two things that matter: the projection leaks no identity, and the share / un-share flow is owner-scoped and reversible. The no-leak test asserts the owning subject and run id appear nowhere in the serialized public payload.
The no-identity-leak assertion, byte-level.