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
⋯ 10 lines hidden (lines 1–10)
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
⋯ 12 lines hidden (lines 1–12)
⋯ 1 line hidden (lines 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
⋯ 18 lines hidden (lines 1–18)
⋯ 1 line hidden (lines 48–48)
The client/secret config, and creditFromSession: paid + valid metadata only, idempotent.
server/utils/stripe.ts · 54 lines
⋯ 21 lines hidden (lines 1–21)
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
⋯ 84 lines hidden (lines 1–84)
⋯ 46 lines hidden (lines 93–138)
⋯ 15 lines hidden (lines 152–166)
The credited_sessions dedupe table + credit_runs (claim-then-add).