Overview

Issue #83 โ€” save a finished run and let players revisit it โ€” already shipped its substance: durable, subject-owned run snapshots (run_snapshots), the read-only "your runs" list and detail, a typed and versioned snapshot contract, and the same-id carry-over when an anonymous player creates a new account. This PR closes the issue's last two items: the discriminating test for that carry-over, and a final call on the existing-account path.

The whole diff is two files: a new unit test, and a four-line doc edit. Blast radius is nil for runtime behavior โ€” no production code path changes. What changes is what's proven (a test) and what's promised (a comment).

The mechanism the test rests on

A run is owned by its account SUBJECT, and the read routes resolve that subject with currentSubject. Carry-over across signup is therefore not a data migration โ€” it falls out of one fact: attaching an email to an anonymous user keeps the same Supabase user id (sub), so currentSubject returns the same string before and after.

server/utils/auth.ts ยท 64 lines
server/utils/auth.ts64 lines ยท TypeScript
โ‹ฏ 39 lines hidden (lines 1โ€“39)
1/**
2 * The account subject โ€” who a run/balance belongs to.
3 *
4 * Accounts are Supabase Auth users: a brand-new visitor gets an ANONYMOUS user
5 * (a real user id, no email) so the balance keys on a durable id from the start;
6 * a magic-link sign-in upgrades that same user in place, so the balance carries
7 * over with no migration. When there's no session (provider off, or a client
8 * without JS), we fall back to the opaque device cookie, so the app keeps working
9 * exactly as before. The balance store and run guard key on whatever this returns.
10 *
11 * Important distinction: NO session and a FAILED auth lookup are different. No
12 * session โ†’ device fallback (normal). A failed lookup (a real cookie present but
13 * verification/provider errored) must NOT silently downgrade a signed-in user to
14 * their device id โ€” that would read the wrong balance and, worse, strand a
15 * purchase under the device key. So `currentUser` only returns null for a genuine
16 * no-session, and THROWS on a real error; callers decide (reads fall back, the
17 * money path fails loudly).
18 */
19import type { H3Event } from 'h3'
20import { parseCookies } from 'h3'
21import { serverSupabaseUser } from '#supabase/server'
22import { deviceId } from '~/server/utils/device'
23 
24/** A normalized view of the signed-in account, decoupled from the module's exact
25 * return shape (it gives the decoded JWT claims: `sub`, `email`, `is_anonymous`). */
26export interface Account {
27 id: string
28 email: string | null
29 isAnonymous: boolean
31 
32/** True when a Supabase auth session cookie is present. @supabase/ssr stores the
33 * session under `sb-<ref>-auth-token` (chunked as `.0`, `.1`). Checking for it
34 * lets us return "no session" WITHOUT calling the auth server โ€” so a thrown error
35 * unambiguously means a real failure, not just an absent session. */
36function hasSessionCookie(event: H3Event): boolean {
37 return Object.keys(parseCookies(event)).some((n) => n.startsWith('sb-') && n.includes('auth-token'))
39 
40/**
41 * The current Supabase account, or null when there's NO session. Throws on a real
42 * auth failure (cookie present but verification/provider errored) โ€” do not swallow
43 * that as "anonymous/absent".
44 */
45export async function currentUser(event: H3Event): Promise<Account | null> {
46 if (!hasSessionCookie(event)) return null
47 const claims = (await serverSupabaseUser(event)) as { id?: string; sub?: string; email?: string; is_anonymous?: boolean } | null
48 const id = claims?.id ?? claims?.sub
49 if (!id) return null
50 return { id, email: claims?.email ?? null, isAnonymous: claims?.is_anonymous ?? false }
52 
53/** The subject id the balance + runs key on: the Supabase user id when there's a
54 * session, else the device-cookie id. READ-safe: a transient auth error falls back
55 * to the device id rather than failing the read (the money path uses currentUser
56 * directly so an error there fails loudly instead of charging the wrong subject). */
57export async function currentSubject(event: H3Event): Promise<string> {
58 try {
59 const user = await currentUser(event)
60 return user?.id ?? deviceId(event)
61 } catch {
62 return deviceId(event)
63 }

The carry-over test

The new spec wires the REAL currentSubject and the REAL InMemoryRunSnapshotStore โ€” not mocks of the thing under test โ€” and drives the Supabase user per phase. Test 1 is the AC3 promise; test 2 is the existing-account non-goal.

tests/unit/server/account-linking.spec.ts ยท 105 lines
tests/unit/server/account-linking.spec.ts105 lines ยท TypeScript
1import { describe, it, expect, vi, beforeEach } from 'vitest'
2import type { H3Event } from 'h3'
3 
4// currentSubject reads the Supabase user off the request; drive it per-test (the unit
5// env otherwise aliases #supabase/server to a null-returning stub). Mirrors the mocks
6// in auth.spec.ts so the subject resolution under test is the real thing.
7const { mockUser } = vi.hoisted(() => ({ mockUser: vi.fn() }))
8vi.mock('#supabase/server', () => ({ serverSupabaseUser: mockUser }))
9vi.mock('~/server/utils/device', () => ({ deviceId: () => 'device-xyz' }))
10 
11import { currentSubject } from '~/server/utils/auth'
12import { InMemoryRunSnapshotStore } from '~/server/utils/run-snapshot-store'
13import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '~/utils/run-snapshot'
14import { DiceOutcome } from '~/utils/dice'
15 
16/**
17 * Account linking for saved runs (issue #83). A finished run is owned by the account
18 * SUBJECT, and the read routes resolve that subject with `currentSubject` โ€” exactly
19 * what these tests wire together (the real subject resolution over the real store).
20 *
21 * Two facts the "your runs survive signup" promise rests on:
22 * - The NEW-account sign-in attaches an email to the SAME anonymous user (`updateUser`
23 * keeps `sub`), so the subject id is unchanged and a run saved while anonymous
24 * carries into the new account for free โ€” no migration.
25 * - Signing into a DIFFERENT, pre-existing account is a different subject, which does
26 * not inherit the anonymous run. Re-keying a finished run across accounts is a
27 * deliberate non-goal โ€” it stays owned by the id that played it.
28 */
29const SB_COOKIE = 'sb-ref-auth-token=tok'
30const evt = (): H3Event =>
31 ({ node: { req: { headers: { cookie: SB_COOKIE } } } }) as unknown as H3Event
32 
33const RUN = '11111111-1111-4111-8111-111111111111'
34 
35function makeSnapshot(runId: string): RunSnapshot {
36 return {
37 version: RUN_SNAPSHOT_VERSION,
38 runId,
39 status: 'victory',
40 objective: { title: 'Save the Library', description: 'Keep the scrolls from the fire.', era: 'Antiquity', icon: '\u{1F4DC}' },
41 objectiveProgress: 100,
42 messagesUsed: 3,
43 totalMessages: 5,
44 bestRoll: { diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS },
45 worstRoll: null,
46 efficiency: null,
47 dispatches: [{ text: 'Hide the scrolls.', figure: 'Hypatia', era: 'Antiquity' }],
48 timeline: [
49 {
50 id: 'evt-1',
51 figureName: 'Hypatia',
52 era: 'Antiquity',
53 headline: 'The scrolls are hidden',
54 detail: 'A copy survives the blaze.',
55 diceRoll: 19,
56 diceOutcome: DiceOutcome.CRITICAL_SUCCESS,
57 progressChange: 60,
58 valence: 'positive'
59 }
60 ],
61 chronicle: { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
62 }
64 
65beforeEach(() => mockUser.mockReset())
66 
67describe('saved-run account linking (issue #83)', () => {
68 it('carries an anonymous run into a NEW account โ€” the in-place email upgrade keeps the same subject', async () => {
69 const store = new InMemoryRunSnapshotStore()
70 
71 // Anonymous play: a real (anonymous) Supabase user owns the run from turn one.
72 mockUser.mockResolvedValue({ sub: 'u1', is_anonymous: true })
73 const anonSubject = await currentSubject(evt())
74 await store.saveRun(anonSubject, makeSnapshot(RUN))
75 
76 // New-account sign-in attaches an email to that SAME user (updateUser keeps `sub`).
77 mockUser.mockResolvedValue({ sub: 'u1', email: 'player@example.com', is_anonymous: false })
78 const upgradedSubject = await currentSubject(evt())
79 
80 // Pin to the authenticated user id, not the 'device-xyz' fallback: a regression
81 // that dropped the session and fell back to the device id would resolve both
82 // phases to the same device id and silently pass, so assert the id explicitly.
83 expect(anonSubject).toBe('u1')
84 expect(upgradedSubject).toBe('u1')
85 expect((await store.listRuns(upgradedSubject)).map((r) => r.runId)).toEqual([RUN])
86 expect(await store.getRun(upgradedSubject, RUN)).not.toBeNull()
87 })
88 
89 it('does NOT leak an anonymous run to a DIFFERENT, pre-existing account (no cross-account merge)', async () => {
90 const store = new InMemoryRunSnapshotStore()
91 
92 mockUser.mockResolvedValue({ sub: 'u1', is_anonymous: true })
93 const anonSubject = await currentSubject(evt())
94 await store.saveRun(anonSubject, makeSnapshot(RUN))
95 
96 // Returning player signs into a pre-existing account -> a different subject id.
97 mockUser.mockResolvedValue({ sub: 'u2', email: 'player@example.com', is_anonymous: false })
98 const existingSubject = await currentSubject(evt())
99 
100 expect(anonSubject).toBe('u1')
101 expect(existingSubject).toBe('u2')
102 expect(await store.listRuns(existingSubject)).toEqual([])
103 expect(await store.getRun(existingSubject, RUN)).toBeNull()
104 })
105})

The doc cleanup

The only non-test change. sign-in.ts previously framed the anonymous -> existing-account re-key as deferred future work ('left behind', 'a separate change', 'the same deferral applies...'). The game is pre-launch, so that cross-account merge is a deliberate non-goal, not a to-do. The comment now states the returning-player behavior as fact.

utils/sign-in.ts ยท 80 lines
utils/sign-in.ts80 lines ยท TypeScript
1/**
2 * Requesting a sign-in link for the run-store account.
3 *
4 * A visitor is already an anonymous Supabase user, which splits sign-in into two
5 * cases the form can't tell apart up front:
6 * - NEW player: attach the email to their anonymous user (`updateUser`) so the
7 * account upgrades in place โ€” same id, runs carried over.
8 * - RETURNING player: the email already belongs to an account, so that in-place
9 * upgrade can't apply โ€” Supabase rejects it with `email_exists`. We fall back to
10 * a magic-link sign-in into that existing account (`signInWithOtp`,
11 * `shouldCreateUser: false`, so we never mint a duplicate).
12 *
13 * Either path emails a link back to /play, where `utils/auth-return.ts` finalizes
14 * it. The NEW-player path keeps the same user id, so the run balance AND any saved
15 * runs (the `run_snapshots` records, owned by that id) carry over for free. The
16 * RETURNING-player path signs into the pre-existing account, which has its own
17 * balance and saved runs.
18 */
19 
โ‹ฏ 61 lines hidden (lines 20โ€“80)
20/** The slice of a Supabase `AuthError` we branch on. */
21export interface AuthErrorLike {
22 code?: string
23 status?: number
24 message?: string
26 
27/** The slice of the Supabase auth client this needs โ€” kept local so the util
28 * carries no Nuxt/Supabase runtime imports and unit-tests in the node env. */
29export interface SignInClient {
30 updateUser(
31 attributes: { email: string },
32 options: { emailRedirectTo?: string }
33 ): Promise<{ error: AuthErrorLike | null }>
34 signInWithOtp(
35 credentials: { email: string; options: { emailRedirectTo?: string; shouldCreateUser: boolean } }
36 ): Promise<{ error: AuthErrorLike | null }>
38 
39export type SignInResult =
40 | { status: 'sent' }
41 | { status: 'error'; message: string }
42 
43/** Error codes that all mean "this email already belongs to an account". The set
44 * spans the variants Supabase has used so a version bump can't silently strand a
45 * returning player back on the failure path. */
46const EMAIL_TAKEN_CODES = new Set(['email_exists', 'identity_already_exists', 'user_already_exists'])
47 
48export const isEmailTakenError = (e: AuthErrorLike | null | undefined): boolean =>
49 Boolean(e && EMAIL_TAKEN_CODES.has(e.code ?? ''))
50 
51const RATE_LIMITED = 'Too many requests just now โ€” give it a minute, then try again.'
52const SEND_FAILED = 'Could not send the sign-in link. Please try again.'
53 
54const messageFor = (e: AuthErrorLike): string =>
55 e.code === 'over_email_send_rate_limit' ? RATE_LIMITED : SEND_FAILED
56 
57/**
58 * Sends a sign-in link: upgrades the anonymous user in place when the email is new,
59 * and signs into the existing account when the email is already taken. Returns
60 * whether the link went out, or a player-facing error message.
61 */
62export async function requestSignInLink(
63 email: string,
64 emailRedirectTo: string | undefined,
65 client: SignInClient
66): Promise<SignInResult> {
67 const { error: upgradeError } = await client.updateUser({ email }, { emailRedirectTo })
68 if (!upgradeError) return { status: 'sent' }
69 
70 // Returning player: send a magic link to the account that already owns this email.
71 if (isEmailTakenError(upgradeError)) {
72 const { error: signInError } = await client.signInWithOtp({
73 email,
74 options: { emailRedirectTo, shouldCreateUser: false }
75 })
76 return signInError ? { status: 'error', message: messageFor(signInError) } : { status: 'sent' }
77 }
78 
79 return { status: 'error', message: messageFor(upgradeError) }

Verification

The enforced gates stay green, and an independent adversarial review (three lenses plus a fresh regression-guard check) signed off.

CheckResult
npx vitest run890 pass (incl. 2 new)
npx nuxt typecheckclean
knip (dead-code)no findings on changed files
adversarial reviewSHIP โ€” test is a real guard; deferral fully removed; AC4 satisfied