What changed, and why
The sign-in email arrived, its link opened the game, but the account stayed anonymous: the popover showed no email, and the "Sign in to buy" gate kept showing. The link did its job — it just landed on /play with nobody home to finish the job.
Sign-in is an in-place upgrade of an anonymous user. A visitor is already an anonymous Supabase user, so SignInForm.vue attaches their email to that same user (updateUser) and emails a confirmation link back to /play. Clicking it should upgrade the account in place — same id, runs carried over. But the app set supabase.redirect: false, so @nuxtjs/supabase registers no /confirm callback, and nothing on /play consumed the auth artifact the link carries. The account view is server-derived (/api/balance reads the session cookie), so until that cookie's session is upgraded and re-read, the UI stays anonymous.
The fix is small and contained: a new utils/auth-return.ts that consumes whatever the link drops, and a few lines in pages/play.vue that run it on mount and re-read the balance. No server, schema, or store-shape changes.
Consuming the return — utils/auth-return.ts
What lands on the URL depends on the project's email flow, so the module handles all of them. PKCE (the @supabase/ssr default) drops ?code=. A token-hash email template drops ?token_hash=&type=. A failed link drops ?error=. The contract below names exactly the slice of the Supabase client this needs, so the logic stays free of Nuxt/Supabase runtime imports and unit-tests in the plain node env.
The params it reads off the URL, and the two client methods it leans on.
utils/auth-return.ts · 109 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 56 lines hidden (lines 54–109)
finalizeAuthReturn is the decision. It branches once on what the link carried. A PKCE ?code= is already exchanged by the browser client during init, so it only settles that init and confirms a signed-in session actually resulted — a stale or cross-browser code reports an error rather than faking a sign-in. A ?token_hash= is not auto-handled by the client, so it verifies that itself (this path also works when the link opens in a different browser). An ?error= short-circuits, consuming nothing.
One branch per artifact; honest status, never a false 'signed-in'.
utils/auth-return.ts · 109 lines
⋯ 84 lines hidden (lines 1–84)
Wiring it on /play — pages/play.vue
The page already loaded the balance on mount and already stripped a ?purchase= query after a Stripe return, so the sign-in return slots into the same shape. It runs first: if the URL carries a sign-in artifact, finalize it, re-read the balance (the account view is server-derived), strip the query so a reload can't re-fire a spent link, and return before the purchase/else branches.
The settle adapter reports a non-anonymous session — the real success signal.
pages/play.vue · 522 lines
⋯ 383 lines hidden (lines 1–383)
⋯ 115 lines hidden (lines 408–522)
The ops dependency — nuxt.config.ts
Code alone can't make the link arrive. With redirect: false the module registers no callback route, and the return is handled in play.vue — but only if Supabase actually redirects there. That's a project-side setting, easy to miss, so it's documented next to the config that depends on it.
The redirect-URL allowlist requirement, stated where a configurer will look.
nuxt.config.ts · 75 lines
⋯ 16 lines hidden (lines 1–16)
⋯ 43 lines hidden (lines 33–75)
Verification
The new test covers each return-from-link branch as a pure-logic unit: a code that settles to a signed-in session, a code that doesn't (error, not a false success), a token hash verified with its stamped type, the default to email_change, a verifyOtp failure, an ?error= short-circuit, and ignoring the unrelated Stripe ?purchase= return.
The finalize branches — each asserts which client method fired (or didn't).