What changed, and why

Identity was a device cookie. That breaks the moment basic auth comes off: free-run farming, the global spend cap locking out paying customers, and — worst — a purchased balance stranded in a clearable cookie. This adds Supabase accounts: anonymous-first (zero-friction play), sign-in to buy, and it degrades gracefully to the device cookie until the provider is switched on, so it's safe to ship now.

Three things carry the change: how the server resolves "who is this" (with a careful no-session-vs-error distinction), the two new gates it enables (sign-in-to-buy, paid-never-cost-gated), and the client that signs in anonymously then upgrades in place. No schema migration — the balance tables already key on an opaque id; we just feed the user id.

Who is this? — the subject

currentSubject is the id the balance + runs key on: the Supabase user id when there's a session, else the device cookie. The subtle, security-relevant part is the no-session vs failed-lookup distinction. We check for the session cookie first, so "no session" returns null without calling the auth server — which means a thrown error unambiguously signals a real failure, not just an absent session. currentSubject is read-safe (an error falls back to the device id); the money path uses currentUser directly so an error fails loudly instead.

Cookie-gate, then verify; currentUser throws on a real error, currentSubject falls back.

server/utils/auth.ts · 64 lines
server/utils/auth.ts64 lines · TypeScript
⋯ 33 lines hidden (lines 1–33)
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)
⋯ 2 lines hidden (lines 63–64)
63 }

The two gates accounts unlock

Sign-in-to-buy. Checkout 401s an anonymous user, server-side, so a purchased balance is always tied to a recoverable account — never a clearable cookie. It resolves the subject from the same currentUser lookup that may throw, so a Supabase blip fails the checkout rather than stamping the device id into Stripe and stranding the purchase.

401 the anonymous buyer; derive the subject from the same (throwing) lookup.

server/api/checkout.post.ts · 70 lines
server/api/checkout.post.ts70 lines · TypeScript
⋯ 30 lines hidden (lines 1–30)
1/**
2 * POST /api/checkout — start a one-time purchase of a run pack. Builds a Stripe
3 * Checkout session from the SERVER-side packs catalog (never a client-supplied
4 * price or run count) and stamps the device id + run count into the session
5 * metadata, so the webhook credits exactly what was bought to the right device.
6 * Returns { url } for the client to redirect to. 503 when Stripe isn't configured.
7 */
8import { stripeClient } from '~/server/utils/stripe'
9import { currentUser } from '~/server/utils/auth'
10import { deviceId } from '~/server/utils/device'
11import { packById } from '~/server/utils/packs'
12 
13export default defineEventHandler(async (event) => {
14 if (getMethod(event) !== 'POST') {
15 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
16 }
17 const body = await readBody(event)
18 const pack = packById(typeof body?.packId === 'string' ? body.packId : '')
19 if (!pack) {
20 throw createError({ statusCode: 400, statusMessage: 'Unknown pack' })
21 }
22 const stripe = stripeClient()
23 if (!stripe) {
24 throw createError({ statusCode: 503, statusMessage: 'Payments unavailable' })
25 }
26 
27 // Buying requires an account: an anonymous user must sign in (magic link) first
28 // so the purchased balance is tied to a recoverable account, not a clearable
29 // cookie. When auth isn't active (no user — device-fallback mode), buying works
30 // as before with the device id.
31 // currentUser THROWS on a real auth error (vs returns null for no session), so
32 // a Supabase blip here fails the checkout instead of silently stamping the
33 // device id into Stripe and stranding the purchase under the wrong subject.
34 const user = await currentUser(event)
35 if (user?.isAnonymous) {
36 throw createError({ statusCode: 401, statusMessage: 'Sign in to buy runs' })
37 }
38 // Resolve the subject from the SAME lookup — no second verify (no session ⇒
39 // device id, the legacy buying path).
40 const device = user?.id ?? deviceId(event)
41 // Pin the post-payment redirect to the configured canonical origin, NOT the
⋯ 29 lines hidden (lines 42–70)
42 // client-supplied Host header (getRequestURL derives origin from Host), so a
43 // spoofed Host can't route a victim's return to an attacker. Falls back to the
44 // request origin only when unset (local dev).
45 const origin = (useRuntimeConfig().siteUrl as string) || getRequestURL(event).origin
46 try {
47 const session = await stripe.checkout.sessions.create({
48 mode: 'payment',
49 line_items: [
50 {
51 price_data: {
52 currency: 'usd',
53 product_data: { name: `Revisionist — ${pack.label} (${pack.runs} runs)` },
54 unit_amount: pack.priceCents
55 },
56 quantity: 1
57 }
58 ],
59 metadata: { device_id: device, pack_id: pack.id, runs: String(pack.runs) },
60 // Return to the board (the game lives at /play), where the purchase=*
61 // query drives the confirmation + a live balance refresh.
62 success_url: `${origin}/play?purchase=success`,
63 cancel_url: `${origin}/play?purchase=cancel`
64 })
65 return { url: session.url }
66 } catch (error) {
67 console.error('Checkout session creation failed:', error)
68 throw createError({ statusCode: 502, statusMessage: 'Could not start checkout' })
69 }
70})

