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
utils/run-snapshot.ts136 lines · TypeScript
⋯ 109 lines hidden (lines 1–109)
1/**
2 * The saved-run contract — the immutable, versioned record of a finished run.
3 *
4 * A run is otherwise client-only: the objective, the dispatches, the timeline
5 * ledger, the Chronicle epilogue, and the verdict all live in the Pinia store and
6 * evaporate on reload. This is the persisted form of that end-state: the store builds
7 * it on game end, the server stores it keyed by the run id, and a read-only "your
8 * runs" view replays it. It is a SNAPSHOT, not a live record — in-progress
9 * save/resume is out of scope; a run is short (five messages) and persists at the end.
10 *
11 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
12 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
13 * the `run_snapshots` table). Same noun, two states — kept distinct in the schema.
14 *
15 * The shape is VERSIONED (`version`) so a later change can't silently break runs
16 * saved under an older shape: a reader can branch on `version` and migrate. It is a
17 * flat, fully JSON-native projection of the store state — the `Date` timestamps on
18 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
19 * math), so the stored blob round-trips through jsonb cleanly.
20 *
21 * This module is the SHARED contract (types + version), client-safe by design: it
22 * carries no runtime imports, only type-only ones, so the client store can import the
23 * version + types without pulling any server-only code. The server-side boundary
24 * validator lives separately in `server/utils/run-snapshot.ts`.
25 */
26import type { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Anachronism } from '~/server/utils/anachronism'
29import type { GameObjective } from '~/server/utils/objectives'
30import type { ChronicleEntry } from '~/server/utils/openai'
31 
32/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
33 * branches on it to migrate older snapshots rather than mis-reading them. */
34export const RUN_SNAPSHOT_VERSION = 1
35 
36/** Only a finished run is ever saved — the two terminal verdicts. */
37export type RunOutcome = 'victory' | 'defeat'
38 
39/** The valence of a ledger swing (mirrors the store's `Valence`). */
40export type RunValence = 'positive' | 'negative' | 'neutral'
41 
42/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
43export type EfficiencyRating = 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
44 
45/** The bit of a roll the read-only view shows: the number and its named outcome. */
46export interface RollMark {
47 diceRoll: number
48 diceOutcome: DiceOutcome
50 
51/** A dispatch keepsake — the player's verbatim message and whom/when it reached. */
52export interface RunDispatch {
53 text: string
54 figure: string
55 era: string
57 
58/** The victory efficiency block (null on defeat) — the same data the end screen shows. */
59export interface RunEfficiency {
60 messagesSaved: number
61 messagesUsed: number
62 efficiencyPercentage: number
63 efficiencyRating: EfficiencyRating
64 isEarlyVictory: boolean
66 
67/** One ledger entry, trimmed to the fields a read-only run view renders (the live
68 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
69export interface RunLedgerEntry {
70 id: string
71 figureName: string
72 era: string
73 headline: string
74 detail: string
75 diceRoll: number
76 diceOutcome: DiceOutcome
77 progressChange: number
78 valence: RunValence
79 craft?: Craft
80 anachronism?: Anachronism
82 
83/** The full saved run — everything a read-only view needs to replay the end screen. */
84export interface RunSnapshot {
85 version: number
86 runId: string
87 status: RunOutcome
88 objective: GameObjective
89 objectiveProgress: number
90 messagesUsed: number
91 totalMessages: number
92 bestRoll: RollMark | null
93 worstRoll: RollMark | null
94 efficiency: RunEfficiency | null
95 dispatches: RunDispatch[]
96 timeline: RunLedgerEntry[]
97 chronicle: ChronicleEntry | null
99 
100/** The list projection — what the "your runs" list shows per run, without pulling
101 * the full snapshot blob for every row. */
102export interface RunSummary {
103 runId: string
104 status: RunOutcome
105 title: string
106 progress: number
107 createdAt: string
109 
110/**
111 * The PUBLIC, shareable projection of a saved run (issue #88) — the sanitized form
112 * served at an unguessable share URL to anyone, no login. It carries the GAME RECORD
113 * (verdict, ledger, dispatches, Chronicle epilogue) and an optional self-chosen display
114 * name, and DELIBERATELY nothing that identifies the player: no user id, no device id,
115 * no email — and not even the run id (the share token is the only handle). `run.runId`
116 * is blanked for that reason; the read-only view never renders it.
117 */
118export interface PublicRun {
119 run: RunSnapshot
120 /** The sharer's opt-in display name, or null when shared anonymously. */
121 attribution: string | null
123 
124/** What the OWNER sees about a run's share status: the live token (null = not shared)
125 * and the current display name. Owner-only — never part of the public projection. */
126export interface ShareState {
127 token: string | null
128 name: string | null
130 
131/** The brag line a share prefills (the X intent + the native sheet) — one source so the
132 * end screen, the saved-run page, and the public page never drift (issue #88). */
133export function buildShareText(objectiveTitle: string, status: RunOutcome, progress: number): string {
134 const verb = status === 'victory' ? 'I rewrote history' : 'I tried to rewrite history'
135 return `${verb} in Revisionist: ${objectiveTitle || 'a historical mission'}${progress}% rewritten.`

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
server/utils/run-snapshot-store.ts277 lines · TypeScript
⋯ 56 lines hidden (lines 1–56)
1/**
2 * The run-snapshot store — where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS — the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import type { RunSnapshot, RunSummary, PublicRun, ShareState } from '~/utils/run-snapshot'
25 
26export interface RunSnapshotStore {
27 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
28 saveRun(subject: string, snapshot: RunSnapshot): Promise<void>
29 /** A subject's saved runs, newest first (the list projection). */
30 listRuns(subject: string): Promise<RunSummary[]>
31 /** One saved run by id, only if `subject` owns it; null otherwise. */
32 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
33 /**
34 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
35 * if already shared, so the URL is stable), set the opt-in display name, and return
36 * the live share state. Null when `subject` doesn't own the run (reads as absent).
37 */
38 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
39 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
40 unshareRun(subject: string, runId: string): Promise<void>
41 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
42 getShareState(subject: string, runId: string): Promise<ShareState | null>
43 /** Resolve a public share token to its sanitized projection; null when unknown. */
44 getPublicRun(token: string): Promise<PublicRun | null>
46 
47/** Project a snapshot down to the list row. */
48function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
49 return {
50 runId: snapshot.runId,
51 status: snapshot.status,
52 title: snapshot.objective.title,
53 progress: snapshot.objectiveProgress,
54 createdAt
55 }
57 
58/**
59 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
60 * already identity-free (the owner lives only in the row's `user_id` column, never in
61 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
62 * share token is the only handle a public viewer gets) and surface only the opt-in
63 * display name. This is the boundary the public endpoint serves — never the raw row.
64 */
65export function toPublicRun(snapshot: RunSnapshot, name: string | null): PublicRun {
66 const trimmed = name?.trim()
67 return {
68 run: { ...snapshot, runId: '' },
69 attribution: trimmed ? trimmed : null
70 }
⋯ 206 lines hidden (lines 72–277)
72 
73/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
74 * external dependency. Not durable across process restarts; the Supabase adapter is
75 * the one that actually preserves history. */
76export class InMemoryRunSnapshotStore implements RunSnapshotStore {
77 private readonly runs: Array<{
78 subject: string
79 snapshot: RunSnapshot
80 createdAt: string
81 shareToken: string | null
82 shareName: string | null
83 }> = []
84 
85 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
86 const createdAt = new Date().toISOString()
87 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
88 if (existing) {
89 // Snapshots are immutable, but the save fires again once the epilogue
90 // lands — overwrite in place (keep the original position/createdAt, and any
91 // share already minted for this run).
92 existing.subject = subject
93 existing.snapshot = snapshot
94 return
95 }
96 this.runs.push({ subject, snapshot, createdAt, shareToken: null, shareName: null })
97 }
98 
99 async listRuns(subject: string): Promise<RunSummary[]> {
100 return this.runs
101 .filter((r) => r.subject === subject)
102 .map((r) => toSummary(r.snapshot, r.createdAt))
103 .reverse()
104 }
105 
106 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
107 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
108 return found ? found.snapshot : null
109 }
110 
111 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
112 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
113 if (!found) return null
114 found.shareToken = found.shareToken ?? randomUUID()
115 found.shareName = name
116 return { token: found.shareToken, name: found.shareName }
117 }
118 
119 async unshareRun(subject: string, runId: string): Promise<void> {
120 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
121 if (!found) return
122 found.shareToken = null
123 found.shareName = null
124 }
125 
126 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
127 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
128 return found ? { token: found.shareToken, name: found.shareName } : null
129 }
130 
131 async getPublicRun(token: string): Promise<PublicRun | null> {
132 const found = this.runs.find((r) => r.shareToken === token)
133 return found ? toPublicRun(found.snapshot, found.shareName) : null
134 }
136 
137/** Records each saved run as a `run_snapshots` row via the service (secret) key,
138 * which bypasses RLS — the table has no public policy, so it is server-only. */
139export class SupabaseRunSnapshotStore implements RunSnapshotStore {
140 constructor(private readonly client: SupabaseClient) {}
141 
142 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
143 // Stamp the owner onto the billing run row too (it carries only device_id
144 // today) so a run's ownership is legible from either table.
145 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
146 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
147 
148 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
149 // first rather than erroring — the snapshot is immutable, the write is not.
150 const { error } = await this.client.from('run_snapshots').upsert({
151 run_id: snapshot.runId,
152 user_id: subject,
153 version: snapshot.version,
154 status: snapshot.status,
155 title: snapshot.objective.title,
156 progress: snapshot.objectiveProgress,
157 data: snapshot
158 })
159 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
160 }
161 
162 async listRuns(subject: string): Promise<RunSummary[]> {
163 const { data, error } = await this.client
164 .from('run_snapshots')
165 .select('run_id, status, title, progress, created_at')
166 .eq('user_id', subject)
167 .order('created_at', { ascending: false })
168 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
169 return ((data ?? []) as Array<{
170 run_id: string
171 status: RunSnapshot['status']
172 title: string
173 progress: number
174 created_at: string
175 }>).map((row) => ({
176 runId: row.run_id,
177 status: row.status,
178 title: row.title,
179 progress: row.progress,
180 createdAt: row.created_at
181 }))
182 }
183 
184 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
185 const { data, error } = await this.client
186 .from('run_snapshots')
187 .select('data')
188 .eq('run_id', runId)
189 .eq('user_id', subject)
190 .maybeSingle()
191 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
192 return (data?.data ?? null) as RunSnapshot | null
193 }
194 
195 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
196 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
197 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
198 // sees the first's token and matches no row — so re-sharing keeps one stable URL
199 // and never trips the unique constraint. (A wasted uuid when already shared is
200 // harmless: the guard excludes the row, so it's never written.)
201 const minted = await this.client
202 .from('run_snapshots')
203 .update({ share_token: randomUUID() })
204 .eq('run_id', runId)
205 .eq('user_id', subject)
206 .is('share_token', null)
207 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
208 
209 // Set the name and read back the live token (the freshly-minted one, or a token
210 // from a prior share). Scoped to the owner, so a run the subject doesn't own
211 // matches no row and reads as absent (null) — never shared on their behalf.
212 const { data, error } = await this.client
213 .from('run_snapshots')
214 .update({ share_name: name })
215 .eq('run_id', runId)
216 .eq('user_id', subject)
217 .select('share_token, share_name')
218 .maybeSingle()
219 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
220 if (!data) return null
221 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
222 }
223 
224 async unshareRun(subject: string, runId: string): Promise<void> {
225 const { error } = await this.client
226 .from('run_snapshots')
227 .update({ share_token: null, share_name: null })
228 .eq('run_id', runId)
229 .eq('user_id', subject)
230 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
231 }
232 
233 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
234 const { data, error } = await this.client
235 .from('run_snapshots')
236 .select('share_token, share_name')
237 .eq('run_id', runId)
238 .eq('user_id', subject)
239 .maybeSingle()
240 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
241 if (!data) return null
242 return {
243 token: (data.share_token as string | null) ?? null,
244 name: (data.share_name as string | null) ?? null
245 }
246 }
247 
248 async getPublicRun(token: string): Promise<PublicRun | null> {
249 // Looked up by the share token ALONE — no owner filter — because the public page
250 // has no subject. The token is the capability; an unknown/revoked token misses.
251 const { data, error } = await this.client
252 .from('run_snapshots')
253 .select('data, share_name')
254 .eq('share_token', token)
255 .maybeSingle()
256 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
257 if (!data) return null
258 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null)
259 }
261 
262let cached: RunSnapshotStore | null = null
263 
264/** The configured run-snapshot store (memoized — reuses one Supabase client). */
265export function runSnapshotStore(): RunSnapshotStore {
266 if (cached) return cached
267 const cfg = supabaseConfig()
268 cached = cfg
269 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
270 : new InMemoryRunSnapshotStore()
271 return cached
273 
274/** Test seam: clear the memoized store so the next call re-reads config. */
275export function resetRunSnapshotStore(): void {
276 cached = null

The store: share, revoke, resolve-by-token

The store gains four methods behind the existing port (both the in-memory and Supabase adapters). The owner-facing three — shareRun, unshareRun, getShareState — are scoped by subject, so a run you don't own reads as absent. The public one — getPublicRun — is looked up by token alone.

shareRun mints a token only if the owned run has none yet, in a single row-atomic UPDATE guarded by share_token IS NULL. That guard is what makes re-sharing return the same stable URL and keeps two concurrent shares from both minting (and tripping the unique constraint).

Atomic mint-if-absent, then set the name and read back the live token — both writes owner-scoped.

server/utils/run-snapshot-store.ts · 277 lines
server/utils/run-snapshot-store.ts277 lines · TypeScript
⋯ 194 lines hidden (lines 1–194)
1/**
2 * The run-snapshot store — where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS — the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import type { RunSnapshot, RunSummary, PublicRun, ShareState } from '~/utils/run-snapshot'
25 
26export interface RunSnapshotStore {
27 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
28 saveRun(subject: string, snapshot: RunSnapshot): Promise<void>
29 /** A subject's saved runs, newest first (the list projection). */
30 listRuns(subject: string): Promise<RunSummary[]>
31 /** One saved run by id, only if `subject` owns it; null otherwise. */
32 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
33 /**
34 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
35 * if already shared, so the URL is stable), set the opt-in display name, and return
36 * the live share state. Null when `subject` doesn't own the run (reads as absent).
37 */
38 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
39 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
40 unshareRun(subject: string, runId: string): Promise<void>
41 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
42 getShareState(subject: string, runId: string): Promise<ShareState | null>
43 /** Resolve a public share token to its sanitized projection; null when unknown. */
44 getPublicRun(token: string): Promise<PublicRun | null>
46 
47/** Project a snapshot down to the list row. */
48function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
49 return {
50 runId: snapshot.runId,
51 status: snapshot.status,
52 title: snapshot.objective.title,
53 progress: snapshot.objectiveProgress,
54 createdAt
55 }
57 
58/**
59 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
60 * already identity-free (the owner lives only in the row's `user_id` column, never in
61 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
62 * share token is the only handle a public viewer gets) and surface only the opt-in
63 * display name. This is the boundary the public endpoint serves — never the raw row.
64 */
65export function toPublicRun(snapshot: RunSnapshot, name: string | null): PublicRun {
66 const trimmed = name?.trim()
67 return {
68 run: { ...snapshot, runId: '' },
69 attribution: trimmed ? trimmed : null
70 }
72 
73/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
74 * external dependency. Not durable across process restarts; the Supabase adapter is
75 * the one that actually preserves history. */
76export class InMemoryRunSnapshotStore implements RunSnapshotStore {
77 private readonly runs: Array<{
78 subject: string
79 snapshot: RunSnapshot
80 createdAt: string
81 shareToken: string | null
82 shareName: string | null
83 }> = []
84 
85 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
86 const createdAt = new Date().toISOString()
87 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
88 if (existing) {
89 // Snapshots are immutable, but the save fires again once the epilogue
90 // lands — overwrite in place (keep the original position/createdAt, and any
91 // share already minted for this run).
92 existing.subject = subject
93 existing.snapshot = snapshot
94 return
95 }
96 this.runs.push({ subject, snapshot, createdAt, shareToken: null, shareName: null })
97 }
98 
99 async listRuns(subject: string): Promise<RunSummary[]> {
100 return this.runs
101 .filter((r) => r.subject === subject)
102 .map((r) => toSummary(r.snapshot, r.createdAt))
103 .reverse()
104 }
105 
106 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
107 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
108 return found ? found.snapshot : null
109 }
110 
111 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
112 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
113 if (!found) return null
114 found.shareToken = found.shareToken ?? randomUUID()
115 found.shareName = name
116 return { token: found.shareToken, name: found.shareName }
117 }
118 
119 async unshareRun(subject: string, runId: string): Promise<void> {
120 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
121 if (!found) return
122 found.shareToken = null
123 found.shareName = null
124 }
125 
126 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
127 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
128 return found ? { token: found.shareToken, name: found.shareName } : null
129 }
130 
131 async getPublicRun(token: string): Promise<PublicRun | null> {
132 const found = this.runs.find((r) => r.shareToken === token)
133 return found ? toPublicRun(found.snapshot, found.shareName) : null
134 }
136 
137/** Records each saved run as a `run_snapshots` row via the service (secret) key,
138 * which bypasses RLS — the table has no public policy, so it is server-only. */
139export class SupabaseRunSnapshotStore implements RunSnapshotStore {
140 constructor(private readonly client: SupabaseClient) {}
141 
142 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
143 // Stamp the owner onto the billing run row too (it carries only device_id
144 // today) so a run's ownership is legible from either table.
145 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
146 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
147 
148 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
149 // first rather than erroring — the snapshot is immutable, the write is not.
150 const { error } = await this.client.from('run_snapshots').upsert({
151 run_id: snapshot.runId,
152 user_id: subject,
153 version: snapshot.version,
154 status: snapshot.status,
155 title: snapshot.objective.title,
156 progress: snapshot.objectiveProgress,
157 data: snapshot
158 })
159 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
160 }
161 
162 async listRuns(subject: string): Promise<RunSummary[]> {
163 const { data, error } = await this.client
164 .from('run_snapshots')
165 .select('run_id, status, title, progress, created_at')
166 .eq('user_id', subject)
167 .order('created_at', { ascending: false })
168 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
169 return ((data ?? []) as Array<{
170 run_id: string
171 status: RunSnapshot['status']
172 title: string
173 progress: number
174 created_at: string
175 }>).map((row) => ({
176 runId: row.run_id,
177 status: row.status,
178 title: row.title,
179 progress: row.progress,
180 createdAt: row.created_at
181 }))
182 }
183 
184 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
185 const { data, error } = await this.client
186 .from('run_snapshots')
187 .select('data')
188 .eq('run_id', runId)
189 .eq('user_id', subject)
190 .maybeSingle()
191 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
192 return (data?.data ?? null) as RunSnapshot | null
193 }
194 
195 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
196 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
197 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
198 // sees the first's token and matches no row — so re-sharing keeps one stable URL
199 // and never trips the unique constraint. (A wasted uuid when already shared is
200 // harmless: the guard excludes the row, so it's never written.)
201 const minted = await this.client
202 .from('run_snapshots')
203 .update({ share_token: randomUUID() })
204 .eq('run_id', runId)
205 .eq('user_id', subject)
206 .is('share_token', null)
207 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
208 
209 // Set the name and read back the live token (the freshly-minted one, or a token
210 // from a prior share). Scoped to the owner, so a run the subject doesn't own
211 // matches no row and reads as absent (null) — never shared on their behalf.
212 const { data, error } = await this.client
213 .from('run_snapshots')
214 .update({ share_name: name })
215 .eq('run_id', runId)
216 .eq('user_id', subject)
217 .select('share_token, share_name')
218 .maybeSingle()
219 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
220 if (!data) return null
221 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
222 }
⋯ 55 lines hidden (lines 223–277)
223 
224 async unshareRun(subject: string, runId: string): Promise<void> {
225 const { error } = await this.client
226 .from('run_snapshots')
227 .update({ share_token: null, share_name: null })
228 .eq('run_id', runId)
229 .eq('user_id', subject)
230 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
231 }
232 
233 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
234 const { data, error } = await this.client
235 .from('run_snapshots')
236 .select('share_token, share_name')
237 .eq('run_id', runId)
238 .eq('user_id', subject)
239 .maybeSingle()
240 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
241 if (!data) return null
242 return {
243 token: (data.share_token as string | null) ?? null,
244 name: (data.share_name as string | null) ?? null
245 }
246 }
247 
248 async getPublicRun(token: string): Promise<PublicRun | null> {
249 // Looked up by the share token ALONE — no owner filter — because the public page
250 // has no subject. The token is the capability; an unknown/revoked token misses.
251 const { data, error } = await this.client
252 .from('run_snapshots')
253 .select('data, share_name')
254 .eq('share_token', token)
255 .maybeSingle()
256 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
257 if (!data) return null
258 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null)
259 }
261 
262let cached: RunSnapshotStore | null = null
263 
264/** The configured run-snapshot store (memoized — reuses one Supabase client). */
265export function runSnapshotStore(): RunSnapshotStore {
266 if (cached) return cached
267 const cfg = supabaseConfig()
268 cached = cfg
269 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
270 : new InMemoryRunSnapshotStore()
271 return cached
273 
274/** Test seam: clear the memoized store so the next call re-reads config. */
275export function resetRunSnapshotStore(): void {
276 cached = null

The public read: filtered by share_token only — the page has no subject.

server/utils/run-snapshot-store.ts · 277 lines
server/utils/run-snapshot-store.ts277 lines · TypeScript
⋯ 247 lines hidden (lines 1–247)
1/**
2 * The run-snapshot store — where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS — the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import type { RunSnapshot, RunSummary, PublicRun, ShareState } from '~/utils/run-snapshot'
25 
26export interface RunSnapshotStore {
27 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
28 saveRun(subject: string, snapshot: RunSnapshot): Promise<void>
29 /** A subject's saved runs, newest first (the list projection). */
30 listRuns(subject: string): Promise<RunSummary[]>
31 /** One saved run by id, only if `subject` owns it; null otherwise. */
32 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
33 /**
34 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
35 * if already shared, so the URL is stable), set the opt-in display name, and return
36 * the live share state. Null when `subject` doesn't own the run (reads as absent).
37 */
38 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
39 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
40 unshareRun(subject: string, runId: string): Promise<void>
41 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
42 getShareState(subject: string, runId: string): Promise<ShareState | null>
43 /** Resolve a public share token to its sanitized projection; null when unknown. */
44 getPublicRun(token: string): Promise<PublicRun | null>
46 
47/** Project a snapshot down to the list row. */
48function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
49 return {
50 runId: snapshot.runId,
51 status: snapshot.status,
52 title: snapshot.objective.title,
53 progress: snapshot.objectiveProgress,
54 createdAt
55 }
57 
58/**
59 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
60 * already identity-free (the owner lives only in the row's `user_id` column, never in
61 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
62 * share token is the only handle a public viewer gets) and surface only the opt-in
63 * display name. This is the boundary the public endpoint serves — never the raw row.
64 */
65export function toPublicRun(snapshot: RunSnapshot, name: string | null): PublicRun {
66 const trimmed = name?.trim()
67 return {
68 run: { ...snapshot, runId: '' },
69 attribution: trimmed ? trimmed : null
70 }
72 
73/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
74 * external dependency. Not durable across process restarts; the Supabase adapter is
75 * the one that actually preserves history. */
76export class InMemoryRunSnapshotStore implements RunSnapshotStore {
77 private readonly runs: Array<{
78 subject: string
79 snapshot: RunSnapshot
80 createdAt: string
81 shareToken: string | null
82 shareName: string | null
83 }> = []
84 
85 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
86 const createdAt = new Date().toISOString()
87 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
88 if (existing) {
89 // Snapshots are immutable, but the save fires again once the epilogue
90 // lands — overwrite in place (keep the original position/createdAt, and any
91 // share already minted for this run).
92 existing.subject = subject
93 existing.snapshot = snapshot
94 return
95 }
96 this.runs.push({ subject, snapshot, createdAt, shareToken: null, shareName: null })
97 }
98 
99 async listRuns(subject: string): Promise<RunSummary[]> {
100 return this.runs
101 .filter((r) => r.subject === subject)
102 .map((r) => toSummary(r.snapshot, r.createdAt))
103 .reverse()
104 }
105 
106 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
107 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
108 return found ? found.snapshot : null
109 }
110 
111 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
112 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
113 if (!found) return null
114 found.shareToken = found.shareToken ?? randomUUID()
115 found.shareName = name
116 return { token: found.shareToken, name: found.shareName }
117 }
118 
119 async unshareRun(subject: string, runId: string): Promise<void> {
120 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
121 if (!found) return
122 found.shareToken = null
123 found.shareName = null
124 }
125 
126 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
127 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
128 return found ? { token: found.shareToken, name: found.shareName } : null
129 }
130 
131 async getPublicRun(token: string): Promise<PublicRun | null> {
132 const found = this.runs.find((r) => r.shareToken === token)
133 return found ? toPublicRun(found.snapshot, found.shareName) : null
134 }
136 
137/** Records each saved run as a `run_snapshots` row via the service (secret) key,
138 * which bypasses RLS — the table has no public policy, so it is server-only. */
139export class SupabaseRunSnapshotStore implements RunSnapshotStore {
140 constructor(private readonly client: SupabaseClient) {}
141 
142 async saveRun(subject: string, snapshot: RunSnapshot): Promise<void> {
143 // Stamp the owner onto the billing run row too (it carries only device_id
144 // today) so a run's ownership is legible from either table.
145 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
146 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
147 
148 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
149 // first rather than erroring — the snapshot is immutable, the write is not.
150 const { error } = await this.client.from('run_snapshots').upsert({
151 run_id: snapshot.runId,
152 user_id: subject,
153 version: snapshot.version,
154 status: snapshot.status,
155 title: snapshot.objective.title,
156 progress: snapshot.objectiveProgress,
157 data: snapshot
158 })
159 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
160 }
161 
162 async listRuns(subject: string): Promise<RunSummary[]> {
163 const { data, error } = await this.client
164 .from('run_snapshots')
165 .select('run_id, status, title, progress, created_at')
166 .eq('user_id', subject)
167 .order('created_at', { ascending: false })
168 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
169 return ((data ?? []) as Array<{
170 run_id: string
171 status: RunSnapshot['status']
172 title: string
173 progress: number
174 created_at: string
175 }>).map((row) => ({
176 runId: row.run_id,
177 status: row.status,
178 title: row.title,
179 progress: row.progress,
180 createdAt: row.created_at
181 }))
182 }
183 
184 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
185 const { data, error } = await this.client
186 .from('run_snapshots')
187 .select('data')
188 .eq('run_id', runId)
189 .eq('user_id', subject)
190 .maybeSingle()
191 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
192 return (data?.data ?? null) as RunSnapshot | null
193 }
194 
195 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
196 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
197 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
198 // sees the first's token and matches no row — so re-sharing keeps one stable URL
199 // and never trips the unique constraint. (A wasted uuid when already shared is
200 // harmless: the guard excludes the row, so it's never written.)
201 const minted = await this.client
202 .from('run_snapshots')
203 .update({ share_token: randomUUID() })
204 .eq('run_id', runId)
205 .eq('user_id', subject)
206 .is('share_token', null)
207 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
208 
209 // Set the name and read back the live token (the freshly-minted one, or a token
210 // from a prior share). Scoped to the owner, so a run the subject doesn't own
211 // matches no row and reads as absent (null) — never shared on their behalf.
212 const { data, error } = await this.client
213 .from('run_snapshots')
214 .update({ share_name: name })
215 .eq('run_id', runId)
216 .eq('user_id', subject)
217 .select('share_token, share_name')
218 .maybeSingle()
219 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
220 if (!data) return null
221 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
222 }
223 
224 async unshareRun(subject: string, runId: string): Promise<void> {
225 const { error } = await this.client
226 .from('run_snapshots')
227 .update({ share_token: null, share_name: null })
228 .eq('run_id', runId)
229 .eq('user_id', subject)
230 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
231 }
232 
233 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
234 const { data, error } = await this.client
235 .from('run_snapshots')
236 .select('share_token, share_name')
237 .eq('run_id', runId)
238 .eq('user_id', subject)
239 .maybeSingle()
240 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
241 if (!data) return null
242 return {
243 token: (data.share_token as string | null) ?? null,
244 name: (data.share_name as string | null) ?? null
245 }
246 }
247 
248 async getPublicRun(token: string): Promise<PublicRun | null> {
249 // Looked up by the share token ALONE — no owner filter — because the public page
250 // has no subject. The token is the capability; an unknown/revoked token misses.
251 const { data, error } = await this.client
252 .from('run_snapshots')
253 .select('data, share_name')
254 .eq('share_token', token)
255 .maybeSingle()
256 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
257 if (!data) return null
258 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null)
259 }
⋯ 18 lines hidden (lines 260–277)
261 
262let cached: RunSnapshotStore | null = null
263 
264/** The configured run-snapshot store (memoized — reuses one Supabase client). */
265export function runSnapshotStore(): RunSnapshotStore {
266 if (cached) return cached
267 const cfg = supabaseConfig()
268 cached = cfg
269 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
270 : new InMemoryRunSnapshotStore()
271 return cached
273 
274/** Test seam: clear the memoized store so the next call re-reads config. */
275export function resetRunSnapshotStore(): void {
276 cached = null

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
server/api/run-share.post.ts47 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * POST /api/run-share — make one of the current subject's saved runs public (issue
3 * #88), or update its opt-in display name. Mints a stable, unguessable share token the
4 * public page (/r/:token) is reached by, and returns the live share state. Re-sharing an
5 * already-public run keeps the same token, so the URL stays stable.
6 *
7 * Ownership is enforced server-side: the store scopes by subject, so a run the subject
8 * doesn't own reads as absent (404) and never gets shared on their behalf.
9 *
10 * The optional display name is the ONLY identity a sharer can attach, and it's untrusted,
11 * public-facing text — so it's trimmed + length-capped and passed through the deterministic
12 * hard-block detector (the unforgivable categories: sexual / minors / self-harm). Note this
13 * is the detector floor only; contextual abuse (hate / harassment of a self-chosen handle on
14 * one's own page) is a documented follow-up, not gated here — the issue flags it as open.
15 */
16import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
17import { currentSubject } from '~/server/utils/auth'
18import { isUuid } from '~/server/utils/uuid'
19import { cleanString, MAX_SHARE_NAME_CHARS } from '~/server/utils/validate'
20import { hardBlockCheck } from '~/server/utils/moderation'
21 
22export default defineEventHandler(async (event) => {
23 if (getMethod(event) !== 'POST') {
24 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
25 }
26 const body = (await readBody(event)) as { runId?: unknown; name?: unknown } | null
27 // The run id is a uuid; isUuid is the gate (no length-coercion that would obscure intent).
28 const runId = typeof body?.runId === 'string' ? body.runId : ''
29 if (!isUuid(runId)) {
30 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
31 }
32 
33 const name = cleanString(body?.name, MAX_SHARE_NAME_CHARS)
34 if (name) {
35 const verdict = await hardBlockCheck(name, 'share-name', 'input')
36 if (verdict.blocked) {
37 throw createError({ statusCode: 400, statusMessage: verdict.block.reason })
38 }
39 }
40 
41 const subject = await currentSubject(event)
42 const state = await runSnapshotStore().shareRun(subject, runId, name || null)
43 if (!state) {
44 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
45 }
46 return state
47})

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
server/api/share/[token].get.ts23 lines · TypeScript
1/**
2 * GET /api/share/:token — the PUBLIC, read-only projection of a shared run (issue #88).
3 *
4 * No auth: the unguessable token IS the capability. Resolves to the sanitized public run
5 * (game record + opt-in display name, and NEVER the owner — no user id, device id, email,
6 * or even the run id) or 404 for an unknown / revoked / malformed token. The 404 is the
7 * same either way, so the endpoint reveals nothing about which tokens or runs exist. The
8 * token is uuid-shape-gated before any query so a malformed param never hits the DB.
9 */
10import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
11import { isUuid } from '~/server/utils/uuid'
12 
13export default defineEventHandler(async (event) => {
14 const token = getRouterParam(event, 'token') ?? ''
15 if (!isUuid(token)) {
16 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
17 }
18 const run = await runSnapshotStore().getPublicRun(token)
19 if (!run) {
20 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
21 }
22 return run
23})

The social share card

The card is the per-run image a link unfurls to — the real virality driver. renderShareCardSvg is a pure string builder: it takes the public run and returns a 1200x630 SVG (verdict, objective, % rewritten, a stat line, the opt-in name). Two constraints keep it robust. Every dynamic value is XML-escaped, and the text is Latin + digits + the brand Δ only — no emoji, since the slim runtime image has no color-emoji font.

escapeXml guards every interpolation; the builder draws structure from shapes, not glyphs the font set may lack.

server/utils/share-card.ts · 83 lines
server/utils/share-card.ts83 lines · TypeScript
⋯ 32 lines hidden (lines 1–32)
1/**
2 * The social share-card SVG (issue #88) — the per-run image a shared link unfurls to in
3 * X / Discord / iMessage, the real virality driver. A pure string builder: it takes the
4 * sanitized public run and returns a 1200x630 SVG the OG route rasterizes to PNG.
5 *
6 * Two deliberate constraints keep it robust where it actually renders (a slim Alpine
7 * container whose only font is libre DejaVu):
8 * - Text is Latin letters + digits + basic punctuation ONLY. No emoji (no color-emoji
9 * font is present, so the objective's icon would render as tofu) — the verdict mark
10 * is the brand's Δ ("change"), which DejaVu covers — and structure comes from drawn
11 * shapes (rules, a ring), never font glyphs.
12 * - Every dynamic string (objective title, attribution) is XML-escaped, so a stray
13 * angle bracket or quote can't break — or inject into — the SVG.
14 */
15import type { PublicRun } from '~/utils/run-snapshot'
16 
17const W = 1200
18const H = 630
19 
20// The dark palette (the card reads best dark on a social feed). Literal hex — SVG has
21// no CSS custom properties — mirroring the `.dark` tokens in assets/css/main.css.
22const C = {
23 bg: '#14110d',
24 fg: '#ece4d6',
25 mut: '#b3a892',
26 faint: '#978c77',
27 line: '#2f2820',
28 accent: '#db7a4f',
29 ok: '#74b35f',
30 bad: '#e08a72'
32 
33function escapeXml(value: string): string {
34 return value.replace(/[<>&'"]/g, (c) =>
35 c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;'
36 )
38 
39/** Single-line fit: SVG text doesn't wrap, so a long title is clipped with an ellipsis. */
40function truncate(value: string, max: number): string {
41 const t = value.trim()
42 return t.length > max ? `${t.slice(0, max - 1).trimEnd()}` : t
⋯ 1 line hidden (lines 44–44)
44 
45export function renderShareCardSvg(pub: PublicRun): string {
46 const run = pub.run
47 const win = run.status === 'victory'
48 const verdict = win ? 'History Rewritten' : 'The Timeline Held'
49 const verdictColor = win ? C.fg : C.mut
50 const sealColor = win ? C.accent : C.faint
51 const pctColor = win ? C.ok : C.bad
52 
53 const title = escapeXml(truncate(run.objective.title || 'A historical mission', 40))
54 const pct = `${run.objectiveProgress}% rewritten`
55 const used = run.messagesUsed
56 const stat = [
57 `${used} ${used === 1 ? 'message' : 'messages'} sent`,
58 run.bestRoll ? `best roll ${run.bestRoll.diceRoll}` : ''
59 ]
60 .filter(Boolean)
61 .join(' · ')
62 const attribution = pub.attribution ? `${escapeXml(truncate(pub.attribution, 40))}` : ''
63 
64 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
65 <rect width="${W}" height="${H}" fill="${C.bg}"/>
66 <rect x="0" y="0" width="${W}" height="6" fill="${C.accent}"/>
67 
68 <text x="80" y="98" font-family="monospace" font-size="26" letter-spacing="4" font-weight="700" fill="${C.accent}">REVISIONIST</text>
69 <text x="322" y="98" font-family="serif" font-size="24" font-style="italic" fill="${C.faint}">rewrite history</text>
70 <line x1="80" y1="124" x2="${W - 80}" y2="124" stroke="${C.line}" stroke-width="1"/>
71 
72 <circle cx="142" cy="320" r="54" fill="none" stroke="${sealColor}" stroke-width="3"/>
⋯ 11 lines hidden (lines 73–83)
73 <text x="142" y="340" text-anchor="middle" font-family="serif" font-size="54" font-weight="700" fill="${sealColor}">Δ</text>
74 
75 <text x="240" y="292" font-family="serif" font-size="70" font-weight="700" fill="${verdictColor}">${verdict}</text>
76 <text x="240" y="352" font-family="serif" font-size="34" fill="${C.mut}">${title}</text>
77 <text x="240" y="424" font-family="monospace" font-size="44" font-weight="700" fill="${pctColor}">${pct}</text>
78 
79 <line x1="80" y1="524" x2="${W - 80}" y2="524" stroke="${C.line}" stroke-width="1"/>
80 <text x="80" y="570" font-family="monospace" font-size="24" fill="${C.faint}">${escapeXml(stat)}</text>
81 ${attribution ? `<text x="${W - 80}" y="570" text-anchor="end" font-family="serif" font-size="26" font-style="italic" fill="${C.mut}">${attribution}</text>` : ''}
82</svg>`

The route resolves the same public projection, rasterizes the SVG to PNG with resvg, and caches only briefly. Rasterizing must never take the unfurl down, so any failure falls back to a committed static brand card.

Render → PNG → short cache; a render hiccup redirects to the static fallback.

server/api/og/[token].get.ts · 43 lines
server/api/og/[token].get.ts43 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1/**
2 * GET /api/og/:token — the per-run social share card as a PNG (issue #88). The page's
3 * og:image / twitter:image point here, so a pasted link unfurls into a real, postable
4 * image. Public + read-only: it resolves the same sanitized projection the share page
5 * does (404 on an unknown / revoked / malformed token, leaking nothing), renders the
6 * brand SVG, and rasterizes it with resvg.
7 *
8 * Rasterizing must never take the unfurl down, so any failure (a fonts-missing or native-
9 * binary hiccup in an unexpected runtime) falls back to the committed static brand card.
10 */
11import { Resvg } from '@resvg/resvg-js'
12import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
13import { isUuid } from '~/server/utils/uuid'
14import { renderShareCardSvg } from '~/server/utils/share-card'
15 
16export default defineEventHandler(async (event) => {
17 const token = getRouterParam(event, 'token') ?? ''
18 if (!isUuid(token)) {
19 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
20 }
21 const pub = await runSnapshotStore().getPublicRun(token)
22 if (!pub) {
23 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
24 }
25 
26 try {
27 const png = new Resvg(renderShareCardSvg(pub), {
28 fitTo: { mode: 'width', value: 1200 },
29 font: { loadSystemFonts: true, defaultFontFamily: 'serif' }
30 })
31 .render()
32 .asPng()
33 setHeader(event, 'Content-Type', 'image/png')
34 // Cache only briefly: the card carries the opt-in name, and a run can be un-shared
35 // or renamed, so a long edge TTL would let a stale card outlive a revocation. Five
36 // minutes keeps crawler refetches cheap without defeating "revocable".
37 setHeader(event, 'Cache-Control', 'public, max-age=300')
38 return png
39 } catch (error) {
40 console.error('OG share-card render failed; serving static fallback:', error)
41 return sendRedirect(event, '/og-default.png', 302)
42 }
⋯ 1 line hidden (lines 43–43)
43})

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
pages/r/[token].vue134 lines · Vue
⋯ 71 lines hidden (lines 1–71)
1<template>
2 <!-- A shared run, public + read-only (issue #88). Reached by an unguessable token, no
3 login. It replays the end screen from the sanitized public projection — the same
4 RunView a saved run uses — adds opt-in attribution, ways to pass it on, and a CTA
5 into a run of your own. Server-rendered so the link unfurls rich in social feeds. -->
6 <div class="min-h-screen rv-bg flex flex-col">
7 <header class="border-b rv-line">
8 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
9 <GameTitle />
10 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
11 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
12 </div>
13 </header>
14 
15 <main class="flex-1 px-5 py-8">
16 <div class="max-w-4xl mx-auto">
17 <div v-if="pending" data-testid="share-loading" class="rv-faint italic text-sm mt-8">Opening the run…</div>
18 
19 <div v-else-if="!run" data-testid="share-not-found" class="mt-8 border-t rv-line pt-6">
20 <h2 class="rv-serif rv-fg text-2xl font-bold">This run isn't available</h2>
21 <p class="rv-muted text-sm mt-2">The link may be wrong, or the run was un-shared by its owner.</p>
22 <NuxtLink to="/play" data-testid="share-not-found-cta" class="rv-btn rv-btn--primary mt-4 inline-flex">
23 Rewrite your own history <span aria-hidden="true"></span>
24 </NuxtLink>
25 </div>
26 
27 <template v-else>
28 <p v-if="attribution" data-testid="share-attribution" class="rv-label">
29 A run shared by {{ attribution }}
30 </p>
31 <RunView :snapshot="run" class="mt-3" />
32 
33 <!-- Pass it on -->
34 <div class="mt-9 border-t rv-line pt-6">
35 <span class="rv-label rv-label--rule">Pass it on</span>
36 <ShareLinks :url="pageUrl" :text="shareText" />
37 </div>
38 
39 <!-- The funnel: turn a viewer into a player -->
40 <div class="mt-7">
41 <p class="rv-muted text-sm mb-3">Think you can do better? Send your own 160-character messages into the past.</p>
42 <NuxtLink to="/play" data-testid="share-play-cta" class="rv-btn rv-btn--primary text-base inline-flex">
43 Rewrite your own history <span aria-hidden="true"></span>
44 </NuxtLink>
45 </div>
46 </template>
47 </div>
48 </main>
49 
50 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
51 <div class="max-w-4xl mx-auto flex items-center gap-2">
52 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
53 <span class="rv-hair-c" aria-hidden="true">·</span>
54 <span class="rv-serif italic">this timeline is written</span>
55 </div>
56 </footer>
57 </div>
58</template>
59 
60<script setup lang="ts">
61/**
62 * The public run page (/r/:token). Fetches the sanitized public projection from
63 * GET /api/share/:token (which 404s an unknown/revoked token → the "not available"
64 * state) and hands its snapshot to RunView — the very component the owner's saved-run
65 * page uses, so a shared run reads exactly like the real end screen. The SEO/unfurl meta
66 * is set with useServerSeoMeta so it's present in the SSR HTML crawlers read, with the
67 * og:image pointed at the per-run share card.
68 */
69import { computed } from 'vue'
70import { buildShareText, type PublicRun } from '~/utils/run-snapshot'
71 
72const route = useRoute()
73const token = computed(() => String(route.params.token))
74 
75const { data, pending } = await useFetch<PublicRun>(() => `/api/share/${token.value}`)
76const run = computed(() => data.value?.run ?? null)
77const attribution = computed(() => data.value?.attribution ?? null)
78 
79// Absolute URLs for the canonical link + the share card. useRequestURL gives the real
80// origin during SSR (what the crawler fetched) and the window origin on the client.
81const reqUrl = useRequestURL()
82const pageUrl = computed(() => `${reqUrl.origin}/r/${token.value}`)
83const cardUrl = computed(() => `${reqUrl.origin}/api/og/${token.value}`)
84 
85const isVictory = computed(() => run.value?.status === 'victory')
86const verdict = computed(() => (isVictory.value ? 'History Rewritten' : 'The Timeline Held'))
87 
88const shareText = computed(() =>
89 run.value
90 ? buildShareText(run.value.objective.title, run.value.status, run.value.objectiveProgress)
91 : 'Revisionist — rewrite history, one message at a time.'
93 
94const colorMode = useColorMode()
95const isDark = computed(() => colorMode.value === 'dark')
⋯ 39 lines hidden (lines 96–134)
96function toggleDark() {
97 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
99 
100const metaTitle = computed(() => {
101 const r = run.value
102 if (!r) return 'A shared run — Revisionist'
103 return `${r.objective.title}: ${r.objectiveProgress}% rewritten — Revisionist`
104})
105const metaDescription = computed(() => {
106 const r = run.value
107 if (!r) return 'A run of Revisionist — rewrite history, one 160-character message at a time.'
108 const epilogue = r.chronicle?.title
109 return epilogue ? `${verdict.value} · "${epilogue}"` : `${verdict.value} · ${r.objective.title} (${r.objectiveProgress}% rewritten).`
110})
111 
112// Server-rendered unfurl: title/description + an OG/Twitter card pointed at the per-run
113// share image, so a pasted link looks good in X / Discord / iMessage.
114useServerSeoMeta({
115 ogSiteName: 'Revisionist',
116 ogTitle: metaTitle,
117 description: metaDescription,
118 ogDescription: metaDescription,
119 ogType: 'article',
120 ogUrl: pageUrl,
121 ogImage: cardUrl,
122 ogImageWidth: 1200,
123 ogImageHeight: 630,
124 ogImageType: 'image/png',
125 twitterCard: 'summary_large_image',
126 twitterTitle: metaTitle,
127 twitterDescription: metaDescription,
128 twitterImage: cardUrl
129})
130 
131// The browser tab title (server + client nav); kept out of useServerSeoMeta so the two
132// don't both write <title>.
133useHead({ title: metaTitle })
134</script>

Server-rendered Open Graph / Twitter meta; title kept in useHead so the two don't both write <title>.

pages/r/[token].vue · 134 lines
pages/r/[token].vue134 lines · Vue
⋯ 112 lines hidden (lines 1–112)
1<template>
2 <!-- A shared run, public + read-only (issue #88). Reached by an unguessable token, no
3 login. It replays the end screen from the sanitized public projection — the same
4 RunView a saved run uses — adds opt-in attribution, ways to pass it on, and a CTA
5 into a run of your own. Server-rendered so the link unfurls rich in social feeds. -->
6 <div class="min-h-screen rv-bg flex flex-col">
7 <header class="border-b rv-line">
8 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
9 <GameTitle />
10 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
11 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
12 </div>
13 </header>
14 
15 <main class="flex-1 px-5 py-8">
16 <div class="max-w-4xl mx-auto">
17 <div v-if="pending" data-testid="share-loading" class="rv-faint italic text-sm mt-8">Opening the run…</div>
18 
19 <div v-else-if="!run" data-testid="share-not-found" class="mt-8 border-t rv-line pt-6">
20 <h2 class="rv-serif rv-fg text-2xl font-bold">This run isn't available</h2>
21 <p class="rv-muted text-sm mt-2">The link may be wrong, or the run was un-shared by its owner.</p>
22 <NuxtLink to="/play" data-testid="share-not-found-cta" class="rv-btn rv-btn--primary mt-4 inline-flex">
23 Rewrite your own history <span aria-hidden="true"></span>
24 </NuxtLink>
25 </div>
26 
27 <template v-else>
28 <p v-if="attribution" data-testid="share-attribution" class="rv-label">
29 A run shared by {{ attribution }}
30 </p>
31 <RunView :snapshot="run" class="mt-3" />
32 
33 <!-- Pass it on -->
34 <div class="mt-9 border-t rv-line pt-6">
35 <span class="rv-label rv-label--rule">Pass it on</span>
36 <ShareLinks :url="pageUrl" :text="shareText" />
37 </div>
38 
39 <!-- The funnel: turn a viewer into a player -->
40 <div class="mt-7">
41 <p class="rv-muted text-sm mb-3">Think you can do better? Send your own 160-character messages into the past.</p>
42 <NuxtLink to="/play" data-testid="share-play-cta" class="rv-btn rv-btn--primary text-base inline-flex">
43 Rewrite your own history <span aria-hidden="true"></span>
44 </NuxtLink>
45 </div>
46 </template>
47 </div>
48 </main>
49 
50 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
51 <div class="max-w-4xl mx-auto flex items-center gap-2">
52 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
53 <span class="rv-hair-c" aria-hidden="true">·</span>
54 <span class="rv-serif italic">this timeline is written</span>
55 </div>
56 </footer>
57 </div>
58</template>
59 
60<script setup lang="ts">
61/**
62 * The public run page (/r/:token). Fetches the sanitized public projection from
63 * GET /api/share/:token (which 404s an unknown/revoked token → the "not available"
64 * state) and hands its snapshot to RunView — the very component the owner's saved-run
65 * page uses, so a shared run reads exactly like the real end screen. The SEO/unfurl meta
66 * is set with useServerSeoMeta so it's present in the SSR HTML crawlers read, with the
67 * og:image pointed at the per-run share card.
68 */
69import { computed } from 'vue'
70import { buildShareText, type PublicRun } from '~/utils/run-snapshot'
71 
72const route = useRoute()
73const token = computed(() => String(route.params.token))
74 
75const { data, pending } = await useFetch<PublicRun>(() => `/api/share/${token.value}`)
76const run = computed(() => data.value?.run ?? null)
77const attribution = computed(() => data.value?.attribution ?? null)
78 
79// Absolute URLs for the canonical link + the share card. useRequestURL gives the real
80// origin during SSR (what the crawler fetched) and the window origin on the client.
81const reqUrl = useRequestURL()
82const pageUrl = computed(() => `${reqUrl.origin}/r/${token.value}`)
83const cardUrl = computed(() => `${reqUrl.origin}/api/og/${token.value}`)
84 
85const isVictory = computed(() => run.value?.status === 'victory')
86const verdict = computed(() => (isVictory.value ? 'History Rewritten' : 'The Timeline Held'))
87 
88const shareText = computed(() =>
89 run.value
90 ? buildShareText(run.value.objective.title, run.value.status, run.value.objectiveProgress)
91 : 'Revisionist — rewrite history, one message at a time.'
93 
94const colorMode = useColorMode()
95const isDark = computed(() => colorMode.value === 'dark')
96function toggleDark() {
97 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
99 
100const metaTitle = computed(() => {
101 const r = run.value
102 if (!r) return 'A shared run — Revisionist'
103 return `${r.objective.title}: ${r.objectiveProgress}% rewritten — Revisionist`
104})
105const metaDescription = computed(() => {
106 const r = run.value
107 if (!r) return 'A run of Revisionist — rewrite history, one 160-character message at a time.'
108 const epilogue = r.chronicle?.title
109 return epilogue ? `${verdict.value} · "${epilogue}"` : `${verdict.value} · ${r.objective.title} (${r.objectiveProgress}% rewritten).`
110})
111 
112// Server-rendered unfurl: title/description + an OG/Twitter card pointed at the per-run
113// share image, so a pasted link looks good in X / Discord / iMessage.
114useServerSeoMeta({
115 ogSiteName: 'Revisionist',
116 ogTitle: metaTitle,
117 description: metaDescription,
118 ogDescription: metaDescription,
119 ogType: 'article',
120 ogUrl: pageUrl,
121 ogImage: cardUrl,
122 ogImageWidth: 1200,
123 ogImageHeight: 630,
124 ogImageType: 'image/png',
125 twitterCard: 'summary_large_image',
126 twitterTitle: metaTitle,
127 twitterDescription: metaDescription,
128 twitterImage: cardUrl
129})
⋯ 5 lines hidden (lines 130–134)
130 
131// The browser tab title (server + client nav); kept out of useServerSeoMeta so the two
132// don't both write <title>.
133useHead({ title: metaTitle })
134</script>

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
components/ShareControls.vue150 lines · Vue
⋯ 97 lines hidden (lines 1–97)
1<template>
2 <!-- The owner's share affordance for one saved run (issue #88): make it public (opt-in
3 display name, off by default), see the live link, send it, or revoke it. Reads its
4 current state from the server so it shows the right face after a reload. -->
5 <div data-testid="share-controls" class="border rv-line rounded-sm p-4">
6 <div v-if="loading" class="rv-faint italic text-sm">Checking share status…</div>
7 
8 <div v-else-if="unavailable" class="rv-faint text-sm" data-testid="share-unavailable">
9 Sharing isn't available for this run yet.
10 </div>
11 
12 <!-- Public: the link + the ways to send it + revoke -->
13 <template v-else-if="shared">
14 <div class="flex items-center gap-2 flex-wrap">
15 <span class="rv-label rv-label--rule mb-0"><span aria-hidden="true">🌍</span> This run is public</span>
16 </div>
17 <p class="rv-muted text-sm mt-2">Anyone with this link can view it — no account needed.</p>
18 <code data-testid="share-url" class="block rv-mono text-xs rv-muted bg-transparent border rv-line rounded-sm px-2.5 py-2 mt-2 break-all">{{ publicUrl }}</code>
19 <div class="mt-3">
20 <ShareLinks :url="publicUrl" :text="shareText" />
21 </div>
22 <p class="rv-faint text-xs mt-3" data-testid="share-attribution">{{ attributionNote }}</p>
23 <button type="button" class="rv-btn text-sm mt-3" :disabled="busy" data-testid="share-stop" @click="stopSharing">
24 {{ busy ? 'Stopping…' : 'Stop sharing' }}
25 </button>
26 </template>
27 
28 <!-- Private: the opt-in to make it public -->
29 <template v-else>
30 <template v-if="!expanded">
31 <span class="rv-label rv-label--rule mb-0">Share this run</span>
32 <p class="rv-muted text-sm mt-2">Post your rewritten history — the verdict, the epilogue, the timeline.</p>
33 <button type="button" class="rv-btn rv-btn--primary text-sm mt-3" data-testid="share-open" @click="expanded = true">
34 ↗ Share this run
35 </button>
36 </template>
37 <template v-else>
38 <span class="rv-label rv-label--rule mb-0">Make this run public</span>
39 <p class="rv-muted text-sm mt-2">A public, read-only page at an unguessable link. Your identity stays private.</p>
40 <label class="flex items-center gap-2 text-sm mt-3 cursor-pointer">
41 <input v-model="showName" type="checkbox" data-testid="share-name-toggle" />
42 <span>Attach a display name</span>
43 </label>
44 <input
45 v-if="showName" v-model="nameInput" type="text" data-testid="share-name-input"
46 class="rv-input mt-2 w-full" :maxlength="MAX_SHARE_NAME_CHARS"
47 placeholder="A name to show — not your email"
48 />
49 <div class="flex items-center gap-2 mt-3 flex-wrap">
50 <button type="button" class="rv-btn rv-btn--primary text-sm" :disabled="busy" data-testid="share-create" @click="makePublic">
51 {{ busy ? 'Creating…' : 'Create public link' }}
52 </button>
53 <button type="button" class="rv-btn text-sm" :disabled="busy" data-testid="share-cancel" @click="expanded = false">
54 Cancel
55 </button>
56 </div>
57 </template>
58 </template>
59 
60 <p v-if="error" class="rv-bad text-sm mt-3" data-testid="share-error">{{ error }}</p>
61 </div>
62</template>
63 
64<script setup lang="ts">
65/**
66 * ShareControls — the owner's share state machine for a saved run. It fetches the run's
67 * current share state on mount (so a reload shows "public, here's the link" vs "share
68 * this run" correctly), toggles public/private against /api/run-share, and offers the
69 * opt-in display name at share time (off by default — anonymous). Ownership is enforced
70 * server-side; this component only ever acts on the current account's own runs.
71 */
72import { ref, computed, onMounted } from 'vue'
73import type { ShareState } from '~/utils/run-snapshot'
74import { MAX_SHARE_NAME_CHARS } from '~/server/utils/validate'
75 
76const props = defineProps<{
77 runId: string
78 /** The prebuilt brag line the parent composes from the run (objective + verdict). */
79 shareText: string
80}>()
81 
82const state = ref<ShareState | null>(null)
83const loading = ref(true)
84const unavailable = ref(false)
85const busy = ref(false)
86const error = ref('')
87const expanded = ref(false)
88const showName = ref(false)
89const nameInput = ref('')
90 
91const shared = computed(() => !!state.value?.token)
92const origin = computed(() => useRequestURL().origin)
93const publicUrl = computed(() => (state.value?.token ? `${origin.value}/r/${state.value.token}` : ''))
94const attributionNote = computed(() =>
95 state.value?.name ? `Shared as "${state.value.name}"` : 'Shared anonymously — no name attached.'
97 
98onMounted(async () => {
99 try {
100 state.value = await $fetch<ShareState>('/api/run-share', { query: { runId: props.runId } })
101 if (state.value?.name) {
102 showName.value = true
103 nameInput.value = state.value.name
104 }
105 } catch {
106 // The run isn't owned/saved yet — nothing to share against.
107 unavailable.value = true
108 } finally {
109 loading.value = false
110 }
111})
112 
113async function makePublic() {
114 busy.value = true
115 error.value = ''
116 try {
117 const name = showName.value && nameInput.value.trim() ? nameInput.value.trim() : null
118 state.value = await $fetch<ShareState>('/api/run-share', {
119 method: 'POST',
120 body: { runId: props.runId, name }
121 })
122 expanded.value = false
123 } catch (e) {
124 // A moderation block comes back as a 400 with a player-facing reason; surface it.
125 error.value = errorMessage(e) ?? 'Could not create the link. Try again.'
126 } finally {
127 busy.value = false
128 }
130 
131async function stopSharing() {
132 busy.value = true
133 error.value = ''
134 try {
135 await $fetch('/api/run-share', { method: 'DELETE', query: { runId: props.runId } })
136 state.value = { token: null, name: null }
137 } catch {
138 error.value = 'Could not stop sharing. Try again.'
139 } finally {
140 busy.value = false
141 }
143 
144/** Pull the server's statusMessage off an H3/ofetch error, if present. */
⋯ 6 lines hidden (lines 145–150)
145function errorMessage(e: unknown): string | null {
146 const data = (e as { data?: { statusMessage?: unknown; message?: unknown } })?.data
147 const msg = data?.statusMessage ?? data?.message
148 return typeof msg === 'string' && msg.trim() ? msg : null
150</script>

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
stores/game.ts1389 lines · TypeScript
⋯ 1329 lines hidden (lines 1–1329)
1import { defineStore } from 'pinia'
2import type { GameObjective, ObjectiveSteer } from '~/server/utils/objectives'
3import type { Pack } from '~/server/utils/packs'
4import type { GroundedFigure } from '~/server/utils/figure-grounding'
5import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
6import type { FigureSuggestion } from '~/server/utils/figure-suggester'
7import type { ChronicleEntry } from '~/server/utils/openai'
8import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
9import type { Anachronism } from '~/server/utils/anachronism'
10import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
11import type { Craft } from '~/utils/craft'
12import type { Continuity } from '~/utils/continuity'
13import type { DiceOutcome } from '~/utils/dice'
14import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
15import { TOTAL_MESSAGES, MAX_PROGRESS_SWING, MAX_PROGRESS } from '~/utils/game-config'
16import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '~/utils/run-snapshot'
17 
18export type Valence = 'positive' | 'negative' | 'neutral'
19 
20/**
21 * A historical figure the player has reached out to. Figures are freeform — the
22 * player can write to anyone, in any era. The AI infers each figure's era and a
23 * short descriptor on first contact, which we cache here for the UI.
24 */
25export interface HistoricalFigure {
26 name: string
27 era: string
28 descriptor: string
30 
31/**
32 * Timeline Ledger entry — a single recorded change to history.
33 *
34 * The ledger is the heart of the game: every resolved turn appends one entry
35 * describing how the world bent, so the player literally watches the timeline
36 * rewrite itself as they play.
37 */
38export interface TimelineEvent {
39 id: string
40 figureName: string
41 era: string
42 headline: string
43 detail: string
44 diceRoll: number
45 diceOutcome: DiceOutcome
46 progressChange: number
47 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
48 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
49 baseProgressChange?: number
50 valence: Valence
51 /** How anachronistic the player's nudge was — it widened this swing. */
52 anachronism?: Anachronism
53 /** How far this landed from the nearest foothold — it decayed this swing. */
54 causalChain?: ChainStatus
55 /** The Judge's grade of the dispatch that caused this change. */
56 craft?: Craft
57 /** Signed year of the intervention, when the contact was grounded. */
58 whenSigned?: number
59 /** True when this was the run's staked last stand (doubled swing). */
60 staked?: boolean
61 timestamp: Date
63 
64/**
65 * Message Interface — one line in the conversation with a figure.
66 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
67 * turn, since that is the resolved result of the roll.
68 */
69export interface Message {
70 text: string
71 sender: 'user' | 'ai' | 'system'
72 timestamp: Date
73 figureName?: string
74 /** The effective (craft-tilted) roll — what the bands judged. */
75 diceRoll?: number
76 diceOutcome?: DiceOutcome
77 /** The die as it actually landed, before the craft modifier. */
78 naturalRoll?: number
79 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
80 rollModifier?: number
81 craft?: Craft
82 craftReason?: string
83 /** Whether the dispatch built on the run's thread (issue #62) — drives the
84 * momentum meter and the 'builds' badge in the reveal. */
85 continuity?: Continuity
86 characterAction?: string
87 timelineImpact?: string
88 progressChange?: number
89 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
90 baseProgressChange?: number
91 /** The PRE-turn momentum that amplified this swing — for the reveal's equation. */
92 momentumAtSwing?: number
93 anachronism?: Anachronism
94 /** The causal-chain decay applied to this swing (for the reveal's equation). */
95 causalChain?: ChainStatus
96 /** True when this turn was the staked last stand. */
97 staked?: boolean
99 
100export type GameStatus = 'playing' | 'victory' | 'defeat'
101 
102/**
103 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
104 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
105 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
106 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
107 * the consumer destructure without optional chaining or null gymnastics.
108 */
109type SendMessageApiResponse =
110 | {
111 success: true
112 message: string
113 data: {
114 userMessage: string
115 figure: { name: string; era: string; descriptor: string }
116 diceRoll: number
117 diceOutcome: DiceOutcome
118 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
119 // the live server always sends them).
120 naturalRoll?: number
121 rollModifier?: number
122 craft?: Craft
123 craftReason?: string
124 continuity?: Continuity
125 momentum?: number
126 staked?: boolean
127 characterResponse: { message: string; action: string }
128 timeline: {
129 headline: string
130 detail: string
131 era: string
132 progressChange: number
133 baseProgressChange?: number
134 momentumAtSwing?: number
135 valence: Valence
136 anachronism?: Anachronism
137 causalChain?: ChainStatus
138 }
139 error: null
140 }
141 }
142 | {
143 success: false
144 message: string
145 data: {
146 userMessage: string
147 diceRoll?: number
148 diceOutcome?: DiceOutcome
149 characterResponse?: { message: string; action: string }
150 error: string
151 /** A content-moderation block (not an infra failure): the dispatch was
152 * refused by the detector, the Sentinel, or the model itself. */
153 blocked?: boolean
154 moderationReason?: string
155 }
156 }
157 
158const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
159 
160// Turn-reveal pacing. A resolved turn releases its beats one at a time — a beat of
161// suspense, then the die lands and the figure's reply writes itself in, then the
162// timeline shift, then the ledger node — so the resolution reads as one conducted
163// moment instead of everything firing in the same tick. Each component already
164// animates when its own slice of state changes; sequencing the WRITES sequences the
165// animations, with no component coordination. Tuned so the swing lands with the
166// reply's own "ripple" line (~0.55s into its staged reveal).
167export const REVEAL = { suspense: 450, swing: 550, node: 250 } as const
168const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
169/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
170 * env (no `matchMedia`) collapse to an instant, atomic apply — so the store's
171 * contract (state present the moment sendMessage resolves) is unchanged for tests. */
172function canAnimateReveal(): boolean {
173 return typeof window !== 'undefined'
174 && typeof window.matchMedia === 'function'
175 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
177 
178function valenceOf(progressChange: number): Valence {
179 if (progressChange > 0) return 'positive'
180 if (progressChange < 0) return 'negative'
181 return 'neutral'
183 
184/** Formats a signed timeline year (AD positive, BCE negative) for display. */
185export function formatContactYear(signed: number): string {
186 return signed < 0 ? `${-signed} BC` : `${signed}`
188 
189/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
190function lifespanText(g: GroundedFigure): string | undefined {
191 if (!g.born) return undefined
192 if (g.died) return `${g.born.display}${g.died.display}`
193 return g.living ? `${g.born.display} – present` : g.born.display
195 
196/** True when a $fetch error is a 402 (out of runs) — the paywall signal. ofetch
197 * surfaces the status as statusCode and on the response, so check both. */
198function isPaymentRequired(error: unknown): boolean {
199 const e = error as { statusCode?: number; status?: number; response?: { status?: number } }
200 return e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
202 
203/** True when a $fetch error is a 503 / at-capacity — the spend cap paused new
204 * runs (distinct from the per-device 402 paywall). */
205function isAtCapacity(error: unknown): boolean {
206 const e = error as { statusCode?: number; status?: number; response?: { status?: number }; data?: { atCapacity?: boolean } }
207 return e?.statusCode === 503 || e?.status === 503 || e?.response?.status === 503 || e?.data?.atCapacity === true
209 
210export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'living' | 'unresolved' | 'unknown'
211 
212/**
213 * Game Store — core state for a session of freeform timeline editing.
214 */
215export const useGameStore = defineStore('game', {
216 state: () => ({
217 remainingMessages: TOTAL_MESSAGES,
218 messageHistory: [] as Message[],
219 gameStatus: 'playing' as GameStatus,
220 isLoading: false,
221 error: null as string | null,
222 lastMessageTime: null as number | null,
223 isRateLimited: false,
224 // Staleness plumbing, not run state. runEpoch increments on every reset so an
225 // async result from a dead run can never write into a fresh one; the seq
226 // counters order overlapping requests of the same kind so only the latest
227 // lands (an earlier chronicle rewrite must not overwrite a later one).
228 // Deliberately NOT restored by resetGame — they must survive it to work.
229 runEpoch: 0,
230 chronicleSeq: 0,
231 groundingSeq: 0,
232 // The current run's id — lazily minted on the run's first server call and
233 // cleared by resetGame so each run gets a fresh one. Tags every AI call
234 // (via the x-run-id header) so the gateway's cost telemetry groups per
235 // run: the GTM cost instrument.
236 runId: null as string | null,
237 // The in-flight begin-run, so concurrent first-calls of a run share one
238 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
239 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
240 runIdInflight: null as Promise<string> | null,
241 // True once this run's snapshot is durably saved (POST /api/run-save returned
242 // saved). Gates the end-screen Share affordance (issue #88) so it appears only for
243 // a run that actually exists server-side to share — never for a degraded local id.
244 // Reset per run.
245 runSnapshotSaved: false,
246 // outOfRuns gates the mission screen's paywall (set when a commit is
247 // refused with 402). The run packs offered there are loaded into `packs`.
248 outOfRuns: false,
249 // atCapacity gates the "at capacity" notice (set when a commit is refused
250 // with 503 because the global spend cap paused new runs).
251 atCapacity: false,
252 packs: [] as Pack[],
253 // The device's run balance, for the header gauge + account popover. null
254 // until first loaded (GET /api/balance); the run-commit response also
255 // refreshes it so the gauge stays live without a second round-trip.
256 runsRemaining: null as number | null,
257 freeRuns: 1,
258 deviceRef: '' as string,
259 // Account state (from /api/balance): the signed-in email, and whether the
260 // current user is anonymous (→ must sign in before buying). Drives the
261 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
262 accountEmail: null as string | null,
263 accountAnonymous: false,
264 // The run-packs sales modal — opened on demand (header) or automatically
265 // when a commit is refused for being out of runs.
266 buyModalOpen: false,
267 // A one-shot notice after returning from Stripe Checkout ('success' shows a
268 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
269 purchaseNotice: null as 'success' | 'cancel' | null,
270 currentObjective: null as GameObjective | null,
271 objectiveProgress: 0,
272 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
273 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
274 momentum: 0,
275 timelineEvents: [] as TimelineEvent[],
276 figures: [] as HistoricalFigure[],
277 activeFigureName: '' as string,
278 // Grounding for the active contact: real facts + the chosen year to reach them.
279 figureGrounding: null as GroundedFigure | null,
280 groundingLoading: false,
281 contactWhen: null as number | null,
282 /** Optional sub-year refinement of contactWhen — display/prompt flavor
283 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
284 contactMoment: null as ContactMoment | null,
285 // Era-relevant figure suggestions for the current objective (the on-ramp).
286 figureSuggestions: [] as FigureSuggestion[],
287 suggestionsLoading: false,
288 suggestionsFor: '' as string,
289 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
290 // rewritten each turn. Non-blocking — see refreshChronicle.
291 chronicle: null as ChronicleEntry | null,
292 chronicleLoading: false,
293 // The Archive (prototype): an objective-blind brief on the active figure, so
294 // the player can research who they're reaching without leaving the game. The
295 // brief is moment-specific, so it's cached against the figure AND the year.
296 figureStudy: null as FigureStudy | null,
297 studyLoading: false,
298 studyFor: '' as string,
299 studyWhen: null as number | null,
300 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
301 archiveResult: null as ArchiveLookup | null,
302 lookupLoading: false,
303 // A content-moderation block — distinct from `error` (an infra hiccup) so
304 // the UI shows an honest, visibly different banner. One per surface that
305 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
306 // the Archivist study, and the figure-suggestions step.
307 moderationNotice: null as string | null,
308 archiveNotice: null as string | null,
309 studyNotice: null as string | null,
310 suggestionsNotice: null as string | null
311 }),
312 
313 getters: {
314 /**
315 * Can the player send right now? (messages left, still playing, not
316 * mid-request, not rate-limited)
317 */
318 canSendMessage(): boolean {
319 return this.remainingMessages > 0 &&
320 this.gameStatus === 'playing' &&
321 !this.isLoading &&
322 !this.isRateLimited
323 },
324 
325 /**
326 * The conversation thread with a single figure (their turns + the
327 * player's turns addressed to them). Each figure keeps a coherent,
328 * independent thread.
329 */
330 conversationWith(): (name: string) => Message[] {
331 return (name: string) => this.messageHistory.filter(
332 m => m.figureName === name && m.sender !== 'system'
333 )
334 },
335 
336 /** Total progress, net of setbacks, the player has clawed back. */
337 gameSummary(): GameSummary {
338 return generateGameSummary(this)
339 },
340 
341 /**
342 * Whether the active figure can be reached at the chosen `when`.
343 *
344 * Require grounding (#73): an UNRESOLVED name can't be reached at all —
345 * 'unresolved' (free-form contact is removed; the picker guides you to a real
346 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
347 * death is living/undatable and never contactable — 'living', fail closed. A
348 * resolved, deceased figure is gated to their lifetime (before-birth /
349 * after-death). 'unknown' remains only for a resolved, deceased figure we
350 * can't fully place (no birth year, or no contact year chosen yet), which
351 * stays permissible.
352 */
353 contactLiveness(): ContactLiveness {
354 const g = this.figureGrounding
355 if (!g || !g.resolved) return 'unresolved'
356 if (!g.died) return 'living'
357 if (!g.born || this.contactWhen == null) return 'unknown'
358 if (this.contactWhen < g.born.signed) return 'before-birth'
359 if (this.contactWhen > g.died.signed) return 'after-death'
360 return 'ok'
361 },
362 
363 /** Can the chosen contact + when actually be reached? */
364 canContact(): boolean {
365 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
366 },
367 
368 /**
369 * The last stand is offered ONLY when the final dispatch can no longer win
370 * inside the normal per-turn fuse — from any nearer position an unstaked
371 * throw can still land it, and offering the (strictly win-probability-
372 * increasing) doubling there would be a dominance trap, not a decision.
373 */
374 canStake(): boolean {
375 return this.remainingMessages === 1 &&
376 this.gameStatus === 'playing' &&
377 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
378 },
379 
380 /**
381 * The active figure's age at the chosen `when` — so the player never has to
382 * do lifetime math in their head. Null when we lack a birth year or a chosen
383 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
384 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
385 */
386 contactAge(): number | null {
387 const g = this.figureGrounding
388 if (!g?.resolved || !g.born || this.contactWhen == null) return null
389 const bornSigned = g.born.signed
390 const whenSigned = this.contactWhen
391 if (whenSigned < bornSigned) return null // before birth — no meaningful age
392 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
393 return whenSigned - bornSigned - crossedZero
394 },
395 
396 /**
397 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
398 * source, mirroring what the Timeline Engine will compute. Footholds are the
399 * objective's anchor year plus every dated change already on the ledger; the
400 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
401 * contacts or an anchorless objective with no dated changes yet.
402 */
403 causalChainRead(): ChainStatus | null {
404 const footholds = [
405 this.currentObjective?.anchorYear,
406 ...this.timelineEvents.map(e => e.whenSigned)
407 ]
408 return chainStatus(this.contactWhen, footholds)
409 }
410 },
411 
412 actions: {
413 // ---------- message helpers ----------
414 addUserMessage(text: string, figureName?: string) {
415 if (this.gameStatus !== 'playing') return
416 this.messageHistory.push({
417 text,
418 sender: 'user',
419 timestamp: new Date(),
420 figureName
421 })
422 },
423 
424 addAIMessage(text: string, figureName?: string) {
425 this.messageHistory.push({
426 text,
427 sender: 'ai',
428 timestamp: new Date(),
429 figureName
430 })
431 },
432 
433 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
434 this.messageHistory.push({
435 text: messageData.text,
436 sender: messageData.sender,
437 timestamp: messageData.timestamp || new Date(),
438 figureName: messageData.figureName,
439 diceRoll: messageData.diceRoll,
440 diceOutcome: messageData.diceOutcome,
441 naturalRoll: messageData.naturalRoll,
442 rollModifier: messageData.rollModifier,
443 craft: messageData.craft,
444 craftReason: messageData.craftReason,
445 continuity: messageData.continuity,
446 characterAction: messageData.characterAction,
447 timelineImpact: messageData.timelineImpact,
448 progressChange: messageData.progressChange,
449 baseProgressChange: messageData.baseProgressChange,
450 momentumAtSwing: messageData.momentumAtSwing,
451 anachronism: messageData.anachronism,
452 causalChain: messageData.causalChain,
453 staked: messageData.staked
454 })
455 },
456 
457 // ---------- figures ----------
458 registerFigure(figure: HistoricalFigure) {
459 const existing = this.figures.find(f => f.name === figure.name)
460 if (existing) {
461 if (figure.era) existing.era = figure.era
462 if (figure.descriptor) existing.descriptor = figure.descriptor
463 } else {
464 this.figures.push({ ...figure })
465 }
466 },
467 
468 setActiveFigure(name: string) {
469 this.activeFigureName = name
470 },
471 
472 /**
473 * Synchronously clears the dossier the moment the contact NAME changes, so
474 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
475 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
476 * and so the wrong year can never be written into the ledger's whenSigned.
477 */
478 clearGrounding() {
479 this.groundingSeq++ // invalidate any lookup still in flight
480 this.figureGrounding = null
481 this.contactWhen = null
482 this.contactMoment = null
483 this.groundingLoading = false
484 this.figureStudy = null
485 this.studyFor = ''
486 this.studyWhen = null
487 this.studyNotice = null
488 },
489 
490 /**
491 * Resolves the active figure against the grounding service and defaults the
492 * "when" to a point inside any known lifetime. Never throws: an unresolved
493 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
494 */
495 async groundActiveFigure(name: string): Promise<void> {
496 const target = (name || '').trim()
497 // A new contact makes any prior Archive study stale.
498 this.figureStudy = null
499 this.studyFor = ''
500 this.studyWhen = null
501 this.studyNotice = null
502 if (!target) {
503 this.groundingSeq++ // invalidate any lookup still in flight
504 this.figureGrounding = null
505 this.contactWhen = null
506 this.contactMoment = null
507 this.groundingLoading = false
508 return
509 }
510 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
511 // response for the previous name attaches the wrong dossier (and a wrong
512 // figureContext on a fast send) to whatever the player typed next.
513 const epoch = this.runEpoch
514 const seq = ++this.groundingSeq
515 this.groundingLoading = true
516 try {
517 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
518 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
519 this.figureGrounding = grounded
520 this.contactMoment = null
521 if (grounded?.resolved && grounded.born) {
522 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
523 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
524 } else {
525 this.contactWhen = null
526 }
527 } catch (error) {
528 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
529 console.error('Figure grounding failed:', error)
530 this.figureGrounding = null
531 this.contactWhen = null
532 this.contactMoment = null
533 } finally {
534 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
535 }
536 },
537 
538 /** The current run's id, minted server-side via POST /api/run on first
539 * need. The run is a server fact (a persisted, charged row); the id then
540 * tags every AI call so cost telemetry groups per run, and the gameplay
541 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
542 * REJECTS (no local fallback) — a run we can't back server-side must not
543 * start, or the paywall gate would reject it mid-run anyway. */
544 async ensureRunId(): Promise<string> {
545 if (this.runId) return this.runId
546 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
547 // calls would otherwise mint two rows). The epoch guards a resetGame
548 // mid-flight — a dead run's id must not become the fresh run's.
549 if (!this.runIdInflight) {
550 const epoch = this.runEpoch
551 this.runIdInflight = (async () => {
552 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
553 if (!res?.runId) throw new Error('begin-run returned no run id')
554 if (epoch === this.runEpoch) {
555 this.runId = res.runId
556 this.runIdInflight = null
557 }
558 return res.runId
559 })().catch((err) => {
560 if (epoch === this.runEpoch) this.runIdInflight = null
561 throw err
562 })
563 }
564 return this.runIdInflight
565 },
566 
567 /** Headers that tag a server call with the current run (the cost
568 * instrument), minting the run id first if needed. */
569 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
570 return { ...base, 'x-run-id': await this.ensureRunId() }
571 },
572 
573 setContactWhen(year: number | null) {
574 // Scrubbing the year keeps any pinned month/day — the pin refines
575 // whichever year is chosen, it doesn't belong to one.
576 this.contactWhen = year
577 },
578 
579 setContactMoment(moment: ContactMoment | null) {
580 this.contactMoment = moment
581 },
582 
583 /**
584 * Studies the active (grounded) figure via the Archivist — an objective-blind
585 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
586 * game instead of a browser tab. The brief is moment-specific, so it's cached
587 * against the figure AND the year: move the contact slider and the player can
588 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
589 * Only resolved figures can be studied — an unresolved name has no record.
590 */
591 async studyActiveFigure(): Promise<void> {
592 const g = this.figureGrounding
593 if (!g?.resolved) {
594 this.figureStudy = null
595 return
596 }
597 // Cache hit only when both the figure AND the studied year still match.
598 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
599 
600 // Capture the moment being studied NOW: the brief describes this figure at
601 // this year. If the slider moves mid-flight, recording the live value would
602 // mislabel the brief and silently suppress the Re-study button.
603 const epoch = this.runEpoch
604 const studiedName = g.name
605 const studiedWhen = this.contactWhen
606 this.studyLoading = true
607 this.studyNotice = null
608 try {
609 const res = await $fetch('/api/research', {
610 method: 'POST',
611 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
612 body: {
613 figureName: studiedName,
614 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
615 description: g.description,
616 extract: g.extract
617 }
618 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
619 if (epoch !== this.runEpoch) return
620 // If the contact changed mid-flight, the stale-study clear in
621 // groundActiveFigure already ran — don't resurrect the old brief.
622 if (res?.blocked && this.figureGrounding?.name === studiedName) {
623 this.studyNotice = res.error || 'That study was blocked by content moderation.'
624 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
625 this.figureStudy = res.study
626 this.studyFor = studiedName
627 this.studyWhen = studiedWhen
628 }
629 } catch (error) {
630 if (epoch !== this.runEpoch) return
631 console.error('Failed to study the figure:', error)
632 } finally {
633 if (epoch === this.runEpoch) this.studyLoading = false
634 }
635 },
636 
637 /**
638 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
639 * domain facts: what it is, what it takes, and when it first became known
640 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
641 * persists across figure changes within a run.
642 */
643 async askArchive(query: string): Promise<void> {
644 const q = (query || '').trim()
645 if (!q) return
646 const epoch = this.runEpoch
647 this.lookupLoading = true
648 this.archiveNotice = null
649 try {
650 const res = await $fetch('/api/lookup', {
651 method: 'POST',
652 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
653 body: { query: q }
654 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
655 if (epoch !== this.runEpoch) return
656 if (res?.blocked) {
657 // The Archive is the sharpest elicitation vector — a block here is
658 // honest and visible, not a silent empty result.
659 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
660 } else if (res?.success && res.lookup) {
661 this.archiveResult = res.lookup
662 }
663 } catch (error) {
664 if (epoch !== this.runEpoch) return
665 console.error('Archive lookup failed:', error)
666 } finally {
667 if (epoch === this.runEpoch) this.lookupLoading = false
668 }
669 },
670 
671 /**
672 * Loads era-relevant figure suggestions for the current objective (the
673 * educational on-ramp). Cached per objective; on any failure it leaves the
674 * list empty so the UI falls back to its generic starters.
675 */
676 async loadSuggestions(): Promise<void> {
677 const objective = this.currentObjective
678 if (!objective) {
679 this.figureSuggestions = []
680 this.suggestionsFor = ''
681 return
682 }
683 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
684 
685 const epoch = this.runEpoch
686 this.suggestionsLoading = true
687 this.suggestionsNotice = null
688 try {
689 const res = await $fetch('/api/suggestions', {
690 method: 'POST',
691 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
692 body: {
693 objective: {
694 title: objective.title,
695 description: objective.description,
696 era: objective.era
697 }
698 }
699 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
700 if (epoch !== this.runEpoch) return
701 // A blocked objective must not masquerade as an empty result — surface it.
702 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
703 this.figureSuggestions = res?.suggestions ?? []
704 this.suggestionsFor = objective.title
705 } catch (error) {
706 if (epoch !== this.runEpoch) return
707 console.error('Failed to load figure suggestions:', error)
708 this.figureSuggestions = []
709 // Record that this objective WAS asked, even though it failed — the
710 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
711 // from "asked and came up empty" (the honest famous-names fallback).
712 this.suggestionsFor = objective.title
713 } finally {
714 if (epoch === this.runEpoch) this.suggestionsLoading = false
715 }
716 },
717 
718 // ---------- timeline ledger ----------
719 addTimelineEvent(
720 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
721 ) {
722 this.timelineEvents.push({
723 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
724 figureName: evt.figureName,
725 era: evt.era,
726 headline: evt.headline,
727 detail: evt.detail,
728 diceRoll: evt.diceRoll,
729 diceOutcome: evt.diceOutcome,
730 progressChange: evt.progressChange,
731 baseProgressChange: evt.baseProgressChange,
732 valence: evt.valence ?? valenceOf(evt.progressChange),
733 anachronism: evt.anachronism,
734 causalChain: evt.causalChain,
735 craft: evt.craft,
736 whenSigned: evt.whenSigned,
737 staked: evt.staked,
738 timestamp: evt.timestamp ?? new Date()
739 })
740 },
741 
742 // ---------- the living chronicle (Layer 3) ----------
743 /**
744 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
745 * non-blocking after a resolved turn (and at game end): the dice/progress
746 * reveal never waits on prose. True to the game's name, the WHOLE account is
747 * rewritten each turn — a later change can re-frame how earlier events read.
748 * Graceful: a failed refresh keeps the prior telling, so the panel never
749 * blanks; an empty timeline clears it (nothing to chronicle yet).
750 */
751 async refreshChronicle(): Promise<void> {
752 if (!this.timelineEvents.length) {
753 this.chronicle = null
754 return
755 }
756 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
757 // only the LATEST issued refresh may land — an earlier telling arriving
758 // late must not regress the account (or worse, the epilogue). The epoch
759 // guard keeps a dead run's telling out of a fresh run entirely.
760 const epoch = this.runEpoch
761 const seq = ++this.chronicleSeq
762 this.chronicleLoading = true
763 try {
764 const res = await $fetch('/api/chronicle', {
765 method: 'POST',
766 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
767 body: {
768 objective: this.currentObjective,
769 status: this.gameStatus,
770 progress: this.objectiveProgress,
771 timeline: this.timelineEvents.map(e => ({
772 era: e.era,
773 figureName: e.figureName,
774 headline: e.headline,
775 detail: e.detail,
776 progressChange: e.progressChange
777 }))
778 }
779 }) as { success?: boolean; chronicle?: ChronicleEntry }
780 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
781 if (res?.success && res.chronicle) this.chronicle = res.chronicle
782 } catch (error) {
783 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
784 console.error('Failed to refresh the chronicle:', error)
785 // Keep the prior chronicle — a failed refresh never blanks the panel.
786 } finally {
787 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
788 }
789 },
790 
791 // ---------- simple setters ----------
792 setLoading(loading: boolean) { this.isLoading = loading },
793 setError(error: string | null) { this.error = error },
794 clearModerationNotice() { this.moderationNotice = null },
795 clearArchiveNotice() { this.archiveNotice = null },
796 
797 // ---------- rate limiting ----------
798 checkRateLimit(): boolean {
799 const now = Date.now()
800 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
801 return true
802 },
803 
804 setRateLimit() {
805 this.isRateLimited = true
806 const remainingTime = this.lastMessageTime
807 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
808 : 0
809 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
810 },
811 
812 // ---------- counter & status ----------
813 decrementMessages() {
814 if (this.remainingMessages > 0) this.remainingMessages--
815 },
816 
817 /**
818 * Rolls back an unresolved turn: refunds the spent message and drops the
819 * dangling user entry, so the counter and history can't drift out of sync.
820 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
821 * `success:false` — an infra hiccup must never burn one of the five
822 * dispatches (or, on the last one, convert into an instant unearned defeat).
823 */
824 refundUnresolvedTurn() {
825 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
826 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
827 if (this.messageHistory[i].sender === 'user') {
828 this.messageHistory.splice(i, 1)
829 break
830 }
831 }
832 },
833 
834 applyProgress(progressChange: number) {
835 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
836 },
837 
838 /**
839 * Resolves win/lose AFTER a turn's progress has been applied.
840 *
841 * Victory the instant the objective hits 100%; defeat only once the
842 * final message is spent without reaching it. This fixes the old
843 * premature-defeat bug, where status flipped on the last decrement
844 * BEFORE that turn's progress had a chance to land.
845 */
846 resolveGameStatus() {
847 if (this.objectiveProgress >= MAX_PROGRESS) {
848 this.gameStatus = 'victory'
849 } else if (this.remainingMessages <= 0) {
850 this.gameStatus = 'defeat'
851 }
852 },
853 
854 // ---------- the turn ----------
855 /**
856 * Sends a 160-char message to a chosen figure and folds the result back
857 * into the world: the figure replies + acts, the dice decide the swing,
858 * and the Timeline Engine records how history bent.
859 *
860 * Returns `true` only when the turn actually RESOLVED (a ledger entry
861 * landed). A blocked or failed send returns `false` so the composer can
862 * keep the player's crafted words instead of wiping them.
863 *
864 * `opts.stake` arms the last stand — honored only when `canStake` holds
865 * (final message, win out of normal reach; the server doubles the resolved
866 * swing, both ways, past the usual cap).
867 */
868 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
869 const target = (figureName ?? this.activeFigureName ?? '').trim()
870 const stake = opts?.stake === true && this.canStake
871 
872 if (!this.canSendMessage) return false
873 if (!target) {
874 this.setError('Choose who in history to send your message to first.')
875 return false
876 }
877 if (!this.checkRateLimit()) {
878 this.setRateLimit()
879 this.setError('The timeline needs a moment — wait before sending again.')
880 return false
881 }
882 if (!this.canContact) {
883 const who = this.figureGrounding?.name || target
884 this.setError(
885 this.contactLiveness === 'unresolved'
886 ? (this.figureGrounding?.transient
887 ? `Couldn't reach the record to verify "${target}" — try again in a moment.`
888 : `No historical record found for "${target}" — reach for a real figure (pick a match as you type).`)
889 : this.contactLiveness === 'before-birth'
890 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
891 : this.contactLiveness === 'living'
892 ? (this.figureGrounding?.born
893 ? `${who} is still living — Revisionist only reaches figures from history.`
894 : `${who} couldn't be dated — Revisionist can only reach figures it can place in history.`)
895 : `${who} has died by that year — choose an earlier moment to reach them.`
896 )
897 return false
898 }
899 
900 this.setError(null)
901 this.moderationNotice = null
902 this.setActiveFigure(target)
903 this.addUserMessage(text, target)
904 this.decrementMessages()
905 this.setLoading(true)
906 this.lastMessageTime = Date.now()
907 
908 // If the run is reset while this request is in flight, every write below
909 // would land in a world that no longer exists — the epoch guard drops the
910 // response (no fold-in, no refund: the new run's counter is not ours).
911 const epoch = this.runEpoch
912 const ledgerBefore = this.timelineEvents.length
913 // Capture the contact year NOW: the slider can move while the request is
914 // in flight, and the ledger must record the year this turn was SENT to.
915 const sentWhen = this.contactWhen
916 const sentMoment = this.contactMoment
917 let resolved = false
918 // Decide once whether to stagger the reveal (browser + motion allowed).
919 const animate = canAnimateReveal()
920 
921 try {
922 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
923 method: 'POST',
924 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
925 body: {
926 message: text,
927 figureName: target,
928 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
929 whenSigned: sentWhen ?? undefined,
930 // The pinned moment travels as validated integers; the server
931 // re-derives the display string rather than trusting ours.
932 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
933 whenDay: sentWhen != null ? sentMoment?.day : undefined,
934 stake,
935 // The pre-turn momentum the server amplifies the swing by, and
936 // returns advanced (the client stays the system of record).
937 momentum: this.momentum,
938 figureContext: this.figureGrounding?.resolved
939 ? {
940 description: this.figureGrounding.description,
941 lifespan: lifespanText(this.figureGrounding)
942 }
943 : undefined,
944 objective: this.currentObjective,
945 timeline: this.timelineEvents.map(e => ({
946 era: e.era,
947 figureName: e.figureName,
948 headline: e.headline,
949 detail: e.detail,
950 progressChange: e.progressChange,
951 whenSigned: e.whenSigned
952 })),
953 conversationHistory: this.conversationWith(target)
954 }
955 })
956 
957 if (epoch !== this.runEpoch) return false
958 
959 if (response?.success && response.data?.characterResponse) {
960 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
961 
962 if (figure?.name) {
963 this.registerFigure({
964 name: figure.name,
965 era: figure.era || '',
966 descriptor: figure.descriptor || ''
967 })
968 }
969 
970 // ---- conducted reveal ----
971 // A beat of suspense (the die keeps shaking) before the result
972 // lands, so the resolution has a moment of anticipation.
973 if (animate) {
974 await wait(REVEAL.suspense)
975 if (epoch !== this.runEpoch) return false
976 }
977 
978 // Beat 1 — the die lands and the figure's reply writes itself in
979 // (its parts stage over ~0.55s via CSS). Flipping loading here
980 // (mid-sequence) is what lands the die now; the finally's flip
981 // becomes a no-op.
982 this.addAIMessageWithData({
983 text: characterResponse.message,
984 sender: 'ai',
985 figureName: target,
986 timestamp: new Date(),
987 diceRoll,
988 diceOutcome,
989 naturalRoll: response.data.naturalRoll ?? diceRoll,
990 rollModifier: response.data.rollModifier ?? 0,
991 craft: response.data.craft,
992 craftReason: response.data.craftReason,
993 continuity: response.data.continuity,
994 characterAction: characterResponse.action,
995 timelineImpact: timeline?.detail,
996 progressChange: timeline?.progressChange,
997 baseProgressChange: timeline?.baseProgressChange,
998 momentumAtSwing: timeline?.momentumAtSwing,
999 anachronism: timeline?.anachronism,
1000 causalChain: timeline?.causalChain,
1001 staked: response.data.staked
1002 })
1003 if (animate) this.setLoading(false)
1005 if (timeline) {
1006 // Beat 2 — the timeline shift (the % gauge flies its delta),
1007 // timed to land with the reply's own "ripple" line.
1008 if (animate) {
1009 await wait(REVEAL.swing)
1010 if (epoch !== this.runEpoch) return false
1012 // Use !== undefined so a genuine neutral (0%) turn still
1013 // registers instead of silently vanishing.
1014 if (timeline.progressChange !== undefined) {
1015 this.applyProgress(timeline.progressChange)
1017 // The arc meter is server-authoritative for the result; advance
1018 // it WITH the swing it amplified — and only past the epoch check
1019 // above, so a stale turn never moves the meter (issue #62).
1020 this.momentum = response.data.momentum ?? this.momentum
1022 // Beat 3 — the change drops onto the Spine ledger.
1023 if (animate) {
1024 await wait(REVEAL.node)
1025 if (epoch !== this.runEpoch) return false
1027 this.addTimelineEvent({
1028 figureName: target,
1029 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
1030 headline: timeline.headline,
1031 detail: timeline.detail,
1032 diceRoll,
1033 diceOutcome,
1034 progressChange: timeline.progressChange ?? 0,
1035 baseProgressChange: timeline.baseProgressChange,
1036 valence: timeline.valence,
1037 anachronism: timeline.anachronism,
1038 causalChain: timeline.causalChain,
1039 craft: response.data.craft,
1040 whenSigned: sentWhen ?? undefined,
1041 staked: response.data.staked
1042 })
1043 resolved = true
1045 } else {
1046 // A graceful failure (HTTP 200, success:false): an AI layer gave
1047 // out mid-turn. No ripple landed, so the dispatch is refunded —
1048 // exactly like the thrown path below.
1049 // Narrow to the failure variant (its data carries `blocked`).
1050 const failed = response && !response.success ? response.data : undefined
1051 if (failed?.blocked) {
1052 // A content-moderation block, not an infra hiccup — show an
1053 // honest, distinct banner (not "try again"). The composer
1054 // keeps the player's words (sendMessage returns false).
1055 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
1056 } else {
1057 this.setError(failed?.error || 'History did not answer. Try again.')
1059 this.refundUnresolvedTurn()
1061 } catch (error) {
1062 if (epoch !== this.runEpoch) return false
1063 console.error('Error sending message:', error)
1064 this.setError('The timeline resisted your message. Please try again.')
1065 this.refundUnresolvedTurn()
1066 } finally {
1067 if (epoch === this.runEpoch) {
1068 this.setLoading(false)
1069 this.resolveGameStatus()
1073 // The living chronicle re-narrates the world from the new timeline — but
1074 // only when this turn actually added a change, and never awaited: the turn
1075 // reveal must not wait on prose. By here the status is resolved, so the
1076 // final turn's chronicle carries the victory/defeat framing (the epilogue).
1077 let refresh: Promise<void> | null = null
1078 if (resolved && this.timelineEvents.length > ledgerBefore) {
1079 refresh = this.refreshChronicle()
1080 void refresh
1082 // The run just ended: persist it (best-effort) so it survives reload, then
1083 // re-save once the epilogue's telling lands — the immediate save guards
1084 // against that refresh failing, the second captures the final Chronicle.
1085 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1086 void this.saveRunSnapshot()
1087 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1089 return resolved
1090 },
1092 /**
1093 * Commits a chosen objective and starts the run from a clean slate of
1094 * progress. Used by the mission-select screen for both curated picks and
1095 * freshly composed ones.
1096 */
1097 async chooseObjective(objective: GameObjective): Promise<boolean> {
1098 // Commit = the run is charged here. Mint the run id server-side, then
1099 // spend one run from the device's balance. Fails CLOSED: out of runs →
1100 // raise the paywall; begin-run or commit failing → surface an error and
1101 // do NOT start. A run that isn't a charged, server-backed run would be
1102 // rejected by the gameplay gate anyway, so starting it only strands the
1103 // player mid-run. The spend cap (not free play) is the outage backstop.
1104 let runId: string
1105 try {
1106 runId = await this.ensureRunId()
1107 } catch (error) {
1108 console.error('Could not begin the run:', error)
1109 this.error = 'Could not start the run. Please try again.'
1110 return false
1112 try {
1113 const res = await $fetch('/api/run-commit', {
1114 method: 'POST',
1115 headers: { 'Content-Type': 'application/json' },
1116 body: { runId }
1117 }) as { runsRemaining?: number }
1118 this.outOfRuns = false
1119 // Keep the header gauge live: the commit returns the post-charge
1120 // balance (−1 marks a degraded, ungated run — leave the gauge as is).
1121 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1122 this.runsRemaining = res.runsRemaining
1124 } catch (error) {
1125 if (isPaymentRequired(error)) {
1126 this.outOfRuns = true
1127 this.openBuyModal()
1128 return false
1130 if (isAtCapacity(error)) {
1131 this.atCapacity = true
1132 return false
1134 console.error('Could not charge the run:', error)
1135 this.error = 'Could not start the run. Please try again.'
1136 return false
1138 this.currentObjective = objective
1139 this.objectiveProgress = 0
1140 this.momentum = 0
1141 this.error = null
1142 return true
1143 },
1145 /**
1146 * Asks the server to compose a fresh objective with the model, WITHOUT
1147 * committing it — the caller previews it, then commits via chooseObjective.
1148 * Generation lives server-side because it needs the OpenAI key (the client
1149 * has no access to it). An optional steer (era + theme, closed enums) biases
1150 * the composition; the server re-validates it against the enums, so a bad
1151 * value is harmless. `avoid` is the titles already composed this session, so
1152 * a reroll lands somewhere new (#95) — the stateless server only knows what
1153 * the client supplies. Returns null rather than throwing if composing fails,
1154 * so the UI can quietly fall back to the curated pool.
1155 */
1156 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GameObjective | null> {
1157 try {
1158 const query: Record<string, string | string[]> = {}
1159 if (steer.era) query.era = steer.era
1160 if (steer.theme) query.theme = steer.theme
1161 if (avoid.length) query.avoid = avoid
1162 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1163 success?: boolean
1164 objective?: GameObjective
1166 return res?.success && res.objective ? res.objective : null
1167 } catch (error) {
1168 console.error('Failed to compose a fresh objective:', error)
1169 return null
1171 },
1173 /**
1174 * Loads the device's run balance for the header gauge + account popover.
1175 * Grants the free trial on a brand-new device (server-side). Graceful: on
1176 * failure it leaves the prior value (the gauge simply doesn't update).
1177 */
1178 async loadBalance(): Promise<void> {
1179 try {
1180 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean }
1181 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1182 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1183 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1184 this.accountEmail = res?.email ?? null
1185 this.accountAnonymous = res?.isAnonymous === true
1186 } catch (error) {
1187 console.error('Failed to load balance:', error)
1189 },
1191 /** Opens the run-packs sales modal, loading the catalog first. */
1192 async openBuyModal(): Promise<void> {
1193 this.buyModalOpen = true
1194 await this.loadPacks()
1195 },
1197 /** Closes the sales modal. */
1198 closeBuyModal(): void {
1199 this.buyModalOpen = false
1200 },
1202 /** Records the Stripe-return outcome and refreshes the balance on success
1203 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1204 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1205 this.purchaseNotice = outcome
1206 this.buyModalOpen = false
1207 if (outcome === 'success') {
1208 this.outOfRuns = false
1209 await this.loadBalance()
1211 },
1213 /** Dismisses the post-purchase notice. */
1214 clearPurchaseNotice(): void {
1215 this.purchaseNotice = null
1216 },
1218 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1219 * result: a transient empty/failed read must not poison the cache and
1220 * strand the modal with zero packs for the rest of the session. */
1221 async loadPacks(): Promise<void> {
1222 if (this.packs.length) return
1223 try {
1224 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1225 const packs = res?.packs ?? []
1226 if (packs.length) this.packs = packs
1227 } catch (error) {
1228 console.error('Failed to load packs:', error)
1230 },
1232 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1233 * to (null on failure). The caller does the redirect. */
1234 async buyPack(packId: string): Promise<string | null> {
1235 try {
1236 const res = await $fetch('/api/checkout', {
1237 method: 'POST',
1238 headers: { 'Content-Type': 'application/json' },
1239 body: { packId }
1240 }) as { url?: string }
1241 return res?.url ?? null
1242 } catch (error) {
1243 console.error('Failed to start checkout:', error)
1244 return null
1246 },
1248 getVictoryEfficiency() {
1249 if (this.gameStatus === 'victory') {
1250 const messagesSaved = this.remainingMessages
1251 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1252 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1254 const efficiencyRating = rateEfficiency(messagesSaved)
1256 return {
1257 messagesSaved,
1258 messagesUsed,
1259 efficiencyPercentage,
1260 efficiencyRating,
1261 isEarlyVictory: messagesSaved > 0
1264 return null
1265 },
1267 /**
1268 * Persist the finished run's snapshot to the player's account, best-effort.
1269 * Captures exactly what the end screen shows — objective, verdict, ledger,
1270 * dispatches, rolls, and the Chronicle epilogue — keyed by the run id, so the
1271 * run survives reload and feeds the read-only "your runs" view. Fires and
1272 * forgets like refreshChronicle: a save failure must never disturb the end
1273 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1274 * is saved; a degraded local id is dropped server-side.
1275 */
1276 async saveRunSnapshot(): Promise<void> {
1277 const epoch = this.runEpoch
1278 const status = this.gameStatus
1279 if (status !== 'victory' && status !== 'defeat') return
1280 const objective = this.currentObjective
1281 const runId = this.runId
1282 if (!objective || !runId) return
1284 const summary = this.gameSummary
1285 const events = this.timelineEvents
1286 // The dispatch keepsake — the player's verbatim messages, paired with
1287 // whom/when they reached (the same zip the end screen renders).
1288 const dispatches = this.messageHistory
1289 .filter((m) => m.sender === 'user')
1290 .map((m, i) => ({
1291 text: m.text,
1292 figure: m.figureName || events[i]?.figureName || 'the past',
1293 era: events[i]?.era || ''
1294 }))
1295 const mark = (m: typeof summary.bestRoll) =>
1296 m && m.diceRoll != null && m.diceOutcome != null
1297 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
1298 : null
1300 const snapshot: RunSnapshot = {
1301 version: RUN_SNAPSHOT_VERSION,
1302 runId,
1303 status,
1304 objective,
1305 objectiveProgress: this.objectiveProgress,
1306 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
1307 totalMessages: TOTAL_MESSAGES,
1308 bestRoll: mark(summary.bestRoll),
1309 worstRoll: mark(summary.worstRoll),
1310 efficiency: this.getVictoryEfficiency(),
1311 dispatches,
1312 timeline: events.map((e) => ({
1313 id: e.id,
1314 figureName: e.figureName,
1315 era: e.era,
1316 headline: e.headline,
1317 detail: e.detail,
1318 diceRoll: e.diceRoll,
1319 diceOutcome: e.diceOutcome,
1320 progressChange: e.progressChange,
1321 valence: e.valence,
1322 craft: e.craft,
1323 anachronism: e.anachronism
1324 })),
1325 chronicle: this.chronicle
1328 try {
1329 const res = (await $fetch('/api/run-save', {
1330 method: 'POST',
1331 headers: { 'Content-Type': 'application/json' },
1332 body: snapshot
1333 })) as { saved?: boolean }
1334 // Mark saved (for the share affordance) only if this run is still current
1335 // and the server actually persisted it (a degraded run returns saved:false).
1336 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true
1337 } catch (error) {
1338 // Saving the run is a nicety; a failure must not break the end screen.
⋯ 51 lines hidden (lines 1339–1389)
1339 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
1341 },
1343 resetGame() {
1344 // Tear the epoch first: anything still in flight belongs to the old run
1345 // and must find no purchase here. (The seq counters deliberately survive.)
1346 this.runEpoch++
1347 this.remainingMessages = TOTAL_MESSAGES
1348 this.messageHistory = []
1349 this.gameStatus = 'playing'
1350 this.isLoading = false
1351 this.error = null
1352 this.lastMessageTime = null
1353 this.isRateLimited = false
1354 // A new run gets a fresh id on its next server call; abandon any
1355 // in-flight mint so a dead run's id can't land in the new run.
1356 this.runId = null
1357 this.runIdInflight = null
1358 this.runSnapshotSaved = false
1359 // The paywall / capacity notices re-check on the next commit.
1360 this.outOfRuns = false
1361 this.atCapacity = false
1362 this.currentObjective = null
1363 this.objectiveProgress = 0
1364 this.momentum = 0
1365 this.timelineEvents = []
1366 this.figures = []
1367 this.activeFigureName = ''
1368 this.figureGrounding = null
1369 this.groundingLoading = false
1370 this.contactWhen = null
1371 this.contactMoment = null
1372 this.figureSuggestions = []
1373 this.suggestionsLoading = false
1374 this.suggestionsFor = ''
1375 this.chronicle = null
1376 this.chronicleLoading = false
1377 this.figureStudy = null
1378 this.studyLoading = false
1379 this.studyFor = ''
1380 this.studyWhen = null
1381 this.archiveResult = null
1382 this.lookupLoading = false
1383 this.moderationNotice = null
1384 this.archiveNotice = null
1385 this.studyNotice = null
1386 this.suggestionsNotice = null

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
supabase/migrations/0006_run_share.sql15 lines · Transact-SQL
1-- Sharing a run (issue #88): an opt-in, per-run, revocable PUBLIC projection of a
2-- saved run. A saved run is private by default (#83); making it public is a separate,
3-- explicit action that mints an UNGUESSABLE token the public page is reached by — never
4-- the run id or the owner subject, so a shared run can't enumerate others or reveal who
5-- played it. Revoking (un-sharing) sets both columns back to null.
6--
7-- share_token — the random handle in the public URL (/r/:token). UNIQUE so a token
8-- maps to exactly one run and the public lookup hits an index. Null =
9-- not shared. Multiple nulls are fine (Postgres unique ignores nulls).
10-- share_name — the optional, self-chosen display name for attribution (NOT the
11-- email / user id). Null = anonymous. Off by default; set only when the
12-- sharer opts in at share time.
13alter table public.run_snapshots
14 add column if not exists share_token text unique,
15 add column if not exists share_name text;

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.

tests/unit/server/utils/run-snapshot-store.spec.ts · 369 lines
tests/unit/server/utils/run-snapshot-store.spec.ts369 lines · TypeScript
⋯ 212 lines hidden (lines 1–212)
1import { describe, it, expect, afterEach } from 'vitest'
2import {
3 InMemoryRunSnapshotStore,
4 SupabaseRunSnapshotStore,
5 runSnapshotStore,
6 resetRunSnapshotStore,
7 toPublicRun
8} from '../../../../server/utils/run-snapshot-store'
9import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '../../../../utils/run-snapshot'
10import { DiceOutcome } from '../../../../utils/dice'
11import { isUuid } from '../../../../server/utils/uuid'
12 
13/**
14 * The run-snapshot store turns a finished run into a durable, owned record. Two
15 * things matter: the round-trip is faithful (save -> list -> load returns the exact
16 * versioned snapshot, scoped to its owner), and the factory picks the adapter by
17 * config — in-memory unless Supabase is wired, which keeps dev + tests dependency-free.
18 */
19const SUBJECT = 'user-abc'
20const OTHER = 'user-xyz'
21 
22function makeSnapshot(runId: string, title = 'Save the Library'): RunSnapshot {
23 return {
24 version: RUN_SNAPSHOT_VERSION,
25 runId,
26 status: 'victory',
27 objective: { title, description: 'Keep the scrolls from the fire.', era: 'Antiquity', icon: '📜' },
28 objectiveProgress: 100,
29 messagesUsed: 3,
30 totalMessages: 5,
31 bestRoll: { diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS },
32 worstRoll: { diceRoll: 4, diceOutcome: DiceOutcome.FAILURE },
33 efficiency: { messagesSaved: 2, messagesUsed: 3, efficiencyPercentage: 40, efficiencyRating: 'Great', isEarlyVictory: true },
34 dispatches: [{ text: 'Hide the scrolls.', figure: 'Hypatia', era: 'Antiquity' }],
35 timeline: [{
36 id: 'evt-1', figureName: 'Hypatia', era: 'Antiquity', headline: 'The scrolls are hidden',
37 detail: 'A copy survives the blaze.', diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS,
38 progressChange: 60, valence: 'positive'
39 }],
40 chronicle: { title: 'The Library Stands', paragraphs: ['It did not burn.', 'The knowledge endured.'] }
41 }
43 
44const RUN_A = '11111111-1111-4111-8111-111111111111'
45const RUN_B = '22222222-2222-4222-8222-222222222222'
46 
47describe('run-snapshot store', () => {
48 afterEach(() => {
49 resetRunSnapshotStore()
50 delete process.env.SUPABASE_URL
51 delete process.env.SUPABASE_SECRET_KEY
52 })
53 
54 // The acceptance round-trip: a saved run lists for its owner and loads back intact,
55 // version and all — the durable record the whole feature rests on.
56 it('InMemoryRunSnapshotStore round-trips save -> list -> load', async () => {
57 const store = new InMemoryRunSnapshotStore()
58 const snap = makeSnapshot(RUN_A)
59 await store.saveRun(SUBJECT, snap)
60 
61 const list = await store.listRuns(SUBJECT)
62 expect(list).toEqual([
63 { runId: RUN_A, status: 'victory', title: 'Save the Library', progress: 100, createdAt: expect.any(String) }
64 ])
65 
66 const loaded = await store.getRun(SUBJECT, RUN_A)
67 expect(loaded).toEqual(snap)
68 expect(loaded?.version).toBe(RUN_SNAPSHOT_VERSION)
69 })
70 
71 it('lists a subject\'s runs newest first', async () => {
72 const store = new InMemoryRunSnapshotStore()
73 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, 'First'))
74 await store.saveRun(SUBJECT, makeSnapshot(RUN_B, 'Second'))
75 const list = await store.listRuns(SUBJECT)
76 expect(list.map((r) => r.title)).toEqual(['Second', 'First'])
77 })
78 
79 it('scopes reads to the owner — another subject sees nothing', async () => {
80 const store = new InMemoryRunSnapshotStore()
81 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
82 expect(await store.listRuns(OTHER)).toEqual([])
83 expect(await store.getRun(OTHER, RUN_A)).toBeNull()
84 })
85 
86 it('overwrites in place on re-save (the post-epilogue second save)', async () => {
87 const store = new InMemoryRunSnapshotStore()
88 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
89 const withEpilogue = makeSnapshot(RUN_A)
90 withEpilogue.chronicle = { title: 'The final telling', paragraphs: ['Now complete.'] }
91 await store.saveRun(SUBJECT, withEpilogue)
92 
93 expect(await store.listRuns(SUBJECT)).toHaveLength(1)
94 expect((await store.getRun(SUBJECT, RUN_A))?.chronicle?.title).toBe('The final telling')
95 })
96 
97 // The durable path's branching logic: stamp the owner onto the run row, upsert the
98 // snapshot, and translate a Supabase {error} envelope into a throw. Fake client.
99 it('SupabaseRunSnapshotStore stamps the owner and upserts the snapshot', async () => {
100 const calls: unknown[] = []
101 const snap = makeSnapshot(RUN_A)
102 const client = {
103 from: (table: string) => ({
104 update: (row: unknown) => ({
105 eq: async (col: string, val: unknown) => {
106 calls.push({ op: 'update', table, row, col, val })
107 return { error: null }
108 }
109 }),
110 upsert: async (row: unknown) => {
111 calls.push({ op: 'upsert', table, row })
112 return { error: null }
113 }
114 })
115 }
116 const store = new SupabaseRunSnapshotStore(client as never)
117 await store.saveRun(SUBJECT, snap)
118 
119 expect(calls).toContainEqual({ op: 'update', table: 'runs', row: { user_id: SUBJECT }, col: 'id', val: RUN_A })
120 expect(calls).toContainEqual({
121 op: 'upsert',
122 table: 'run_snapshots',
123 row: {
124 run_id: RUN_A, user_id: SUBJECT, version: RUN_SNAPSHOT_VERSION,
125 status: 'victory', title: 'Save the Library', progress: 100, data: snap
126 }
127 })
128 })
129 
130 it('SupabaseRunSnapshotStore throws when the snapshot upsert errors', async () => {
131 const client = {
132 from: () => ({
133 update: () => ({ eq: async () => ({ error: null }) }),
134 upsert: async () => ({ error: { message: 'boom' } })
135 })
136 }
137 const store = new SupabaseRunSnapshotStore(client as never)
138 await expect(store.saveRun(SUBJECT, makeSnapshot(RUN_A))).rejects.toThrow(/run_snapshots upsert failed: boom/)
139 })
140 
141 it('SupabaseRunSnapshotStore lists by subject, newest first, mapping rows', async () => {
142 const calls: unknown[][] = []
143 const builder = {
144 select: (cols: string) => { calls.push(['select', cols]); return builder },
145 eq: (col: string, val: unknown) => { calls.push(['eq', col, val]); return builder },
146 order: async (col: string, opts: unknown) => {
147 calls.push(['order', col, opts])
148 return { data: [{ run_id: RUN_A, status: 'defeat', title: 'A run', progress: 80, created_at: '2026-06-17T00:00:00Z' }], error: null }
149 }
150 }
151 const client = { from: (t: string) => { calls.push(['from', t]); return builder } }
152 const store = new SupabaseRunSnapshotStore(client as never)
153 const list = await store.listRuns(SUBJECT)
154 
155 expect(list).toEqual([{ runId: RUN_A, status: 'defeat', title: 'A run', progress: 80, createdAt: '2026-06-17T00:00:00Z' }])
156 expect(calls).toEqual(expect.arrayContaining([['from', 'run_snapshots'], ['eq', 'user_id', SUBJECT], ['order', 'created_at', { ascending: false }]]))
157 })
158 
159 it('SupabaseRunSnapshotStore loads one run filtered by id AND owner', async () => {
160 const calls: unknown[][] = []
161 const snap = makeSnapshot(RUN_A)
162 const builder = {
163 select: (cols: string) => { calls.push(['select', cols]); return builder },
164 eq: (col: string, val: unknown) => { calls.push(['eq', col, val]); return builder },
165 maybeSingle: async () => { calls.push(['maybeSingle']); return { data: { data: snap }, error: null } }
166 }
167 const client = { from: (t: string) => { calls.push(['from', t]); return builder } }
168 const store = new SupabaseRunSnapshotStore(client as never)
169 const loaded = await store.getRun(SUBJECT, RUN_A)
170 
171 expect(loaded).toEqual(snap)
172 expect(calls).toEqual(expect.arrayContaining([['eq', 'run_id', RUN_A], ['eq', 'user_id', SUBJECT], ['maybeSingle']]))
173 })
174 
175 it('getRun returns null when the row is absent (non-owned reads as missing)', async () => {
176 const builder = {
177 select: () => builder,
178 eq: () => builder,
179 maybeSingle: async () => ({ data: null, error: null })
180 }
181 const store = new SupabaseRunSnapshotStore({ from: () => builder } as never)
182 expect(await store.getRun(OTHER, RUN_A)).toBeNull()
183 })
184 
185 it('defaults to the in-memory store when Supabase is unconfigured', () => {
186 resetRunSnapshotStore()
187 expect(runSnapshotStore()).toBeInstanceOf(InMemoryRunSnapshotStore)
188 })
189 
190 it('uses the Supabase store when URL + secret key are both configured', () => {
191 process.env.SUPABASE_URL = 'https://example.supabase.co'
192 process.env.SUPABASE_SECRET_KEY = 'sb_secret_test'
193 resetRunSnapshotStore()
194 expect(runSnapshotStore()).toBeInstanceOf(SupabaseRunSnapshotStore)
195 })
196})
197 
198/**
199 * Sharing a run (issue #88): a private saved run becomes a PUBLIC, read-only projection
200 * at an unguessable token, opt-in and revocable. Two things must hold: the public
201 * projection leaks NO owner identity (no subject, no run id), and the share/un-share flow
202 * is owner-scoped and reversible. The fake Supabase client records the query shapes so the
203 * security-critical ones are pinned — above all that the public lookup is by token ALONE.
204 */
205describe('run-snapshot store — sharing (issue #88)', () => {
206 afterEach(() => {
207 resetRunSnapshotStore()
208 delete process.env.SUPABASE_URL
209 delete process.env.SUPABASE_SECRET_KEY
210 })
211 
212 // --- The public projection: the sanitization boundary ---
213 it('toPublicRun keeps the game record but strips the run id and owner identity', () => {
214 const snap = makeSnapshot(RUN_A)
215 const pub = toPublicRun(snap, null)
216 // The token is the only handle — the run id is redacted.
217 expect(pub.run.runId).toBe('')
218 expect(pub.attribution).toBeNull()
219 // No owning subject and no run id anywhere in the public payload.
220 const json = JSON.stringify(pub)
221 expect(json).not.toContain(SUBJECT)
222 expect(json).not.toContain(RUN_A)
223 // The game record itself is intact (verdict, objective, ledger, epilogue).
224 expect(pub.run.status).toBe('victory')
225 expect(pub.run.objective.title).toBe('Save the Library')
226 expect(pub.run.timeline).toHaveLength(1)
227 expect(pub.run.chronicle?.title).toBe('The Library Stands')
228 })
⋯ 141 lines hidden (lines 229–369)
229 
230 it('toPublicRun trims a display name and nulls a blank one', () => {
231 expect(toPublicRun(makeSnapshot(RUN_A), ' Cleo ').attribution).toBe('Cleo')
232 expect(toPublicRun(makeSnapshot(RUN_A), ' ').attribution).toBeNull()
233 expect(toPublicRun(makeSnapshot(RUN_A), null).attribution).toBeNull()
234 })
235 
236 // --- InMemory share / un-share flow (the round-trip the feature rests on) ---
237 it('shares an owned run and resolves its token to the public projection', async () => {
238 const store = new InMemoryRunSnapshotStore()
239 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
240 
241 const state = await store.shareRun(SUBJECT, RUN_A, 'Cleo')
242 expect(state).not.toBeNull()
243 expect(isUuid(state!.token!)).toBe(true)
244 expect(state!.name).toBe('Cleo')
245 
246 const pub = await store.getPublicRun(state!.token!)
247 expect(pub?.attribution).toBe('Cleo')
248 expect(pub?.run.runId).toBe('')
249 expect(pub?.run.objective.title).toBe('Save the Library')
250 })
251 
252 it('share is owner-scoped — another subject can neither share nor see state', async () => {
253 const store = new InMemoryRunSnapshotStore()
254 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
255 expect(await store.shareRun(OTHER, RUN_A, null)).toBeNull()
256 expect(await store.getShareState(OTHER, RUN_A)).toBeNull()
257 })
258 
259 it('re-sharing keeps the same token (stable URL) and updates the name', async () => {
260 const store = new InMemoryRunSnapshotStore()
261 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
262 const first = await store.shareRun(SUBJECT, RUN_A, null)
263 const second = await store.shareRun(SUBJECT, RUN_A, 'Cleo')
264 expect(second!.token).toBe(first!.token)
265 expect(second!.name).toBe('Cleo')
266 expect((await store.getShareState(SUBJECT, RUN_A))!.name).toBe('Cleo')
267 })
268 
269 it('un-sharing revokes the token — the public link goes dead', async () => {
270 const store = new InMemoryRunSnapshotStore()
271 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
272 const { token } = (await store.shareRun(SUBJECT, RUN_A, 'Cleo'))!
273 await store.unshareRun(SUBJECT, RUN_A)
274 expect(await store.getPublicRun(token!)).toBeNull()
275 expect((await store.getShareState(SUBJECT, RUN_A))!.token).toBeNull()
276 })
277 
278 it('getShareState: token null until shared; null for a run the subject lacks', async () => {
279 const store = new InMemoryRunSnapshotStore()
280 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
281 expect(await store.getShareState(SUBJECT, RUN_A)).toEqual({ token: null, name: null })
282 expect(await store.getShareState(SUBJECT, RUN_B)).toBeNull()
283 })
284 
285 it('getPublicRun returns null for an unknown token', async () => {
286 const store = new InMemoryRunSnapshotStore()
287 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
288 expect(await store.getPublicRun('11111111-2222-4333-8444-555555555555')).toBeNull()
289 })
290 
291 it('a post-epilogue re-save preserves the share (the link survives, gains the epilogue)', async () => {
292 const store = new InMemoryRunSnapshotStore()
293 await store.saveRun(SUBJECT, makeSnapshot(RUN_A))
294 const { token } = (await store.shareRun(SUBJECT, RUN_A, 'Cleo'))!
295 const withEpilogue = makeSnapshot(RUN_A)
296 withEpilogue.chronicle = { title: 'The final telling', paragraphs: ['Now complete.'] }
297 await store.saveRun(SUBJECT, withEpilogue)
298 
299 const pub = await store.getPublicRun(token!)
300 expect(pub?.run.chronicle?.title).toBe('The final telling')
301 expect(pub?.attribution).toBe('Cleo')
302 })
303 
304 // --- Supabase query shapes (the durable path's security-critical filters) ---
305 it('Supabase shareRun mints (only when absent), sets the name, and is owner-scoped', async () => {
306 const calls: unknown[][] = []
307 const client = { from: () => fakeChain({ data: { share_token: 'tok-1', share_name: 'Cleo' }, error: null }, calls) }
308 const store = new SupabaseRunSnapshotStore(client as never)
309 
310 const state = await store.shareRun(SUBJECT, RUN_A, 'Cleo')
311 expect(state).toEqual({ token: 'tok-1', name: 'Cleo' })
312 // The mint is row-atomic and guarded by `share_token IS NULL` — it mints only when
313 // absent, so a re-share keeps the stable URL and concurrent shares can't both mint.
314 expect(calls).toContainEqual(['is', 'share_token', null])
315 const mint = calls.find((c) => c[0] === 'update' && !!(c[1] as Record<string, unknown>)?.share_token)
316 expect(isUuid((mint![1] as { share_token: string }).share_token)).toBe(true)
317 // The name is set and every write is scoped to BOTH the run id and the owner.
318 expect(calls).toContainEqual(['update', { share_name: 'Cleo' }])
319 expect(calls).toEqual(expect.arrayContaining([['eq', 'run_id', RUN_A], ['eq', 'user_id', SUBJECT]]))
320 })
321 
322 it('Supabase shareRun returns null when the subject does not own the run', async () => {
323 const client = { from: () => fakeChain({ data: null, error: null }, []) }
324 const store = new SupabaseRunSnapshotStore(client as never)
325 expect(await store.shareRun(OTHER, RUN_A, null)).toBeNull()
326 })
327 
328 it('Supabase getPublicRun looks up by the token ALONE — never the owner', async () => {
329 const calls: unknown[][] = []
330 const snap = makeSnapshot(RUN_A)
331 const client = { from: () => fakeChain({ data: { data: snap, share_name: 'Cleo' }, error: null }, calls) }
332 const store = new SupabaseRunSnapshotStore(client as never)
333 
334 const pub = await store.getPublicRun('tok-1')
335 expect(pub?.run.runId).toBe('')
336 expect(pub?.attribution).toBe('Cleo')
337 expect(calls).toContainEqual(['eq', 'share_token', 'tok-1'])
338 // Security: the public read must NOT filter by user_id (the page has no subject).
339 expect(calls.some((c) => c[0] === 'eq' && c[1] === 'user_id')).toBe(false)
340 })
341 
342 it('Supabase unshareRun nulls the token + name for the owned run', async () => {
343 const calls: unknown[][] = []
344 const client = { from: () => fakeChain({ data: null, error: null }, calls) }
345 const store = new SupabaseRunSnapshotStore(client as never)
346 await store.unshareRun(SUBJECT, RUN_A)
347 expect(calls).toContainEqual(['update', { share_token: null, share_name: null }])
348 expect(calls).toEqual(expect.arrayContaining([['eq', 'run_id', RUN_A], ['eq', 'user_id', SUBJECT]]))
349 })
350})
351 
352/**
353 * A chainable, awaitable fake of the Supabase query builder. `select`/`update`/`eq` record
354 * their args and return the same builder (so chains of any length work); `maybeSingle` and
355 * awaiting the chain both resolve to `result`. One `result` serves a method that both reads
356 * (via .data) and writes (via .error), which is exactly shareRun's read-then-update shape.
357 */
358function fakeChain(result: { data?: unknown; error: unknown }, calls: unknown[][]) {
359 const builder: Record<string, unknown> = {
360 select: (c: string) => { calls.push(['select', c]); return builder },
361 update: (row: unknown) => { calls.push(['update', row]); return builder },
362 eq: (c: string, v: unknown) => { calls.push(['eq', c, v]); return builder },
363 is: (c: string, v: unknown) => { calls.push(['is', c, v]); return builder },
364 maybeSingle: async () => { calls.push(['maybeSingle']); return result },
365 then: (onF: (v: typeof result) => unknown, onR?: (e: unknown) => unknown) =>
366 Promise.resolve(result).then(onF, onR)
367 }
368 return builder