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
+38 -4
View File
@@ -25,22 +25,28 @@ function files(dir: string): string[] {
}
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
// kept by the store that applies them: it paints the first frame, and for the language the
// signed-out pages too, before the account is known.
// Preferences whose local copy is kept by the store that applies them. The Settings page's
// (PrefsProvider saves them to the server): the store paints the first frame, and for the language
// the signed-out pages too, before the account is known. The side panels' group cards: App seeds
// them from the account and saves every change to it, through lib/panelLayout.ts.
const SYNCED_BY_STORE: Record<string, string> = {
theme: "lib/theme.ts",
hints: "lib/hints.ts",
notifSettings: "lib/notifications.ts",
lang: "i18n/index.ts",
sidebarLayout: "lib/panelLayout.ts",
plexLayout: "lib/panelLayout.ts",
playlistsLayout: "lib/panelLayout.ts",
filesLayout: "lib/panelLayout.ts",
};
describe("storage kinds", () => {
it("spells out no key outside the registry", () => {
// The debug recorder's BroadcastChannel is a channel name, not a stored key.
const allowed = new Set(['"siftlode.debugSession"']);
// Any literal that starts the namespace, so a key built by concatenation or a template counts.
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
[...text.matchAll(/["'`]siftlode\.[\w.]+["'`]/g)]
[...text.matchAll(/["'`]siftlode\.[^"'`]*["'`]?/g)]
.map((m) => m[0])
.filter((lit) => !allowed.has(lit))
.map((lit) => `${f}: ${lit}`),
@@ -48,6 +54,23 @@ describe("storage kinds", () => {
expect(hits).toEqual([]);
});
it("hands the Storage API no key of its own", () => {
// A key outside the namespace (`"sf.x"`) would slip past the scan above and the claim's wipe:
// a literal handed to the Storage API, or a constant holding one.
const call = /(?:localStorage|sessionStorage)\.(?:getItem|setItem|removeItem)\(\s*([^,)]+)/g;
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
[...text.matchAll(call)]
.map((m) => m[1]!.trim())
.filter(
(arg) =>
/^["'`]/.test(arg) ||
new RegExp(`\\b(?:const|let)\\s+${arg}\\s*=\\s*["'\`]`).test(text),
)
.map((arg) => `${f}: ${arg}`),
);
expect(hits).toEqual([]);
});
it("keeps an account preference on the server: its local copy only through usePref", () => {
const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
expect(prefs.length).toBeGreaterThan(10);
@@ -63,6 +86,17 @@ describe("storage kinds", () => {
expect(hits).toEqual([]);
});
// usePref saves what it holds to the account: a device, view or cache key handed to it would follow
// the account to every device.
it("hands usePref a preference and nothing else", () => {
const hits = SOURCES.flatMap(({ f, text }) =>
[...text.matchAll(/\blocalKey:\s*LS\.(\w+)/g)]
.filter((m) => KIND_OF.get(LS[m[1] as keyof typeof LS]) !== "pref")
.map((m) => `${f}: LS.${m[1]}`),
);
expect(hits).toEqual([]);
});
it("lists only preferences as kept by their store", () => {
for (const name of Object.keys(SYNCED_BY_STORE))
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");