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

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

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
This commit is contained in:
2026-10-20 15:29:41 +02:00
parent 6645caa157
commit d6dada7fe5
51 changed files with 672 additions and 559 deletions
+2 -15
View File
@@ -49,14 +49,6 @@ export function displayName(a: { display_name: string | null; email: string }):
return a.display_name ?? a.email.split("@")[0]!;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
@@ -73,14 +65,9 @@ export const accountApi = {
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
// Ends this browser's session.
logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, unknown>) =>
+3 -8
View File
@@ -16,11 +16,6 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
@@ -155,9 +150,9 @@ describe("uploadReq", () => {
});
it("lets its own identity headers win over a caller's", () => {
// Who a request is from is this layer's to state, not its caller's. Merged the other way round,
// a caller's header of the same name would silently re-attribute the upload — to another
// account, or (here) into someone else's debug session recording.
// What a request is part of is this layer's to state, not its caller's. Merged the other way
// round, a caller's header of the same name would silently re-attribute the upload into someone
// else's debug session recording.
setDebugSession("0123456789abcdef");
try {
void uploadReq("/u", body(), {
+6 -21
View File
@@ -1,12 +1,11 @@
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
// error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
@@ -268,28 +267,16 @@ export const setupHeaders = (token: string) => ({
"X-Setup-Token": token,
});
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
export const getActiveAccount = activeAccountId;
/** Who this request is from — the tab's account and the debug session it records into — as the
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
* cannot quietly go out without them. */
/** What this request carries besides the session cookie (which alone says who is asking) — the
* debug session it records into — as the headers every request path sends (`req`, `beacon`,
* `uploadReq`): one definition, so a path cannot quietly go out without them. */
export function identityHeaders(): Record<string, string> {
const active = getActiveAccount();
const session = currentDebugSession();
return {
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
};
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
@@ -315,8 +302,6 @@ export function beacon(url: string, body: Record<string, unknown>, onSettled?: (
}
}
export const setActiveAccount = pinActiveAccount;
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
@@ -398,7 +383,7 @@ export async function req<T = unknown>(
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
@@ -13,11 +13,6 @@ vi.mock("../notifications", () => ({
getActiveToasts: vi.fn((): { id: number }[] => []),
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { debugSessionsApi } from "./diagnostics";
+4 -9
View File
@@ -2,7 +2,7 @@
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import type { ServerLogRow } from "./admin";
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
import { beacon, identityHeaders, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -123,12 +123,7 @@ export const debugSessionsApi = {
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
* per-tab account's screenshot resolves to the session default and 404s. */
debugScreenshotUrl: (id: string, shotId: number): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
return a != null ? `${url}?account=${a}` : url;
},
/** Where an `<img>` loads a screenshot from. */
debugScreenshotUrl: (id: string, shotId: number): string =>
`${SESSIONS}/${id}/screenshots/${shotId}`,
};
+6 -18
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { beacon, req, getActiveAccount } from "./core";
// reference each other, and its methods only touch the shared `req`/`beacon` from core.
import { beacon, req } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -314,22 +314,10 @@ export const downloadsApi = {
},
onSettled,
),
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
: `/api/downloads/${id}/storyboard.jpg`;
},
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
// which reads as a blank Library thumbnail once the poster is the card's primary image.
posterImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/poster.jpg?account=${a}`
: `/api/downloads/${id}/poster.jpg`;
},
// Filmstrip sprite (an <img>/background src).
storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`,
// The locally-generated poster (an <img> src).
posterImageUrl: (id: number): string => `/api/downloads/${id}/poster.jpg`,
// admin
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
+5 -14
View File
@@ -1,6 +1,6 @@
// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { getActiveAccount, req, uploadReq } from "./core";
import { req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
@@ -144,19 +144,10 @@ export const filesApi = {
headers,
});
},
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
// file resolves to the session-default account and 404s for a per-tab account's file.
fileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// The file itself, for a plain <a> navigation.
fileUrl: (id: number): string => `/api/files/${id}/file`,
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source.
previewUrl: (id: number): string => `/api/files/${id}/preview`,
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });