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
⋯ 33 lines hidden (lines 1–33)
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
⋯ 30 lines hidden (lines 1–30)
⋯ 29 lines hidden (lines 42–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
⋯ 35 lines hidden (lines 1–35)
⋯ 18 lines hidden (lines 42–59)
hasPurchased: any credited session for the subject (both adapters implement it).
server/utils/balance-store.ts · 188 lines
⋯ 98 lines hidden (lines 1–98)
⋯ 87 lines hidden (lines 102–188)
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
⋯ 9 lines hidden (lines 1–9)
⋯ 1 line hidden (lines 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
⋯ 22 lines hidden (lines 1–22)
⋯ 36 lines hidden (lines 35–70)
updateUser({email}) upgrades the anonymous user in place.
components/SignInForm.vue · 57 lines
⋯ 42 lines hidden (lines 1–42)
⋯ 10 lines hidden (lines 48–57)
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
⋯ 31 lines hidden (lines 1–31)
⋯ 124 lines hidden (lines 41–164)
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.