fix(guests): expire stale tokens, sync tabs, survive unwritable storage

Codex review round 1 on #1268. All three findings are consequences of the
storage move itself.

Expired tokens now read as absent. GUEST_TOKEN_TTL is 30 days and
sessionStorage almost never survived that long, so 'stored but expired' was
unreachable before; persisting the token makes it routine. Nothing else
clears it -- the 401 handler in config/api.ts only drops gallery_event_<slug>
-- so the visitor was shown as signed in while every like 401'd, and
ensureIdentity() short-circuited so recovery was never offered. The signature
is still the server's business; an unparseable token is left alone.

Tabs now stay in step. localStorage is shared where sessionStorage gave each
tab its own copy, so 'Not you?' or a registration in one tab silently changed
the token every other tab sends while they still displayed the old name --
their likes would land on the new guest, the exact misattribution this branch
set out to stop. A storage listener rehydrates the others.

Storage is probed for writability, not just readability. A store that reads
but throws on setItem (quota, private mode) sailed past the read-only guard,
and storeGuestIdentity threw after the server had created the guest: failed
registration, retry, duplicate row. Writes are also wrapped so a storage
failure degrades to a per-session identity instead of rejecting registration.
This commit is contained in:
Paul Nothaft
2026-09-02 10:22:55 +02:00
parent a21c4d3bf5
commit 51db1e09e9
3 changed files with 176 additions and 12 deletions
@@ -67,6 +67,27 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
setIdentity(getGuestIdentity(slug));
}, [slug]);
// Keep tabs in step. The identity now lives in localStorage, which is shared
// across tabs — where sessionStorage gave each tab its own copy. So "Not
// you?" or a fresh registration in one tab silently changes the token the
// axios interceptor sends from every other tab, while those tabs still show
// the old name. Their likes would then be recorded against the new guest:
// the same misattribution this change set out to stop.
//
// `storage` fires only in the OTHER tabs, which is exactly the audience that
// needs to catch up. A null key means the whole store was cleared.
useEffect(() => {
if (typeof window === 'undefined') return;
const onStorage = (event: StorageEvent) => {
if (event.key && event.key !== `guest_token_${slug}` && event.key !== `guest_identity_${slug}`) {
return;
}
setIdentity(getGuestIdentity(slug));
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [slug]);
// When an invite token is present on the URL (?invite=xxx), redeem it once
// on mount. The server returns a guest token we can persist.
useEffect(() => {