fix(auth): a tab acts only as the account it was built for, and the review's other findings

A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
2026-10-21 01:05:12 +02:00
parent 28b77fed1e
commit 94418a087e
39 changed files with 576 additions and 78 deletions
+27 -10
View File
@@ -3,8 +3,11 @@ import { useCallback, useState } from "react";
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
// (storageKinds.contract.test.ts).
// declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
// reading of the source can: no key is spelled out (or handed to the Storage API as a literal)
// anywhere else, a `pref` is read and written only through usePref or the one store listed as
// keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does
// with its key is the reviewer's to check.
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
// follows the account to every device — usePref, whose local copy only carries a value
// this browser held before the server did.
@@ -17,11 +20,12 @@ export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
const KEYS = {
theme: { key: "siftlode.theme", kind: "pref" },
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
// A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
filesLayout: { key: "siftlode.filesLayout", kind: "pref" },
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
// The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: { key: "siftlode.filesView", kind: "view" },
@@ -107,10 +111,12 @@ const KEYS = {
// reason as above: the viewer has no account to keep it in.
watchResume: { key: "siftlode.watchResume", kind: "public" },
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and
// when the tab last reloaded onto a newer build (`lib/staleBuild.ts`).
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
logTab: { key: "siftlode.logTab", kind: "device" },
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
/** Every key the app stores, by name. */
@@ -178,12 +184,16 @@ function settleStore(store: Storage, id: string, wipe: boolean): void {
export function claimLocalData(id: number): void {
const me = String(id);
try {
// Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the
// account another tab already claimed the browser for still holds the previous account's. A
// tab that records no owner (one a build before this opened) goes by the browser's.
const owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me;
settleStore(localStorage, me, wipe);
settleStore(sessionStorage, me, wipe);
const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
settleStore(localStorage, me, owner != null && owner !== me);
settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me);
sessionStorage.setItem(OWNER_KEY, me);
} catch {
/* storage unavailable: nothing to settle */
}
@@ -191,6 +201,13 @@ export function claimLocalData(id: number): void {
claimListeners.forEach((reread) => reread());
}
/** The account this tab was built for (claimed at boot), as its id; null before the claim. Every
* request says so (`identityHeaders`): the server refuses one from a tab whose browser has since
* signed in as someone else, and the tab reloads as whoever is signed in now. */
export function claimedAccount(): string | null {
return claimed;
}
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
export function ownKey(base: string): string | null {