What changed, and why
Sharing a run already mints a public page at /r/:token. This change turns that into a growth loop: when a new player who arrived via a shared run signs up with an email, the sharer earns one run — the same balance players buy in packs.
The qualifying event is deliberately narrow. Not a page view, not a play, not a purchase: a free, non-paid email signup (an anonymous→email account upgrade). The reward is single-sided — only the sharer is credited.
The whole path is server-authoritative and reuses the money-path machinery already in the repo. A referral is just another credit source on the existing balances ledger, and it borrows the exact idempotency shape that credited_sessions gives Stripe webhooks. The new surface area is small: a referrals ledger + one SQL function, a claim endpoint fired after sign-in, a cookie marker set by middleware, and a passive "+N from sharing" line in the account popover.
Read the ledger first (it sets the contract), then the claim flow that drives it.
| Concern | How it's handled |
|---|---|
| Attribution | an httpOnly ew_ref cookie holding the share token; resolved to the owner at signup |
| Trust | the qualifying email is read from the real Supabase session — the client only triggers the check |
| Idempotency | one credit per referred subject, ever (a referrals ledger, like credited_sessions) |
| Sybil | one credited referral per browser; a per-referrer cap; email raises the cost of each fake |
| Notification | in-app tally + a batched threshold-or-window digest, never one ping per referral |
The ledger, and one atomic credit
referrals is the idempotency gate. Its primary key is the referred subject, so a given new player can credit their referrer exactly once, ever — the same trick credited_sessions plays for Stripe. A second column, referred_device, is a parallel idempotency axis that the Sybil guard keys on.
credit_referral does the whole decision in one round-trip. It claims the referred-subject row first (insert … on conflict do nothing); if the claim loses, this identity was already processed and the function no-ops. If it wins, two soft guards run before any credit: the device-burn (a browser that already earned a credit can't earn another) and the per-referrer cap. Only then does it add the reward to the referrer's balance, with the same upsert credit_runs uses.
The ledger table, and the claim-then-guard-then-credit function.
supabase/migrations/0009_referrals.sql · 109 lines
⋯ 22 lines hidden (lines 1–22)
⋯ 1 line hidden (lines 51–51)
⋯ 1 line hidden (lines 109–109)
Crediting the balance — two adapters, one behavior
Like the rest of the store, creditReferral has two implementations behind one port: an in-memory adapter (the default — dev and tests need nothing) and a Supabase adapter that calls the SQL function. The in-memory version mirrors the SQL exactly, guard for guard, so a test driving it proves the same logic the database runs.
Note the credit line: it adds the reward onto whatever balance exists, creating the row at the reward if there's none — it never re-grants the free run. That matches credit_runs, not the free-trial ensureDevice.
In-memory mirror (the readable spec) above; the Supabase RPC mapping below.
server/utils/balance-store.ts · 328 lines
⋯ 146 lines hidden (lines 1–146)
⋯ 88 lines hidden (lines 173–260)
⋯ 55 lines hidden (lines 274–328)
Resolving a token to its owner — server-only
The public share endpoint deliberately strips the owner from everything a viewer sees. But the referral needs to know who to credit, so a new server-internal resolver reads the owner subject straight off the run row by share token. It is never wired to a client response — only the claim path calls it.
The in-memory and Supabase resolvers — owner subject by share token.
server/utils/run-snapshot-store.ts · 410 lines
⋯ 204 lines hidden (lines 1–204)
⋯ 173 lines hidden (lines 209–381)
⋯ 17 lines hidden (lines 394–410)
Attribution: the marker middleware
When someone opens a shared run, server middleware stamps an httpOnly ew_ref cookie holding the token only — never the owner. It runs on the real /r/:token page request, which matters: a cookie set inside the page's own SSR useFetch to /api/share would not propagate to the browser, but a cookie set by middleware on the page request does.
The path is untrusted input, so the token decode is guarded — a malformed URL like /r/%FF sets no marker instead of throwing a 500. The marker itself is just the opaque token already sitting in the URL, so it leaks nothing.
Match /r/:token, decode safely, stamp the marker.
server/middleware/referral.ts · 30 lines
⋯ 17 lines hidden (lines 1–17)
The cookie: httpOnly, sameSite lax, 30 days — token only.
server/utils/referral.ts · 209 lines
⋯ 85 lines hidden (lines 1–85)
⋯ 113 lines hidden (lines 97–209)
The claim — server-authoritative, end to end
This is the heart. The client fires POST /api/referral/claim after a sign-in finalizes, but the endpoint decides everything from the server's view of the request. Read the numbered steps top to bottom:
- 0 — an off-switch: reward <= 0 short-circuits before recording anything, so disabling the program doesn't burn the idempotency ledger. - 1 — the qualifying gate: the user must be a non-anonymous account with an email, read from the real Supabase session. A client can't fake this. - 2–3 — read the marker, resolve it to the sharer's subject. - 4 — reject self-referral: the sharer can't be the new player's account or their browser. - 5–6 — credit (atomic, idempotent, capped in the store), then maybe send a batched notice.
claimReferral — the qualifying gate, the guards, the credit.
server/utils/referral.ts · 209 lines
⋯ 124 lines hidden (lines 1–124)
⋯ 21 lines hidden (lines 189–209)
The conversion hook
Where does the claim fire? The magic-link return already lands on /play and finalizes the sign-in there. On a successful finalize, the page calls a thin store action that POSTs the claim, then reloads the balance. It's fire-and-forget by design: the credit lands on the sharer, so there's nothing in this player's UI to wait on, and a referral hiccup must never disturb the sign-in itself.
After finalize → signed-in, claim the referral, then reload the gauge.
pages/play.vue · 630 lines
⋯ 435 lines hidden (lines 1–435)
⋯ 184 lines hidden (lines 447–630)
The store action: a best-effort POST that never throws into sign-in.
stores/game.ts · 1758 lines
⋯ 1354 lines hidden (lines 1–1354)
⋯ 391 lines hidden (lines 1368–1758)
Notification — batched, never per-referral
The primary notice is passive and zero-spam: the account popover shows "+N from sharing", pulled from the balance endpoint. No push, no interruption.
For the additive digest, the decision is a pure function: send when enough credits have piled up (the threshold) or the oldest un-notified credit has waited long enough (the window). It's claim-triggered, so the window clause means "something's waiting and it's been a while" without needing a background timer. Keeping it pure makes the throttle provable in a unit test, independent of any delivery channel.
The throttle: threshold OR elapsed-window, debounced below the threshold.
utils/referral-digest.ts · 44 lines
⋯ 29 lines hidden (lines 1–29)
The gauge read surfaces the accrued referral credits (read-safe).
server/api/balance.get.ts · 44 lines
⋯ 21 lines hidden (lines 1–21)
The passive '+N from sharing' line, shown only once something's earned.
components/RunsBalance.vue · 145 lines
⋯ 25 lines hidden (lines 1–25)
⋯ 112 lines hidden (lines 34–145)
How it was verified
npx vitest run is green and npx nuxt typecheck is clean. The tests cover the acceptance criteria directly, at the layer each behavior lives:
- Attribution + credit, idempotency, self-referral, cap — claimReferral driven end to end over the real in-memory stores, with the Supabase session and device id mocked. - The ledger guards — creditReferral at the store level: idempotent per subject and per device, the cap, and the "add onto existing balance" rule. - The throttle — the pure digest decision, including the threshold, the window, and the window=0 edge. - The middleware — stamps for a valid token, and never throws on malformed percent-encoding.
An independent adversarial review (four lenses: correctness, anti-abuse, the SSR attribution flow, test gaps) ran against the diff; its three confirmed findings — the middleware decode crash, the reward=0 foot-gun, and the window=0 test gap — are all fixed in this PR.