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
+19 -1
View File
@@ -263,8 +263,26 @@ def stamp_session(request: Request, user: User) -> None:
request.session["epoch"] = user.session_epoch or 0 request.session["epoch"] = user.session_epoch or 0
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
# someone else in another tab replaces it under every open tab. A tab still showing the previous
# account would otherwise act as the new one — its player checkpoints, pref saves, Sign out and
# Delete account landing there — until it noticed. It never selects an account; a mismatch is
# refused, and the tab reloads as whoever is signed in now.
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
def current_user(request: Request, db: Session = Depends(get_db)) -> User: def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return _session_user(request, db, session_user_id(request)) user = _session_user(request, db, session_user_id(request))
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
if expected is not None and expected != str(user.id):
# The session stays: it is the other tab's sign-in, and it holds.
log.info(
"a request from a tab built for another account was refused",
extra=logs.event("auth.session.account_changed", user_id=user.id),
)
raise refused(401, "account_changed")
return user
def _session_user(request: Request, db: Session, user_id: int | None) -> User: def _session_user(request: Request, db: Session, user_id: int | None) -> User:
+21 -8
View File
@@ -25,6 +25,7 @@ from app import sysconfig
from app.auth import ( from app.auth import (
_YOUTUBE_SCOPES, _YOUTUBE_SCOPES,
BASE_SCOPES, BASE_SCOPES,
EXPECTED_ACCOUNT_HEADER,
PASSWORD_MIN_LEN, PASSWORD_MIN_LEN,
READ_SCOPES, READ_SCOPES,
WRITE_SCOPES, WRITE_SCOPES,
@@ -36,6 +37,7 @@ from app.auth import (
is_allowed, is_allowed,
log, log,
operator_contact, operator_contact,
optional_current_user,
session_user_id, session_user_id,
stamp_session, stamp_session,
) )
@@ -330,6 +332,11 @@ async def login(request: Request, db: Session = Depends(get_db)):
# Google sign-in isn't configured on this instance — land back on the login page (the UI # Google sign-in isn't configured on this instance — land back on the login page (the UI
# hides the button, so this is just a safety net for a stale/direct hit). # hides the button, so this is just a safety net for a stale/direct hit).
return RedirectResponse(url="/") return RedirectResponse(url="/")
# A sign-in, never a link: a connect abandoned at Google (Back, a closed tab) never reached the
# callback that pops its markers, and left behind they would attach the identity signing in
# here to the account still signed into this browser.
request.session.pop("oauth_link_uid", None)
request.session.pop("oauth_link_explicit", None)
return await client.authorize_redirect( return await client.authorize_redirect(
request, request,
_oauth_redirect_uri(request), _oauth_redirect_uri(request),
@@ -354,7 +361,7 @@ async def callback(
# /auth/google/connect; when present we attach to the current user instead of identifying by sub. # /auth/google/connect; when present we attach to the current user instead of identifying by sub.
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange # Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link # throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
# attempt. Popping up front means an aborted link flow leaves no residue in the session. # attempt. A connect that never comes back here at all is cleared by /auth/login instead.
link_uid = request.session.pop("oauth_link_uid", None) link_uid = request.session.pop("oauth_link_uid", None)
link_explicit = request.session.pop("oauth_link_explicit", False) link_explicit = request.session.pop("oauth_link_explicit", False)
try: try:
@@ -584,12 +591,12 @@ def _complete_link(
Google identity owned by another account, or to silently swap a different linked identity. Google identity owned by another account, or to silently swap a different linked identity.
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from `explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
a YouTube grant — the caller pops it up front so an aborted flow leaves nothing behind.""" a YouTube grant — the caller pops it up front so an aborted flow leaves nothing behind."""
# The marker must name the account still signed into THIS browser: a sign-in as someone else # The marker must name the account still signed into THIS browser, and that session must still
# between the start and the callback replaced it, and the identity must not land on the newcomer. # hold: a sign-in as someone else between the start and the callback replaced it (the identity
if link_uid != session_user_id(request): # must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
return RedirectResponse(url="/?link=error") # password change, a suspension) must not finish what it started.
user = db.get(User, link_uid) user = optional_current_user(request, db)
if user is None: if user is None or user.id != link_uid:
return RedirectResponse(url="/?link=error") return RedirectResponse(url="/?link=error")
other = db.execute( other = db.execute(
select(User).where(User.google_sub == sub, User.id != user.id) select(User).where(User.google_sub == sub, User.id != user.id)
@@ -616,7 +623,13 @@ def _complete_link(
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign this browser out: the session holds one account, and it ends.""" """Sign this browser out: the session holds one account, and it ends — unless the tab asking was
built for another account than the one signed in now (a sign-in as someone else in another tab):
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
signed_in = session_user_id(request)
if expected is not None and signed_in is not None and expected != str(signed_in):
raise refused(401, "account_changed")
request.session.clear() request.session.clear()
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
+4 -1
View File
@@ -136,7 +136,10 @@ def update_preferences(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
# Merge so partial updates don't wipe other keys. # Merge so partial updates don't wipe other keys — over the stored row read again under a row
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
# copies to the account), and two merges over the same earlier read would keep only the last.
db.refresh(user, with_for_update=True)
merged = dict(user.preferences or {}) merged = dict(user.preferences or {})
merged.update(preferences.root) merged.update(preferences.root)
user.preferences = merged user.preferences = merged
+1 -1
View File
@@ -231,7 +231,7 @@ def _configured(monkeypatch):
@pytest.fixture @pytest.fixture
def client(db, override_dep, _configured): def client(db, override_dep, _configured):
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no """A TestClient wired to the test DB that goes through the REAL auth/session stack — no
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end. `current_user` override — so login, the one-account session and epoch revoke are exercised end to end.
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off).""" the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
override_dep(get_db, db.info["request"]) override_dep(get_db, db.info["request"])
+42 -1
View File
@@ -140,7 +140,7 @@ def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user,
from app.routes.auth import _complete_link from app.routes.auth import _complete_link
starter, other = make_user(), make_user() starter, other = make_user(), make_user()
session = {"user_id": starter.id if signed_in_as == "starter" else other.id} session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
request = Request({"type": "http", "session": session, "headers": []}) request = Request({"type": "http", "session": session, "headers": []})
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""}) resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
db.refresh(starter) db.refresh(starter)
@@ -165,6 +165,47 @@ def test_no_app_code_reads_a_second_account():
assert hits == [] assert hits == []
def test_a_tab_built_for_the_account_signed_in_before_is_refused(client, make_user):
# Two tabs of one browser: one signed in as someone else, so the cookie is Bob's under both. The
# tab still showing Alice says so with every request; acting as Bob there would put her player
# positions, pref saves and Sign out on his account. Refused, and the session stays Bob's.
alice = make_user(email="alice5@example.com", password=_PW)
bob = make_user(email="bob5@example.com", password=_PW)
assert _login(client, "alice5@example.com", _PW).status_code == 200
assert _login(client, "bob5@example.com", _PW).status_code == 200
stale = client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(alice.id)})
assert stale.status_code == 401
assert stale.json()["detail"]["reason"] == "account_changed"
assert (
client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(bob.id)}).status_code
== 200
)
assert client.get("/api/feed").status_code == 200
# Nor may its Sign out end Bob's session.
assert (
client.post(
"/auth/logout", headers={"X-Siftlode-Expect-Account": str(alice.id)}
).status_code
== 401
)
# The probe answers who is signed in now, so the stale tab can reload as Bob.
probe = client.get("/api/me", headers={"X-Siftlode-Expect-Account": str(alice.id)})
assert probe.json()["email"] == "bob5@example.com"
def test_setting_a_password_keeps_the_caller_signed_in(client, make_user):
# The change ends every OTHER session (the epoch moves); the caller is stamped with the new one.
make_user(email="dave@example.com", password=_PW)
assert _login(client, "dave@example.com", _PW).status_code == 200
r = client.post(
"/auth/set-password",
json={"password": "a brand new passphrase", "current_password": _PW},
)
assert r.status_code == 200
assert client.get("/api/feed").status_code == 200
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user): def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one # Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller # bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
+35
View File
@@ -270,6 +270,41 @@ def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com") assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
client, db, google, make_user, monkeypatch
):
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
# Google identity onto the account still signed in here.
me = make_user(email="me4@example.com")
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
_start_link(client, "me4@example.com")
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
assert r.headers["location"] == "/"
assert client.get("/api/me").json()["email"] == "them4@example.com"
db.refresh(me)
assert me.google_sub is None
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
# still names the account, but its session no longer holds, so it may not attach an identity.
me = make_user(email="me5@example.com")
_start_link(client, "me5@example.com")
me.session_epoch = (me.session_epoch or 0) + 1
db.commit()
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
assert r.headers["location"] == "/?link=error"
db.refresh(me)
assert me.google_sub is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
("access", "scope", "prompt"), ("access", "scope", "prompt"),
[ [
+44
View File
@@ -0,0 +1,44 @@
"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane.
A client saves its preferences a key at a time, and several at once on the boot that moves this
browser's copies to the account. Each merge must start from the row as stored when it runs, not as
the request first read it — two merges over the same earlier read keep only the last one's keys.
"""
from fastapi import Depends, Request
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import SessionLocal, get_db
from app.models import User
_PW = "correct horse battery"
def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
client, db, make_user, override_dep
):
user = make_user(email="prefs@example.com", password=_PW)
assert (
client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW})
).status_code == 200
def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User:
# The account as this request reads it, then a second save committing in the meantime.
caller = current_user(request, session)
with SessionLocal() as other:
other.execute(
update(User)
.where(User.id == caller.id)
.values(preferences={"seenVersion": "1.2.3"})
)
other.commit()
return caller
override_dep(current_user, read_then_another_save)
r = client.put("/api/me/preferences", json={"startOnHome": False})
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
+2 -1
View File
@@ -107,7 +107,8 @@ project checks needs a row.
| Scope | Narrowed | Why | Measured | | Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 5 specs × 3 engines | | check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — | | pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) | | coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) | | CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
+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, 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, 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 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 (`e2e/.auth/member.json`). Two specs run as it: `member.spec.ts`, holding what a non-admin sees (no
entry, no admin call, an admin link lands on the feed). Credentials come from `E2E_EMAIL` (default 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` `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 (both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it
from the secret store. Override the container name with 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 gotoPage(desktop, "settings");
await desktop.getByRole("switch", { name: "Performance mode" }).click(); await desktop.getByRole("switch", { name: "Performance mode" }).click();
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1"); 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 openApp(ipad);
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1"); 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); * 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 * 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 * 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"; const STORAGE = "e2e/.auth/state.json";
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test"; 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"; export const MEMBER_STORAGE = "e2e/.auth/member.json";
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */ /** 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", "settings",
]; ];
/** Collect every admin call and every refused response the page makes while `run` goes. */ const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
async function watchRequests(page: Page, run: () => Promise<void>): Promise<string[]> {
/** 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 bad: string[] = [];
const pending = new Set<Request>();
const onRequest = (r: 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()}`); 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) => { 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("request", onRequest);
page.on("requestfinished", onDone);
page.on("requestfailed", onDone);
page.on("response", onResponse); page.on("response", onResponse);
try { try {
await run(); await run(settled);
await settled();
} finally { } finally {
page.off("request", onRequest); page.off("request", onRequest);
page.off("requestfinished", onDone);
page.off("requestfailed", onDone);
page.off("response", onResponse); page.off("response", onResponse);
} }
return bad; 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 ({ test("gets no admin entry, and every page of the rail opens without an error", async ({
page, page,
}) => { }) => {
const bad = await watchRequests(page, async () => { const bad = await watchRequests(page, async (settled) => {
await openApp(page); await openApp(page);
await expect(page.getByTestId("nav-admin")).toHaveCount(0); await expect(page.getByTestId("nav-admin")).toHaveCount(0);
@@ -59,6 +77,7 @@ test.describe("a member", () => {
for (const id of ids) { for (const id of ids) {
await page.getByTestId(`nav-${id}`).click(); await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page"); 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. // 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); 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}`)); const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
expect(bad, id).toEqual([]); expect(bad, id).toEqual([]);
await expect(page.getByTestId("nav-admin")).toHaveCount(0); await expect(page.getByTestId("nav-admin")).toHaveCount(0);
// The member lands on one of their own pages instead. // The member lands on the feed instead, and the rail says so.
await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1); 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 { notifyActionError } from "./lib/actionErrors";
import { setLanguage, type LangCode } from "./i18n"; import { setLanguage, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState"; import { clearParams } from "./lib/urlState";
import { refusedSwitchNotice } from "./lib/entryUrl";
import { import {
PREF_KEY, PREF_KEY,
saveLayoutLocal, saveLayoutLocal,
@@ -230,6 +231,16 @@ export default function App() {
if (signedIn) clearParams("access", "verify", "reset"); if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]); }, [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 // 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 // `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) // 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 // 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 // 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). // 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. // a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null { function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search); 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "../i18n"; 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"; import NavSidebar from "./NavSidebar";
vi.mock("./navigationContext", () => ({ vi.mock("./navigationContext", () => ({
@@ -108,4 +109,17 @@ describe("the account panel", () => {
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
expect(logout).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"; } from "./ui/NavRail";
import { usePrefs } from "./prefsContext"; import { usePrefs } from "./prefsContext";
import { pageHref } from "../lib/nav"; 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 // 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 // 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; const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() { async function logout() {
// Ends this browser's session, then reloads onto the sign-in page. // Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
try { // so and stays: the reload would only bring back the session that is still there.
await api.logout(); const signedOut = await api.logout().then(
} catch { () => true,
/* reload regardless: the page then shows whatever the server says */ (err: unknown) => {
} notifyActionError(err, t("errors.writeFailed.signOut"));
location.reload(); return false;
},
);
if (signedOut) location.reload();
} }
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // 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 { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys"; 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 { api } from "../lib/api";
import { PAGE_KEY } from "./navigationInit"; import { PAGE_KEY } from "./navigationInit";
@@ -350,6 +350,26 @@ describe("NavigationProvider · a page the account cannot open", () => {
await settle(); await settle();
expect(result.current.page).toBe("feed"); 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", () => { 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 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 }) { export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only // 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. // and the address bar (stamped from it below) agree.
const me = useMe(); const me = useMe();
const page = reachablePage(chosenPage, me); 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 // 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 // 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 // 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( window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() }, { sfPage: next, sfScroll: nextNavId() },
"", "",
urlWithParams({ urlWithParams(pageRootParams(next)),
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])),
}),
); );
notifyNav(); notifyNav();
then?.(); then?.();
@@ -195,6 +210,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · "); document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]); }, [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 // 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. // entry: every later page change writes its own entry through setPage.
const [mountPage] = useState(page); 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/>). // 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 // 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 // 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 // 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. // into (and is never greeted by a stale value from) the feed.
@@ -84,7 +84,7 @@ function readPlexFilters(): PlexFilters {
return EMPTY_PLEX_FILTERS; 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. // 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). // `present` records which dimensions the link actually carried: only those are persisted (below).
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean }; 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 // 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 // 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 // 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 // account's stored-filters key, which is always readable here: BootGate hands the browser's local
// before this provider mounts. // state to the account (claimLocalData) before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set()); 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 // `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` // fire on the views query's first resolve for a source and never again, which `seededRef`
+2
View File
@@ -55,6 +55,7 @@
}, },
"writeFailed": { "writeFailed": {
"hideChannel": "Couldn't change whether the channel is hidden", "hideChannel": "Couldn't change whether the channel is hidden",
"signOut": "Couldn't sign you out",
"blockChannel": "Couldn't change the channel's block", "blockChannel": "Couldn't change the channel's block",
"unblockChannel": "Couldn't unblock the channel", "unblockChannel": "Couldn't unblock the channel",
"channelSettings": "Couldn't save the channel's settings", "channelSettings": "Couldn't save the channel's settings",
@@ -134,6 +135,7 @@
"gone": "This playlist no longer exists on YouTube" "gone": "This playlist no longer exists on YouTube"
}, },
"refused": { "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", "address_has_account": "That address already has an account — delete the account instead",
"admin_first": "Create the admin account first", "admin_first": "Create the admin account first",
"admin_only": "Only an admin can do that", "admin_only": "Only an admin can do that",
+1
View File
@@ -31,6 +31,7 @@
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",
"signInAsOther": "Sign in as someone else", "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}}", "googleAddress": "Google account: {{email}}",
"activity": "Activity" "activity": "Activity"
}, },
+2
View File
@@ -55,6 +55,7 @@
}, },
"writeFailed": { "writeFailed": {
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését", "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", "blockChannel": "Nem sikerült módosítani a csatorna tiltását",
"unblockChannel": "Nem sikerült feloldani 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", "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" "gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
}, },
"refused": { "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", "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_first": "Előbb hozd létre az admin fiókot",
"admin_only": "Ezt csak admin teheti meg", "admin_only": "Ezt csak admin teheti meg",
+1
View File
@@ -31,6 +31,7 @@
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",
"signInAsOther": "Belépés más fiókkal", "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}}", "googleAddress": "Google-fiók: {{email}}",
"activity": "Napló" "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" } })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log"; import { setDebugSession } from "../log";
import { claimLocalData } from "../storage";
import { downloadsApi } from "./downloads"; import { downloadsApi } from "./downloads";
import { filesApi } from "./files"; import { filesApi } from "./files";
import { 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 // 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 // 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 // `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 { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log"; 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"). /** 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 * 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 // 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. // 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"; 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 /** 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`, * account the tab was built for, the debug session it records into — as the headers every request
* `uploadReq`): one definition, so a path cannot quietly go out without them. */ * path sends (`req`, `beacon`, `uploadReq`): one definition, so a path cannot quietly go out
* without them. */
export function identityHeaders(): Record<string, string> { export function identityHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const account = claimedAccount();
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
const session = currentDebugSession(); 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 // 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", credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
// Per-tab identity override (only setup calls and raw uploads pass their own headers: // Who the tab is (only setup calls and raw uploads pass their own headers: setup is
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own). // pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(), ...identityHeaders(),
}, },
...opts, ...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 // 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. // 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 entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, "")); export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
@@ -58,3 +60,19 @@ export function clearEntryUrl(): void {
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`, `${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([]); expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
}, 120_000); }, 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", () => { 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> }; const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0"); 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( expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(), ["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 () => { 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(), ["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
); );
expect(localStorage.getItem("siftlode.owner")).toBe("1"); 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 () => { 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 // 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. // 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 // Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
// the error boundary all log through here, so it must not import any of them. // 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 { LS } from "./storage";
import { FRONTEND_VERSION } from "./version"; 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=` // 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 // 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 // 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 }, () => { it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
const PIN = const PIN =
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i; /X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
+2 -1
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LS } from "./storage";
const fire = (): Event => { const fire = (): Event => {
const e = new Event("vite:preloadError", { cancelable: true }); 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 () => { 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 { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
const reload = vi.fn(); const reload = vi.fn();
uninstall = installStaleBuildReload(reload); 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 // 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. // the second failure inside the minute is let through to the error boundary, which says so.
import { log } from "./log"; import { log } from "./log";
import { LS } from "./storage";
const KEY = "sf.staleBuildReload"; const KEY = LS.staleBuildReload;
const WINDOW_MS = 60_000; const WINDOW_MS = 60_000;
let reloading = false; 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 // 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 // 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 // `"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 // declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
// (storageKinds.contract.test.ts). // 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 // 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 // follows the account to every device — usePref, whose local copy only carries a value
// this browser held before the server did. // this browser held before the server did.
@@ -17,11 +20,12 @@ export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
const KEYS = { const KEYS = {
theme: { key: "siftlode.theme", kind: "pref" }, theme: { key: "siftlode.theme", kind: "pref" },
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" }, // A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
plexLayout: { key: "siftlode.plexLayout", kind: "device" }, sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" }, plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", 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" }, filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
// The Files view last open (every file, shared with me, or a folder id), per-account. // The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: { key: "siftlode.filesView", kind: "view" }, filesView: { key: "siftlode.filesView", kind: "view" },
@@ -107,10 +111,12 @@ const KEYS = {
// reason as above: the viewer has no account to keep it in. // reason as above: the viewer has no account to keep it in.
watchResume: { key: "siftlode.watchResume", kind: "public" }, watchResume: { key: "siftlode.watchResume", kind: "public" },
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in // 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" }, onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
debugSteps: { key: "siftlode.debugSteps", kind: "view" }, debugSteps: { key: "siftlode.debugSteps", kind: "view" },
logTab: { key: "siftlode.logTab", kind: "device" }, logTab: { key: "siftlode.logTab", kind: "device" },
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>; } as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
/** Every key the app stores, by name. */ /** 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 { export function claimLocalData(id: number): void {
const me = String(id); const me = String(id);
try { 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 owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me; const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
settleStore(localStorage, me, wipe); settleStore(localStorage, me, owner != null && owner !== me);
settleStore(sessionStorage, me, wipe); settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key); for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me); localStorage.setItem(OWNER_KEY, me);
sessionStorage.setItem(OWNER_KEY, me);
} catch { } catch {
/* storage unavailable: nothing to settle */ /* storage unavailable: nothing to settle */
} }
@@ -191,6 +201,13 @@ export function claimLocalData(id: number): void {
claimListeners.forEach((reread) => reread()); 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 /** `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). */ * after another tab has claimed it for a different account (a sign-in there replaces this one). */
export function ownKey(base: string): string | null { 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") })); 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 // Preferences whose local copy is kept by the store that applies them. The Settings page's
// kept by the store that applies them: it paints the first frame, and for the language the // (PrefsProvider saves them to the server): the store paints the first frame, and for the language
// signed-out pages too, before the account is known. // 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> = { const SYNCED_BY_STORE: Record<string, string> = {
theme: "lib/theme.ts", theme: "lib/theme.ts",
hints: "lib/hints.ts", hints: "lib/hints.ts",
notifSettings: "lib/notifications.ts", notifSettings: "lib/notifications.ts",
lang: "i18n/index.ts", lang: "i18n/index.ts",
sidebarLayout: "lib/panelLayout.ts",
plexLayout: "lib/panelLayout.ts",
playlistsLayout: "lib/panelLayout.ts",
filesLayout: "lib/panelLayout.ts",
}; };
describe("storage kinds", () => { describe("storage kinds", () => {
it("spells out no key outside the registry", () => { it("spells out no key outside the registry", () => {
// The debug recorder's BroadcastChannel is a channel name, not a stored key. // The debug recorder's BroadcastChannel is a channel name, not a stored key.
const allowed = new Set(['"siftlode.debugSession"']); 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 }) => 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]) .map((m) => m[0])
.filter((lit) => !allowed.has(lit)) .filter((lit) => !allowed.has(lit))
.map((lit) => `${f}: ${lit}`), .map((lit) => `${f}: ${lit}`),
@@ -48,6 +54,23 @@ describe("storage kinds", () => {
expect(hits).toEqual([]); 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", () => { 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"); const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
expect(prefs.length).toBeGreaterThan(10); expect(prefs.length).toBeGreaterThan(10);
@@ -63,6 +86,17 @@ describe("storage kinds", () => {
expect(hits).toEqual([]); 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", () => { it("lists only preferences as kept by their store", () => {
for (const name of Object.keys(SYNCED_BY_STORE)) for (const name of Object.keys(SYNCED_BY_STORE))
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref"); expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");