What changed, and why

The buy side of "sell runs." Until now a player got a free run, hit the paywall, and had nothing to buy. This adds one-time, non-expiring run packs through Stripe Checkout, credited to the per-device balance.

The flow is four small pieces: a server-side catalog, a checkout endpoint that opens a Stripe session, a webhook that hears "paid" and credits runs, and a paywall that shows buy buttons. The whole thing is money-handling, so the security properties below are the point — read the webhook and the catalog with that lens.

The catalog

Three tiers, server-authored. Both checkout (the line item + the run count it stamps) and the paywall UI read this one list, so price and runs can never drift between what's charged and what's credited.

id / price / runs, and a lookup by id. The single source of truth.

server/utils/packs.ts · 26 lines
server/utils/packs.ts26 lines · TypeScript
⋯ 10 lines hidden (lines 1–10)
1/**
2 * The run packs — one-time, non-expiring bundles of runs (the GTM doc's three
3 * tiers). Price is in cents; runs is what a purchase credits to the balance. Run
4 * counts are estimates against the doc's "~N runs" until #46's real cost-per-run
5 * data lets them be priced at 3-5x p95 — tune here, the one source of truth.
6 *
7 * The catalog is server-authored: checkout builds the Stripe line item from it and
8 * stamps the run count into session metadata, so the webhook credits exactly what
9 * was bought (never a client-supplied amount).
10 */
11export interface Pack {
12 id: string
13 label: string
14 priceCents: number
15 runs: number
17 
18export const PACKS: readonly Pack[] = [
19 { id: 'courier', label: 'Courier', priceCents: 500, runs: 7 },
20 { id: 'envoy', label: 'Envoy', priceCents: 1200, runs: 18 },
21 { id: 'plenipotentiary', label: 'Plenipotentiary', priceCents: 2500, runs: 45 }
23 
24export function packById(id: string): Pack | undefined {
25 return PACKS.find((p) => p.id === id)

Checkout — server-stamped, never client-trusted

The endpoint looks the pack up in the catalog (a bad id is a 400), builds the Stripe line item from that pack's price, and stamps the device id + run count into the session metadata. The webhook later credits exactly this — so a client can't ask to be credited more than it paid for. 503 when Stripe isn't configured.

Catalog lookup → Stripe session; device id + runs go in metadata, from the server.

server/api/checkout.post.ts · 50 lines
server/api/checkout.post.ts50 lines · TypeScript
⋯ 12 lines hidden (lines 1–12)
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 { deviceId } from '~/server/utils/device'
10import { packById } from '~/server/utils/packs'
11 
12export default defineEventHandler(async (event) => {
13 if (getMethod(event) !== 'POST') {
14 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
15 }
16 const body = await readBody(event)
17 const pack = packById(typeof body?.packId === 'string' ? body.packId : '')
18 if (!pack) {
19 throw createError({ statusCode: 400, statusMessage: 'Unknown pack' })
20 }
21 const stripe = stripeClient()
22 if (!stripe) {
23 throw createError({ statusCode: 503, statusMessage: 'Payments unavailable' })
24 }
25 
26 const device = deviceId(event)
27 const origin = getRequestURL(event).origin
28 try {
29 const session = await stripe.checkout.sessions.create({
30 mode: 'payment',
31 line_items: [
32 {
33 price_data: {
34 currency: 'usd',
35 product_data: { name: `Revisionist — ${pack.label} (${pack.runs} runs)` },
36 unit_amount: pack.priceCents
37 },
38 quantity: 1
39 }
40 ],
41 metadata: { device_id: device, pack_id: pack.id, runs: String(pack.runs) },
42 success_url: `${origin}/?purchase=success`,
43 cancel_url: `${origin}/?purchase=cancel`
44 })
45 return { url: session.url }
46 } catch (error) {
47 console.error('Checkout session creation failed:', error)
48 throw createError({ statusCode: 502, statusMessage: 'Could not start checkout' })
49 }
⋯ 1 line hidden (lines 50–50)
50})

The webhook — verify, then credit (the security heart)

Stripe calls this when a checkout completes. The signature is checked against the raw body (readRawBody, not the parsed body) plus the webhook secret; a bad signature is a hard 400, so a forged event credits nothing. Only then, and only for a paid session, does it credit — handing off to creditFromSession, which reads the run count from the server-stamped metadata and is idempotent per session.

constructEvent on the raw body → reject on bad signature → credit a paid session.

server/api/stripe/webhook.post.ts · 48 lines
server/api/stripe/webhook.post.ts48 lines · TypeScript
⋯ 18 lines hidden (lines 1–18)
1/**
2 * POST /api/stripe/webhook — Stripe's notification that a checkout completed. The
3 * signature is verified against the RAW body (readRawBody, not the parsed body) +
4 * the webhook secret, so a forged "completed" event can't credit runs. On a valid
5 * checkout.session.completed, credit the session's runs to its device — idempotent
6 * per session id, since Stripe retries deliveries. Always 200 once verified, so
7 * Stripe doesn't retry a successfully-handled event.
8 */
9import type Stripe from 'stripe'
10import { stripeClient, stripeWebhookSecret, creditFromSession } from '~/server/utils/stripe'
11 
12export default defineEventHandler(async (event) => {
13 if (getMethod(event) !== 'POST') {
14 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
15 }
16 const stripe = stripeClient()
17 const whsec = stripeWebhookSecret()
18 if (!stripe || !whsec) {
19 throw createError({ statusCode: 503, statusMessage: 'Payments unavailable' })
20 }
21 
22 const signature = getHeader(event, 'stripe-signature')
23 const rawBody = await readRawBody(event)
24 if (!signature || !rawBody) {
25 throw createError({ statusCode: 400, statusMessage: 'Missing signature or body' })
26 }
27 
28 let stripeEvent: Stripe.Event
29 try {
30 stripeEvent = stripe.webhooks.constructEvent(rawBody, signature, whsec)
31 } catch {
32 // Bad signature (or tampered body): reject, don't credit anything.
33 throw createError({ statusCode: 400, statusMessage: 'Invalid signature' })
34 }
35 
36 if (stripeEvent.type === 'checkout.session.completed') {
37 try {
38 await creditFromSession(stripeEvent.data.object as Stripe.Checkout.Session)
39 } catch (error) {
40 // A crediting failure is a 500 so Stripe retries (idempotency makes the
41 // retry safe); the signature was already verified.
42 console.error('Crediting a checkout session failed:', error)
43 throw createError({ statusCode: 500, statusMessage: 'Crediting failed' })
44 }
45 }
46 
47 return { received: true }
⋯ 1 line hidden (lines 48–48)
48})

The client/secret config, and creditFromSession: paid + valid metadata only, idempotent.

server/utils/stripe.ts · 54 lines
server/utils/stripe.ts54 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * Shared Stripe wiring — the secret key + a memoized client, the webhook signing
3 * secret, and the post-verification credit logic. Creds come from runtimeConfig
4 * (Nuxt) or raw env; absent → no client, and the routes return 503 rather than
5 * crash. Test mode is whatever key is supplied (sk_test_ in the alpha).
6 */
7import Stripe from 'stripe'
8import { balanceStore } from './balance-store'
9 
10function fromConfig(key: 'stripeSecretKey' | 'stripeWebhookSecret', envName: string): string | undefined {
11 let v: string | undefined
12 try {
13 v = useRuntimeConfig()[key] as string | undefined
14 } catch {
15 // not in a Nuxt context (tests / evals)
16 }
17 return v || process.env[envName]
19 
20let cached: Stripe | null = null
21 
22export function stripeClient(): Stripe | null {
23 if (cached) return cached
24 const key = fromConfig('stripeSecretKey', 'STRIPE_SECRET_KEY')
25 if (!key) return null
26 cached = new Stripe(key)
27 return cached
29 
30export function stripeWebhookSecret(): string | undefined {
31 return fromConfig('stripeWebhookSecret', 'STRIPE_WEBHOOK_SECRET')
33 
34/** Test seam: drop the memoized client so the next stripeClient() re-reads config. */
35export function resetStripeClient(): void {
36 cached = null
38 
39/**
40 * Credit a completed checkout session's runs to its device, idempotently. The
41 * device id and run count come from the session metadata that checkout stamped
42 * server-side — never a client-supplied amount. Credits only a paid session with
43 * valid metadata; returns whether it actually credited (false on a replay or an
44 * unpaid / malformed session).
45 */
46export async function creditFromSession(session: Stripe.Checkout.Session): Promise<boolean> {
47 const device = session.metadata?.device_id
48 const runs = Number(session.metadata?.runs)
49 if (session.payment_status !== 'paid' || !device || !Number.isFinite(runs) || runs <= 0) {
50 return false
51 }
52 const { credited } = await balanceStore().credit(device, runs, session.id)
53 return credited

The idempotent credit

Crediting is once-per-session. The Supabase path is one SQL function: claim the session row (insert; on conflict do nothing) — if the claim took, add the runs to the balance; if it conflicted, the session was already credited, so report the balance unchanged. Atomic, so two concurrent deliveries of the same webhook credit exactly once. The in-memory adapter mirrors it with a seen-sessions set.

credit() on both adapters — idempotent per session id.

server/utils/balance-store.ts · 166 lines
server/utils/balance-store.ts166 lines · TypeScript
⋯ 84 lines hidden (lines 1–84)
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>
52 
53/** Dev default — process-local balances, durable enough for dev. Mirrors the
54 * spend_run SQL function: free grant on first sight, one charge per run. */
55export class InMemoryBalanceStore implements BalanceStore {
56 private readonly balances = new Map<string, number>()
57 // runId → the device that charged it. Idempotency is per (device, run), so a
58 // different device can't ride another's already-charged run for free.
59 private readonly chargedRuns = new Map<string, string>()
60 // Stripe session ids already credited, so a retried webhook is a no-op.
61 private readonly creditedSessions = new Set<string>()
62 
63 async ensureDevice(deviceId: string): Promise<number> {
64 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
65 return this.balances.get(deviceId) ?? 0
66 }
67 
68 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
69 const remaining = await this.ensureDevice(deviceId)
70 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
71 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
72 this.balances.set(deviceId, remaining - 1)
73 this.chargedRuns.set(runId, deviceId)
74 return { charged: true, runsRemaining: remaining - 1 }
75 }
76 
77 async remaining(deviceId: string): Promise<number> {
78 return this.balances.get(deviceId) ?? 0
79 }
80 
81 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
82 return this.chargedRuns.get(runId) === deviceId
83 }
84 
85 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
86 const current = await this.ensureDevice(deviceId)
87 if (this.creditedSessions.has(sessionId)) return { credited: false, runsRemaining: current }
88 this.creditedSessions.add(sessionId)
89 this.balances.set(deviceId, current + runs)
90 return { credited: true, runsRemaining: current + runs }
91 }
⋯ 46 lines hidden (lines 93–138)
93 
94/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
95export class SupabaseBalanceStore implements BalanceStore {
96 constructor(private readonly client: SupabaseClient) {}
97 
98 async ensureDevice(deviceId: string): Promise<number> {
99 const { error } = await this.client
100 .from('balances')
101 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
102 // 23505 = unique_violation: the row already exists, which is fine.
103 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
104 return this.remaining(deviceId)
105 }
106 
107 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
108 const { data, error } = await this.client.rpc('spend_run', {
109 p_device: deviceId,
110 p_run: runId
111 })
112 if (error) throw new Error(`spend_run failed: ${error.message}`)
113 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
114 return { charged: row.charged, runsRemaining: row.runs_remaining }
115 }
116 
117 async remaining(deviceId: string): Promise<number> {
118 const { data, error } = await this.client
119 .from('balances')
120 .select('runs_remaining')
121 .eq('device_id', deviceId)
122 .maybeSingle()
123 if (error) throw new Error(`balance read failed: ${error.message}`)
124 return (data?.runs_remaining as number | undefined) ?? 0
125 }
126 
127 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
128 const { data, error } = await this.client
129 .from('runs')
130 .select('id')
131 .eq('id', runId)
132 .eq('device_id', deviceId)
133 .not('charged_at', 'is', null)
134 .maybeSingle()
135 if (error) throw new Error(`run check failed: ${error.message}`)
136 return !!data
137 }
138 
139 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
140 const { data, error } = await this.client.rpc('credit_runs', {
141 p_device: deviceId,
142 p_runs: runs,
143 p_session: sessionId
144 })
145 if (error) throw new Error(`credit_runs failed: ${error.message}`)
146 const row = (Array.isArray(data) ? data[0] : data) as { credited: boolean; runs_remaining: number }
147 return { credited: row.credited, runsRemaining: row.runs_remaining }
148 }
150 
151let cached: BalanceStore | null = null
⋯ 15 lines hidden (lines 152–166)
152 
153/** The configured balance store (memoized). */
154export function balanceStore(): BalanceStore {
155 if (cached) return cached
156 const cfg = supabaseConfig()
157 cached = cfg
158 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
159 : new InMemoryBalanceStore()
160 return cached
162 
163/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
164export function resetBalanceStore(): void {
165 cached = null

The credited_sessions dedupe table + credit_runs (claim-then-add).

supabase/migrations/0004_credits.sql · 42 lines
supabase/migrations/0004_credits.sql42 lines · Transact-SQL
1-- credited_sessions: one row per Stripe checkout session whose runs have been
2-- credited — the idempotency ledger so a retried webhook never double-credits.
3-- RLS on with no policy: service-key-only, like the rest.
4create table if not exists public.credited_sessions (
5 session_id text primary key,
6 device_id text not null,
7 runs int not null,
8 created_at timestamptz not null default now()
9);
10 
11alter table public.credited_sessions enable row level security;
12 
13-- credit_runs: credit p_runs to the device, ONCE per Stripe session. Claims the
14-- session row first (insert; on conflict do nothing) — if the claim took, add the
15-- runs to the balance (creating it if absent); if it conflicted, the session was
16-- already credited, so report the current balance without adding. Atomic, so two
17-- concurrent deliveries of the same webhook credit exactly once.
18create or replace function public.credit_runs(p_device text, p_runs int, p_session text)
19returns table(credited boolean, runs_remaining int)
20language plpgsql as $$
21declare
22 v_remaining int;
23begin
24 insert into public.credited_sessions(session_id, device_id, runs)
25 values (p_session, p_device, p_runs)
26 on conflict (session_id) do nothing;
27 
28 if not found then
29 -- Already credited (the insert hit the conflict): no-op, report balance.
30 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
31 return query select false, coalesce(v_remaining, 0);
32 return;
33 end if;
34 
35 insert into public.balances(device_id, runs_remaining)
36 values (p_device, p_runs)
37 on conflict (device_id)
38 do update set runs_remaining = public.balances.runs_remaining + p_runs, updated_at = now()
39 returning runs_remaining into v_remaining;
40 return query select true, v_remaining;
41end;
42$$;