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
+4 -2
View File
@@ -59,8 +59,10 @@ user), reset to a deterministic baseline every run by
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
one in-progress + one watched + one saved video, prefs pinned to `language: en`. A second account,
the plain **member** `e2e-member@siftlode.local`, gets the same baseline and its own session
(`e2e/.auth/member.json`); only `member.spec.ts` runs as it, holding what a non-admin sees (no admin
entry, no admin call, an admin link lands on the feed). Credentials come from `E2E_EMAIL` (default
(`e2e/.auth/member.json`). Two specs run as it: `member.spec.ts`, holding what a non-admin sees (no
admin entry, no admin call, no failed request, an admin link lands on the feed), and `devices.spec.ts`,
which signs it in on a desktop and an iPad at once and changes its channel layout on the server (put
back in a `finally`) — a spec that reads the member's server prefs must not run beside it. Credentials come from `E2E_EMAIL` (default
`e2e@siftlode.local`), `E2E_MEMBER_EMAIL` (default `e2e-member@siftlode.local`) and `E2E_PASSWORD`
(both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it
from the secret store. Override the container name with
+6
View File
@@ -39,6 +39,12 @@ test("a preference follows the member to another device, a device setting stays"
await gotoPage(desktop, "settings");
await desktop.getByRole("switch", { name: "Performance mode" }).click();
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1");
// The Settings prefs save as one payload: the next one (a hints change) would carry performance
// mode if it went to the account. Waited for, so the iPad below cannot simply load first.
const hints = desktop.getByRole("switch", { name: "Show hints" });
const [save] = await Promise.all([savedPrefs(desktop), hints.click()]);
expect(Object.keys(save.request().postDataJSON() as object)).not.toContain("performanceMode");
await Promise.all([savedPrefs(desktop), hints.click()]);
await openApp(ipad);
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1");
+2 -1
View File
@@ -12,7 +12,8 @@ import { retryConnect } from "./retryConnect";
* 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent);
* 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own
* storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite
* runs as the admin; `member.spec.ts` runs as the plain member, from MEMBER_STORAGE.
* runs as the admin; `member.spec.ts` and `devices.spec.ts` run as the plain member, from
* MEMBER_STORAGE.
*/
const STORAGE = "e2e/.auth/state.json";
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` runs from it. */
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */
export const MEMBER_STORAGE = "e2e/.auth/member.json";
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
+26 -7
View File
@@ -19,21 +19,39 @@ const CORE = [
"settings",
];
/** Collect every admin call and every refused response the page makes while `run` goes. */
async function watchRequests(page: Page, run: () => Promise<void>): Promise<string[]> {
const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
/** Collect every admin call and every failed API answer the page gets while `run` goes. `run` is
* handed `settled`, which waits until no API request of the page is in flight: an error state
* renders only once its request has answered, and a check made before that passes on a page that
* is still loading. */
async function watchRequests(
page: Page,
run: (settled: () => Promise<void>) => Promise<void>,
): Promise<string[]> {
const bad: string[] = [];
const pending = new Set<Request>();
const onRequest = (r: Request) => {
if (!isApi(r.url())) return;
pending.add(r);
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
};
const onDone = (r: Request) => pending.delete(r);
const onResponse = (r: Response) => {
if (r.status() === 401 || r.status() === 403) bad.push(`${r.status()} ${r.url()}`);
if (isApi(r.url()) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
};
const settled = () => expect.poll(() => pending.size, { timeout: 15_000 }).toBe(0);
page.on("request", onRequest);
page.on("requestfinished", onDone);
page.on("requestfailed", onDone);
page.on("response", onResponse);
try {
await run();
await run(settled);
await settled();
} finally {
page.off("request", onRequest);
page.off("requestfinished", onDone);
page.off("requestfailed", onDone);
page.off("response", onResponse);
}
return bad;
@@ -43,7 +61,7 @@ test.describe("a member", () => {
test("gets no admin entry, and every page of the rail opens without an error", async ({
page,
}) => {
const bad = await watchRequests(page, async () => {
const bad = await watchRequests(page, async (settled) => {
await openApp(page);
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
@@ -59,6 +77,7 @@ test.describe("a member", () => {
for (const id of ids) {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
await settled();
// An error state is a `role=alert` StateMessage; a page that failed to load shows one.
await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0);
}
@@ -71,8 +90,8 @@ test.describe("a member", () => {
const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
expect(bad, id).toEqual([]);
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
// The member lands on one of their own pages instead.
await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1);
// The member lands on the feed instead, and the rail says so.
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
}
});
});
+11
View File
@@ -7,6 +7,7 @@ import { api, setUnauthorizedHandler, type Me } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import { refusedSwitchNotice } from "./lib/entryUrl";
import {
PREF_KEY,
saveLayoutLocal,
@@ -230,6 +231,16 @@ export default function App() {
if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]);
// A sign-in as someone else that the server refused (an account not approved, or suspended) came
// back here as the account still signed in — say so, once.
const switchAnswered = useRef(false);
useEffect(() => {
if (!signedIn || switchAnswered.current) return;
switchAnswered.current = true;
const notice = refusedSwitchNotice();
if (notice) notify({ level: notice.level, message: t(notice.key) });
}, [signedIn, t]);
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
+1 -1
View File
@@ -81,7 +81,7 @@ function readStoredFilters(): FilterMap {
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search);
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "../i18n";
import { api, type Me } from "../lib/api";
import { api, HttpError, type Me } from "../lib/api";
import * as actionErrors from "../lib/actionErrors";
import NavSidebar from "./NavSidebar";
vi.mock("./navigationContext", () => ({
@@ -108,4 +109,17 @@ describe("the account panel", () => {
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
expect(logout).toHaveBeenCalledOnce();
});
// Reloading after a sign-out that failed only brought the session back, with no word of why.
it("a sign-out that failed says so and keeps the page", async () => {
vi.spyOn(api, "logout").mockRejectedValue(new HttpError(500, "boom"));
const report = vi.spyOn(actionErrors, "notifyActionError").mockImplementation(() => {});
const panel = await mount(me());
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
expect(reload).not.toHaveBeenCalled();
});
});
+11 -7
View File
@@ -34,6 +34,7 @@ import {
} from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
import { pageHref } from "../lib/nav";
import { notifyActionError } from "../lib/actionErrors";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -74,13 +75,16 @@ export default function NavSidebar({
const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() {
// Ends this browser's session, then reloads onto the sign-in page.
try {
await api.logout();
} catch {
/* reload regardless: the page then shows whatever the server says */
}
location.reload();
// Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
// so and stays: the reload would only bring back the session that is still there.
const signedOut = await api.logout().then(
() => true,
(err: unknown) => {
notifyActionError(err, t("errors.writeFailed.signOut"));
return false;
},
);
if (signedOut) location.reload();
}
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
@@ -31,7 +31,7 @@ import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { api } from "../lib/api";
import { PAGE_KEY } from "./navigationInit";
@@ -350,6 +350,26 @@ describe("NavigationProvider · a page the account cannot open", () => {
await settle();
expect(result.current.page).toBe("feed");
});
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
// feed was already on screen), and a later grant switched the page unasked.
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(window.location.search).toBe("");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => {
qc.setQueryData(qk.me(), { ...member, role: "admin" });
});
await settle();
expect(result.current.page).toBe("feed");
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
+41 -14
View File
@@ -38,6 +38,24 @@ import {
//
// The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
};
}
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
@@ -49,6 +67,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// and the address bar (stamped from it below) agree.
const me = useMe();
const page = reachablePage(chosenPage, me);
// A page this account can no longer open (a role taken away, a module switched off mid-session)
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
// grant would switch the page by itself. Adjusted during render; the history entry and the
// remembered page follow in the effect below.
const [givenUp, setGivenUp] = useState(0);
if (page !== chosenPage) {
setPageState(page);
setGivenUp((n) => n + 1);
}
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
@@ -97,20 +125,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}),
urlWithParams(pageRootParams(next)),
);
notifyNav();
then?.();
@@ -195,6 +210,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]);
// The page given up above is the feed, and the current entry says so (replaced, not pushed: no
// step was taken). Its own keys go with it, as on any page switch.
useEffect(() => {
if (!givenUp) return;
setAccountRaw(PAGE_KEY, "feed");
window.history.replaceState(
{ ...window.history.state, sfPage: "feed" },
"",
urlWithParams(pageRootParams("feed")),
);
}, [givenUp]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
const [mountPage] = useState(page);
+2 -2
View File
@@ -28,7 +28,7 @@ import {
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
@@ -84,7 +84,7 @@ function readPlexFilters(): PlexFilters {
return EMPTY_PLEX_FILTERS;
}
}
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
// `present` records which dimensions the link actually carried: only those are persisted (below).
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
@@ -146,8 +146,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
// state to the account (claimLocalData) before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
// fire on the views query's first resolve for a source and never again, which `seededRef`
+2
View File
@@ -55,6 +55,7 @@
},
"writeFailed": {
"hideChannel": "Couldn't change whether the channel is hidden",
"signOut": "Couldn't sign you out",
"blockChannel": "Couldn't change the channel's block",
"unblockChannel": "Couldn't unblock the channel",
"channelSettings": "Couldn't save the channel's settings",
@@ -134,6 +135,7 @@
"gone": "This playlist no longer exists on YouTube"
},
"refused": {
"account_changed": "This tab was showing another account than the one signed in now, so it reloads as that one",
"address_has_account": "That address already has an account — delete the account instead",
"admin_first": "Create the admin account first",
"admin_only": "Only an admin can do that",
+1
View File
@@ -31,6 +31,7 @@
"about": "About",
"signOut": "Sign out",
"signInAsOther": "Sign in as someone else",
"switchNotApproved": "That Google account isn't approved for this instance yet, so you're still signed in as before.",
"googleAddress": "Google account: {{email}}",
"activity": "Activity"
},
+2
View File
@@ -55,6 +55,7 @@
},
"writeFailed": {
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
"signOut": "Nem sikerült kijelentkeztetni",
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
@@ -134,6 +135,7 @@
"gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
},
"refused": {
"account_changed": "Ez a fül más fiókot mutatott, mint ami most be van lépve, ezért újratölt azzal",
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette",
"admin_first": "Előbb hozd létre az admin fiókot",
"admin_only": "Ezt csak admin teheti meg",
+1
View File
@@ -31,6 +31,7 @@
"about": "Névjegy",
"signOut": "Kijelentkezés",
"signInAsOther": "Belépés más fiókkal",
"switchNotApproved": "Ez a Google-fiók még nincs jóváhagyva ezen a példányon, ezért továbbra is az eddigi fiókkal vagy belépve.",
"googleAddress": "Google-fiók: {{email}}",
"activity": "Napló"
},
+25
View File
@@ -18,6 +18,7 @@ vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log";
import { claimLocalData } from "../storage";
import { downloadsApi } from "./downloads";
import { filesApi } from "./files";
import {
@@ -465,6 +466,30 @@ describe("the debug session a tab records into", () => {
});
});
// A tab built for one account must not act as another: a sign-in as someone else in another tab
// replaces the cookie under this one. Every request path names the account the tab was claimed for,
// so the server can refuse it — a beacon or an upload that went out without it would land on the
// account signed in now.
describe("the account a tab was built for", () => {
const sentHeaders = () => {
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
return new Headers(init.headers as HeadersInit);
};
it("rides every request path once the tab is claimed", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve(new Response("{}", { status: 200 })),
);
claimLocalData(7);
await req("/api/x");
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
beacon("/api/y", {});
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
void uploadReq("/u", body());
expect(FakeXHR.last.headers["X-Siftlode-Expect-Account"]).toBe("7");
});
});
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
+17 -5
View File
@@ -8,6 +8,7 @@ import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
import { claimedAccount } from "../storage";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -270,13 +271,24 @@ export const setupHeaders = (token: string) => ({
// 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";
// The account this tab was built for. The cookie alone says who is asking, and a sign-in as someone
// else in another tab replaces it under this one too: the server refuses (401) a request whose tab
// shows another account than the one signed in, and the 401 reloads the tab as the one who is. It
// never picks an account — a player checkpoint or a pref save from a tab still showing the previous
// account would otherwise land on the new one.
const EXPECTED_ACCOUNT_HEADER = "X-Siftlode-Expect-Account";
/** 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. */
* account the tab was built for, 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 headers: Record<string, string> = {};
const account = claimedAccount();
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
const session = currentDebugSession();
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
if (session) headers[DEBUG_SESSION_HEADER] = session;
return headers;
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
@@ -320,8 +332,8 @@ export async function req<T = unknown>(
credentials: "include",
headers: {
"Content-Type": "application/json",
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
// Who the tab is (only setup calls and raw uploads pass their own headers: setup is
// pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(),
},
...opts,
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// A tab names the account it was built for, and the server refuses a request from a tab showing
// another one than the cookie's. Spelled on both sides, so nothing but this test notices a rename:
// the server would see no header, and a tab left showing the previous account would act as the new
// one without a word.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf-8");
const AUTH = read("../../../../backend/app/auth.py");
const CORE = read("./core.ts");
describe("the account a tab was built for", () => {
it("is the header the server checks", () => {
const backend = /^EXPECTED_ACCOUNT_HEADER = "([^"]+)"$/m.exec(AUTH)?.[1];
const frontend = /^const EXPECTED_ACCOUNT_HEADER = "([^"]+)";$/m.exec(CORE)?.[1];
expect(backend).toBeTruthy();
expect(backend).toBe(frontend?.toLowerCase());
});
});
+18
View File
@@ -14,6 +14,8 @@
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted) ride the query string.
import type { NotifLevel } from "./notifications";
export const entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
@@ -58,3 +60,19 @@ export function clearEntryUrl(): void {
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
);
}
/** What a sign-in as someone else that did not happen says to the account still signed in here. The
* callback answers a Google account that is not approved, or is suspended, without replacing the
* session, and with the flags the sign-in page reads; the signed-in app read none of them, so the
* person came back as before without a word. A request recorded on the way says so. */
export function refusedSwitchNotice(
search: URLSearchParams = entrySearch,
hash: URLSearchParams = entryHash,
): { level: NotifLevel; key: string } | null {
if (search.get("login") === "suspended")
return { level: "warning", key: "welcome.auth.suspended" };
const access = hash.get("access");
if (access === "requested") return { level: "info", key: "welcome.auth.accessRequested" };
if (access === "denied") return { level: "warning", key: "header.account.switchNotApproved" };
return null;
}
+16
View File
@@ -412,6 +412,22 @@ describe("the gate register (docs/gates.md)", () => {
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
}, 120_000);
// A spec left out of the webkit and ipad projects runs on one engine only.
it("lists every e2e spec that runs on one engine only", () => {
const cfg = read("frontend/playwright.config.ts");
const alternatives = /const CHROMIUM_ONLY = \/\(([^)]+)\)\\.spec\\.ts\//.exec(cfg)?.[1];
expect(alternatives, "CHROMIUM_ONLY").toBeTruthy();
const found = alternatives!
.split("|")
.map((name): [string, string] => ["e2e", `${name}.spec.ts`]);
expect(missingFrom(found)).toEqual([]);
// Any other way of leaving a spec out of a project would be a narrowing this does not read.
expect([...cfg.matchAll(/testIgnore:\s*([^,\n]+)/g)].map((m) => m[1])).toEqual([
"CHROMIUM_ONLY",
"CHROMIUM_ONLY",
]);
});
it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0");
+18 -2
View File
@@ -33,7 +33,7 @@ describe("claiming the browser's local data", () => {
expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
);
expect(keys(sessionStorage)).toEqual(["siftlode.settingsTab"]);
expect(keys(sessionStorage)).toEqual(["siftlode.owner", "siftlode.settingsTab"]);
});
it("an old per-account copy wins over a plain key already there", async () => {
@@ -82,7 +82,23 @@ describe("claiming the browser's local data", () => {
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
);
expect(localStorage.getItem("siftlode.owner")).toBe("1");
expect(keys(sessionStorage)).toEqual([]);
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
});
it("a tab reloading for the account another tab claimed the browser for drops its own view state", async () => {
// Tab 2 signed in as 7 and claimed the browser; this tab still holds 1's sub-tab and the
// onboarding flow 1 was in. Its sessionStorage is its own, so the browser's owner says nothing
// about it.
localStorage.setItem("siftlode.owner", "7");
sessionStorage.setItem("siftlode.owner", "1");
sessionStorage.setItem("siftlode.settingsTab", "plex");
sessionStorage.setItem("siftlode.onboarding.active", "1");
const { claimLocalData } = await storage();
claimLocalData(7);
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
expect(sessionStorage.getItem("siftlode.owner")).toBe("7");
});
it("the same account starting again keeps everything", async () => {
+4 -2
View File
@@ -15,8 +15,10 @@
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
// call site asking `enabled(level)` before it builds the attribute.
//
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
// the error boundary all log through here, so it must not import any of them.
// Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
// nothing of the app at runtime): the request layer, the notification store and the error boundary
// all log through here, so it must not import any of them, not even through another module
// (logImports.contract.test.ts follows the runtime imports).
import { LS } from "./storage";
import { FRONTEND_VERSION } from "./version";
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The request layer, the notification store and the error boundary all log through lib/log.ts, so
// log.ts must not reach any of them at runtime: the import would be a cycle, and the module that
// logs a failure would load the one that failed. A type-only import is erased, so it does not count.
// The comment in log.ts said this for a long time while nothing checked it.
const LIB = new URL("./", import.meta.url);
const ALLOWED = ["log.ts", "storage.ts", "version.ts"];
/** The app modules `file` imports at runtime (relative, not `import type`), as lib/ file names. */
function runtimeImports(file: string): string[] {
const text = readFileSync(new URL(file, LIB), "utf-8");
return [...text.matchAll(/^import\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)";/gms)].map(
(m) => `${m[1]!.replace(/^\.\//, "")}.ts`,
);
}
describe("lib/log.ts", () => {
it("reaches only the build stamp and the key registry at runtime", () => {
const seen = new Set<string>();
const queue = ["log.ts"];
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
if (ALLOWED.includes(file)) queue.push(...runtimeImports(file));
}
expect([...seen].sort()).toEqual([...ALLOWED].sort());
});
});
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// "Sign in as someone else" with a Google account the server will not sign in (not approved, or
// suspended) comes back to the account still signed in here, with the flags the sign-in page reads.
// The signed-in app read none of them, so the person was back as before without a word.
import { describe, expect, it } from "vitest";
import { refusedSwitchNotice } from "./entryUrl";
const notice = (search: string, hash = "") =>
refusedSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
describe("a sign-in as someone else the server refused", () => {
it("says a request was recorded for an address not approved yet", () => {
expect(notice("", "access=requested&email=b%40example.com")).toEqual({
level: "info",
key: "welcome.auth.accessRequested",
});
});
it("says the account is not approved, and nothing changed here", () => {
expect(notice("", "access=denied")).toEqual({
level: "warning",
key: "header.account.switchNotApproved",
});
});
it("says the account is suspended", () => {
expect(notice("?login=suspended")).toEqual({
level: "warning",
key: "welcome.auth.suspended",
});
});
it("says nothing on an ordinary load", () => {
expect(notice("?page=stats", "")).toBeNull();
});
});
@@ -53,7 +53,9 @@ describe("removed features stay removed", () => {
// 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.
// claim that clears the old pin keys (storage.ts) and its test may name them. (The tab's
// X-Siftlode-Expect-Account is not a pin: it never picks an account, the server refuses a request
// from a tab showing another one than the cookie's.)
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;
+2 -1
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LS } from "./storage";
const fire = (): Event => {
const e = new Event("vite:preloadError", { cancelable: true });
@@ -32,7 +33,7 @@ describe("a lazy chunk the server no longer has", () => {
});
it("lets the error through when the reload a moment ago did not help", async () => {
sessionStorage.setItem("sf.staleBuildReload", String(Date.now() - 5_000));
sessionStorage.setItem(LS.staleBuildReload, String(Date.now() - 5_000));
const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
const reload = vi.fn();
uninstall = installStaleBuildReload(reload);
+2 -1
View File
@@ -8,8 +8,9 @@
// minute at most: a chunk that is missing from the NEW build too would otherwise reload forever, so
// the second failure inside the minute is let through to the error boundary, which says so.
import { log } from "./log";
import { LS } from "./storage";
const KEY = "sf.staleBuildReload";
const KEY = LS.staleBuildReload;
const WINDOW_MS = 60_000;
let reloading = false;
+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 {
+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");