Overview

Two endpoints — POST /api/run-save and POST /api/run-draft — take a run's UUID straight from the request body and hand it to a store that upserts on the run_id primary key, stamping the caller as owner. The Supabase service key bypasses row-level security, so the run id was the only thing the database checked. A leaked run id (server logs, x-run-id telemetry, a shared device) let any player overwrite another's saved run — hijacking the public /r/:token that still pointed at it — or wipe their resume draft.

The fix splits every write into a first-writer INSERT and an owner-scoped UPDATE, so an existing row is only ever overwritten by the subject who owns it. A different subject is refused with a typed error the API maps to a clean 403. Below: the guard, both stores, the edge, and the tests that only the fix passes.

Why the database won't catch this

The stores connect with the service (secret) key, and these tables carry no RLS policy — server-only by design. That's a deliberate choice, but it means there is no database-level owner check behind the app. A new typed error names the condition the store now guards against, so the API layer can tell a forbidden action apart from a real fault.

server/utils/run-ownership.ts · 18 lines
server/utils/run-ownership.ts18 lines · TypeScript
1/**
2 * Raised when a run write targets a run owned by a DIFFERENT subject — the cross-subject
3 * overwrite guard (#215). The store rejects the write and never touches the owner's row;
4 * the API layer maps it to a 403 (a forbidden action, not a server fault).
5 *
6 * Why this guard has to exist: both run stores (snapshots + drafts) key their durable row
7 * on the run id ALONE, and they write with the Supabase service key, which bypasses RLS.
8 * So the row-id is the only thing the database checks — without an owner check in the app,
9 * a leaked run id would let any subject overwrite another player's saved run (hijacking the
10 * public share URL that still points at it) or wipe their resume draft. This error is how a
11 * store signals "that run isn't yours" so the write is refused, fail-safe.
12 */
13export class RunOwnershipError extends Error {
14 constructor(runId: string) {
15 super(`run ${runId} is owned by another subject`);
16 this.name = "RunOwnershipError";
17 }

The snapshot store: insert-if-new, else owner-scoped update

The in-memory adapter used to reassign existing.subject = subject outright — the hijack in one line. It now refuses a mismatched owner (lines 185–186). The Supabase adapter can't read-then-write safely, so it does the same guard race-safely in two statements: a first-writer INSERT (ON CONFLICT DO NOTHING) claims an unseen id; if the row already exists, an UPDATE scoped to run_id and user_id handles it — matching zero rows for anyone but the owner.

server/utils/run-snapshot-store.ts · 603 lines
server/utils/run-snapshot-store.ts603 lines · TypeScript
⋯ 177 lines hidden (lines 1–177)
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 { RunOwnershipError } from "./run-ownership";
25import { migrateSnapshot } from "~/utils/run-snapshot";
26import type {
27 RunSnapshot,
28 RunSummary,
29 PublicRun,
30 ShareState,
31 RemixCredit,
32} from "~/utils/run-snapshot";
33 
34/** Save-time extras the snapshot blob itself doesn't carry. */
35export interface SaveRunOptions {
36 /** When this run is a replay (issue #89), the SOURCE run's share token — resolved
37 * server-side to the parent's stable run id and stamped as the lineage pointer. The
38 * client only ever holds the public token (the run id is never exposed to it). */
39 derivedFromToken?: string | null;
41 
42export interface RunSnapshotStore {
43 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
44 saveRun(
45 subject: string,
46 snapshot: RunSnapshot,
47 opts?: SaveRunOptions,
48 ): Promise<void>;
49 /** A subject's saved runs, newest first (the list projection). */
50 listRuns(subject: string): Promise<RunSummary[]>;
51 /** Total shared runs (share_token set) across all accounts — the viral top of
52 * funnel. A global operator dashboard read. */
53 sharedRunCount(): Promise<number>;
54 /** One saved run by id, only if `subject` owns it; null otherwise. */
55 getRun(subject: string, runId: string): Promise<RunSnapshot | null>;
56 /**
57 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
58 * if already shared, so the URL is stable), set the attribution name, and return the
59 * live share state. Null when `subject` doesn't own the run (reads as absent).
60 *
61 * `name` is the account's GENERATED username when the owner opts into attribution, or
62 * null for anonymous (issue #117). It is server-resolved — never client-typed — so the
63 * old self-chosen-handle abuse surface is gone; the store just persists the string it's
64 * handed.
65 */
66 shareRun(
67 subject: string,
68 runId: string,
69 name: string | null,
70 ): Promise<ShareState | null>;
71 /**
72 * Re-stamp the attribution name on every one of `subject`'s ALREADY-attributed shares
73 * (issue #117). Called after a reroll so a player's public handle stays consistent
74 * across all their shared runs — the username is an account property, so changing it
75 * follows everywhere it appears. Anonymous shares (name null) are left untouched.
76 */
77 syncAttributedShares(subject: string, username: string): Promise<void>;
78 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
79 unshareRun(subject: string, runId: string): Promise<void>;
80 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
81 getShareState(subject: string, runId: string): Promise<ShareState | null>;
82 /** Resolve a public share token to its sanitized projection; null when unknown. */
83 getPublicRun(token: string): Promise<PublicRun | null>;
84 /** Resolve a share token to its run's OWNER subject — server-internal only (the
85 * referral attribution key, issue #96). Never crosses to a client; the public
86 * projection above is the only thing a viewer ever sees. Null when the token is
87 * unknown / revoked. */
88 ownerSubjectByToken(token: string): Promise<string | null>;
90 
91/** Project a snapshot down to the list row. */
92function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
93 return {
94 runId: snapshot.runId,
95 status: snapshot.status,
96 title: snapshot.objective.title,
97 progress: snapshot.objectiveProgress,
98 createdAt,
99 };
101 
102/**
103 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
104 * already identity-free (the owner lives only in the row's `user_id` column, never in
105 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
106 * share token is the only handle a public viewer gets) and surface only the opt-in
107 * display name. This is the boundary the public endpoint serves — never the raw row.
108 */
109export function toPublicRun(
110 snapshot: RunSnapshot,
111 name: string | null,
112 remixedFrom: RemixCredit | null = null,
113): PublicRun {
114 const trimmed = name?.trim();
115 // Normalize the stored blob to the current shape (an older snapshot may predate a
116 // field, e.g. v2's peakProgress) before it crosses to the public viewer.
117 const run = migrateSnapshot(snapshot);
118 return {
119 run: { ...run, runId: "" },
120 attribution: trimmed ? trimmed : null,
121 remixedFrom,
122 };
124 
125/**
126 * Build the one-hop "remixed from" credit (issue #89) from the PARENT run's live fields.
127 * Only public, opt-in data crosses: the parent's display name (anonymous if blank/unset),
128 * its objective title, and its current share token (for an optional backlink, null when
129 * the parent is no longer shared). Never the parent's run id, owner, or device.
130 */
131function toRemixCredit(
132 parent: {
133 title: string;
134 shareName: string | null;
135 shareToken: string | null;
136 } | null,
137): RemixCredit | null {
138 if (!parent) return null;
139 const name = parent.shareName?.trim();
140 return {
141 attribution: name ? name : null,
142 objectiveTitle: parent.title,
143 sourceToken: parent.shareToken ?? null,
144 };
146 
147/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
148 * external dependency. Not durable across process restarts; the Supabase adapter is
149 * the one that actually preserves history. */
150export class InMemoryRunSnapshotStore implements RunSnapshotStore {
151 private readonly runs: Array<{
152 subject: string;
153 snapshot: RunSnapshot;
154 createdAt: string;
155 shareToken: string | null;
156 shareName: string | null;
157 derivedFromRunId: string | null;
158 }> = [];
159 
160 /** Resolve a source share token to the parent's stable run id (issue #89), guarding
161 * self-derivation. Returns null for an unknown token or no token. */
162 private resolveDerivedRunId(
163 token: string | null | undefined,
164 selfRunId: string,
165 ): string | null {
166 if (!token) return null;
167 const parent = this.runs.find((r) => r.shareToken === token);
168 return parent && parent.snapshot.runId !== selfRunId
169 ? parent.snapshot.runId
170 : null;
171 }
172 
173 async saveRun(
174 subject: string,
175 snapshot: RunSnapshot,
176 opts?: SaveRunOptions,
177 ): Promise<void> {
178 const createdAt = new Date().toISOString();
179 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId);
180 if (existing) {
181 // Snapshots are immutable, but the save fires again once the epilogue lands — the
182 // OWNER overwrites in place (keep the original position/createdAt, and any share
183 // already minted for this run). Owner-scoped (#215): a DIFFERENT subject holding
184 // this run id is refused, never allowed to reassign or overwrite another's run.
185 if (existing.subject !== subject)
186 throw new RunOwnershipError(snapshot.runId);
187 existing.snapshot = snapshot;
188 // Stamp the pointer only on a SUCCESSFUL resolve, and never overwrite one
189 // already set with null: it's stable once stamped, so a later un-share (the
190 // token stops resolving) leaves the lineage intact — only the walked credit
191 // goes anonymous (see getPublicRun).
192 const resolved = this.resolveDerivedRunId(
193 opts?.derivedFromToken,
194 snapshot.runId,
195 );
⋯ 136 lines hidden (lines 196–331)
196 if (resolved) existing.derivedFromRunId = resolved;
197 return;
198 }
199 this.runs.push({
200 subject,
201 snapshot,
202 createdAt,
203 shareToken: null,
204 shareName: null,
205 derivedFromRunId: this.resolveDerivedRunId(
206 opts?.derivedFromToken,
207 snapshot.runId,
208 ),
209 });
210 }
211 
212 async sharedRunCount(): Promise<number> {
213 return this.runs.filter((r) => r.shareToken != null).length;
214 }
215 
216 async listRuns(subject: string): Promise<RunSummary[]> {
217 return this.runs
218 .filter((r) => r.subject === subject)
219 .map((r) => toSummary(r.snapshot, r.createdAt))
220 .reverse();
221 }
222 
223 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
224 const found = this.runs.find(
225 (r) => r.snapshot.runId === runId && r.subject === subject,
226 );
227 return found ? migrateSnapshot(found.snapshot) : null;
228 }
229 
230 async shareRun(
231 subject: string,
232 runId: string,
233 name: string | null,
234 ): Promise<ShareState | null> {
235 const found = this.runs.find(
236 (r) => r.snapshot.runId === runId && r.subject === subject,
237 );
238 if (!found) return null;
239 found.shareToken = found.shareToken ?? randomUUID();
240 found.shareName = name;
241 return { token: found.shareToken, name: found.shareName };
242 }
243 
244 async syncAttributedShares(subject: string, username: string): Promise<void> {
245 for (const run of this.runs) {
246 // Only the subject's already-attributed shares (a name set); anonymous ones
247 // (null) stay anonymous — a reroll never opts a private share into attribution.
248 if (run.subject === subject && run.shareName !== null)
249 run.shareName = username;
250 }
251 }
252 
253 async unshareRun(subject: string, runId: string): Promise<void> {
254 const found = this.runs.find(
255 (r) => r.snapshot.runId === runId && r.subject === subject,
256 );
257 if (!found) return;
258 found.shareToken = null;
259 found.shareName = null;
260 }
261 
262 async getShareState(
263 subject: string,
264 runId: string,
265 ): Promise<ShareState | null> {
266 const found = this.runs.find(
267 (r) => r.snapshot.runId === runId && r.subject === subject,
268 );
269 return found ? { token: found.shareToken, name: found.shareName } : null;
270 }
271 
272 async getPublicRun(token: string): Promise<PublicRun | null> {
273 const found = this.runs.find((r) => r.shareToken === token);
274 if (!found) return null;
275 // Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89),
276 // so the source's current opt-in (name / still-shared) is what surfaces.
277 const parent = found.derivedFromRunId
278 ? (this.runs.find((r) => r.snapshot.runId === found.derivedFromRunId) ??
279 null)
280 : null;
281 const remixedFrom = toRemixCredit(
282 parent
283 ? {
284 title: parent.snapshot.objective.title,
285 shareName: parent.shareName,
286 shareToken: parent.shareToken,
287 }
288 : null,
289 );
290 return toPublicRun(found.snapshot, found.shareName, remixedFrom);
291 }
292 
293 async ownerSubjectByToken(token: string): Promise<string | null> {
294 const found = this.runs.find((r) => r.shareToken === token);
295 return found ? found.subject : null;
296 }
298 
299/** Records each saved run as a `run_snapshots` row via the service (secret) key,
300 * which bypasses RLS — the table has no public policy, so it is server-only. */
301export class SupabaseRunSnapshotStore implements RunSnapshotStore {
302 constructor(private readonly client: SupabaseClient) {}
303 
304 async saveRun(
305 subject: string,
306 snapshot: RunSnapshot,
307 opts?: SaveRunOptions,
308 ): Promise<void> {
309 const row: Record<string, unknown> = {
310 run_id: snapshot.runId,
311 user_id: subject,
312 version: snapshot.version,
313 status: snapshot.status,
314 title: snapshot.objective.title,
315 progress: snapshot.objectiveProgress,
316 data: snapshot,
317 };
318 // Stamp the lineage pointer ONLY for a replay (issue #89): resolve the source's
319 // public share token to the parent's STABLE run id (a token can be revoked / re-
320 // minted; the id can't), guarding self-derivation. The column is set only on a
321 // SUCCESSFUL resolve and is NEVER written as null — so an ordinary run carries no
322 // pointer, and a replay whose source un-shares before the run ends keeps the pointer
323 // an earlier save stamped (the credit then walks to an anonymized parent).
324 if (opts?.derivedFromToken) {
325 const parentId = await this.resolveDerivedRunId(opts.derivedFromToken);
326 if (parentId && parentId !== snapshot.runId)
327 row.derived_from_run_id = parentId;
328 }
329 
330 // Owner-scoped write (#215). The row is keyed on the run id ALONE and the service key
331 // bypasses RLS, so a bare upsert would let any subject holding a leaked run id
332 // overwrite another player's snapshot — reassigning its owner and hijacking the public
333 // share URL that still points at it. Split the upsert into two guarded halves.
334 //
335 // First-writer INSERT (ON CONFLICT DO NOTHING): claims an unseen run id. A returned
336 // row means we created it — nothing to guard, we own it.
337 const inserted = await this.client
338 .from("run_snapshots")
339 .upsert(row, { onConflict: "run_id", ignoreDuplicates: true })
340 .select("run_id");
341 if (inserted.error)
342 throw new Error(`run_snapshots insert failed: ${inserted.error.message}`);
343 
344 if (!inserted.data?.length) {
345 // The run id already exists — overwrite it ONLY if this subject owns it (the
346 // legitimate post-epilogue re-save). Scoped to run_id + user_id, so a DIFFERENT
347 // subject matches no row: the write is refused and the owner's snapshot is left
348 // untouched. The SET carries the mutable snapshot columns only — never user_id /
349 // run_id (the identity) and never share_token / share_name, so a re-save preserves
350 // the live share exactly as before.
351 const update: Record<string, unknown> = {
352 version: snapshot.version,
353 status: snapshot.status,
354 title: snapshot.objective.title,
355 progress: snapshot.objectiveProgress,
356 data: snapshot,
357 };
358 if (row.derived_from_run_id)
359 update.derived_from_run_id = row.derived_from_run_id;
360 const { data, error } = await this.client
361 .from("run_snapshots")
362 .update(update)
363 .eq("run_id", snapshot.runId)
364 .eq("user_id", subject)
365 .select("run_id");
366 if (error)
367 throw new Error(`run_snapshots update failed: ${error.message}`);
368 if (!data?.length) throw new RunOwnershipError(snapshot.runId);
369 }
370 
371 // Stamp the owner onto the billing run row too (it otherwise carries only device_id),
372 // FIRST-WRITER only (`user_id IS NULL`) so it becomes a durable audit fact rather than
373 // a write-only field a later save could flip. Only ever reached by the legitimate
374 // owner — the snapshot write above already refused any cross-subject caller.
375 const owned = await this.client
376 .from("runs")
377 .update({ user_id: subject })
378 .eq("id", snapshot.runId)
379 .is("user_id", null);
380 if (owned.error)
⋯ 223 lines hidden (lines 381–603)
381 throw new Error(`runs owner update failed: ${owned.error.message}`);
382 }
383 
384 async sharedRunCount(): Promise<number> {
385 // Head-count of shared runs (share_token set) — service-key read, all accounts.
386 const { count, error } = await this.client
387 .from("run_snapshots")
388 .select("*", { count: "exact", head: true })
389 .not("share_token", "is", null);
390 if (error) throw new Error(`shared run count failed: ${error.message}`);
391 return count ?? 0;
392 }
393 
394 /** Resolve a source share token to the parent's stable run id (issue #89); null when
395 * the token is unknown / revoked. */
396 private async resolveDerivedRunId(token: string): Promise<string | null> {
397 const { data, error } = await this.client
398 .from("run_snapshots")
399 .select("run_id")
400 .eq("share_token", token)
401 .maybeSingle();
402 if (error)
403 throw new Error(`run_snapshots lineage resolve failed: ${error.message}`);
404 return (data?.run_id as string | null) ?? null;
405 }
406 
407 /** Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89) —
408 * its current title, opt-in name, and share token. Null when there's no parent (not
409 * a replay) or the parent has since been deleted. */
410 private async resolveRemixCredit(
411 parentRunId: string | null,
412 ): Promise<RemixCredit | null> {
413 if (!parentRunId) return null;
414 const { data, error } = await this.client
415 .from("run_snapshots")
416 .select("title, share_name, share_token")
417 .eq("run_id", parentRunId)
418 .maybeSingle();
419 if (error)
420 throw new Error(`run_snapshots remix credit failed: ${error.message}`);
421 if (!data) return null;
422 return toRemixCredit({
423 title: data.title as string,
424 shareName: (data.share_name as string | null) ?? null,
425 shareToken: (data.share_token as string | null) ?? null,
426 });
427 }
428 
429 async listRuns(subject: string): Promise<RunSummary[]> {
430 const { data, error } = await this.client
431 .from("run_snapshots")
432 .select("run_id, status, title, progress, created_at")
433 .eq("user_id", subject)
434 .order("created_at", { ascending: false });
435 if (error) throw new Error(`run_snapshots list failed: ${error.message}`);
436 return (
437 (data ?? []) as Array<{
438 run_id: string;
439 status: RunSnapshot["status"];
440 title: string;
441 progress: number;
442 created_at: string;
443 }>
444 ).map((row) => ({
445 runId: row.run_id,
446 status: row.status,
447 title: row.title,
448 progress: row.progress,
449 createdAt: row.created_at,
450 }));
451 }
452 
453 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
454 const { data, error } = await this.client
455 .from("run_snapshots")
456 .select("data")
457 .eq("run_id", runId)
458 .eq("user_id", subject)
459 .maybeSingle();
460 if (error) throw new Error(`run_snapshots get failed: ${error.message}`);
461 // Normalize an older stored blob (e.g. a v1 row predating v2's peakProgress) so a
462 // saved-run view always reads a current-shape snapshot.
463 return data?.data ? migrateSnapshot(data.data as RunSnapshot) : null;
464 }
465 
466 async shareRun(
467 subject: string,
468 runId: string,
469 name: string | null,
470 ): Promise<ShareState | null> {
471 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
472 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
473 // sees the first's token and matches no row — so re-sharing keeps one stable URL
474 // and never trips the unique constraint. (A wasted uuid when already shared is
475 // harmless: the guard excludes the row, so it's never written.)
476 const minted = await this.client
477 .from("run_snapshots")
478 .update({ share_token: randomUUID() })
479 .eq("run_id", runId)
480 .eq("user_id", subject)
481 .is("share_token", null);
482 if (minted.error)
483 throw new Error(
484 `run_snapshots share mint failed: ${minted.error.message}`,
485 );
486 
487 // Set the name and read back the live token (the freshly-minted one, or a token
488 // from a prior share). Scoped to the owner, so a run the subject doesn't own
489 // matches no row and reads as absent (null) — never shared on their behalf.
490 const { data, error } = await this.client
491 .from("run_snapshots")
492 .update({ share_name: name })
493 .eq("run_id", runId)
494 .eq("user_id", subject)
495 .select("share_token, share_name")
496 .maybeSingle();
497 if (error) throw new Error(`run_snapshots share failed: ${error.message}`);
498 if (!data) return null;
499 return {
500 token: data.share_token as string,
501 name: (data.share_name as string | null) ?? null,
502 };
503 }
504 
505 async syncAttributedShares(subject: string, username: string): Promise<void> {
506 // One row-set UPDATE: re-stamp every attributed share this subject owns. The
507 // `share_name IS NOT NULL` filter leaves anonymous shares untouched, so a reroll
508 // never converts a private share into an attributed one.
509 const { error } = await this.client
510 .from("run_snapshots")
511 .update({ share_name: username })
512 .eq("user_id", subject)
513 .not("share_name", "is", null);
514 if (error)
515 throw new Error(
516 `run_snapshots attribution sync failed: ${error.message}`,
517 );
518 }
519 
520 async unshareRun(subject: string, runId: string): Promise<void> {
521 const { error } = await this.client
522 .from("run_snapshots")
523 .update({ share_token: null, share_name: null })
524 .eq("run_id", runId)
525 .eq("user_id", subject);
526 if (error)
527 throw new Error(`run_snapshots unshare failed: ${error.message}`);
528 }
529 
530 async getShareState(
531 subject: string,
532 runId: string,
533 ): Promise<ShareState | null> {
534 const { data, error } = await this.client
535 .from("run_snapshots")
536 .select("share_token, share_name")
537 .eq("run_id", runId)
538 .eq("user_id", subject)
539 .maybeSingle();
540 if (error)
541 throw new Error(`run_snapshots share state failed: ${error.message}`);
542 if (!data) return null;
543 return {
544 token: (data.share_token as string | null) ?? null,
545 name: (data.share_name as string | null) ?? null,
546 };
547 }
548 
549 async getPublicRun(token: string): Promise<PublicRun | null> {
550 // Looked up by the share token ALONE — no owner filter — because the public page
551 // has no subject. The token is the capability; an unknown/revoked token misses.
552 const { data, error } = await this.client
553 .from("run_snapshots")
554 .select("data, share_name, derived_from_run_id")
555 .eq("share_token", token)
556 .maybeSingle();
557 if (error)
558 throw new Error(`run_snapshots public get failed: ${error.message}`);
559 if (!data) return null;
560 const remixedFrom = await this.resolveRemixCredit(
561 (data.derived_from_run_id as string | null) ?? null,
562 );
563 return toPublicRun(
564 data.data as RunSnapshot,
565 (data.share_name as string | null) ?? null,
566 remixedFrom,
567 );
568 }
569 
570 async ownerSubjectByToken(token: string): Promise<string | null> {
571 // The owner subject lives only in the row's user_id — read it by token alone
572 // (no owner filter; the public page has no subject), for referral attribution.
573 const { data, error } = await this.client
574 .from("run_snapshots")
575 .select("user_id")
576 .eq("share_token", token)
577 .maybeSingle();
578 if (error)
579 throw new Error(`run_snapshots owner resolve failed: ${error.message}`);
580 return (data?.user_id as string | null) ?? null;
581 }
583 
584let cached: RunSnapshotStore | null = null;
585 
586/** The configured run-snapshot store (memoized — reuses one Supabase client). */
587export function runSnapshotStore(): RunSnapshotStore {
588 if (cached) return cached;
589 const cfg = supabaseConfig();
590 cached = cfg
591 ? new SupabaseRunSnapshotStore(
592 createClient(cfg.url, cfg.secretKey, {
593 auth: { persistSession: false },
594 }),
595 )
596 : new InMemoryRunSnapshotStore();
597 return cached;
599 
600/** Test seam: clear the memoized store so the next call re-reads config. */
601export function resetRunSnapshotStore(): void {
602 cached = null;

The draft store: the same guard

run_drafts is the per-turn autosave, keyed on run_id the same way. Same two halves: first-writer INSERT, then an owner-scoped UPDATE for the returning autosave. A different subject matches no row and is refused — their draft can be neither overwritten nor wiped.

server/utils/run-draft-store.ts · 172 lines
server/utils/run-draft-store.ts172 lines · TypeScript
⋯ 50 lines hidden (lines 1–50)
1/**
2 * The run-draft store — where an IN-PROGRESS run is parked so a refresh or a crash
3 * can't lose it (#152). POST /api/run-draft upserts the draft here keyed by the run
4 * id; GET /api/run-draft reads back the subject's latest in-progress draft to resume;
5 * DELETE /api/run-draft clears it (also cleared on terminal save). The terminal,
6 * immutable record lives in the separate run-snapshot store — a draft is the live,
7 * overwritten mirror, never the saved run.
8 *
9 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
10 * (cf. runSnapshotStore, runStore):
11 * - in-memory (default): no external dependency, so `npm run dev` and the unit tests
12 * need nothing. Drafts don't outlive the process, but the round-trip (save -> read
13 * latest -> delete) is real, which is what the resume path rests on.
14 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Upserts
15 * the `run_drafts` row via the service (secret) key, which bypasses RLS — the table
16 * has no public policy, so it is server-only.
17 *
18 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
19 * same string balances/runs/snapshots key on. Reads filter by it, so a player only
20 * ever resumes their OWN run. A sign-in lands on a fresh or returning account (a
21 * DIFFERENT subject; see utils/sign-in.ts), so an anonymous draft does not follow
22 * into it — the trade utils/sign-in.ts records as acceptable.
23 */
24import { createClient, type SupabaseClient } from "@supabase/supabase-js";
25import { supabaseConfig } from "./supabase";
26import { RunOwnershipError } from "./run-ownership";
27import { migrateDraft, type RunDraft } from "~/utils/run-snapshot";
28 
29export interface RunDraftStore {
30 /** Persist (or overwrite) the in-progress draft, owned by `subject`, keyed by run id. */
31 saveDraft(subject: string, draft: RunDraft): Promise<void>;
32 /** The subject's most-recently-updated draft, or null when they have none. A
33 * player has a single active run (starting one spends a credit), so "latest"
34 * is the one to resume. */
35 getLatestDraft(subject: string): Promise<RunDraft | null>;
36 /** Drop a draft the subject owns. Idempotent; a no-op when absent / not theirs. */
37 deleteDraft(subject: string, runId: string): Promise<void>;
39 
40/** Dev/test default — keeps the round-trip real without any external dependency. A
41 * monotonic tick orders "latest" deterministically (clock ties can't reorder it). */
42export class InMemoryRunDraftStore implements RunDraftStore {
43 private tick = 0;
44 private readonly drafts: Array<{
45 subject: string;
46 runId: string;
47 draft: RunDraft;
48 updatedAt: number;
49 }> = [];
50 
51 async saveDraft(subject: string, draft: RunDraft): Promise<void> {
52 const updatedAt = ++this.tick;
53 const existing = this.drafts.find((d) => d.runId === draft.runId);
54 if (existing) {
55 // Owner-scoped overwrite (#215): the owner re-autosaves in place each turn; a
56 // DIFFERENT subject holding this run id is refused, never allowed to overwrite or
57 // wipe another player's resume draft.
58 if (existing.subject !== subject)
59 throw new RunOwnershipError(draft.runId);
60 existing.draft = draft;
61 existing.updatedAt = updatedAt;
62 return;
63 }
64 this.drafts.push({ subject, runId: draft.runId, draft, updatedAt });
⋯ 23 lines hidden (lines 65–87)
65 }
66 
67 async getLatestDraft(subject: string): Promise<RunDraft | null> {
68 const owned = this.drafts.filter((d) => d.subject === subject);
69 if (!owned.length) return null;
70 return migrateDraft(
71 owned.reduce((a, b) => (b.updatedAt > a.updatedAt ? b : a)).draft,
72 );
73 }
74 
75 async deleteDraft(subject: string, runId: string): Promise<void> {
76 const i = this.drafts.findIndex(
77 (d) => d.runId === runId && d.subject === subject,
78 );
79 if (i >= 0) this.drafts.splice(i, 1);
80 }
82 
83/** Records each draft as a `run_drafts` row via the service (secret) key, which
84 * bypasses RLS — the table has no public policy, so it is server-only. */
85export class SupabaseRunDraftStore implements RunDraftStore {
86 constructor(private readonly client: SupabaseClient) {}
87 
88 async saveDraft(subject: string, draft: RunDraft): Promise<void> {
89 // Owner-scoped write (#215). run_drafts is keyed on the run id ALONE and the service
90 // key bypasses RLS, so a bare upsert would let any subject holding a leaked run id
91 // overwrite — or wipe — another player's resume draft. Split the upsert into two
92 // guarded halves. `updated_at` is stamped explicitly on both (a column default fires
93 // only on insert, not on the update half) so "latest" stays accurate across the run.
94 //
95 // First-writer INSERT (ON CONFLICT DO NOTHING): claims an unseen run id. A returned
96 // row means we created it.
97 const row = {
98 run_id: draft.runId,
99 user_id: subject,
100 version: draft.version,
101 data: draft,
102 updated_at: new Date().toISOString(),
103 };
104 const inserted = await this.client
105 .from("run_drafts")
106 .upsert(row, { onConflict: "run_id", ignoreDuplicates: true })
107 .select("run_id");
108 if (inserted.error)
109 throw new Error(`run_drafts insert failed: ${inserted.error.message}`);
110 if (inserted.data?.length) return;
111 
112 // The run id already exists — overwrite ONLY if this subject owns it (the per-turn
113 // autosave). Scoped to run_id + user_id, so a DIFFERENT subject matches no row: the
114 // write is refused and the owner's draft left untouched.
115 const { data, error } = await this.client
116 .from("run_drafts")
117 .update({
118 version: draft.version,
119 data: draft,
120 updated_at: new Date().toISOString(),
121 })
122 .eq("run_id", draft.runId)
123 .eq("user_id", subject)
124 .select("run_id");
125 if (error) throw new Error(`run_drafts update failed: ${error.message}`);
126 if (!data?.length) throw new RunOwnershipError(draft.runId);
127 }
⋯ 45 lines hidden (lines 128–172)
128 
129 async getLatestDraft(subject: string): Promise<RunDraft | null> {
130 const { data, error } = await this.client
131 .from("run_drafts")
132 .select("data")
133 .eq("user_id", subject)
134 .order("updated_at", { ascending: false })
135 .limit(1)
136 .maybeSingle();
137 if (error) throw new Error(`run_drafts get failed: ${error.message}`);
138 // Drop a draft from a bygone RUN_DRAFT_VERSION rather than rehydrate an old shape
139 // as if current — the read-side check the write-side stamp always implied (#257).
140 return data?.data ? migrateDraft(data.data as RunDraft) : null;
141 }
142 
143 async deleteDraft(subject: string, runId: string): Promise<void> {
144 const { error } = await this.client
145 .from("run_drafts")
146 .delete()
147 .eq("run_id", runId)
148 .eq("user_id", subject);
149 if (error) throw new Error(`run_drafts delete failed: ${error.message}`);
150 }
152 
153let cached: RunDraftStore | null = null;
154 
155/** The configured run-draft store (memoized — reuses one Supabase client). */
156export function runDraftStore(): RunDraftStore {
157 if (cached) return cached;
158 const cfg = supabaseConfig();
159 cached = cfg
160 ? new SupabaseRunDraftStore(
161 createClient(cfg.url, cfg.secretKey, {
162 auth: { persistSession: false },
163 }),
164 )
165 : new InMemoryRunDraftStore();
166 return cached;
168 
169/** Test seam: clear the memoized store so the next call re-reads config. */
170export function resetRunDraftStore(): void {
171 cached = null;

A clean 403 at the edge

The store throws RunOwnershipError; the endpoint maps it to a 403 (a forbidden action, not a server fault) and lets every other error keep its 500. run-draft.post.ts carries the identical branch.

server/api/run-save.post.ts · 68 lines
server/api/run-save.post.ts68 lines · TypeScript
⋯ 39 lines hidden (lines 1–39)
1/**
2 * POST /api/run-save — persist a finished run's snapshot, owned by the account
3 * subject. The client posts the end-of-run snapshot on victory/defeat; the server
4 * validates it, then stores it keyed by the run id so it survives reload and seeds
5 * the read-only "your runs" view.
6 *
7 * Best-effort by design: the client fires this without blocking the end screen, so a
8 * failure here degrades history, never the game. A degraded run (a non-uuid local id,
9 * never recorded server-side) has nothing to own — it's accepted as a no-op.
10 */
11import { sanitizeSnapshot } from "~/server/utils/run-snapshot";
12import { runSnapshotStore } from "~/server/utils/run-snapshot-store";
13import { RunOwnershipError } from "~/server/utils/run-ownership";
14import { balanceStore } from "~/server/utils/balance-store";
15import { currentSubject } from "~/server/utils/auth";
16import { isUuid } from "~/server/utils/uuid";
17 
18export default defineEventHandler(async (event) => {
19 if (getMethod(event) !== "POST") {
20 throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
21 }
22 const body = await readBody(event);
23 const snapshot = sanitizeSnapshot(body);
24 if (!snapshot) {
25 throw createError({
26 statusCode: 400,
27 statusMessage: "Bad Request: invalid run snapshot",
28 });
29 }
30 // A degraded (non-uuid local) run was never recorded server-side — nothing to own.
31 if (!isUuid(snapshot.runId)) {
32 return { saved: false, degraded: true };
33 }
34 // Replay lineage (issue #89): a sibling field, NOT part of the snapshot blob — the
35 // source run's public share token, which the server resolves to the parent's stable
36 // run id. uuid-gated so a malformed value is simply ignored (lineage is best-effort).
37 const rawToken =
38 typeof body?.remixedFromToken === "string" ? body.remixedFromToken : "";
39 const derivedFromToken = isUuid(rawToken) ? rawToken : undefined;
40 const subject = await currentSubject(event);
41 try {
42 await runSnapshotStore().saveRun(subject, snapshot, { derivedFromToken });
43 } catch (error) {
44 // A save aimed at a run owned by another subject (#215): refuse cleanly — the owner's
45 // snapshot is untouched. It's a forbidden action, not a server fault, so 403 not 500.
46 if (error instanceof RunOwnershipError) {
47 throw createError({
48 statusCode: 403,
49 statusMessage: "Forbidden: run owned by another subject",
50 });
51 }
52 console.error("Failed to save run snapshot:", error);
53 throw createError({
54 statusCode: 500,
55 statusMessage: "Internal Server Error",
56 });
57 }
58 // Mark the billing run ended so a finished (victory/defeat — sanitizeSnapshot enforces
59 // it) run can't take further turns even with budget to spare (#212). Best-effort +
60 // owner-scoped: a failure here just leaves the turn/aux caps as the backstop, and never
⋯ 8 lines hidden (lines 61–68)
61 // fails the best-effort history save.
62 try {
63 await balanceStore().endRun(subject, snapshot.runId);
64 } catch (error) {
65 console.error("Failed to mark run ended:", error);
66 }
67 return { saved: true };
68});

What only the fix passes

Each adapter gets three discriminating tests: a different subject can't overwrite another's run (and the owner's snapshot, list membership, and public share all still serve the original); the owner's re-save still lands; a first-writer insert works for a brand-new run. The in-memory hijack-rejection test is the clearest read — it asserts the victim's data survives.

tests/unit/server/utils/run-snapshot-store.spec.ts · 974 lines
tests/unit/server/utils/run-snapshot-store.spec.ts974 lines · TypeScript
⋯ 171 lines hidden (lines 1–171)
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 {
10 RUN_SNAPSHOT_VERSION,
11 type RunSnapshot,
12} from "../../../../utils/run-snapshot";
13import { RunOwnershipError } from "../../../../server/utils/run-ownership";
14import { DiceOutcome } from "../../../../utils/dice";
15import { isUuid } from "../../../../server/utils/uuid";
16 
17/**
18 * The run-snapshot store turns a finished run into a durable, owned record. Two
19 * things matter: the round-trip is faithful (save -> list -> load returns the exact
20 * versioned snapshot, scoped to its owner), and the factory picks the adapter by
21 * config — in-memory unless Supabase is wired, which keeps dev + tests dependency-free.
22 */
23const SUBJECT = "user-abc";
24const OTHER = "user-xyz";
25 
26function makeSnapshot(runId: string, title = "Save the Library"): RunSnapshot {
27 return {
28 version: RUN_SNAPSHOT_VERSION,
29 runId,
30 status: "victory",
31 objective: {
32 title,
33 description: "Keep the scrolls from the fire.",
34 era: "Antiquity",
35 icon: "📜",
36 },
37 objectiveProgress: 100,
38 peakProgress: 100,
39 messagesUsed: 3,
40 totalMessages: 5,
41 bestRoll: { diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS },
42 worstRoll: { diceRoll: 4, diceOutcome: DiceOutcome.FAILURE },
43 efficiency: {
44 messagesSaved: 2,
45 messagesUsed: 3,
46 efficiencyPercentage: 40,
47 efficiencyRating: "Great",
48 isEarlyVictory: true,
49 },
50 dispatches: [
51 { text: "Hide the scrolls.", figure: "Hypatia", era: "Antiquity" },
52 ],
53 timeline: [
54 {
55 id: "evt-1",
56 figureName: "Hypatia",
57 era: "Antiquity",
58 headline: "The scrolls are hidden",
59 detail: "A copy survives the blaze.",
60 diceRoll: 19,
61 diceOutcome: DiceOutcome.CRITICAL_SUCCESS,
62 progressChange: 60,
63 valence: "positive",
64 },
65 ],
66 chronicle: {
67 title: "The Library Stands",
68 paragraphs: ["It did not burn.", "The knowledge endured."],
69 },
70 };
72 
73const RUN_A = "11111111-1111-4111-8111-111111111111";
74const RUN_B = "22222222-2222-4222-8222-222222222222";
75 
76describe("run-snapshot store", () => {
77 afterEach(() => {
78 resetRunSnapshotStore();
79 delete process.env.SUPABASE_URL;
80 delete process.env.SUPABASE_SECRET_KEY;
81 });
82 
83 // The acceptance round-trip: a saved run lists for its owner and loads back intact,
84 // version and all — the durable record the whole feature rests on.
85 it("InMemoryRunSnapshotStore round-trips save -> list -> load", async () => {
86 const store = new InMemoryRunSnapshotStore();
87 const snap = makeSnapshot(RUN_A);
88 await store.saveRun(SUBJECT, snap);
89 
90 const list = await store.listRuns(SUBJECT);
91 expect(list).toEqual([
92 {
93 runId: RUN_A,
94 status: "victory",
95 title: "Save the Library",
96 progress: 100,
97 createdAt: expect.any(String),
98 },
99 ]);
100 
101 const loaded = await store.getRun(SUBJECT, RUN_A);
102 expect(loaded).toEqual(snap);
103 expect(loaded?.version).toBe(RUN_SNAPSHOT_VERSION);
104 });
105 
106 // A v1 blob as it sits in storage from before #151: peakProgress was live-session-only,
107 // so the persisted record has none (and version 1). A read must normalize it to the
108 // current shape, defaulting peak to the final progress (peak == final → "no peak shown"),
109 // so a pre-existing saved/shared run renders exactly as it did before the field existed.
110 function legacyV1(runId: string): RunSnapshot {
111 const blob: Record<string, unknown> = {
112 ...makeSnapshot(runId),
113 version: 1,
114 status: "defeat",
115 objectiveProgress: 40,
116 efficiency: null,
117 };
118 delete blob.peakProgress;
119 return blob as unknown as RunSnapshot;
120 }
121 
122 it("getRun migrates a v1 snapshot, defaulting its peak to the final progress", async () => {
123 const store = new InMemoryRunSnapshotStore();
124 await store.saveRun(SUBJECT, legacyV1(RUN_A));
125 const loaded = await store.getRun(SUBJECT, RUN_A);
126 expect(loaded?.version).toBe(RUN_SNAPSHOT_VERSION);
127 expect(loaded?.peakProgress).toBe(40);
128 });
129 
130 it("getPublicRun migrates a v1 snapshot the same way (the public read path)", async () => {
131 const store = new InMemoryRunSnapshotStore();
132 await store.saveRun(SUBJECT, legacyV1(RUN_A));
133 const state = await store.shareRun(SUBJECT, RUN_A, null);
134 const pub = await store.getPublicRun(state!.token!);
135 expect(pub?.run.version).toBe(RUN_SNAPSHOT_VERSION);
136 expect(pub?.run.peakProgress).toBe(40);
137 });
138 
139 it("lists a subject's runs newest first", async () => {
140 const store = new InMemoryRunSnapshotStore();
141 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "First"));
142 await store.saveRun(SUBJECT, makeSnapshot(RUN_B, "Second"));
143 const list = await store.listRuns(SUBJECT);
144 expect(list.map((r) => r.title)).toEqual(["Second", "First"]);
145 });
146 
147 it("scopes reads to the owner — another subject sees nothing", async () => {
148 const store = new InMemoryRunSnapshotStore();
149 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
150 expect(await store.listRuns(OTHER)).toEqual([]);
151 expect(await store.getRun(OTHER, RUN_A)).toBeNull();
152 });
153 
154 it("overwrites in place on re-save (the post-epilogue second save)", async () => {
155 const store = new InMemoryRunSnapshotStore();
156 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
157 const withEpilogue = makeSnapshot(RUN_A);
158 withEpilogue.chronicle = {
159 title: "The final telling",
160 paragraphs: ["Now complete."],
161 };
162 await store.saveRun(SUBJECT, withEpilogue);
163 
164 expect(await store.listRuns(SUBJECT)).toHaveLength(1);
165 expect((await store.getRun(SUBJECT, RUN_A))?.chronicle?.title).toBe(
166 "The final telling",
167 );
168 });
169 
170 // The ownership guard (#215): a leaked run id must NOT let another subject overwrite an
171 // owned run. The owner keeps their snapshot, their list membership, and their live share.
172 it("refuses another subject overwriting an owned run — the owner's data survives (#215)", async () => {
173 const store = new InMemoryRunSnapshotStore();
174 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Owner's run"));
175 const { token } = (await store.shareRun(SUBJECT, RUN_A, "Cleo"))!;
176 
177 await expect(
178 store.saveRun(OTHER, makeSnapshot(RUN_A, "Hijacked")),
179 ).rejects.toBeInstanceOf(RunOwnershipError);
180 
181 // The owner's snapshot, list, and public share all still serve the ORIGINAL content.
182 expect((await store.getRun(SUBJECT, RUN_A))?.objective.title).toBe(
183 "Owner's run",
184 );
185 expect((await store.listRuns(SUBJECT)).map((r) => r.title)).toEqual([
186 "Owner's run",
187 ]);
188 expect((await store.getPublicRun(token!))?.run.objective.title).toBe(
189 "Owner's run",
190 );
191 // ...and the attacker owns nothing.
192 expect(await store.listRuns(OTHER)).toEqual([]);
193 });
194 
195 // The durable path, first write: a brand-new run id is claimed by the first-writer INSERT
196 // (ON CONFLICT DO NOTHING, keyed on run_id), and the billing-row owner stamp is guarded
⋯ 65 lines hidden (lines 197–261)
197 // FIRST-WRITER (`user_id IS NULL`) so it can never flip an already-set owner.
198 it("SupabaseRunSnapshotStore first-writer INSERTs a new run and stamps the billing owner", async () => {
199 const calls: unknown[][] = [];
200 const snap = makeSnapshot(RUN_A);
201 const client = fakeWriteClient(
202 [
203 { data: [{ run_id: RUN_A }], error: null }, // insert claimed the new run id
204 { error: null }, // billing-row owner stamp
205 ],
206 calls,
207 );
208 const store = new SupabaseRunSnapshotStore(client as never);
209 await store.saveRun(SUBJECT, snap);
210 
211 expect(calls).toContainEqual([
212 "upsert",
213 {
214 run_id: RUN_A,
215 user_id: SUBJECT,
216 version: RUN_SNAPSHOT_VERSION,
217 status: "victory",
218 title: "Save the Library",
219 progress: 100,
220 data: snap,
221 },
222 { onConflict: "run_id", ignoreDuplicates: true },
223 ]);
224 // The billing-row stamp is scoped to the id AND guarded by `user_id IS NULL`.
225 expect(calls).toContainEqual(["from", "runs"]);
226 expect(calls).toContainEqual(["update", { user_id: SUBJECT }]);
227 expect(calls).toContainEqual(["eq", "id", RUN_A]);
228 expect(calls).toContainEqual(["is", "user_id", null]);
229 });
230 
231 // The durable path, re-save: the OWNER overwrites an existing run in place (the post-
232 // epilogue second save) via an owner-scoped UPDATE whose SET never touches the share
233 // columns — so a live share survives the re-save.
234 it("SupabaseRunSnapshotStore lets the owner re-save an existing run, owner-scoped, share preserved", async () => {
235 const calls: unknown[][] = [];
236 const client = fakeWriteClient(
237 [
238 { data: [], error: null }, // insert: run id already exists (conflict, no row)
239 { data: [{ run_id: RUN_A }], error: null }, // owner-scoped update matches
240 { error: null }, // billing stamp (no-op: already owned)
241 ],
242 calls,
243 );
244 const store = new SupabaseRunSnapshotStore(client as never);
245 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
246 
247 const update = calls.find(
248 (c) => c[0] === "update" && !!(c[1] as Record<string, unknown>).data,
249 );
250 expect(update).toBeTruthy();
251 expect(update![1]).not.toHaveProperty("share_token");
252 expect(update![1]).not.toHaveProperty("share_name");
253 expect(update![1]).not.toHaveProperty("user_id");
254 // Scoped to BOTH the run id and the owner — the crux of the guard.
255 expect(calls).toContainEqual(["eq", "run_id", RUN_A]);
256 expect(calls).toContainEqual(["eq", "user_id", SUBJECT]);
257 });
258 
259 // The guard on the durable path: the run id exists but is owned by someone else, so the
260 // owner-scoped UPDATE matches no row → RunOwnershipError, and the billing row is never
261 // reached (no cross-subject stamp).
262 it("SupabaseRunSnapshotStore refuses a cross-subject overwrite (#215)", async () => {
263 const calls: unknown[][] = [];
264 const client = fakeWriteClient(
265 [
266 { data: [], error: null }, // insert: conflict, nothing inserted
267 { data: [], error: null }, // owner-scoped update: no row for THIS subject
268 ],
269 calls,
270 );
271 const store = new SupabaseRunSnapshotStore(client as never);
272 await expect(
273 store.saveRun(OTHER, makeSnapshot(RUN_A)),
274 ).rejects.toBeInstanceOf(RunOwnershipError);
275 // The overwrite was scoped to the caller's OWN subject (so it matched nothing)...
276 expect(calls).toContainEqual(["eq", "run_id", RUN_A]);
277 expect(calls).toContainEqual(["eq", "user_id", OTHER]);
278 // ...and it never fell through to touch the billing `runs` row.
279 expect(calls).not.toContainEqual(["from", "runs"]);
280 });
281 
⋯ 693 lines hidden (lines 282–974)
282 it("SupabaseRunSnapshotStore throws when the snapshot insert errors", async () => {
283 const store = new SupabaseRunSnapshotStore(
284 fakeWriteClient([{ error: { message: "boom" } }], []) as never,
285 );
286 await expect(store.saveRun(SUBJECT, makeSnapshot(RUN_A))).rejects.toThrow(
287 /run_snapshots insert failed: boom/,
288 );
289 });
290 
291 it("SupabaseRunSnapshotStore lists by subject, newest first, mapping rows", async () => {
292 const calls: unknown[][] = [];
293 const builder = {
294 select: (cols: string) => {
295 calls.push(["select", cols]);
296 return builder;
297 },
298 eq: (col: string, val: unknown) => {
299 calls.push(["eq", col, val]);
300 return builder;
301 },
302 order: async (col: string, opts: unknown) => {
303 calls.push(["order", col, opts]);
304 return {
305 data: [
306 {
307 run_id: RUN_A,
308 status: "defeat",
309 title: "A run",
310 progress: 80,
311 created_at: "2026-06-17T00:00:00Z",
312 },
313 ],
314 error: null,
315 };
316 },
317 };
318 const client = {
319 from: (t: string) => {
320 calls.push(["from", t]);
321 return builder;
322 },
323 };
324 const store = new SupabaseRunSnapshotStore(client as never);
325 const list = await store.listRuns(SUBJECT);
326 
327 expect(list).toEqual([
328 {
329 runId: RUN_A,
330 status: "defeat",
331 title: "A run",
332 progress: 80,
333 createdAt: "2026-06-17T00:00:00Z",
334 },
335 ]);
336 expect(calls).toEqual(
337 expect.arrayContaining([
338 ["from", "run_snapshots"],
339 ["eq", "user_id", SUBJECT],
340 ["order", "created_at", { ascending: false }],
341 ]),
342 );
343 });
344 
345 it("SupabaseRunSnapshotStore loads one run filtered by id AND owner", async () => {
346 const calls: unknown[][] = [];
347 const snap = makeSnapshot(RUN_A);
348 const builder = {
349 select: (cols: string) => {
350 calls.push(["select", cols]);
351 return builder;
352 },
353 eq: (col: string, val: unknown) => {
354 calls.push(["eq", col, val]);
355 return builder;
356 },
357 maybeSingle: async () => {
358 calls.push(["maybeSingle"]);
359 return { data: { data: snap }, error: null };
360 },
361 };
362 const client = {
363 from: (t: string) => {
364 calls.push(["from", t]);
365 return builder;
366 },
367 };
368 const store = new SupabaseRunSnapshotStore(client as never);
369 const loaded = await store.getRun(SUBJECT, RUN_A);
370 
371 expect(loaded).toEqual(snap);
372 expect(calls).toEqual(
373 expect.arrayContaining([
374 ["eq", "run_id", RUN_A],
375 ["eq", "user_id", SUBJECT],
376 ["maybeSingle"],
377 ]),
378 );
379 });
380 
381 it("getRun returns null when the row is absent (non-owned reads as missing)", async () => {
382 const builder = {
383 select: () => builder,
384 eq: () => builder,
385 maybeSingle: async () => ({ data: null, error: null }),
386 };
387 const store = new SupabaseRunSnapshotStore({
388 from: () => builder,
389 } as never);
390 expect(await store.getRun(OTHER, RUN_A)).toBeNull();
391 });
392 
393 it("defaults to the in-memory store when Supabase is unconfigured", () => {
394 resetRunSnapshotStore();
395 expect(runSnapshotStore()).toBeInstanceOf(InMemoryRunSnapshotStore);
396 });
397 
398 it("uses the Supabase store when URL + secret key are both configured", () => {
399 process.env.SUPABASE_URL = "https://example.supabase.co";
400 process.env.SUPABASE_SECRET_KEY = "sb_secret_test";
401 resetRunSnapshotStore();
402 expect(runSnapshotStore()).toBeInstanceOf(SupabaseRunSnapshotStore);
403 });
404});
405 
406/**
407 * Sharing a run (issue #88): a private saved run becomes a PUBLIC, read-only projection
408 * at an unguessable token, opt-in and revocable. Two things must hold: the public
409 * projection leaks NO owner identity (no subject, no run id), and the share/un-share flow
410 * is owner-scoped and reversible. The fake Supabase client records the query shapes so the
411 * security-critical ones are pinned — above all that the public lookup is by token ALONE.
412 */
413describe("run-snapshot store — sharing (issue #88)", () => {
414 afterEach(() => {
415 resetRunSnapshotStore();
416 delete process.env.SUPABASE_URL;
417 delete process.env.SUPABASE_SECRET_KEY;
418 });
419 
420 // --- The public projection: the sanitization boundary ---
421 it("toPublicRun keeps the game record but strips the run id and owner identity", () => {
422 const snap = makeSnapshot(RUN_A);
423 const pub = toPublicRun(snap, null);
424 // The token is the only handle — the run id is redacted.
425 expect(pub.run.runId).toBe("");
426 expect(pub.attribution).toBeNull();
427 // No owning subject and no run id anywhere in the public payload.
428 const json = JSON.stringify(pub);
429 expect(json).not.toContain(SUBJECT);
430 expect(json).not.toContain(RUN_A);
431 // The game record itself is intact (verdict, objective, ledger, epilogue).
432 expect(pub.run.status).toBe("victory");
433 expect(pub.run.objective.title).toBe("Save the Library");
434 expect(pub.run.timeline).toHaveLength(1);
435 expect(pub.run.chronicle?.title).toBe("The Library Stands");
436 });
437 
438 it("toPublicRun trims a display name and nulls a blank one", () => {
439 expect(toPublicRun(makeSnapshot(RUN_A), " Cleo ").attribution).toBe(
440 "Cleo",
441 );
442 expect(toPublicRun(makeSnapshot(RUN_A), " ").attribution).toBeNull();
443 expect(toPublicRun(makeSnapshot(RUN_A), null).attribution).toBeNull();
444 });
445 
446 // --- InMemory share / un-share flow (the round-trip the feature rests on) ---
447 it("shares an owned run and resolves its token to the public projection", async () => {
448 const store = new InMemoryRunSnapshotStore();
449 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
450 
451 const state = await store.shareRun(SUBJECT, RUN_A, "Cleo");
452 expect(state).not.toBeNull();
453 expect(isUuid(state!.token!)).toBe(true);
454 expect(state!.name).toBe("Cleo");
455 
456 const pub = await store.getPublicRun(state!.token!);
457 expect(pub?.attribution).toBe("Cleo");
458 expect(pub?.run.runId).toBe("");
459 expect(pub?.run.objective.title).toBe("Save the Library");
460 });
461 
462 it("share is owner-scoped — another subject can neither share nor see state", async () => {
463 const store = new InMemoryRunSnapshotStore();
464 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
465 expect(await store.shareRun(OTHER, RUN_A, null)).toBeNull();
466 expect(await store.getShareState(OTHER, RUN_A)).toBeNull();
467 });
468 
469 it("re-sharing keeps the same token (stable URL) and updates the name", async () => {
470 const store = new InMemoryRunSnapshotStore();
471 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
472 const first = await store.shareRun(SUBJECT, RUN_A, null);
473 const second = await store.shareRun(SUBJECT, RUN_A, "Cleo");
474 expect(second!.token).toBe(first!.token);
475 expect(second!.name).toBe("Cleo");
476 expect((await store.getShareState(SUBJECT, RUN_A))!.name).toBe("Cleo");
477 });
478 
479 it("un-sharing revokes the token — the public link goes dead", async () => {
480 const store = new InMemoryRunSnapshotStore();
481 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
482 const { token } = (await store.shareRun(SUBJECT, RUN_A, "Cleo"))!;
483 await store.unshareRun(SUBJECT, RUN_A);
484 expect(await store.getPublicRun(token!)).toBeNull();
485 expect((await store.getShareState(SUBJECT, RUN_A))!.token).toBeNull();
486 });
487 
488 it("getShareState: token null until shared; null for a run the subject lacks", async () => {
489 const store = new InMemoryRunSnapshotStore();
490 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
491 expect(await store.getShareState(SUBJECT, RUN_A)).toEqual({
492 token: null,
493 name: null,
494 });
495 expect(await store.getShareState(SUBJECT, RUN_B)).toBeNull();
496 });
497 
498 it("getPublicRun returns null for an unknown token", async () => {
499 const store = new InMemoryRunSnapshotStore();
500 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
501 expect(
502 await store.getPublicRun("11111111-2222-4333-8444-555555555555"),
503 ).toBeNull();
504 });
505 
506 it("a post-epilogue re-save preserves the share (the link survives, gains the epilogue)", async () => {
507 const store = new InMemoryRunSnapshotStore();
508 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
509 const { token } = (await store.shareRun(SUBJECT, RUN_A, "Cleo"))!;
510 const withEpilogue = makeSnapshot(RUN_A);
511 withEpilogue.chronicle = {
512 title: "The final telling",
513 paragraphs: ["Now complete."],
514 };
515 await store.saveRun(SUBJECT, withEpilogue);
516 
517 const pub = await store.getPublicRun(token!);
518 expect(pub?.run.chronicle?.title).toBe("The final telling");
519 expect(pub?.attribution).toBe("Cleo");
520 });
521 
522 // --- Supabase query shapes (the durable path's security-critical filters) ---
523 it("Supabase shareRun mints (only when absent), sets the name, and is owner-scoped", async () => {
524 const calls: unknown[][] = [];
525 const client = {
526 from: () =>
527 fakeChain(
528 { data: { share_token: "tok-1", share_name: "Cleo" }, error: null },
529 calls,
530 ),
531 };
532 const store = new SupabaseRunSnapshotStore(client as never);
533 
534 const state = await store.shareRun(SUBJECT, RUN_A, "Cleo");
535 expect(state).toEqual({ token: "tok-1", name: "Cleo" });
536 // The mint is row-atomic and guarded by `share_token IS NULL` — it mints only when
537 // absent, so a re-share keeps the stable URL and concurrent shares can't both mint.
538 expect(calls).toContainEqual(["is", "share_token", null]);
539 const mint = calls.find(
540 (c) =>
541 c[0] === "update" && !!(c[1] as Record<string, unknown>)?.share_token,
542 );
543 expect(isUuid((mint![1] as { share_token: string }).share_token)).toBe(
544 true,
545 );
546 // The name is set and every write is scoped to BOTH the run id and the owner.
547 expect(calls).toContainEqual(["update", { share_name: "Cleo" }]);
548 expect(calls).toEqual(
549 expect.arrayContaining([
550 ["eq", "run_id", RUN_A],
551 ["eq", "user_id", SUBJECT],
552 ]),
553 );
554 });
555 
556 it("Supabase shareRun returns null when the subject does not own the run", async () => {
557 const client = { from: () => fakeChain({ data: null, error: null }, []) };
558 const store = new SupabaseRunSnapshotStore(client as never);
559 expect(await store.shareRun(OTHER, RUN_A, null)).toBeNull();
560 });
561 
562 it("Supabase getPublicRun looks up by the token ALONE — never the owner", async () => {
563 const calls: unknown[][] = [];
564 const snap = makeSnapshot(RUN_A);
565 const client = {
566 from: () =>
567 fakeChain(
568 { data: { data: snap, share_name: "Cleo" }, error: null },
569 calls,
570 ),
571 };
572 const store = new SupabaseRunSnapshotStore(client as never);
573 
574 const pub = await store.getPublicRun("tok-1");
575 expect(pub?.run.runId).toBe("");
576 expect(pub?.attribution).toBe("Cleo");
577 expect(calls).toContainEqual(["eq", "share_token", "tok-1"]);
578 // Security: the public read must NOT filter by user_id (the page has no subject).
579 expect(calls.some((c) => c[0] === "eq" && c[1] === "user_id")).toBe(false);
580 });
581 
582 it("Supabase unshareRun nulls the token + name for the owned run", async () => {
583 const calls: unknown[][] = [];
584 const client = {
585 from: () => fakeChain({ data: null, error: null }, calls),
586 };
587 const store = new SupabaseRunSnapshotStore(client as never);
588 await store.unshareRun(SUBJECT, RUN_A);
589 expect(calls).toContainEqual([
590 "update",
591 { share_token: null, share_name: null },
592 ]);
593 expect(calls).toEqual(
594 expect.arrayContaining([
595 ["eq", "run_id", RUN_A],
596 ["eq", "user_id", SUBJECT],
597 ]),
598 );
599 });
600});
601 
602/**
603 * Attribution sync (issue #117): the username is an ACCOUNT property, so a reroll has to
604 * follow everywhere the handle appears in public. syncAttributedShares re-stamps every one
605 * of a subject's ALREADY-attributed shares with the new handle — and leaves anonymous
606 * shares anonymous (a reroll never opts a private share into attribution) and other
607 * subjects untouched.
608 */
609describe("run-snapshot store — attribution sync (issue #117)", () => {
610 const RUN_C = "33333333-3333-4333-8333-333333333333";
611 
612 afterEach(() => {
613 resetRunSnapshotStore();
614 delete process.env.SUPABASE_URL;
615 delete process.env.SUPABASE_SECRET_KEY;
616 });
617 
618 it("re-stamps the subject's attributed shares, leaving anonymous + other subjects alone", async () => {
619 const store = new InMemoryRunSnapshotStore();
620 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
621 await store.saveRun(SUBJECT, makeSnapshot(RUN_B));
622 await store.saveRun(OTHER, makeSnapshot(RUN_C));
623 await store.shareRun(SUBJECT, RUN_A, "Old Handle"); // attributed
624 await store.shareRun(SUBJECT, RUN_B, null); // anonymous
625 await store.shareRun(OTHER, RUN_C, "Other Handle"); // a different account, attributed
626 
627 await store.syncAttributedShares(SUBJECT, "New Handle");
628 
629 expect((await store.getShareState(SUBJECT, RUN_A))!.name).toBe(
630 "New Handle",
631 ); // re-stamped
632 expect((await store.getShareState(SUBJECT, RUN_B))!.name).toBeNull(); // stayed anonymous
633 expect((await store.getShareState(OTHER, RUN_C))!.name).toBe(
634 "Other Handle",
635 ); // untouched
636 });
637 
638 it("Supabase syncAttributedShares updates only attributed rows, owner-scoped", async () => {
639 const calls: unknown[][] = [];
640 const client = {
641 from: () => fakeChain({ data: null, error: null }, calls),
642 };
643 const store = new SupabaseRunSnapshotStore(client as never);
644 
645 await store.syncAttributedShares(SUBJECT, "New Handle");
646 expect(calls).toContainEqual(["update", { share_name: "New Handle" }]);
647 expect(calls).toContainEqual(["eq", "user_id", SUBJECT]);
648 // `share_name IS NOT NULL` — anonymous shares are never re-stamped.
649 expect(calls).toContainEqual(["not", "share_name", "is", null]);
650 // And never scoped to a single run id — it's the subject's whole attributed set.
651 expect(calls.some((c) => c[0] === "eq" && c[1] === "run_id")).toBe(false);
652 });
653});
654 
655/**
656 * Replay lineage (issue #89): a replayed run is stamped with a pointer back to the run it
657 * was seeded from, and the "remixed from" credit is WALKED one hop at read time — so it
658 * always reflects the source's CURRENT opt-in (a later un-share drops the name + backlink),
659 * and never copies identity into the child. These pin: the pointer round-trips, the credit
660 * honors privacy, and the durable path resolves a token to the parent's stable run id.
661 */
662describe("run-snapshot store — replay lineage (issue #89)", () => {
663 afterEach(() => {
664 resetRunSnapshotStore();
665 delete process.env.SUPABASE_URL;
666 delete process.env.SUPABASE_SECRET_KEY;
667 });
668 
669 // The core round-trip: a child seeded from a shared source carries the credit, walked
670 // from the SOURCE's live row — its name, its objective title, and a backlink token.
671 it("a replay stamps the pointer; getPublicRun walks one hop to the source credit", async () => {
672 const store = new InMemoryRunSnapshotStore();
673 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Save the Library"));
674 const source = await store.shareRun(SUBJECT, RUN_A, "Cleo");
675 
676 await store.saveRun(OTHER, makeSnapshot(RUN_B, "A remix"), {
677 derivedFromToken: source!.token!,
678 });
679 const child = await store.shareRun(OTHER, RUN_B, "Remixer");
680 
681 const pub = await store.getPublicRun(child!.token!);
682 expect(pub?.remixedFrom).toEqual({
683 attribution: "Cleo",
684 objectiveTitle: "Save the Library",
685 sourceToken: source!.token,
686 });
687 // The credit carries no parent run id and no owning subject — only public, opt-in data.
688 const json = JSON.stringify(pub?.remixedFrom);
689 expect(json).not.toContain(RUN_A);
690 expect(json).not.toContain(SUBJECT);
691 });
692 
693 it("an anonymous source is credited anonymously (no name invented or leaked)", async () => {
694 const store = new InMemoryRunSnapshotStore();
695 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Save the Library"));
696 const source = await store.shareRun(SUBJECT, RUN_A, null); // shared, but no display name
697 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
698 derivedFromToken: source!.token!,
699 });
700 const child = await store.shareRun(OTHER, RUN_B, null);
701 
702 const pub = await store.getPublicRun(child!.token!);
703 expect(pub?.remixedFrom).toEqual({
704 attribution: null,
705 objectiveTitle: "Save the Library",
706 sourceToken: source!.token,
707 });
708 });
709 
710 // Privacy carries forward: it's a POINTER, not a copy — revoking the source's share
711 // drops the credit back to anonymous and removes the backlink, even on existing replays.
712 it("un-sharing the source anonymizes the credit and drops the backlink", async () => {
713 const store = new InMemoryRunSnapshotStore();
714 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Save the Library"));
715 const source = await store.shareRun(SUBJECT, RUN_A, "Cleo");
716 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
717 derivedFromToken: source!.token!,
718 });
719 const child = await store.shareRun(OTHER, RUN_B, null);
720 
721 await store.unshareRun(SUBJECT, RUN_A); // the source stops sharing
722 
723 const pub = await store.getPublicRun(child!.token!);
724 expect(pub?.remixedFrom).toEqual({
725 attribution: null,
726 objectiveTitle: "Save the Library",
727 sourceToken: null,
728 });
729 });
730 
731 it("an original (non-replay) run has no credit", async () => {
732 const store = new InMemoryRunSnapshotStore();
733 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
734 const { token } = (await store.shareRun(SUBJECT, RUN_A, "Cleo"))!;
735 expect((await store.getPublicRun(token!))?.remixedFrom).toBeNull();
736 });
737 
738 it("a post-epilogue re-save WITHOUT the token preserves the stamped pointer", async () => {
739 const store = new InMemoryRunSnapshotStore();
740 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Save the Library"));
741 const source = await store.shareRun(SUBJECT, RUN_A, "Cleo");
742 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
743 derivedFromToken: source!.token!,
744 });
745 // The epilogue re-save fires without the lineage token; it must not erase it.
746 await store.saveRun(OTHER, makeSnapshot(RUN_B));
747 const child = await store.shareRun(OTHER, RUN_B, null);
748 
749 expect(
750 (await store.getPublicRun(child!.token!))?.remixedFrom?.attribution,
751 ).toBe("Cleo");
752 });
753 
754 // The pointer is STABLE once stamped: a source un-sharing mid-run must not erase the
755 // lineage (only the walked credit anonymizes). Guards the null-clobber regression.
756 it("a stamped pointer survives the source un-sharing mid-run (re-save never nulls it)", async () => {
757 const store = new InMemoryRunSnapshotStore();
758 await store.saveRun(SUBJECT, makeSnapshot(RUN_A, "Save the Library"));
759 const source = await store.shareRun(SUBJECT, RUN_A, "Cleo");
760 // First save stamps the pointer while the source is shared...
761 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
762 derivedFromToken: source!.token!,
763 });
764 // ...the source then un-shares, so the token no longer resolves...
765 await store.unshareRun(SUBJECT, RUN_A);
766 // ...and the epilogue re-save (still carrying the now-dead token) must NOT erase it.
767 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
768 derivedFromToken: source!.token!,
769 });
770 const child = await store.shareRun(OTHER, RUN_B, null);
771 
772 expect((await store.getPublicRun(child!.token!))?.remixedFrom).toEqual({
773 attribution: null,
774 objectiveTitle: "Save the Library",
775 sourceToken: null,
776 });
777 });
778 
779 it("self-derivation is ignored (a run can never be its own parent)", async () => {
780 const store = new InMemoryRunSnapshotStore();
781 await store.saveRun(SUBJECT, makeSnapshot(RUN_A));
782 const { token } = (await store.shareRun(SUBJECT, RUN_A, "Cleo"))!;
783 // Re-save the same run pointing at its OWN token — the guard nulls it out.
784 await store.saveRun(SUBJECT, makeSnapshot(RUN_A), {
785 derivedFromToken: token!,
786 });
787 expect((await store.getPublicRun(token!))?.remixedFrom).toBeNull();
788 });
789 
790 // Durable path: the token is resolved to the parent's STABLE run id (never trusting a
791 // client-supplied id) and stamped on the first-writer INSERT row.
792 it("Supabase saveRun resolves the source token to a stable parent id and stamps it", async () => {
793 const PARENT = "33333333-3333-4333-8333-333333333333";
794 const TOKEN = "44444444-4444-4444-8444-444444444444";
795 const calls: unknown[][] = [];
796 const client = fakeWriteClient(
797 [
798 { data: { run_id: PARENT }, error: null }, // lineage resolve
799 { data: [{ run_id: RUN_B }], error: null }, // first-writer insert
800 { error: null }, // billing stamp
801 ],
802 calls,
803 );
804 const store = new SupabaseRunSnapshotStore(client as never);
805 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
806 derivedFromToken: TOKEN,
807 });
808 
809 expect(calls).toContainEqual(["eq", "share_token", TOKEN]);
810 const upsert = calls.find((c) => c[0] === "upsert") as [
811 string,
812 Record<string, unknown>,
813 unknown,
814 ];
815 expect(upsert[1].derived_from_run_id).toBe(PARENT);
816 });
817 
818 it("Supabase saveRun never writes a null pointer (omits the column when the token misses)", async () => {
819 const calls: unknown[][] = [];
820 const client = fakeWriteClient(
821 [
822 { data: null, error: null }, // lineage resolve: the token resolves to nothing
823 { data: [{ run_id: RUN_B }], error: null }, // first-writer insert
824 { error: null }, // billing stamp
825 ],
826 calls,
827 );
828 const store = new SupabaseRunSnapshotStore(client as never);
829 await store.saveRun(OTHER, makeSnapshot(RUN_B), {
830 derivedFromToken: "44444444-4444-4444-8444-444444444444",
831 });
832 
833 const upsert = calls.find((c) => c[0] === "upsert") as [
834 string,
835 Record<string, unknown>,
836 unknown,
837 ];
838 // Never null — a missing resolve leaves the column out so a prior pointer survives.
839 expect(upsert[1]).not.toHaveProperty("derived_from_run_id");
840 });
841 
842 it("Supabase getPublicRun walks one hop for the credit, filtered by run id never owner", async () => {
843 const results = [
844 {
845 data: {
846 data: makeSnapshot(RUN_B),
847 share_name: "Remixer",
848 derived_from_run_id: RUN_A,
849 },
850 error: null,
851 }, // child
852 {
853 data: {
854 title: "Save the Library",
855 share_name: "Cleo",
856 share_token: "parent-tok",
857 },
858 error: null,
859 }, // parent
860 ];
861 const calls: unknown[][] = [];
862 const builder: Record<string, unknown> = {
863 select: (c: string) => {
864 calls.push(["select", c]);
865 return builder;
866 },
867 eq: (c: string, v: unknown) => {
868 calls.push(["eq", c, v]);
869 return builder;
870 },
871 maybeSingle: async () => results.shift(),
872 };
873 const store = new SupabaseRunSnapshotStore({
874 from: () => builder,
875 } as never);
876 
877 const pub = await store.getPublicRun("child-tok");
878 expect(pub?.run.runId).toBe(""); // still sanitized
879 expect(pub?.remixedFrom).toEqual({
880 attribution: "Cleo",
881 objectiveTitle: "Save the Library",
882 sourceToken: "parent-tok",
883 });
884 // The second hop resolves by the parent RUN ID; the public read never filters by owner.
885 expect(calls).toContainEqual(["eq", "run_id", RUN_A]);
886 expect(calls.some((c) => c[0] === "eq" && c[1] === "user_id")).toBe(false);
887 });
888});
889 
890/**
891 * A chainable, awaitable fake of the Supabase query builder. `select`/`update`/`eq` record
892 * their args and return the same builder (so chains of any length work); `maybeSingle` and
893 * awaiting the chain both resolve to `result`. One `result` serves a method that both reads
894 * (via .data) and writes (via .error), which is exactly shareRun's read-then-update shape.
895 */
896function fakeChain(
897 result: { data?: unknown; error: unknown },
898 calls: unknown[][],
899) {
900 const builder: Record<string, unknown> = {
901 select: (c: string) => {
902 calls.push(["select", c]);
903 return builder;
904 },
905 update: (row: unknown) => {
906 calls.push(["update", row]);
907 return builder;
908 },
909 eq: (c: string, v: unknown) => {
910 calls.push(["eq", c, v]);
911 return builder;
912 },
913 is: (c: string, v: unknown) => {
914 calls.push(["is", c, v]);
915 return builder;
916 },
917 not: (c: string, op: string, v: unknown) => {
918 calls.push(["not", c, op, v]);
919 return builder;
920 },
921 maybeSingle: async () => {
922 calls.push(["maybeSingle"]);
923 return result;
924 },
925 then: (onF: (v: typeof result) => unknown, onR?: (e: unknown) => unknown) =>
926 Promise.resolve(result).then(onF, onR),
927 };
928 return builder;
930 
931/**
932 * A write-path fake: like fakeChain, but it hands back a QUEUE of results — one per awaited
933 * statement, consumed in call order. The owner-scoped saveRun runs up to four statements
934 * (lineage resolve, first-writer insert, owner-scoped update, billing stamp), each needing
935 * its own {data,error}; a single fixed result can't express "insert conflicts, THEN the
936 * owner-update matches / misses". Records every builder call so a test can pin the
937 * security-critical filters (`eq user_id`, the first-writer `is user_id null`).
938 */
939function fakeWriteClient(
940 results: Array<{ data?: unknown; error?: unknown }>,
941 calls: unknown[][],
942) {
943 const builder: Record<string, unknown> = {
944 upsert: (row: unknown, opts: unknown) => {
945 calls.push(["upsert", row, opts]);
946 return builder;
947 },
948 update: (row: unknown) => {
949 calls.push(["update", row]);
950 return builder;
951 },
952 select: (c: string) => {
953 calls.push(["select", c]);
954 return builder;
955 },
956 eq: (c: string, v: unknown) => {
957 calls.push(["eq", c, v]);
958 return builder;
959 },
960 is: (c: string, v: unknown) => {
961 calls.push(["is", c, v]);
962 return builder;
963 },
964 maybeSingle: async () => results.shift(),
965 then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) =>
966 Promise.resolve(results.shift()).then(onF, onR),
967 };
968 return {
969 from: (t: string) => {
970 calls.push(["from", t]);
971 return builder;
972 },
973 };