Files
yuanzonghao 3a012d46bf fix(auth): harden snapshot paths per PR agent review
Address two suggestions from the PR agent review:

1. lib/authResume.ts — catch isAuthed() exceptions in
   consumeResumeSnapshot. The network/timeout path now returns
   null (snapshot already removed earlier to prevent the play-page
   bootstrap's retryBootstrap loop from re-entering this path).
   Document the intentional removeItem-before-isAuthed ordering.

2. components/AuthModal.tsx — wrap onBeforeOAuth in try-catch so
   a snapshot failure (e.g. sessionStorage blocked in privacy mode)
   does not abort the OAuth flow and leave the UI stuck in loading.
2026-06-15 14:32:04 +08:00

90 lines
3.9 KiB
TypeScript

// Shared primitives for surviving an OAuth full-page round-trip.
//
// Google / GitHub OAuth is a full-page redirect: it unmounts the React tree
// and discards all in-memory state (the server is stateless, so the client
// carries everything). To resume where the user left off after the redirect,
// a page snapshots its domain state into sessionStorage just before navigating
// away, then consumes the snapshot on the next mount — but only if the user is
// now actually signed in.
//
// Email-OTP login never redirects (it resolves in-page), so it bypasses this
// machinery entirely and resumes synchronously via AuthModal.onSuccess.
//
// This module holds the three page-agnostic pieces: the login check, a
// quota-safe sessionStorage write (heavy data-URL fields are stripped on
// QuotaExceededError), and the consume-once resume gate. Each page keeps its
// own snapshot shape and restore side effects — only the plumbing is shared.
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
// True when auth is disabled (self-host with blank Supabase env) or the visitor
// already has a session. Gates any auth-required action (and the resume path).
export async function isAuthed(): Promise<boolean> {
if (!AUTH_ENABLED) return true;
const sb = createSupabaseClient();
const { data } = await sb.auth.getUser();
return !!data.user;
}
// Write a resume snapshot to sessionStorage with a quota-safe fallback.
// `fallbacks` is an ordered list of progressively-lighter payloads to try if
// the primary write fails (typically QuotaExceededError from a data-URL image).
// Each fallback drops some non-essential heavy field while keeping the data
// needed to resume. A dropped field only affects *future* generation (e.g. the
// painter on later scenes), never the scene being resumed, so degrading is
// graceful. Returns true if any write succeeded.
export function writeResumeSnapshot<T>(
key: string,
primary: T,
fallbacks: readonly T[] = [],
): boolean {
const tryWrite = (candidate: T): boolean => {
try {
sessionStorage.setItem(key, JSON.stringify(candidate));
return true;
} catch {
return false; // QuotaExceededError (or disabled storage)
}
};
if (tryWrite(primary)) return true;
for (const fb of fallbacks) {
if (tryWrite(fb)) return true;
}
return false;
}
// Consume-once resume gate. Returns the parsed snapshot if one exists at `key`
// AND the user is now signed in (so a stale snapshot from a failed/abandoned
// login doesn't resurrect a half-flow). Always removes the entry — either it's
// consumed here, or it's stale and must not linger. Returns null when there's
// nothing to resume, the user isn't signed in, or the payload is corrupt.
//
// `removeItem` intentionally runs before `isAuthed()` so that a network error
// during the auth check does not leave a zombie snapshot behind. Without this
// ordering, callers that guard on the snapshot's presence (play-page bootstrap)
// would re-enter this path on every effect cycle, producing an infinite retry
// loop. Dropping the snapshot on a transient network glitch is an acceptable
// trade-off — the worst case is the user lands on the first scene instead of
// resuming mid-story, which is the same experience as before this feature.
export async function consumeResumeSnapshot<T>(key: string): Promise<T | null> {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
sessionStorage.removeItem(key);
let authed: boolean;
try {
authed = await isAuthed();
} catch {
// Network / unexpected error during auth check. Snapshot already removed
// (prevents the caller's retry loop); return null so callers fall back to
// their default path (normal bootstrap).
return null;
}
if (!authed) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null; // corrupt snapshot — ignore
}
}