refactor(frontend): one account per browser, its local state under plain keys

The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone
says who is asking. localStorage keys lose their account suffix; the browser records whose state it
holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different
account starts from a clean slate (the public /watch keys stay). The first boot moves the account's
old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and
after another tab claimed the browser for someone else, the account helpers read and write nothing.

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
This commit is contained in:
2026-10-20 15:29:41 +02:00
parent 6645caa157
commit d6dada7fe5
51 changed files with 672 additions and 559 deletions
@@ -1,89 +0,0 @@
// @vitest-environment jsdom
//
// The notification and hints stores read their per-account localStorage when they are imported. In a new
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
// nobody's history, and the tab's first notice replaced the account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const storedMessages = (account: number) =>
(
JSON.parse(localStorage.getItem(`siftlode.notifications.${account}`) ?? "[]") as {
message: string;
}[]
).map((n) => n.message);
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
// vi.resetModules, so each case still gets fresh module instances, only faster.
beforeAll(async () => {
await import("./notifications");
await import("./hints");
await import("./api/core");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
setActiveAccount(5);
notifications.notify({ message: "after the pin" });
expect(storedMessages(5)).toEqual(["older", "before the pin", "after the pin"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once the tab is pinned", async () => {
localStorage.setItem("siftlode.hints.5", "0");
const hints = await import("./hints");
const { setActiveAccount } = await import("./api/core");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
setActiveAccount(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => {
it("leaves the previous account's notices with that account", async () => {
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
sessionStorage.setItem("siftlode.activeAccount", "4");
localStorage.setItem("siftlode.notifications.4", JSON.stringify([entry(7, "four's")]));
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(8, "five's")]));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "as four" });
setActiveAccount(5);
notifications.notify({ message: "as five" });
expect(storedMessages(5)).toEqual(["five's", "as five"]);
expect(storedMessages(4)).toEqual(["four's", "as four"]);
});
});
@@ -1,37 +0,0 @@
// @vitest-environment jsdom
//
// A switch or sign-out hands the tab's next page its account instead of moving the pin: the page being left
// still unloads as its own account. The players' resume beacons read the pin as they fire on the way out,
// and with the pin already moved they saved one account's playback position into the other.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
sessionStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "3");
vi.resetModules(); // the hand-over is taken at import: each import below is a page load
});
describe("pinOnNextPage", () => {
it("leaves the pin alone until the next page, which takes the account over once", async () => {
const leaving = await import("./activeAccount");
leaving.pinOnNextPage(7);
expect(leaving.activeAccountId()).toBe(3); // the unload still acts as 3
vi.resetModules();
const next = await import("./activeAccount");
expect(next.activeAccountId()).toBe(7);
next.pinActiveAccount(9); // BootGate corrects it
vi.resetModules(); // and an F5 keeps the correction
expect((await import("./activeAccount")).activeAccountId()).toBe(9);
});
it("hands over no account at all, so the next page adopts the session's default", async () => {
const leaving = await import("./activeAccount");
leaving.pinOnNextPage(null);
expect(leaving.activeAccountId()).toBe(3);
vi.resetModules();
expect((await import("./activeAccount")).activeAccountId()).toBeNull();
});
});
-78
View File
@@ -1,78 +0,0 @@
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module
// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage
// on it, without a circular import — the account id is a lower-level primitive than the storage
// helpers that consume it.
//
// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads
// the page (handing the new pin over through `pinOnNextPage`), so nothing mounted ever sees the id change
// underneath it. The one exception is state read at
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
const pinListeners = new Set<() => void>();
export function onAccountPinned(reload: () => void): void {
pinListeners.add(reload);
}
export function pinActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
pinListeners.forEach((reload) => reload());
}
// The account the tab's NEXT page is pinned to, handed over by a switch or sign-out that is about to leave.
// The pin itself stays until the page is gone: the unload still runs as the account the page was built for
// (the players' resume beacons read the pin as they fire), and with the pin already moved it would save one
// account's position into the other.
const NEXT_ACCOUNT_KEY = "siftlode.nextAccount";
/** Pin the tab's next page to `id` — or to none, so it adopts the session's default account. */
export function pinOnNextPage(id: number | null): void {
try {
sessionStorage.setItem(NEXT_ACCOUNT_KEY, id == null ? "" : String(id));
} catch {
/* ignore */
}
}
// Taken over at import, before anything reads the pin.
try {
const next = sessionStorage.getItem(NEXT_ACCOUNT_KEY);
if (next != null) {
sessionStorage.removeItem(NEXT_ACCOUNT_KEY);
if (next) sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, next);
else sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
}
} catch {
/* ignore */
}
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
// Append `?account=<active>` to a top-level navigation URL (SEC-11). XHRs carry the active account in
// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth
// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the
// identity/token would then land on the wrong account. This threads the active account through the URL
// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet.
export function withActiveAccount(path: string): string {
const id = activeAccountId();
if (id == null) return path;
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
}
+2 -2
View File
@@ -6,10 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
import { HttpError, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
export { HttpError, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
+2 -15
View File
@@ -49,14 +49,6 @@ export function displayName(a: { display_name: string | null; email: string }):
return a.display_name ?? a.email.split("@")[0]!;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
@@ -73,14 +65,9 @@ export const accountApi = {
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
// Ends this browser's session.
logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, unknown>) =>
+3 -8
View File
@@ -16,11 +16,6 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
@@ -155,9 +150,9 @@ describe("uploadReq", () => {
});
it("lets its own identity headers win over a caller's", () => {
// Who a request is from is this layer's to state, not its caller's. Merged the other way round,
// a caller's header of the same name would silently re-attribute the upload — to another
// account, or (here) into someone else's debug session recording.
// What a request is part of is this layer's to state, not its caller's. Merged the other way
// round, a caller's header of the same name would silently re-attribute the upload into someone
// else's debug session recording.
setDebugSession("0123456789abcdef");
try {
void uploadReq("/u", body(), {
+6 -21
View File
@@ -1,12 +1,11 @@
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
// error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
@@ -268,28 +267,16 @@ export const setupHeaders = (token: string) => ({
"X-Setup-Token": token,
});
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
export const getActiveAccount = activeAccountId;
/** Who this request is from — the tab's account and the debug session it records into — as the
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
* cannot quietly go out without them. */
/** What this request carries besides the session cookie (which alone says who is asking) — the
* debug session it records into — as the headers every request path sends (`req`, `beacon`,
* `uploadReq`): one definition, so a path cannot quietly go out without them. */
export function identityHeaders(): Record<string, string> {
const active = getActiveAccount();
const session = currentDebugSession();
return {
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
};
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
@@ -315,8 +302,6 @@ export function beacon(url: string, body: Record<string, unknown>, onSettled?: (
}
}
export const setActiveAccount = pinActiveAccount;
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
@@ -398,7 +383,7 @@ export async function req<T = unknown>(
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
@@ -13,11 +13,6 @@ vi.mock("../notifications", () => ({
getActiveToasts: vi.fn((): { id: number }[] => []),
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { debugSessionsApi } from "./diagnostics";
+4 -9
View File
@@ -2,7 +2,7 @@
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import type { ServerLogRow } from "./admin";
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
import { beacon, identityHeaders, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -123,12 +123,7 @@ export const debugSessionsApi = {
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
* per-tab account's screenshot resolves to the session default and 404s. */
debugScreenshotUrl: (id: string, shotId: number): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
return a != null ? `${url}?account=${a}` : url;
},
/** Where an `<img>` loads a screenshot from. */
debugScreenshotUrl: (id: string, shotId: number): string =>
`${SESSIONS}/${id}/screenshots/${shotId}`,
};
+6 -18
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { beacon, req, getActiveAccount } from "./core";
// reference each other, and its methods only touch the shared `req`/`beacon` from core.
import { beacon, req } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -314,22 +314,10 @@ export const downloadsApi = {
},
onSettled,
),
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
: `/api/downloads/${id}/storyboard.jpg`;
},
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
// which reads as a blank Library thumbnail once the poster is the card's primary image.
posterImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/poster.jpg?account=${a}`
: `/api/downloads/${id}/poster.jpg`;
},
// Filmstrip sprite (an <img>/background src).
storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`,
// The locally-generated poster (an <img> src).
posterImageUrl: (id: number): string => `/api/downloads/${id}/poster.jpg`,
// admin
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
+5 -14
View File
@@ -1,6 +1,6 @@
// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { getActiveAccount, req, uploadReq } from "./core";
import { req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
@@ -144,19 +144,10 @@ export const filesApi = {
headers,
});
},
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
// file resolves to the session-default account and 404s for a per-tab account's file.
fileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// The file itself, for a plain <a> navigation.
fileUrl: (id: number): string => `/api/files/${id}/file`,
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source.
previewUrl: (id: number): string => `/api/files/${id}/preview`,
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The notification and hints stores read the account's localStorage when they are imported, which is
// before BootGate knows the account, so they read as nobody's; they must read again on the claim. The
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const storedMessages = () =>
(
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
message: string;
}[]
).map((n) => n.message);
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
// vi.resetModules, so each case still gets fresh module instances, only faster.
beforeAll(async () => {
await import("./notifications");
await import("./hints");
await import("./storage");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
claimLocalData(5);
notifications.notify({ message: "after the claim" });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.hints", "0");
const hints = await import("./hints");
const { claimLocalData } = await import("./storage");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
claimLocalData(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
it("starts the new account's notices from an empty history", async () => {
localStorage.setItem("siftlode.owner", "4");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")]));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: "as five" });
expect(storedMessages()).toEqual(["as five"]);
});
});
+3 -4
View File
@@ -1,17 +1,16 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { onAccountPinned } from "./activeAccount";
import { createStore } from "./store";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
import { getAccountRaw, LS, onLocalDataClaimed, setAccountRaw } from "./storage";
function load(): boolean {
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
}
const store = createStore<boolean>(load());
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
onAccountPinned(() => store.set(load()));
// Read at import — before BootGate knows the account — so read again once it is claimed.
onLocalDataClaimed(() => store.set(load()));
export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
+151
View File
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
//
// A browser holds ONE account's local state: the keys carry no account id, and the browser records
// whose they are. These pin the boot-time claim — the move of the old per-account keys onto the plain
// ones, the wipe when another account starts here — and the guard that keeps a tab from reading or
// writing them before its account is known, or after another tab has claimed them for someone else.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the claim lives in module state: each case starts unclaimed
});
const storage = () => import("./storage");
const keys = (s: Storage) => Object.keys(s).sort();
describe("claiming the browser's local data", () => {
it("moves the account's old per-account copies onto the plain keys and drops other accounts' copies", async () => {
localStorage.setItem("siftlode.filters.1", '{"q":"mine"}');
localStorage.setItem("siftlode.filters.7", '{"q":"theirs"}');
localStorage.setItem("siftlode.onboarding.dismissed.1", "1");
localStorage.setItem("siftlode.notifications.7", "[]");
sessionStorage.setItem("siftlode.settingsTab.1", "account");
sessionStorage.setItem("siftlode.settingsTab.7", "plex");
const { claimLocalData, LS } = await storage();
claimLocalData(1);
expect(localStorage.getItem(LS.filters)).toBe('{"q":"mine"}');
expect(localStorage.getItem(LS.onboardingDismissed)).toBe("1");
expect(sessionStorage.getItem(LS.settingsTab)).toBe("account");
expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
);
expect(keys(sessionStorage)).toEqual(["siftlode.settingsTab"]);
});
it("an old per-account copy wins over a plain key already there", async () => {
localStorage.setItem("siftlode.hints", "1");
localStorage.setItem("siftlode.hints.1", "0");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.hints")).toBe("0");
});
it("leaves a key that only looks like an account copy alone", async () => {
localStorage.setItem("siftlode.somethingElse.3", "x");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.somethingElse.3")).toBe("x");
});
it("drops the per-tab account pin the multi-account build kept", async () => {
sessionStorage.setItem("siftlode.activeAccount", "1");
sessionStorage.setItem("siftlode.nextAccount", "7");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(sessionStorage.getItem("siftlode.activeAccount")).toBeNull();
expect(sessionStorage.getItem("siftlode.nextAccount")).toBeNull();
});
it("another account starting here wipes the previous one's state, but not the browser's own", async () => {
localStorage.setItem("siftlode.owner", "7");
localStorage.setItem("siftlode.filters", '{"q":"theirs"}');
localStorage.setItem("siftlode.theme", "dark");
localStorage.setItem("siftlode.watchResume", '{"tok":{"at":5,"t":1}}');
localStorage.setItem("siftlode.watchRepeat", "false");
localStorage.setItem("unrelated.key", "kept");
sessionStorage.setItem("siftlode.settingsTab", "plex");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(keys(localStorage)).toEqual(
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
);
expect(localStorage.getItem("siftlode.owner")).toBe("1");
expect(keys(sessionStorage)).toEqual([]);
});
it("the same account starting again keeps everything", async () => {
localStorage.setItem("siftlode.owner", "1");
localStorage.setItem("siftlode.filters", '{"q":"mine"}');
sessionStorage.setItem("siftlode.settingsTab", "account");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.filters")).toBe('{"q":"mine"}');
expect(sessionStorage.getItem("siftlode.settingsTab")).toBe("account");
});
it("tells the stores read at import to read again", async () => {
const { claimLocalData, onLocalDataClaimed } = await storage();
const reread = vi.fn();
onLocalDataClaimed(reread);
claimLocalData(1);
expect(reread).toHaveBeenCalledOnce();
});
});
describe("the account's local state", () => {
it("is neither read nor written before the account is known", async () => {
localStorage.setItem("siftlode.hints", "0");
const { getAccountRaw, setAccountRaw, readAccount, writeAccount, ownKey } = await storage();
expect(getAccountRaw("siftlode.hints")).toBeNull();
expect(readAccount("siftlode.filters", "fallback")).toBe("fallback");
expect(ownKey("siftlode.filters")).toBeNull();
setAccountRaw("siftlode.page", "plex");
writeAccount("siftlode.filters", { q: "x" });
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(localStorage.getItem("siftlode.filters")).toBeNull();
});
it("is read and written under the plain key once claimed", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw, ownKey } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
expect(localStorage.getItem("siftlode.page")).toBe("plex");
expect(getAccountRaw("siftlode.page")).toBe("plex");
expect(ownKey("siftlode.filters")).toBe("siftlode.filters");
});
it("stops being touched once another tab has claimed the browser for someone else", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
// Another tab signed in as account 6: its boot wiped the state and recorded the new owner.
localStorage.clear();
localStorage.setItem("siftlode.owner", "6");
localStorage.setItem("siftlode.hints", "0"); // account 6's
setAccountRaw("siftlode.page", "feed");
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(getAccountRaw("siftlode.hints")).toBeNull();
});
});
+8 -11
View File
@@ -2,8 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
@@ -226,18 +225,16 @@ function emit() {
recompute();
// The history and settings above are read at import, which in a new tab is before BootGate pins its
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
// tab's first write would replace the account's stored history with its own empty one. A notice raised
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
let loadedFor = activeAccountId();
onAccountPinned(() => {
const unpinned = loadedFor == null ? items : [];
loadedFor = activeAccountId();
// The history and settings above are read at import, before BootGate knows the account — so they
// read as nobody's. Read them again once the account is claimed (once per page: a different account
// reloads it): otherwise the first write would replace the account's stored history with an empty one.
// A notice raised before the claim (a failed boot probe) belongs to no account, so it stays, after the
// history.
onLocalDataClaimed(() => {
config = loadSettings();
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
const history = load().map((n) => ({ ...n, id: counter++ }));
items = [...history, ...unpinned].slice(-MAX_HISTORY);
items = [...history, ...items].slice(-MAX_HISTORY);
recompute();
listeners.forEach((l) => l());
});
+1 -1
View File
@@ -16,7 +16,7 @@ import { connectGoogle } from "./googleConnect";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// The account's (see ownKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it.
const ONBOARD_DISMISSED = LS.onboardingDismissed;
+3 -4
View File
@@ -5,15 +5,14 @@
// never show a layout changed on another device.
import { beforeEach, describe, expect, it } from "vitest";
import { seedLayout } from "./panelLayout";
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
import { accountKey, LS } from "./storage";
import { claimLocalData, LS } from "./storage";
const local = { order: [], collapsed: { genre: true }, hidden: {} };
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
localStorage.setItem(accountKey(LS.plexLayout, 5)!, JSON.stringify(local));
claimLocalData(5);
localStorage.setItem(LS.plexLayout, JSON.stringify(local));
});
describe("seedLayout", () => {
-1
View File
@@ -49,7 +49,6 @@ export const qk = {
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
// --- account / misc --- (me/myStatus/tags already declared as shared roots above)
accounts: () => ["accounts"] as const,
version: () => ["version"] as const,
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
releaseNotes: (lang: string) => ["release-notes", lang] as const,
@@ -49,4 +49,26 @@ describe("removed features stay removed", () => {
);
expect(hits).toEqual([]);
});
// One account per browser session (2026-10-20): the per-tab account pin, the header and `?account=`
// that carried it, and the account list and switch went. A request naming an account would now
// be ignored by the server, so a pin left behind reads as working while it does nothing. Only the
// claim that clears the old pin keys (storage.ts) and its test may name them.
it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
const PIN =
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
const allowed = new Set([
"frontend/src/lib/storage.ts",
"frontend/src/lib/localData.dom.test.ts",
]);
const hits = ["frontend/src", "frontend/e2e"]
.flatMap(files)
.filter((f) => !allowed.has(f) && f !== "frontend/src/lib/removedFeatures.contract.test.ts")
.flatMap((f) =>
readFileSync(new URL(f, REPO), "utf-8")
.split("\n")
.flatMap((line, i) => (PIN.test(line) ? [`${f}:${i + 1}: ${line.trim()}`] : [])),
);
expect(hits).toEqual([]);
});
});
+91 -33
View File
@@ -1,5 +1,4 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
@@ -35,8 +34,9 @@ export const LS = {
page: "siftlode.page",
// Start every session on the Home surface rather than on the module you happened to leave
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
// account is pinned — so the landing is decided right before the first paint, never corrected after.
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
// corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
@@ -96,43 +96,103 @@ export const LS = {
watchResume: "siftlode.watchResume",
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
// --- the signed-in account's local state --------------------------------------------------
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
// records whose state it holds instead (OWNER_KEY), and an account that is not the recorded one starts
// from a clean slate. The account is known only once BootGate has it from the server, and `claimLocalData`
// is how BootGate hands it over; until then the helpers below read nothing and write nothing, so the
// signed-out landing, or a boot still asking, cannot touch the state of whoever signs in next.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
* of touching another account's data. */
export function accountKey(
base: string,
id: number | null | undefined = activeAccountId(),
): string | null {
return id != null ? `${base}.${id}` : null;
const OWNER_KEY = "siftlode.owner";
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
const BROWSER_KEYS: ReadonlySet<string> = new Set([LS.watchRepeat, LS.watchResume, OWNER_KEY]);
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
// the other accounts' copies; only keys the registry knows are touched.
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
const REGISTERED: ReadonlySet<string> = new Set(Object.values(LS));
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
let claimed: string | null = null;
const claimListeners = new Set<() => void>();
/** Re-read module-level state read at import (the notification and hints stores), which runs before
* BootGate knows the account. */
export function onLocalDataClaimed(reread: () => void): void {
claimListeners.add(reread);
}
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
function each(store: Storage, visit: (key: string) => void): void {
for (const key of Object.keys(store)) visit(key);
}
function settleStore(store: Storage, id: string, wipe: boolean): void {
each(store, (key) => {
if (!key.startsWith("siftlode.")) return;
if (wipe && !BROWSER_KEYS.has(key)) {
store.removeItem(key);
return;
}
const [, base, owner] = ACCOUNT_COPY.exec(key) ?? [];
if (!base || !REGISTERED.has(base)) return;
const value = store.getItem(key);
if (owner === id && value != null) store.setItem(base, value);
store.removeItem(key);
});
}
/** Hand the browser's local state to the signed-in account `id` (BootGate, before the app mounts).
* Another account's state is wiped first; the old per-account copies are moved onto the plain keys. */
export function claimLocalData(id: number): void {
const me = String(id);
try {
const owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me;
settleStore(localStorage, me, wipe);
settleStore(sessionStorage, me, wipe);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me);
} catch {
/* storage unavailable: nothing to settle */
}
claimed = me;
claimListeners.forEach((reread) => reread());
}
/** `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 {
if (claimed == null) return null;
try {
return localStorage.getItem(OWNER_KEY) === claimed ? base : null;
} catch {
return null;
}
}
/** The account's read/write helpers: no-op / the fallback while the state is not this tab's (see ownKey). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
const k = ownKey(base);
try {
return k ? localStorage.getItem(k) : null;
} catch {
return null;
}
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) {
try {
localStorage.setItem(k, value);
@@ -176,11 +236,9 @@ export function writeJSON(key: string, value: unknown): void {
// --- reactive persisted string state ----------------------------------------------------
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
/** A `useState` whose string value is mirrored to the account's localStorage (see ownKey). Survives reload (F5); validation is deferred to the caller (clamp at render) so it
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
* mount: BootGate mounts every consumer after the account is pinned. */
* mount: BootGate mounts every consumer after the account is claimed. */
export function useAccountPersistedState(
base: string,
fallback = "",
@@ -201,13 +259,13 @@ export function useAccountPersistedState(
// --- a page's sub-tab, per browser tab ---------------------------------------------------------
// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser
// tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new
// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new
// tab opens the page on its first sub-tab (the owner's call: a position remembered forever in
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
/** The sub-tab stored for this browser tab, or null. */
function getTabRaw(base: string): string | null {
const k = accountKey(base);
const k = ownKey(base);
try {
return k ? sessionStorage.getItem(k) : null;
} catch {
@@ -217,7 +275,7 @@ function getTabRaw(base: string): string | null {
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
export function setTabRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (!k) return;
try {
sessionStorage.setItem(k, value);
@@ -233,7 +291,7 @@ export function useTabPersistedState(
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => {
// The position used to live in localStorage; a value left there would never be read again.
const old = accountKey(base);
const old = ownKey(base);
if (old) {
try {
localStorage.removeItem(old);
+6 -7
View File
@@ -5,11 +5,10 @@
// (sessionStorage): it survives a reload and a trip to another page, and a new tab starts on the first.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
import { setTabRaw, useTabPersistedState } from "./storage";
import { claimLocalData, setTabRaw, useTabPersistedState } from "./storage";
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "1");
claimLocalData(1);
});
afterEach(() => {
sessionStorage.clear();
@@ -21,8 +20,8 @@ describe("a page's sub-tab, per browser tab", () => {
const first = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
expect(first.result.current[0]).toBe("access");
act(() => first.result.current[1]("plex"));
expect(sessionStorage.getItem("siftlode.configTab.1")).toBe("plex");
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
expect(sessionStorage.getItem("siftlode.configTab")).toBe("plex");
expect(localStorage.getItem("siftlode.configTab")).toBeNull();
// The page mounts again in this tab (a reload, or back from another page): it is where it was.
first.unmount();
expect(
@@ -31,10 +30,10 @@ describe("a page's sub-tab, per browser tab", () => {
});
it("ignores a position left in localStorage by an older build, and clears it", () => {
localStorage.setItem("siftlode.configTab.1", "plex");
localStorage.setItem("siftlode.configTab", "plex");
const { result } = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
expect(result.current[0]).toBe("access");
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
expect(localStorage.getItem("siftlode.configTab")).toBeNull();
});
it("a pre-selected tab is the one the page opens on", () => {
+2 -1
View File
@@ -14,13 +14,14 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { useCardPager } from "./useCardPager";
import { claimLocalData } from "./storage";
const rows = (n: number) => Array.from({ length: n }, (_, i) => i);
const KEY = "siftlode.test.cardSize";
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1");
claimLocalData(1);
});
describe("useCardPager — an out-of-range page", () => {