Paid never cost-gated. The spend cap protects the free-tier AI budget — so it should pause free runs, not paying customers. run-commit exempts any subject who has purchased from the 503, so free-run abuse can't lock out buyers.

The cap gate now also checks hasPurchased — buyers skip it.

server/api/run-commit.post.ts · 59 lines
server/api/run-commit.post.ts59 lines · TypeScript
⋯ 35 lines hidden (lines 1–35)
1/**
2 * POST /api/run-commit — the paywall + capacity gate. The client calls this when
3 * the player commits to an objective. It spends one run from the device's balance
4 * for this runId (once, idempotently), granting the free trial on the device's
5 * first sight. Returns { runsRemaining } when charged, 402 when out of runs, or
6 * 503 when the global spend cap has paused new runs.
7 *
8 * Two gates, both at run START only (in-progress runs keep going): the spend cap
9 * (isOverSpendCap) pauses NEW runs while the window is over the dollar ceiling,
10 * and the per-device balance gates on runs remaining. NOTE the cap bounds run
11 * STARTS, not a run's in-progress AI cost — an in-progress run's dispatches and
12 * its Archive lookups/studies are not cap-gated (a tracked gap).
13 *
14 * A run id that isn't a server-minted UUID means begin-run degraded to a local id;
15 * that run was never recorded, so it isn't gated here.
16 */
17import { currentSubject } from '~/server/utils/auth'
18import { balanceStore } from '~/server/utils/balance-store'
19import { isOverSpendCap } from '~/server/utils/spend-store'
20import { isUuid } from '~/server/utils/uuid'
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)
27 const runId = typeof body?.runId === 'string' ? body.runId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 64) : ''
28 if (!runId) {
29 throw createError({ statusCode: 400, statusMessage: 'Bad Request: runId is required' })
30 }
31 
32 const subject = await currentSubject(event)
33 // A degraded (non-UUID) run was never recorded server-side — don't gate it.
34 if (!isUuid(runId)) return { runsRemaining: -1, degraded: true }
35 
36 // Spend cap gates FREE-tier run starts only. A subject who has ever purchased
37 // is NEVER blocked by the cap — they pre-paid for these runs; the cap protects
38 // the free-tier AI budget, not paying customers (so free-run abuse can't lock
39 // out buyers). Best-effort: fails open on a counter/purchase read error.
40 if (await isOverSpendCap() && !(await balanceStore().hasPurchased(subject))) {
41 throw createError({ statusCode: 503, statusMessage: 'At capacity', data: { atCapacity: true } })
⋯ 18 lines hidden (lines 42–59)
42 }
43 
44 let result
45 try {
46 result = await balanceStore().chargeRun(subject, runId)
47 } catch (error) {
48 console.error('Run commit failed:', error)
49 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
50 }
51 if (!result.charged) {
52 throw createError({
53 statusCode: 402,
54 statusMessage: 'Out of runs',
55 data: { runsRemaining: result.runsRemaining }
56 })
57 }
58 return { runsRemaining: result.runsRemaining }
59})

hasPurchased: any credited session for the subject (both adapters implement it).

