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
utils/auth-return.ts109 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1/**
2 * Finalizing a magic-link / email-change sign-in on return.
3 *
4 * A visitor is already an anonymous Supabase user; the sign-in form attaches an
5 * email to that user (`updateUser`) and emails a confirmation link that lands back
6 * on /play. Something has to CONSUME the artifact that link carries, or the account
7 * never upgrades and the popover stays signed-out — the bug this fixes.
8 *
9 * What lands depends on the project's email flow, so we handle all of them:
10 * - PKCE (the @supabase/ssr default) drops `?code=`. The browser client's
11 * `detectSessionInUrl` already exchanges it during init, so we only settle that
12 * init and let the caller re-read the account.
13 * - A token-hash email template drops `?token_hash=&type=email_change`. The client
14 * does NOT auto-handle this, so we verify it ourselves — and `verifyOtp` works
15 * even when the link opens in a different browser, where no PKCE verifier exists.
16 * - A failed link drops `?error=&error_description=`.
17 *
18 * On success the upgrade is in place: the same user id gains the email and sheds its
19 * anonymous flag, so any runs (free or purchased) carry over with no migration. The
20 * caller then re-reads the balance (the account view is server-derived) and strips
21 * the query so a reload can't re-fire a spent artifact.
22 */
23 
24/** The one-time-password types Supabase stamps on an email link. We forward
25 * whatever the link carries; an unknown or absent value falls back to
26 * `email_change` — the anonymous-upgrade case the sign-in form creates. */
27const OTP_TYPES = ['email', 'email_change', 'magiclink', 'recovery', 'signup', 'invite'] as const
28export type OtpType = (typeof OTP_TYPES)[number]
29 
30export interface AuthReturnParams {
31 code?: string
32 tokenHash?: string
33 type?: string
34 error?: string
35 errorDescription?: string
37 
38/** The minimal slice of the Supabase auth client this module needs, kept local so
39 * the util carries no Nuxt/Supabase runtime imports and unit-tests in the node env. */
40export interface AuthReturnClient {
41 verifyOtp(params: { token_hash: string; type: OtpType }): Promise<{ error: { message: string } | null }>
42 /** Awaits the client's own initialization — and thus its auto-exchange of `?code=`
43 * — then reports whether a signed-in (non-anonymous) session resulted. */
44 settle(): Promise<boolean>
46 
47export type AuthReturnResult =
48 | { status: 'none' }
49 | { status: 'signed-in' }
50 | { status: 'error'; message: string }
51 
52type QueryValue = string | (string | null)[] | null | undefined
53 
⋯ 56 lines hidden (lines 54–109)
54/** A route-query value can be a string, null, or an array of either (repeated keys);
55 * take the first non-empty string. */
56const first = (v: QueryValue): string | undefined => {
57 const s = Array.isArray(v) ? v.find((x) => typeof x === 'string' && x.length > 0) : v
58 return typeof s === 'string' && s.length > 0 ? s : undefined
60 
61/** Reads the auth artifacts off a route query (the Vue Router `LocationQuery` shape). */
62export function readAuthReturnParams(query: Record<string, QueryValue>): AuthReturnParams {
63 return {
64 code: first(query.code),
65 tokenHash: first(query.token_hash),
66 type: first(query.type),
67 error: first(query.error),
68 errorDescription: first(query.error_description)
69 }
71 
72/** True when the URL carries something a sign-in return must consume. A bare
73 * `?type=` with no code/hash is not actionable, so it reads as "nothing to do". */
74export function hasAuthReturn(p: AuthReturnParams): boolean {
75 return Boolean(p.code || p.tokenHash || p.error)
77 
78/** Player-facing reason a return failed — covers the two common causes (an expired
79 * link, or one opened in a different browser than the one that requested it). */
80export const SIGN_IN_FAILED =
81 'That sign-in link could not be completed — it may have expired, or been opened in a different browser. Send a fresh link from the same browser.'
82 
83const normalizeOtpType = (t: string | undefined): OtpType =>
84 (OTP_TYPES as readonly string[]).includes(t ?? '') ? (t as OtpType) : 'email_change'
85 
86/**
87 * Consumes the sign-in artifact on the URL and reports the outcome. Deliberately
88 * does NOT touch the balance or the URL — the caller re-reads the account and strips
89 * the query once, regardless of outcome, keeping this a pure decision over its deps.
90 */
91export async function finalizeAuthReturn(p: AuthReturnParams, client: AuthReturnClient): Promise<AuthReturnResult> {
92 if (p.error) {
93 return { status: 'error', message: SIGN_IN_FAILED }
94 }
95 if (p.tokenHash) {
96 const { error } = await client.verifyOtp({ token_hash: p.tokenHash, type: normalizeOtpType(p.type) })
97 return error ? { status: 'error', message: SIGN_IN_FAILED } : { status: 'signed-in' }
98 }
99 if (p.code) {
100 // The browser client's detectSessionInUrl already exchanged the code during
101 // init; settle awaits that and reports whether a signed-in session resulted.
102 // A code that couldn't be exchanged (expired, or opened in a different browser
103 // where no PKCE verifier exists) leaves the user anonymous — report that
104 // honestly rather than claim a sign-in that didn't happen.
105 const signedIn = await client.settle()
106 return signedIn ? { status: 'signed-in' } : { status: 'error', message: SIGN_IN_FAILED }
107 }
108 return { status: 'none' }

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
utils/auth-return.ts109 lines · TypeScript
⋯ 84 lines hidden (lines 1–84)
1/**
2 * Finalizing a magic-link / email-change sign-in on return.
3 *
4 * A visitor is already an anonymous Supabase user; the sign-in form attaches an
5 * email to that user (`updateUser`) and emails a confirmation link that lands back
6 * on /play. Something has to CONSUME the artifact that link carries, or the account
7 * never upgrades and the popover stays signed-out — the bug this fixes.
8 *
9 * What lands depends on the project's email flow, so we handle all of them:
10 * - PKCE (the @supabase/ssr default) drops `?code=`. The browser client's
11 * `detectSessionInUrl` already exchanges it during init, so we only settle that
12 * init and let the caller re-read the account.
13 * - A token-hash email template drops `?token_hash=&type=email_change`. The client
14 * does NOT auto-handle this, so we verify it ourselves — and `verifyOtp` works
15 * even when the link opens in a different browser, where no PKCE verifier exists.
16 * - A failed link drops `?error=&error_description=`.
17 *
18 * On success the upgrade is in place: the same user id gains the email and sheds its
19 * anonymous flag, so any runs (free or purchased) carry over with no migration. The
20 * caller then re-reads the balance (the account view is server-derived) and strips
21 * the query so a reload can't re-fire a spent artifact.
22 */
23 
24/** The one-time-password types Supabase stamps on an email link. We forward
25 * whatever the link carries; an unknown or absent value falls back to
26 * `email_change` — the anonymous-upgrade case the sign-in form creates. */
27const OTP_TYPES = ['email', 'email_change', 'magiclink', 'recovery', 'signup', 'invite'] as const
28export type OtpType = (typeof OTP_TYPES)[number]
29 
30export interface AuthReturnParams {
31 code?: string
32 tokenHash?: string
33 type?: string
34 error?: string
35 errorDescription?: string
37 
38/** The minimal slice of the Supabase auth client this module needs, kept local so
39 * the util carries no Nuxt/Supabase runtime imports and unit-tests in the node env. */
40export interface AuthReturnClient {
41 verifyOtp(params: { token_hash: string; type: OtpType }): Promise<{ error: { message: string } | null }>
42 /** Awaits the client's own initialization — and thus its auto-exchange of `?code=`
43 * — then reports whether a signed-in (non-anonymous) session resulted. */
44 settle(): Promise<boolean>
46 
47export type AuthReturnResult =
48 | { status: 'none' }
49 | { status: 'signed-in' }
50 | { status: 'error'; message: string }
51 
52type QueryValue = string | (string | null)[] | null | undefined
53 
54/** A route-query value can be a string, null, or an array of either (repeated keys);
55 * take the first non-empty string. */
56const first = (v: QueryValue): string | undefined => {
57 const s = Array.isArray(v) ? v.find((x) => typeof x === 'string' && x.length > 0) : v
58 return typeof s === 'string' && s.length > 0 ? s : undefined
60 
61/** Reads the auth artifacts off a route query (the Vue Router `LocationQuery` shape). */
62export function readAuthReturnParams(query: Record<string, QueryValue>): AuthReturnParams {
63 return {
64 code: first(query.code),
65 tokenHash: first(query.token_hash),
66 type: first(query.type),
67 error: first(query.error),
68 errorDescription: first(query.error_description)
69 }
71 
72/** True when the URL carries something a sign-in return must consume. A bare
73 * `?type=` with no code/hash is not actionable, so it reads as "nothing to do". */
74export function hasAuthReturn(p: AuthReturnParams): boolean {
75 return Boolean(p.code || p.tokenHash || p.error)
77 
78/** Player-facing reason a return failed — covers the two common causes (an expired
79 * link, or one opened in a different browser than the one that requested it). */
80export const SIGN_IN_FAILED =
81 'That sign-in link could not be completed — it may have expired, or been opened in a different browser. Send a fresh link from the same browser.'
82 
83const normalizeOtpType = (t: string | undefined): OtpType =>
84 (OTP_TYPES as readonly string[]).includes(t ?? '') ? (t as OtpType) : 'email_change'
85 
86/**
87 * Consumes the sign-in artifact on the URL and reports the outcome. Deliberately
88 * does NOT touch the balance or the URL — the caller re-reads the account and strips
89 * the query once, regardless of outcome, keeping this a pure decision over its deps.
90 */
91export async function finalizeAuthReturn(p: AuthReturnParams, client: AuthReturnClient): Promise<AuthReturnResult> {
92 if (p.error) {
93 return { status: 'error', message: SIGN_IN_FAILED }
94 }
95 if (p.tokenHash) {
96 const { error } = await client.verifyOtp({ token_hash: p.tokenHash, type: normalizeOtpType(p.type) })
97 return error ? { status: 'error', message: SIGN_IN_FAILED } : { status: 'signed-in' }
98 }
99 if (p.code) {
100 // The browser client's detectSessionInUrl already exchanged the code during
101 // init; settle awaits that and reports whether a signed-in session resulted.
102 // A code that couldn't be exchanged (expired, or opened in a different browser
103 // where no PKCE verifier exists) leaves the user anonymous — report that
104 // honestly rather than claim a sign-in that didn't happen.
105 const signedIn = await client.settle()
106 return signedIn ? { status: 'signed-in' } : { status: 'error', message: SIGN_IN_FAILED }
107 }
108 return { status: 'none' }

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
pages/play.vue522 lines · Vue
⋯ 383 lines hidden (lines 1–383)
1<template>
2 <div class="min-h-screen lg:h-screen rv-bg flex flex-col lg:overflow-hidden">
3 <!-- HUD: wordmark · objective mission-strip · gauge · status · dark · new-timeline -->
4 <header class="border-b rv-line">
5 <!-- On phones the bar wraps: controls stay on the top line, and the objective
6 (with its foldable brief) drops to its own full-width line so the brief
7 reads at full width instead of a thin column. Single row from sm up. -->
8 <div class="px-3 sm:px-5 py-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:flex-nowrap sm:gap-4">
9 <GameTitle />
10 
11 <div v-if="gameStore.currentObjective" class="order-last basis-full min-w-0 sm:order-none sm:basis-auto sm:flex-1">
12 <ObjectiveDisplay data-testid="objective-display" />
13 </div>
14 <div v-else class="flex-1" />
15 
16 <div v-if="gameStore.currentObjective" class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto sm:ml-0">
17 <MessagesCounter data-testid="messages-counter" />
18 <span class="flex items-center gap-1.5 pl-1">
19 <span class="rv-dot" :class="statusDot" aria-hidden="true" />
20 <span class="rv-mono text-[11px] uppercase tracking-wide rv-muted sr-only sm:not-sr-only">{{ statusWord }}</span>
21 </span>
22 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
23 progress the reset arms a tiny inline confirm (auto-reverts); an
24 untouched run still resets in one tap. -->
25 <span v-if="resetArmed" class="flex items-center gap-1.5">
26 <span class="text-[11px] rv-warn">abandon this history?</span>
27 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
28 aria-label="Yes — abandon this history" @click="performReset"></button>
29 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
30 aria-label="Keep playing" @click="disarmReset"></button>
31 </span>
32 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
33 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
34 </div>
35 
36 <!-- Runs gauge — always visible (briefing + board), and the entry to the
37 account popover / the run-packs modal. -->
38 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
39 
40 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
41 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
42 </div>
43 </header>
44 
45 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
46 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
47 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
48 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
49 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
50 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
51 <span class="rv-fg">
52 <template v-if="gameStore.purchaseNotice === 'success'">
53 Payment received — your runs have been added to your balance.
54 </template>
55 <template v-else>Checkout canceled — you weren't charged.</template>
56 </span>
57 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
58 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
59 </div>
60 
61 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
62 board reads as "history is being rewritten," in concert with the shaking die. -->
63 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
64 <span class="sr-only">Resolving your move — history is being rewritten…</span>
65 </div>
66 
67 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
68 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
69 on a short viewport that clipping hid the Begin button under the footer — give
70 the briefing a scrollable main instead. -->
71 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
72 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
73 <!-- Briefing: choose (or compose) an objective before the run begins -->
74 <MissionSelect v-if="!gameStore.currentObjective" />
75 
76 <template v-else>
77 <!-- The board, only while the run is live — once it ends, the end-screen
78 takeover fully replaces it (no board bleeding under the verdict).
79 On phones the three zones become one-at-a-time panels driven by the
80 bottom tab bar; from md up every zone is shown together as before. -->
81 <template v-if="gameStore.gameStatus === 'playing'">
82 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
83 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
84 left, and the act-model (the compose dock) pinned on the right, so the move
85 is never buried below the read. Below lg this wrapper is inert and the zones
86 fall back to the stacked page (md) / phone tab panels (sm). -->
87 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
88 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
89 pane: the board/Spine on top at its natural height, then the story zone
90 flexes to fill the rest, so the Chronicle & Thread are full-height panels
91 (each scrolls inside itself) rather than a single scrolling stack. -->
92 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
93 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
94 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
95 <div class="grid sm:grid-cols-2 gap-4">
96 <div class="sm:border-r rv-line sm:pr-6 py-1">
97 <span class="rv-label mb-2 block">Dice of fate</span>
98 <DiceRoller />
99 <!-- The bands, disclosed: the player can always see how fate maps to
100 swing — the same table the Timeline Engine is instructed with. -->
101 <details data-testid="roll-legend" class="mt-2 text-[11px]">
102 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
103 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
104 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
105 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
106 </li>
107 </ul>
108 <p class="rv-faint mt-1.5 leading-snug">✒ craft tilts the roll (±2) · ⚡ anachronism widens the result — gains gently, losses hard · ⚑ a staked final dispatch doubles everything</p>
109 </details>
110 </div>
111 <div class="sm:pl-2 flex flex-col justify-center">
112 <ProgressTracker />
113 </div>
114 </div>
115 <TimelineLedger />
116 </section>
117 
118 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
119 this flexes to fill the rest of the pane and lays the two out side by side
120 as equal full-height columns (md keeps the two-up grid in the page flow). -->
121 <section class="rv-panel mt-9 lg:mt-10 gap-x-8 gap-y-2" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'story' ? 'grid' : 'hidden', 'md:grid md:grid-cols-2', 'lg:grid-rows-1 lg:flex-1 lg:min-h-0']">
122 <Chronicle :force-open="isMd" />
123 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
124 column height), a collapsible <details> on phones. -->
125 <component :is="isMd ? 'div' : 'details'" class="rv-rail lg:h-full lg:min-h-0 lg:flex lg:flex-col" :open="isMd ? null : threadOpen" @toggle="onThreadToggle">
126 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
127 <span class="rv-label">Thread<span class="normal-case tracking-normal rv-faint font-normal"> · your messages<span v-if="gameStore.activeFigureName"> · {{ gameStore.activeFigureName }}</span></span></span>
128 </summary>
129 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
130 <MessageHistory data-testid="message-history" />
131 </div>
132 </component>
133 </section>
134 </div><!-- /LEFT pane -->
135 
136 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
137 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
138 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
139 lg, where it's a column of its own, not a section below the read). -->
140 <section class="rv-panel mt-9 border-t rv-line pt-5 lg:mt-0 lg:border-t-0 lg:pt-0" :class="[mobileTab === 'compose' ? '' : 'hidden', 'md:block']">
141 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
142 <span class="rv-label">Send a message into the past</span>
143 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
144 known-since and a contact year is chosen, the chip translates the
145 reach into the same ⚡ tiers the spine uses. Otherwise the general
146 principle is stated — on the first turn too, where it teaches most. -->
147 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
148 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
149 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
150 </span>
151 <span v-else class="text-[11px] rv-warn"><span aria-hidden="true"></span> the further your words reach beyond their era, the harder the timeline swings — both ways</span>
152 <!-- The chain, read BEFORE the roll: how far this moment lands from your
153 nearest foothold (the objective's era or a change you've made). A
154 lone leap into the deep past is diffuse; building a chain forward
155 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
156 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
157 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
158 <span aria-hidden="true"></span> {{ chainRead.text }}
159 </span>
160 </div>
161 
162 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
163 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
164 </p>
165 
166 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
167 <div>
168 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
169 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
170 </div>
171 
172 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
173 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
174 <MessageInput ref="messageInputRef" />
175 <!-- The last stand: offered only when the final dispatch can no
176 longer win inside the normal per-turn cap — a true last resort,
177 never a free doubling from a winnable position. -->
178 <div v-if="gameStore.canStake" data-testid="stake-control"
179 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
180 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
181 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
182 :style="{ accentColor: 'var(--rv-accent)' }" />
183 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
184 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
185 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
186 </label>
187 </div>
188 <div class="flex items-center gap-3 flex-wrap">
189 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
190 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
191 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
192 <span class="rv-label shrink-0">notice</span>
193 <span class="rv-muted truncate">{{ gameStore.error }}</span>
194 <button type="button" data-testid="error-close" class="rv-tap rv-hover-fg shrink-0 ml-auto" aria-label="Dismiss notice" @click="gameStore.setError(null)"></button>
195 </div>
196 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
197 class="flex items-start gap-2 text-xs flex-1 min-w-0 border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
198 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
199 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
200 <button type="button" data-testid="moderation-close" class="rv-tap shrink-0 ml-auto hover:text-red-100" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
201 </div>
202 </div>
203 <ArchiveLookup />
204 </div>
205 </div>
206 </section>
207 </div><!-- /RIGHT pane -->
208 </div><!-- /workspace -->
209 </template>
210 </template>
211 </main>
212 
213 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
214 board never becomes one infinite scroll and your next move is always one tap
215 away. Hidden from md up (where every zone is shown at once). -->
216 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
217 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
218 The active view is conveyed with aria-current, the honest, complete pattern. -->
219 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
220 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
221 aria-label="Switch board view">
222 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
223 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
224 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
225 <span class="rv-label flex items-center gap-1">
226 {{ t.label }}
227 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
228 </span>
229 </button>
230 </nav>
231 
232 <!-- A ledger running-foot: header + footer frame the page as a document.
233 (Hidden on phones, where the tab bar is the foot.) -->
234 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
235 <div class="flex items-center gap-2">
236 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
237 <span class="rv-hair-c" aria-hidden="true">·</span>
238 <span class="rv-mono">a history you are writing</span>
239 <span class="ml-auto rv-serif italic">the past is not fixed</span>
240 </div>
241 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
242 <p data-testid="footer-disclosure" class="mt-1.5">
243 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
244 </p>
245 </footer>
246 
247 <EndGameScreen @play-again="performReset" />
248 
249 <!-- The run-packs store: opened from the header/account or automatically when a
250 commit is refused for being out of runs. Mounted once, here. -->
251 <RunPacksModal />
252 </div>
253</template>
254 
255<script setup lang="ts">
256/**
257 * Main game page — Revisionist, "The Spine Console" layout.
258 *
259 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
260 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
261 * by space, not chrome. State lives in the store; this only arranges it.
262 */
263import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
264import { useGameStore, formatContactYear } from '~/stores/game'
265import { SWING_BANDS } from '~/utils/swing-bands'
266import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
267import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
268import { CHAIN_HINT } from '~/utils/causal-chain'
269import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
270import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
271 
272const gameStore = useGameStore()
273 
274// The disclosed band table — rendered from the same constants the Timeline
275// Engine is instructed with, so the legend can't lie.
276const fmtSwing = (b: { min: number; max: number }) =>
277 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
278const rollLegend = [
279 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
280 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
281 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
282 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
283 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
285 
286// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
287const wagerRead = computed(() => {
288 const lookup = gameStore.archiveResult
289 const when = gameStore.contactWhen
290 if (!lookup?.knownSince || when == null) return null
291 const knownYear = parseKnownSinceYear(lookup.knownSince)
292 if (knownYear == null) return null
293 const tier = wagerTier(knownYear, when)
294 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
295 // Hedged copy: this is a year-gap compass, not the engine's verdict.
296 const text = tier === 'in-period'
297 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
298 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
299 return { tier, pips, text }
300})
301 
302// The pre-send chain chip: how far the chosen moment lands from the nearest
303// foothold (the objective's era or a prior change). Hidden at the hinge (full
304// force, nothing to flag); otherwise it reads the dilution + how to close it.
305const chainRead = computed(() => {
306 const s = gameStore.causalChainRead
307 if (!s || s.tier === 'at-hinge') return null
308 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
309})
310 
311// The last stand, armed by the player on their final dispatch.
312const stakeArmed = ref(false)
313 
314const figure = ref('')
315watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
316 
317const messageInputRef = ref()
318const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
319const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
320 
321// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
322const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
323 
324// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
325// this state is inert there). The run opens on the move; the instant a turn is rolling
326// we flip to the Board so the die + shift + spine land as the payoff beat, then the
327// player taps back to Compose. A nudge dot marks Compose when it's their move.
328type MobileTab = 'board' | 'story' | 'compose'
329const mobileTabs = [
330 { id: 'board', glyph: '🎲', label: 'Board' },
331 { id: 'story', glyph: '📖', label: 'Story' },
332 { id: 'compose', glyph: '✎', label: 'Compose' }
333] as const
334const mobileTab = ref<MobileTab>('compose')
335const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
336watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
337watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
338 
339const statusDot = computed(() =>
340 gameStore.gameStatus === 'victory' ? 'rv-dot--ok' : gameStore.gameStatus === 'defeat' ? 'rv-dot--bad' : 'rv-dot--accent'
342const statusWord = computed(() =>
343 gameStore.gameStatus === 'victory' ? 'Victory' : gameStore.gameStatus === 'defeat' ? 'Defeat' : 'Active'
345 
346// Thread rail (below md): open by default (the figure's replies are the game's most
347// charming content — collapsed-by-default made them too easy to miss). A fold is the
348// player's explicit choice now, so nothing force-reopens it. From md up it's forced
349// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
350// guard the handler so the native toggle event can't flip our own state.
351const threadOpen = ref(true)
352function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
353 
354// When a run begins, drop the cursor straight into the "who" field.
355function focusFigure() {
356 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
358watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
359 
360// Lock body scroll while the end-screen takeover is up (so the board can't peek).
361watch(() => gameStore.gameStatus, (s) => {
362 if (typeof document === 'undefined') return
363 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
364})
365 
366// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
367// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
368// a collapsible rail in the Story tab); tracked reactively.
369const isMd = ref(false)
370let mdMql: MediaQueryList | null = null
371function syncMd() { isMd.value = mdMql?.matches ?? false }
372onMounted(() => {
373 mdMql = window.matchMedia('(min-width: 768px)')
374 syncMd()
375 mdMql.addEventListener('change', syncMd)
376})
377onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
378 
379// Load the device's run balance for the header gauge, and handle the return from
380// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
381// then the query is stripped so a later refresh can't re-fire it.
382const route = useRoute()
383const router = useRouter()
384const supabase = useSupabaseClient()
385onMounted(async () => {
386 // A magic-link / email-change sign-in lands back here carrying an artifact to
387 // consume (?code= the Supabase client auto-exchanges; ?token_hash= we verify;
388 // ?error= means the link failed). On success the anonymous account upgrades in
389 // place — same id, runs carried over — so re-read the balance (the account view
390 // is server-derived) and strip the query so a reload can't re-fire a spent link.
391 const authReturn = readAuthReturnParams(route.query)
392 if (hasAuthReturn(authReturn)) {
393 const result = await finalizeAuthReturn(authReturn, {
394 settle: async () => {
395 const { data } = await supabase.auth.getSession()
396 return Boolean(data.session && !data.session.user.is_anonymous)
397 },
398 verifyOtp: (params) => supabase.auth.verifyOtp(params)
399 })
400 if (result.status === 'error') {
401 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
402 }
403 await gameStore.loadBalance()
404 router.replace({ query: {} })
405 return
406 }
407 
⋯ 115 lines hidden (lines 408–522)
408 const purchase = route.query.purchase
409 if (purchase === 'success' || purchase === 'cancel') {
410 await gameStore.notePurchaseReturn(purchase)
411 router.replace({ query: {} })
412 // The webhook credits asynchronously and can lag Stripe's redirect, so the
413 // first read may predate the credit. Poll briefly so the header gauge
414 // converges to the credited total without a manual reload — stopping as soon
415 // as it changes, or after a few tries (the credit is guaranteed by the
416 // webhook + its retries regardless). The banner asserts no specific count.
417 if (purchase === 'success') {
418 const before = gameStore.runsRemaining
419 for (let i = 0; i < 4; i++) {
420 await new Promise((r) => setTimeout(r, 1500))
421 await gameStore.loadBalance()
422 if (gameStore.runsRemaining !== before) break
423 }
424 }
425 } else {
426 await gameStore.loadBalance()
427 }
428})
429 
430// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
431// reloads, respects the OS preference, and ships a no-flash inline script — replacing
432// the old manual classList toggle that did none of those.
433const colorMode = useColorMode()
434const isDark = computed(() => colorMode.value === 'dark')
435function toggleDark() {
436 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
438 
439const handleSendMessage = async () => {
440 if (!messageInputRef.value) return
441 const messageText = messageInputRef.value.message?.trim()
442 const target = figure.value.trim()
443 if (!messageText || !messageInputRef.value.isValid || !target) return
444 
445 // Clear the composer only when the turn actually resolved — a blocked or
446 // failed send keeps the player's crafted 160 characters for another try.
447 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
448 if (resolved) {
449 stakeArmed.value = false
450 if (messageInputRef.value) messageInputRef.value.message = ''
451 }
453 
454// The header reset arms a confirm when a run is in progress (and auto-disarms);
455// performReset is the single unified reset path — the end screen's Play Again
456// lands here too, so every reset clears the figure input and composer alike.
457const resetArmed = ref(false)
458let resetArmTimer: ReturnType<typeof setTimeout> | null = null
459 
460const handleResetGame = () => {
461 // One tap only when there is truly nothing to lose: no resolved turns AND no
462 // words or contact in progress (a typed first dispatch is work too).
463 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
464 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
465 performReset()
466 return
467 }
468 resetArmed.value = true
469 if (resetArmTimer) clearTimeout(resetArmTimer)
470 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
472 
473function disarmReset() {
474 resetArmed.value = false
475 if (resetArmTimer) clearTimeout(resetArmTimer)
477 
478function performReset() {
479 disarmReset()
480 stakeArmed.value = false
481 gameStore.resetGame()
482 figure.value = ''
483 if (messageInputRef.value) messageInputRef.value.message = ''
484 focusFigure()
486 
487onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
488 
489useHead({
490 title: 'Revisionist — Rewrite History',
491 meta: [
492 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
493 ]
494})
495</script>
496 
497<style scoped>
498/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
499 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
500.mobile-tab {
501 display: flex;
502 flex-direction: column;
503 align-items: center;
504 justify-content: center;
505 gap: 2px;
506 padding: 8px 0 9px;
507 color: var(--rv-faint);
508 border-top: 2px solid transparent;
509 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
511.mobile-tab .rv-label { color: inherit; }
512.mobile-tab.is-active {
513 color: var(--rv-accent);
514 border-top-color: var(--rv-accent);
516 
517/* The armed last stand — a quiet accent wash, not a flashing alarm. */
518.stake-armed {
519 border-color: var(--rv-accent);
520 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
522</style>

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
nuxt.config.ts75 lines · TypeScript
⋯ 16 lines hidden (lines 1–16)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Register required modules
13 modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/supabase'],
14 
15 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
16 // user so the balance keys on a real user id from the first second; signing in
17 // with a magic link upgrades that same user in place (balance carries over).
18 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
19 // free-trial hook); buying is what requires an account. It also means the module
20 // registers no /confirm callback route, so the magic-link return is finalized in
21 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
22 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
23 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
24 // back to the Site URL and the link never reaches the handler. The client key is
25 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
26 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
27 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
28 supabase: {
29 url: process.env.SUPABASE_URL || 'https://placeholder.supabase.co',
30 key: process.env.SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_placeholder',
31 redirect: false,
32 // We don't use generated DB types (the balance store talks to Supabase with the
⋯ 43 lines hidden (lines 33–75)
33 // service client directly); disable the lookup so the module doesn't warn.
34 types: false
35 },
36 
37 // Global CSS imports
38 css: ['~/assets/css/main.css'],
39 
40 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
41 // class on <html> is exactly `dark` / `light` — what the .dark token block and
42 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
43 // the module's inline no-flash script + cookie persistence replace the old manual
44 // classList toggle (which didn't persist and could FOUC).
45 colorMode: {
46 classSuffix: '',
47 preference: 'system',
48 fallback: 'light'
49 },
50 
51 // Runtime configuration
52 runtimeConfig: {
53 // Private keys (only available on server-side)
54 openaiApiKey: process.env.OPENAI_API_KEY,
55 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
56 // Supabase — the run store (server-side only). Absent → in-memory dev store.
57 supabaseUrl: process.env.SUPABASE_URL,
58 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
59 // Stripe — run packs (server-side only). Absent → checkout returns 503.
60 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
61 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
62 // Canonical site origin for Stripe redirect URLs (never the client Host header).
63 siteUrl: process.env.REVISIONIST_BASE_URL,
64 // Public keys (exposed to client-side)
65 public: {}
66 },
67 
68 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
69 // out of the app's type program so `nuxt typecheck` never compiles the loops.
70 typescript: {
71 tsConfig: {
72 exclude: ['../agents']
73 }
74 }
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).

tests/unit/utils/auth-return.spec.ts · 100 lines
tests/unit/utils/auth-return.spec.ts100 lines · TypeScript
⋯ 40 lines hidden (lines 1–40)
1import { describe, it, expect, vi } from 'vitest'
2import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '../../../utils/auth-return'
3 
4describe('readAuthReturnParams (artifacts off the return URL)', () => {
5 it('reads a PKCE code', () => {
6 expect(readAuthReturnParams({ code: 'pkce-code' })).toMatchObject({ code: 'pkce-code' })
7 })
8 
9 it('reads a token hash with its stamped type', () => {
10 expect(readAuthReturnParams({ token_hash: 'th', type: 'email_change' }))
11 .toMatchObject({ tokenHash: 'th', type: 'email_change' })
12 })
13 
14 it('reads an error and its description', () => {
15 expect(readAuthReturnParams({ error: 'access_denied', error_description: 'expired' }))
16 .toMatchObject({ error: 'access_denied', errorDescription: 'expired' })
17 })
18 
19 it('coerces a repeated query key to its first non-empty value', () => {
20 expect(readAuthReturnParams({ code: ['', 'a', 'b'] }).code).toBe('a')
21 })
22 
23 it('ignores an unrelated query (the Stripe return is not a sign-in return)', () => {
24 expect(hasAuthReturn(readAuthReturnParams({ purchase: 'success' }))).toBe(false)
25 })
26})
27 
28describe('hasAuthReturn', () => {
29 it('is true when a code, token hash, or error is present', () => {
30 expect(hasAuthReturn({ code: 'x' })).toBe(true)
31 expect(hasAuthReturn({ tokenHash: 'x' })).toBe(true)
32 expect(hasAuthReturn({ error: 'x' })).toBe(true)
33 })
34 
35 it('is false for a bare type or an empty query', () => {
36 expect(hasAuthReturn({ type: 'email_change' })).toBe(false)
37 expect(hasAuthReturn({})).toBe(false)
38 })
39})
40 
41describe('finalizeAuthReturn (consuming the return-from-link artifact)', () => {
42 const client = (over: Record<string, unknown> = {}) => ({
43 verifyOtp: vi.fn().mockResolvedValue({ error: null }),
44 settle: vi.fn().mockResolvedValue(true),
45 ...over
46 })
47 
48 it('settles the auto-exchanged PKCE code → signed-in when a session resulted', async () => {
49 const c = client()
50 const r = await finalizeAuthReturn({ code: 'pkce-code' }, c)
51 expect(r.status).toBe('signed-in')
52 expect(c.settle).toHaveBeenCalledOnce()
53 expect(c.verifyOtp).not.toHaveBeenCalled()
54 })
55 
56 it('a code that yields no signed-in session → error, not a false sign-in', async () => {
57 const c = client({ settle: vi.fn().mockResolvedValue(false) })
58 const r = await finalizeAuthReturn({ code: 'stale-or-cross-browser' }, c)
59 expect(r.status).toBe('error')
60 })
61 
62 it('verifies a token hash with its stamped type → signed-in', async () => {
63 const c = client()
64 const r = await finalizeAuthReturn({ tokenHash: 'th', type: 'email_change' }, c)
65 expect(r.status).toBe('signed-in')
66 expect(c.verifyOtp).toHaveBeenCalledWith({ token_hash: 'th', type: 'email_change' })
67 })
68 
69 it('defaults an unknown or absent token type to email_change (the anon-upgrade case)', async () => {
70 const c1 = client()
⋯ 30 lines hidden (lines 71–100)
71 await finalizeAuthReturn({ tokenHash: 'th' }, c1)
72 expect(c1.verifyOtp).toHaveBeenCalledWith({ token_hash: 'th', type: 'email_change' })
73 
74 const c2 = client()
75 await finalizeAuthReturn({ tokenHash: 'th', type: 'bogus' }, c2)
76 expect(c2.verifyOtp).toHaveBeenCalledWith({ token_hash: 'th', type: 'email_change' })
77 })
78 
79 it('reports a verifyOtp failure as error, never a false sign-in', async () => {
80 const c = client({ verifyOtp: vi.fn().mockResolvedValue({ error: { message: 'token expired' } }) })
81 const r = await finalizeAuthReturn({ tokenHash: 'th', type: 'email_change' }, c)
82 expect(r.status).toBe('error')
83 })
84 
85 it('an error param short-circuits without consuming anything', async () => {
86 const c = client()
87 const r = await finalizeAuthReturn({ error: 'access_denied', errorDescription: 'expired' }, c)
88 expect(r.status).toBe('error')
89 expect(c.verifyOtp).not.toHaveBeenCalled()
90 expect(c.settle).not.toHaveBeenCalled()
91 })
92 
93 it('no artifact → none, nothing consumed', async () => {
94 const c = client()
95 const r = await finalizeAuthReturn({}, c)
96 expect(r.status).toBe('none')
97 expect(c.verifyOtp).not.toHaveBeenCalled()
98 expect(c.settle).not.toHaveBeenCalled()
99 })
100})