server/utils/balance-store.ts · 188 lines
server/utils/balance-store.ts188 lines · TypeScript
⋯ 98 lines hidden (lines 1–98)
1/**
2 * The run balance — the ledger that makes a run a thing you spend. A brand-new
3 * device gets FREE_RUNS for free (the doc's "first run free"); chargeRun spends
4 * one run when the player commits to an objective, once per run. Out of runs →
5 * the commit is refused (the paywall). Packs (Stripe) credit this balance later.
6 *
7 * Same two-adapter shape as the run store: in-memory by default (dev + tests need
8 * nothing), Supabase when configured. Keyed by an opaque device id (a server-set
9 * cookie) until accounts arrive, when a user id will supersede it.
10 *
11 * The Supabase charge goes through one SQL function (spend_run) so the
12 * ensure-grant, the idempotency check, and the decrement are atomic in a single
13 * round-trip — no read-modify-write race. The in-memory adapter mirrors that
14 * logic exactly.
15 */
16import { createClient, type SupabaseClient } from '@supabase/supabase-js'
17import { supabaseConfig } from './supabase'
18 
19/** Runs granted to a brand-new device — the free trial ("first run free").
20 * Mirrored by the spend_run SQL function; keep the two in sync. */
21export const FREE_RUNS = 1
22 
23export interface ChargeResult {
24 /** False only when the device is out of runs (the paywall). */
25 charged: boolean
26 runsRemaining: number
28 
29export interface CreditResult {
30 /** False when this session was already credited (an idempotent no-op). */
31 credited: boolean
32 runsRemaining: number
34 
35export interface BalanceStore {
36 /** Ensure a balance row for the device, granting the free trial on first
37 * sight; returns the current runs remaining. */
38 ensureDevice(deviceId: string): Promise<number>
39 /** Spend one run for the device, once per runId (idempotent — re-committing
40 * the same run never double-charges). charged=false means out of runs. */
41 chargeRun(deviceId: string, runId: string): Promise<ChargeResult>
42 /** Runs remaining for the device (0 if unknown). */
43 remaining(deviceId: string): Promise<number>
44 /** Is this run charged AND owned by this device? The gameplay AI path checks
45 * this before spending tokens, so a forged or unpaid run id can't draw free
46 * generation. */
47 runActiveFor(deviceId: string, runId: string): Promise<boolean>
48 /** Credit runs from a paid pack, idempotently per Stripe session — a retried
49 * webhook never double-credits. */
50 credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult>
51 /** Has this subject ever purchased (≥1 credited pack)? Lets run-commit exempt
52 * paying customers from the free-tier spend-cap gate. */
53 hasPurchased(deviceId: string): Promise<boolean>
55 
56/** Dev default — process-local balances, durable enough for dev. Mirrors the
57 * spend_run SQL function: free grant on first sight, one charge per run. */
58export class InMemoryBalanceStore implements BalanceStore {
59 private readonly balances = new Map<string, number>()
60 // runId → the device that charged it. Idempotency is per (device, run), so a
61 // different device can't ride another's already-charged run for free.
62 private readonly chargedRuns = new Map<string, string>()
63 // Stripe session ids already credited, so a retried webhook is a no-op.
64 private readonly creditedSessions = new Set<string>()
65 // Subjects who have purchased at least once (exempt from the spend-cap gate).
66 private readonly purchasers = new Set<string>()
67 
68 async ensureDevice(deviceId: string): Promise<number> {
69 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
70 return this.balances.get(deviceId) ?? 0
71 }
72 
73 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
74 const remaining = await this.ensureDevice(deviceId)
75 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
76 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
77 this.balances.set(deviceId, remaining - 1)
78 this.chargedRuns.set(runId, deviceId)
79 return { charged: true, runsRemaining: remaining - 1 }
80 }
81 
82 async remaining(deviceId: string): Promise<number> {
83 return this.balances.get(deviceId) ?? 0
84 }
85 
86 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
87 return this.chargedRuns.get(runId) === deviceId
88 }
89 
90 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
91 const current = await this.ensureDevice(deviceId)
92 if (this.creditedSessions.has(sessionId)) return { credited: false, runsRemaining: current }
93 this.creditedSessions.add(sessionId)
94 this.purchasers.add(deviceId)
95 this.balances.set(deviceId, current + runs)
96 return { credited: true, runsRemaining: current + runs }
97 }
98 
99 async hasPurchased(deviceId: string): Promise<boolean> {
100 return this.purchasers.has(deviceId)
101 }
⋯ 87 lines hidden (lines 102–188)
103 
104/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
105export class SupabaseBalanceStore implements BalanceStore {
106 constructor(private readonly client: SupabaseClient) {}
107 
108 async ensureDevice(deviceId: string): Promise<number> {
109 const { error } = await this.client
110 .from('balances')
111 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
112 // 23505 = unique_violation: the row already exists, which is fine.
113 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
114 return this.remaining(deviceId)
115 }
116 
117 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
118 const { data, error } = await this.client.rpc('spend_run', {
119 p_device: deviceId,
120 p_run: runId
121 })
122 if (error) throw new Error(`spend_run failed: ${error.message}`)
123 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
124 return { charged: row.charged, runsRemaining: row.runs_remaining }
125 }
126 
127 async remaining(deviceId: string): Promise<number> {
128 const { data, error } = await this.client
129 .from('balances')
130 .select('runs_remaining')
131 .eq('device_id', deviceId)
132 .maybeSingle()
133 if (error) throw new Error(`balance read failed: ${error.message}`)
134 return (data?.runs_remaining as number | undefined) ?? 0
135 }
136 
137 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
138 const { data, error } = await this.client
139 .from('runs')
140 .select('id')
141 .eq('id', runId)
142 .eq('device_id', deviceId)
143 .not('charged_at', 'is', null)
144 .maybeSingle()
145 if (error) throw new Error(`run check failed: ${error.message}`)
146 return !!data
147 }
148 
149 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
150 const { data, error } = await this.client.rpc('credit_runs', {
151 p_device: deviceId,
152 p_runs: runs,
153 p_session: sessionId
154 })
155 if (error) throw new Error(`credit_runs failed: ${error.message}`)
156 const row = (Array.isArray(data) ? data[0] : data) as { credited: boolean; runs_remaining: number }
157 return { credited: row.credited, runsRemaining: row.runs_remaining }
158 }
159 
160 async hasPurchased(deviceId: string): Promise<boolean> {
161 // Any credited session for this subject means they've paid at least once.
162 const { data, error } = await this.client
163 .from('credited_sessions')
164 .select('session_id')
165 .eq('device_id', deviceId)
166 .limit(1)
167 .maybeSingle()
168 if (error) throw new Error(`purchase check failed: ${error.message}`)
169 return !!data
170 }
172 
173let cached: BalanceStore | null = null
174 
175/** The configured balance store (memoized). */
176export function balanceStore(): BalanceStore {
177 if (cached) return cached
178 const cfg = supabaseConfig()
179 cached = cfg
180 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
181 : new InMemoryBalanceStore()
182 return cached
184 
185/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
186export function resetBalanceStore(): void {
187 cached = null

Anonymous-first, upgraded in place

On the client, the moment there's no session we sign in anonymously — every visitor gets a real, durable user id with zero friction, same instant play as before. Graceful: if the provider isn't enabled yet, the error is swallowed and the app runs on the device cookie.

No session → signInAnonymously; failure → device fallback.

plugins/supabase-anon.client.ts · 19 lines
plugins/supabase-anon.client.ts19 lines · TypeScript
⋯ 9 lines hidden (lines 1–9)
1/**
2 * Anonymous-first auth. On the client, the moment there's no session, sign in
3 * anonymously so the balance keys on a real (durable) user id from the start —
4 * zero friction, same instant play as before. A magic-link sign-in later upgrades
5 * this same anonymous user in place, so the balance carries over.
6 *
7 * Graceful: if anonymous sign-in is unavailable (provider not enabled yet), we
8 * swallow the error and the app falls back to the device-cookie identity — it
9 * keeps working exactly as it did pre-accounts.
10 */
11export default defineNuxtPlugin(async () => {
12 const supabase = useSupabaseClient()
13 try {
14 const { data } = await supabase.auth.getSession()
15 if (!data.session) await supabase.auth.signInAnonymously()
16 } catch (error) {
17 console.warn('[auth] anonymous sign-in unavailable; using device identity', error)
18 }
⋯ 1 line hidden (lines 19–19)
19})

Signing in attaches an email to THAT anonymous user (updateUser), so the account upgrades in place — same id, so the balance (free or purchased) carries over with no migration. The module is configured redirect:false (we gate buying, not playing) with a publishable client key; the service key stays server-only.

Module config — anonymous play allowed; build-safe placeholders, runtime keys.

nuxt.config.ts · 70 lines
nuxt.config.ts70 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Register required modules
13 modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/supabase'],
14 
15 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
16 // user so the balance keys on a real user id from the first second; signing in
17 // with a magic link upgrades that same user in place (balance carries over).
18 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
19 // free-trial hook); buying is what requires an account. The client key is the
20 // PUBLISHABLE key (safe to ship); the service key stays server-only in the
21 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
22 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
23 supabase: {
24 url: process.env.SUPABASE_URL || 'https://placeholder.supabase.co',
25 key: process.env.SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_placeholder',
26 redirect: false,
27 // We don't use generated DB types (the balance store talks to Supabase with the
28 // service client directly); disable the lookup so the module doesn't warn.
29 types: false
30 },
31 
32 // Global CSS imports
33 css: ['~/assets/css/main.css'],
34 
⋯ 36 lines hidden (lines 35–70)
35 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
36 // class on <html> is exactly `dark` / `light` — what the .dark token block and
37 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
38 // the module's inline no-flash script + cookie persistence replace the old manual
39 // classList toggle (which didn't persist and could FOUC).
40 colorMode: {
41 classSuffix: '',
42 preference: 'system',
43 fallback: 'light'
44 },
45 
46 // Runtime configuration
47 runtimeConfig: {
48 // Private keys (only available on server-side)
49 openaiApiKey: process.env.OPENAI_API_KEY,
50 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
51 // Supabase — the run store (server-side only). Absent → in-memory dev store.
52 supabaseUrl: process.env.SUPABASE_URL,
53 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
54 // Stripe — run packs (server-side only). Absent → checkout returns 503.
55 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
56 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
57 // Canonical site origin for Stripe redirect URLs (never the client Host header).
58 siteUrl: process.env.REVISIONIST_BASE_URL,
59 // Public keys (exposed to client-side)
60 public: {}
61 },
62 
63 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
64 // out of the app's type program so `nuxt typecheck` never compiles the loops.
65 typescript: {
66 tsConfig: {
67 exclude: ['../agents']
68 }
69 }
70})

updateUser({email}) upgrades the anonymous user in place.

components/SignInForm.vue · 57 lines
components/SignInForm.vue57 lines · Vue
⋯ 42 lines hidden (lines 1–42)
1<template>
2 <form data-testid="signin-form" @submit.prevent="send">
3 <p v-if="sent" data-testid="signin-sent" class="rv-muted text-sm leading-relaxed">
4 <span class="rv-ok" aria-hidden="true"></span> Check your email — a sign-in link is on its way to
5 <span class="rv-fg">{{ email }}</span>.
6 </p>
7 <template v-else>
8 <p v-if="hint" class="rv-faint text-xs leading-relaxed mb-2">{{ hint }}</p>
9 <div class="flex gap-2">
10 <input v-model="email" type="email" required autocomplete="email" placeholder="you@example.com"
11 data-testid="signin-email" class="rv-input flex-1 min-w-0" :disabled="sending" />
12 <button type="submit" data-testid="signin-send" class="rv-btn shrink-0" :disabled="sending || !email">
13 {{ sending ? 'Sending…' : 'Send link' }}
14 </button>
15 </div>
16 <p v-if="error" data-testid="signin-error" class="text-xs rv-bad mt-1.5">{{ error }}</p>
17 </template>
18 </form>
19</template>
20 
21<script setup lang="ts">
22/**
23 * Magic-link sign-in. The visitor is already an anonymous Supabase user, so we
24 * attach their email to THAT user (updateUser) — the account upgrades in place,
25 * keeping the same id, so any runs they hold (free or purchased) carry over. A
26 * confirmation link is emailed; clicking it finalizes the email + persists the
27 * account across devices and cookie-clears.
28 */
29import { ref } from 'vue'
30 
31defineProps<{ hint?: string }>()
32 
33const supabase = useSupabaseClient()
34const email = ref('')
35const sending = ref(false)
36const sent = ref(false)
37const error = ref<string | null>(null)
38 
39async function send() {
40 if (!email.value || sending.value) return
41 sending.value = true
42 error.value = null
43 try {
44 const emailRedirectTo = typeof window !== 'undefined' ? `${window.location.origin}/play` : undefined
45 const { error: e } = await supabase.auth.updateUser({ email: email.value }, { emailRedirectTo })
46 if (e) throw e
47 sent.value = true
⋯ 10 lines hidden (lines 48–57)
48 } catch {
49 // Most often: the email already belongs to an account (a returning player).
50 // Cross-device restore for existing accounts is a follow-up (P2); for now,
51 // guide them rather than fail silently.
52 error.value = 'Could not send the link. If you already have an account, sign-in restore is coming soon.'
53 } finally {
54 sending.value = false
55 }
57</script>

The buy gate, client-side

The server enforces sign-in-to-buy; the modal mirrors it: an anonymous user sees a sign-in form instead of the packs. The account popover shows the same form (or the email + sign-out when signed in). accountAnonymous / accountEmail come from /api/balance.

Anonymous → the sign-in gate; otherwise the packs.

components/RunPacksModal.vue · 164 lines
components/RunPacksModal.vue164 lines · Vue
⋯ 31 lines hidden (lines 1–31)
1<template>
2 <!-- The run-packs store: a focused, dismissible dialog (not the easily-missed
3 inline strip). Opened on demand from the header, and automatically when a
4 commit is refused for being out of runs. Mirrors EndGameScreen's bespoke
5 takeover idiom rather than @nuxt/ui chrome, to stay in the ledger aesthetic. -->
6 <Transition name="rv-modal">
7 <div v-if="gameStore.buyModalOpen" ref="dialogRef" data-testid="run-packs-modal"
8 role="dialog" aria-modal="true" aria-labelledby="run-packs-heading"
9 class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-5"
10 @keydown="onKeydown">
11 <!-- Backdrop -->
12 <div class="absolute inset-0 rv-modal-backdrop" @click="close" />
13 
14 <!-- Panel -->
15 <div class="relative rv-bg border rv-line w-full sm:max-w-lg rounded-t-lg sm:rounded-lg
16 shadow-xl max-h-[92vh] overflow-y-auto">
17 <div class="px-5 sm:px-6 py-5">
18 <div class="flex items-start justify-between gap-4">
19 <div>
20 <p class="rv-label">Keep rewriting history</p>
21 <h2 id="run-packs-heading" class="rv-serif rv-fg text-2xl font-semibold mt-1 leading-tight">
22 {{ gameStore.outOfRuns ? "You're out of runs" : 'Get more runs' }}
23 </h2>
24 <p class="rv-muted text-sm mt-1.5">
25 Each run is a full game — five messages to remake a world. One-time, never expires.
26 </p>
27 </div>
28 <button ref="closeRef" type="button" data-testid="close-packs"
29 class="rv-tap rv-hover-fg text-lg leading-none shrink-0 -mr-1" aria-label="Close" @click="close"></button>
30 </div>
31 
32 <!-- Anonymous users must sign in before buying, so the purchased balance is
33 tied to a recoverable account, not a clearable cookie (the checkout
34 endpoint enforces this too). -->
35 <div v-if="gameStore.accountAnonymous" data-testid="buy-signin-gate" class="mt-5 pt-4 border-t rv-line">
36 <p class="rv-fg text-sm font-semibold">Sign in to buy</p>
37 <p class="rv-muted text-xs mt-0.5 mb-3">Runs are saved to your account — never lost, and they work across every device.</p>
38 <SignInForm />
39 </div>
40 
⋯ 124 lines hidden (lines 41–164)
41 <!-- Packs, as ledger rows: label · runs · price, with the per-run cost and a
42 single "best value" mark on the cheapest-per-run tier. -->
43 <template v-else>
44 <ul class="mt-5 border-t rv-line">
45 <li v-for="pack in gameStore.packs" :key="pack.id" class="border-b rv-line">
46 <button type="button" :data-testid="`buy-${pack.id}`"
47 class="w-full text-left flex items-center gap-3 py-3.5 pr-1 transition rv-hover disabled:opacity-50"
48 :disabled="buyingPack !== null" @click="buy(pack.id)">
49 <span class="rv-dot mt-0.5 shrink-0" :class="pack.id === bestValueId ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
50 <span class="min-w-0 flex-1">
51 <span class="flex items-center gap-2 flex-wrap">
52 <span class="rv-fg font-semibold">{{ pack.label }}</span>
53 <span v-if="pack.id === bestValueId" class="rv-accent text-[10px] uppercase tracking-wider">★ best value</span>
54 </span>
55 <span class="block rv-muted text-xs mt-0.5">
56 {{ pack.runs }} runs · {{ perRun(pack) }} each
57 </span>
58 </span>
59 <span class="rv-mono rv-fg font-semibold shrink-0">${{ dollars(pack.priceCents) }}</span>
60 <span class="rv-faint shrink-0 text-sm" aria-hidden="true">{{ buyingPack === pack.id ? '…' : '→' }}</span>
61 </button>
62 </li>
63 </ul>
64 
65 <p v-if="buyError" data-testid="buy-error" class="text-xs rv-bad mt-3">{{ buyError }}</p>
66 
67 <p class="rv-faint text-[11px] leading-relaxed mt-4">
68 Secure checkout by Stripe · you'll return here with your runs added.
69 </p>
70 </template>
71 </div>
72 </div>
73 </div>
74 </Transition>
75</template>
76 
77<script setup lang="ts">
78/**
79 * RunPacksModal — the sales surface. Reads the catalog + open state from the store
80 * and drives Stripe Checkout via gameStore.buyPack (a full-page redirect). Loads
81 * the catalog when it opens (store.openBuyModal does this), marks the cheapest
82 * per-run tier as "best value", and is dismissible (backdrop · ✕ · Escape).
83 */
84import { ref, computed, watch, nextTick } from 'vue'
85import { useGameStore } from '~/stores/game'
86import type { Pack } from '~/server/utils/packs'
87 
88const gameStore = useGameStore()
89const buyingPack = ref<string | null>(null)
90const buyError = ref<string | null>(null)
91const dialogRef = ref<HTMLElement | null>(null)
92const closeRef = ref<HTMLButtonElement | null>(null)
93 
94const dollars = (cents: number) => (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)
95const perRun = (pack: Pack) => `$${(pack.priceCents / 100 / pack.runs).toFixed(2)}`
96 
97// The cheapest run, by per-run price — the one tier worth nudging toward.
98const bestValueId = computed(() => {
99 let best: Pack | null = null
100 for (const p of gameStore.packs) {
101 if (!best || p.priceCents / p.runs < best.priceCents / best.runs) best = p
102 }
103 return best?.id ?? null
104})
105 
106function close() {
107 buyError.value = null
108 gameStore.closeBuyModal()
110 
111// Escape closes; Tab is trapped within the panel so focus can't wander to the
112// page behind the backdrop (which aria-modal="true" declares inert).
113function onKeydown(e: KeyboardEvent) {
114 if (e.key === 'Escape') { close(); return }
115 if (e.key !== 'Tab' || !dialogRef.value) return
116 const focusables = Array.from(
117 dialogRef.value.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
118 ).filter((el) => !el.hasAttribute('disabled'))
119 if (!focusables.length) return
120 const first = focusables[0]
121 const last = focusables[focusables.length - 1]
122 const active = document.activeElement
123 if (e.shiftKey && active === first) {
124 e.preventDefault()
125 last.focus()
126 } else if (!e.shiftKey && active === last) {
127 e.preventDefault()
128 first.focus()
129 }
131 
132async function buy(packId: string) {
133 if (buyingPack.value) return
134 buyingPack.value = packId
135 buyError.value = null
136 const url = await gameStore.buyPack(packId)
137 if (url) {
138 window.location.href = url // off to Stripe Checkout
139 } else {
140 buyingPack.value = null
141 buyError.value = 'Could not start checkout — please try again.'
142 }
144 
145// Move focus into the dialog when it opens (the close button), for keyboard + SR users.
146watch(() => gameStore.buyModalOpen, (open) => {
147 if (open) nextTick(() => closeRef.value?.focus())
148})
149</script>
150 
151<style scoped>
152.rv-modal-backdrop {
153 background: color-mix(in srgb, var(--rv-bg) 55%, #000 45%);
154 backdrop-filter: blur(2px);
156.rv-modal-enter-active,
157.rv-modal-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
158.rv-modal-enter-from,
159.rv-modal-leave-to { opacity: 0; }
160@media (prefers-reduced-motion: reduce) {
161 .rv-modal-enter-active,
162 .rv-modal-leave-active { transition: none; }
164</style>

Tests + the graceful fallback

New coverage: currentUser/currentSubject branch resolution (no-session, signed-in, anonymous), hasPurchased on both adapters, the account fields on loadBalance, and the buy sign-in gate. A #supabase/server stub lets the node test env load the server utils. Full suite green (543) + typecheck; booted and confirmed the device-fallback path is unchanged from today.

Activation needs two live steps the code can't self-enable: turning on anonymous sign-ins on the Supabase project, and wiring the publishable key into the deploy. Until then it runs in device-fallback mode.