feat!: remove direct messaging (chat)

E2EE direct messages, the chat dock, the Messages module, the message
WebSocket and its realtime registry, and the messages / message_keys
tables (migration 0097; the downgrade restores the empty schema). Prod
held two welcome notes, no message between people and one key — never
used (owner decision, E34). WebSocket now has no owner module: the lint
bans it everywhere. Privacy policy updated: it no longer promises E2EE
for a feature that is gone.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent 7f08c510bf
commit cca3315836
71 changed files with 122 additions and 3011 deletions
-3
View File
@@ -58,8 +58,6 @@ export {
parsePlexFilters,
isIntrinsicReadOnly,
} from "./api/plex";
import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
@@ -81,7 +79,6 @@ export const api = {
...quotaApi,
...adminApi,
...plexApi,
...messagingApi,
...downloadsApi,
...diagnosticsApi,
...debugSessionsApi,
-1
View File
@@ -30,7 +30,6 @@ const SLICE_MODULES = [
["quotaApi", () => import("./quota")],
["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")],
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
-72
View File
@@ -1,72 +0,0 @@
// Direct-messaging domain: the E2EE key bundle setup/reset, the conversation list, threads, and
// send/unread. Self-contained — types reference only each other, methods only touch core's req.
// (The WebCrypto layer that encrypts/decrypts message bodies lives in e2ee.ts, not here.)
import { req } from "./core";
export interface MessageUser {
id: number;
name: string;
avatar_url: string | null;
}
export interface Message {
id: number;
kind: "user" | "system";
sender_id: number | null;
recipient_id: number;
body: string | null; // plaintext, system messages only
ciphertext: string | null; // base64, E2EE user messages only
iv: string | null;
read_at: string | null;
created_at: string | null;
}
export interface Conversation {
partner: MessageUser;
last_message: Message;
unread: number;
}
// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
export interface MyKeyBundle {
configured: boolean;
public_key?: string;
wrapped_private_key?: string;
salt?: string;
wrap_iv?: string;
key_check?: string;
}
export interface KeySetup {
public_key: string;
wrapped_private_key: string;
salt: string;
wrap_iv: string;
key_check: string;
}
export const messagingApi = {
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
// Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new
// passphrase can be set. Password accounts must pass their current password; errors shown inline.
resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> =>
req(
"/api/messages/keys/reset",
{ method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) },
{ quiet: true },
),
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
req(`/api/messages/keys/${userId}`),
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
req(`/api/messages/conversations/${partnerId}`),
sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
req("/api/messages", {
method: "POST",
body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
}),
messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),
};
-52
View File
@@ -1,52 +0,0 @@
// @vitest-environment jsdom
//
// The floating chat dock's store (E32 review: moved onto lib/store's createStore). It restores the
// signed-in user's windows, keeps at most three, flashes a window a message arrives for, and
// persists everything but the transient flash counter.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
closeChat,
initDock,
notifyIncoming,
openChat,
toggleMinimize,
useDockChats,
} from "./chatDock";
import { LS } from "./storage";
const partner = (id: number) => ({ id, name: `P${id}`, avatar_url: null });
describe("the chat dock", () => {
it("restores, caps, flashes and persists the user's windows", () => {
localStorage.setItem(
LS.chatDock(7),
JSON.stringify([{ partnerId: 1, name: "P1", avatar: null, minimized: true }]),
);
const { result } = renderHook(() => useDockChats());
act(() => initDock(7));
expect(result.current).toEqual([
{ partnerId: 1, name: "P1", avatar: null, minimized: true, flash: 0 },
]);
act(() => notifyIncoming(partner(1))); // already open: flash it, keep it minimised
expect(result.current[0]).toMatchObject({ partnerId: 1, minimized: true, flash: 1 });
act(() => {
for (const id of [2, 3, 4]) openChat(partner(id));
});
expect(result.current.map((c) => c.partnerId)).toEqual([4, 3, 2]); // newest first, three at most
act(() => toggleMinimize(3));
act(() => closeChat(2));
expect(result.current.map((c) => [c.partnerId, c.minimized])).toEqual([
[4, false],
[3, true],
]);
const stored = JSON.parse(localStorage.getItem(LS.chatDock(7))!) as object[];
expect(stored).toEqual([
{ partnerId: 4, name: "P4", avatar: null, minimized: false },
{ partnerId: 3, name: "P3", avatar: null, minimized: true },
]);
});
});
-113
View File
@@ -1,113 +0,0 @@
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
// when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload.
import { LS } from "./storage";
import { createStore } from "./store";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
flash: number; // bumped to trigger a one-shot attention flash on the window
}
type PartnerLike = { id: number; name: string; avatar_url: string | null };
const MAX_OPEN = 3; // keep the dock from overflowing the corner
const dock = createStore<DockChat[]>([]);
let userId: number | null = null;
function storageKey(): string | null {
return userId != null ? LS.chatDock(userId) : null;
}
function persist(): void {
const k = storageKey();
if (!k) return;
try {
// Don't persist the transient flash counter.
localStorage.setItem(k, JSON.stringify(dock.get().map(({ flash, ...c }) => c)));
} catch {
/* ignore quota/serialization errors */
}
}
function emit(next: DockChat[]): void {
dock.set(next);
persist();
}
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
export function initDock(uid: number): void {
if (userId === uid) return;
userId = uid;
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw)
restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
dock.set(restored.slice(0, MAX_OPEN));
}
export function openChat(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...dock.get().filter((c) => c.partnerId !== partner.id),
]);
return;
}
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 0,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
}
// An incoming message arrived from `partner`: open the window if it's closed, otherwise just
// flash it (whether expanded or minimised — don't disturb the minimised state).
export function notifyIncoming(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (!existing) {
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 1,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
return;
}
emit(dock.get().map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
}
export function closeChat(partnerId: number): void {
emit(dock.get().filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(dock.get().map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return dock.useValue();
}
-180
View File
@@ -1,180 +0,0 @@
// E32 S2: lib/e2ee was 14% covered, and every way it can break is silent — a message that will not
// decrypt, a passphrase that unlocks nothing, a key that does not survive a reload. Real WebCrypto
// (Node's), a minimal in-memory IndexedDB (the module uses open / one store / get / put / delete),
// and a fresh module per "device", because the unlocked key is module state.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MyKeyBundle } from "./api";
type E2ee = typeof import("./e2ee");
function memoryIndexedDb() {
const stores = new Map<string, Map<unknown, unknown>>();
const later = (fn: () => void) => setTimeout(fn, 0);
const request = <T>(run: () => T) => {
const r: { result?: T; onsuccess?: () => void; onerror?: () => void } = {};
later(() => {
r.result = run();
r.onsuccess?.();
});
return r;
};
const db = {
createObjectStore: (name: string) => stores.set(name, new Map()),
transaction: (name: string) => {
const tx: { oncomplete?: () => void; onerror?: () => void; objectStore: () => unknown } = {
objectStore: () => {
const s = stores.get(name)!;
const done = <T>(v: T) => (later(() => tx.oncomplete?.()), v);
return {
get: (k: unknown) => request(() => s.get(k)),
put: (v: unknown, k: unknown) => done(s.set(k, v)),
delete: (k: unknown) => done(s.delete(k)),
};
},
};
return tx;
},
};
return {
open: () => {
const r: { result?: unknown; onupgradeneeded?: () => void; onsuccess?: () => void } = {};
later(() => {
r.result = db;
if (stores.size === 0) r.onupgradeneeded?.();
r.onsuccess?.();
});
return r;
},
};
}
async function freshDevice(): Promise<E2ee> {
vi.resetModules();
return import("./e2ee");
}
const bundleOf = (k: Awaited<ReturnType<E2ee["setup"]>>): MyKeyBundle =>
({ ...k, has_key: true }) as unknown as MyKeyBundle;
beforeEach(() => {
vi.stubGlobal("indexedDB", memoryIndexedDb());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("e2ee", () => {
it("a message one user encrypts, the other decrypts — and nobody else", async () => {
const alice = await freshDevice();
const aliceKey = await alice.setup(1, "alice passphrase");
const bob = await freshDevice();
const bobKey = await bob.setup(2, "bob passphrase");
const msg = await alice.encryptFor(2, bobKey.public_key, "szia, Bob — ő 🎬");
expect(msg.ciphertext).not.toContain("Bob");
expect(await bob.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).toBe(
"szia, Bob — ő 🎬",
);
const eve = await freshDevice();
await eve.setup(3, "eve passphrase");
await expect(eve.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).rejects.toThrow();
}, 30_000);
it("each message gets its own IV", async () => {
const alice = await freshDevice();
await alice.setup(1, "p");
const bob = await freshDevice();
const bobKey = await bob.setup(2, "q");
const a = await alice.encryptFor(2, bobKey.public_key, "same");
const b = await alice.encryptFor(2, bobKey.public_key, "same");
expect(a.iv).not.toBe(b.iv);
expect(a.ciphertext).not.toBe(b.ciphertext);
}, 30_000);
it("a new device unlocks with the passphrase and refuses a wrong one", async () => {
const first = await freshDevice();
const bundle = bundleOf(await first.setup(1, "the right one"));
vi.stubGlobal("indexedDB", memoryIndexedDb()); // another browser: nothing stored
const second = await freshDevice();
expect(await second.loadFromDevice(1)).toBe(false);
await expect(second.unlock(1, "a wrong one", bundle)).rejects.toThrow();
expect(second.isUnlocked()).toBe(false);
await second.unlock(1, "the right one", bundle);
expect(second.isUnlocked()).toBe(true);
}, 30_000);
it("the unlocked key survives a reload on the same device, until it is reset", async () => {
const before = await freshDevice();
await before.setup(7, "p");
const after = await freshDevice(); // a reload: module state gone, IndexedDB kept
expect(after.isUnlocked()).toBe(false);
const seen = vi.fn();
after.subscribeUnlock(seen);
expect(await after.loadFromDevice(7)).toBe(true);
expect(after.isUnlocked()).toBe(true);
expect(seen).toHaveBeenCalled();
await after.resetLocal(7);
expect(after.isUnlocked()).toBe(false);
const again = await freshDevice();
expect(await again.loadFromDevice(7)).toBe(false);
}, 30_000);
it("an incomplete bundle and a locked device are refused, not guessed around", async () => {
const e = await freshDevice();
await expect(e.unlock(1, "p", { has_key: true } as unknown as MyKeyBundle)).rejects.toThrow(
"incomplete key bundle",
);
await expect(e.encryptFor(2, "AAAA", "x")).rejects.toThrow("locked");
});
it("the key check uses its own nonce, never the wrap IV (GCM nonce reuse)", async () => {
const e = await freshDevice();
const k = await e.setup(1, "p");
// Re-derive the wrap key the way the module does; then the key check must NOT open under the
// wrap IV (it would, were the nonce reused), while the wrapped key does.
const raw = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const base = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode("p"),
"PBKDF2",
false,
["deriveKey"],
);
const wrapKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: raw(k.salt), iterations: 600_000, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
const open = (ct: string) =>
crypto.subtle.decrypt({ name: "AES-GCM", iv: raw(k.wrap_iv) }, wrapKey, raw(ct));
await expect(open(k.wrapped_private_key)).resolves.toBeDefined();
await expect(open(k.key_check)).rejects.toThrow();
}, 30_000);
it("a partner's rotated key is used only after forgetConv", async () => {
const alice = await freshDevice();
const aliceKey = await alice.setup(1, "p");
const bobOld = await freshDevice();
const oldKey = await bobOld.setup(2, "q");
await alice.encryptFor(2, oldKey.public_key, "warm the cache");
const bobNew = await freshDevice(); // Bob reset his passphrase: a new keypair
const newKey = await bobNew.setup(2, "r");
// Cached by partner id alone: without forgetConv the OLD conversation key is still used…
const stale = await alice.encryptFor(2, newKey.public_key, "after the reset");
await expect(
bobNew.decryptFrom(1, aliceKey.public_key, stale.ciphertext, stale.iv),
).rejects.toThrow();
// …and after it, the new one.
alice.forgetConv(2);
const fresh = await alice.encryptFor(2, newKey.public_key, "after the reset");
expect(await bobNew.decryptFrom(1, aliceKey.public_key, fresh.ciphertext, fresh.iv)).toBe(
"after the reset",
);
}, 60_000);
});
-253
View File
@@ -1,253 +0,0 @@
// End-to-end encryption for direct messages (notification module phase 2).
//
// Contract (validated against the backend): each user has an ECDH P-256 keypair. The private
// key is wrapped in the browser with an AES-GCM key derived (PBKDF2) from a message passphrase
// the server never sees; only the wrapped blob is uploaded. A conversation key is derived from
// ECDH(my private, partner public) → HKDF → AES-GCM, and each message is AES-GCM encrypted with
// a random IV. The server stores only ciphertext, so not even an admin can read a conversation.
//
// The unlocked private key is persisted per-device as a NON-EXTRACTABLE CryptoKey in IndexedDB,
// so the passphrase is needed once per device, then survives reloads (cleared with browser data).
import type { KeySetup, MyKeyBundle } from "./api";
const subtle = crypto.subtle;
const enc = new TextEncoder();
const dec = new TextDecoder();
const PBKDF2_ITERS = 600_000;
const HKDF_INFO = "siftlode-dm-v1";
const b64 = (buf: ArrayBuffer | Uint8Array): string => {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
};
const ub64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
// WebCrypto byte inputs: the DOM lib types want BufferSource backed by a plain ArrayBuffer;
// our Uint8Arrays/ArrayBuffers are functionally that, so coerce at the call sites.
const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
// --- module state ---
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
// Unlock state is shared app-wide (the Messages page and the floating chat dock both observe
// it), so expose a subscription for useSyncExternalStore.
const unlockSubs = new Set<() => void>();
function emitUnlock(): void {
for (const cb of unlockSubs) cb();
}
export function subscribeUnlock(cb: () => void): () => void {
unlockSubs.add(cb);
return () => unlockSubs.delete(cb);
}
export function isUnlocked(): boolean {
return !!privKey;
}
// --- IndexedDB (per-device persistence of the unlocked private key) ---
const DB_NAME = "siftlode-e2ee";
const STORE = "privkeys";
function idb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbGet(userId: number): Promise<CryptoKey | null> {
const db = await idb();
return new Promise((resolve) => {
const r = db.transaction(STORE, "readonly").objectStore(STORE).get(userId);
r.onsuccess = () => resolve((r.result as CryptoKey) ?? null);
r.onerror = () => resolve(null);
});
}
async function idbPut(userId: number, key: CryptoKey): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(key, userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
export async function clearDevice(userId: number): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
// Wipe this device's E2EE identity: forget the unlocked key + every derived conversation key,
// delete the persisted copy, and notify observers so the gate re-appears. Used by the passphrase
// reset, which also drops the server key so the next screen is a fresh setup.
export async function resetLocal(userId: number): Promise<void> {
privKey = null;
convKeys.clear();
await clearDevice(userId);
emitUnlock();
}
// --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
"deriveKey",
]);
return subtle.deriveKey(
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
// Non-extractable: usable for deriveBits but can never be exported again.
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, [
"deriveBits",
]);
}
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
export async function loadFromDevice(userId: number): Promise<boolean> {
const stored = await idbGet(userId);
if (stored) {
privKey = stored;
emitUnlock();
return true;
}
return false;
}
// Install a freshly-imported private key as the current identity: drop any derived conversation
// keys, persist it to this device, and notify unlock subscribers. Shared by setup() and unlock().
async function installKey(userId: number, key: CryptoKey): Promise<void> {
privKey = key;
convKeys.clear();
await idbPut(userId, key);
emitUnlock();
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const salt = crypto.getRandomValues(new Uint8Array(16));
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await wrapKeyFrom(passphrase, salt);
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8));
// key_check is retained for the upload schema but MUST use its own nonce: reusing wrapIv under
// wrapKey would be GCM nonce-reuse (leaks keystream + enables GHASH forgery). It is no longer a
// verification oracle either — unlock() authenticates the passphrase via the wrapped-key GCM tag.
const checkIv = crypto.getRandomValues(new Uint8Array(12));
const keyCheck = await subtle.encrypt(
{ name: "AES-GCM", iv: bsrc(checkIv) },
wrapKey,
bsrc(enc.encode("ok")),
);
const spki = await subtle.exportKey("spki", kp.publicKey);
await installKey(userId, await importPrivate(pkcs8));
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
salt: b64(salt),
wrap_iv: b64(wrapIv),
key_check: b64(keyCheck),
};
}
// Unlock on a device from the server blob; throws if the passphrase is wrong.
export async function unlock(
userId: number,
passphrase: string,
bundle: MyKeyBundle,
): Promise<void> {
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
throw new Error("incomplete key bundle");
}
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
// Wrong passphrase → the AES-GCM auth tag fails on this decrypt. (No separate key_check oracle:
// that was redundant with this tag and formerly shared wrapIv — see setup().)
const pkcs8 = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
wrapKey,
bsrc(ub64(bundle.wrapped_private_key)),
);
await installKey(userId, await importPrivate(pkcs8));
}
// Drop the cached derived conversation key for a partner. convKeyFor caches by partnerId alone, so
// after a partner rotates their public key (a passphrase reset) the caller must call this before
// re-deriving with the fresh key — otherwise the stale key would be returned.
export function forgetConv(partnerId: number): void {
convKeys.delete(partnerId);
}
// --- per-conversation encryption ---
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
const cached = convKeys.get(partnerId);
if (cached) return cached;
if (!privKey) throw new Error("locked");
const pub = await subtle.importKey(
"spki",
bsrc(ub64(partnerPubB64)),
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
const key = await subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: bsrc(new Uint8Array(0)),
info: bsrc(enc.encode(HKDF_INFO)),
},
hk,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
convKeys.set(partnerId, key);
return key;
}
export async function encryptFor(
partnerId: number,
partnerPubB64: string,
plaintext: string,
): Promise<{ ciphertext: string; iv: string }> {
const key = await convKeyFor(partnerId, partnerPubB64);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv: bsrc(iv) },
key,
bsrc(enc.encode(plaintext)),
);
return { ciphertext: b64(ct), iv: b64(iv) };
}
export async function decryptFrom(
partnerId: number,
partnerPubB64: string,
ciphertext: string,
iv: string,
): Promise<string> {
const key = await convKeyFor(partnerId, partnerPubB64);
const pt = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(iv)) },
key,
bsrc(ub64(ciphertext)),
);
return dec.decode(pt);
}
+6 -67
View File
@@ -270,7 +270,7 @@
"name": "XMLHttpRequest"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -338,7 +338,7 @@
"name": "fetch"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -386,7 +386,7 @@
"name": "XMLHttpRequest"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -408,54 +408,6 @@
},
"settings": null
},
"lib/messagesSocket.ts": {
"languageOptions": {
"ecmaVersion": 2022,
"globals": "1169 names, sha1 aee621727a03",
"parser": "typescript-eslint/parser",
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
"sourceType": "module"
},
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [],
"plugins": [
"@",
"@eslint-community/eslint-comments",
"@typescript-eslint",
"local",
"react-hooks",
"react-refresh"
],
"rules": {
"no-restricted-globals": [
2,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"name": "XMLHttpRequest"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
],
"no-restricted-imports": [
2,
{
"patterns": [
{
"message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
"regex": "^(\\.\\./)+components(/|$)"
}
]
}
]
},
"settings": null
},
"src": {
"languageOptions": {
"ecmaVersion": 2022,
@@ -852,7 +804,7 @@
"name": "XMLHttpRequest"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -1159,7 +1111,7 @@
"name": "XMLHttpRequest"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -1362,7 +1314,7 @@
"name": "XMLHttpRequest"
},
{
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
@@ -1465,7 +1417,6 @@
"src/lib/channelOrder.dom.test.ts",
"src/lib/channelWriteFailures.dom.test.tsx",
"src/lib/channelsUrlState.dom.test.ts",
"src/lib/chatDock.dom.test.ts",
"src/lib/chipLane.contract.test.ts",
"src/lib/chipOverflow.test.ts",
"src/lib/columnSort.test.ts",
@@ -1478,7 +1429,6 @@
"src/lib/downloadActions.test.ts",
"src/lib/downloadFailure.test.ts",
"src/lib/downloads.test.ts",
"src/lib/e2ee.test.ts",
"src/lib/fadeEdge.contract.test.ts",
"src/lib/feedSort.yt.test.ts",
"src/lib/focusSafe.contract.test.ts",
@@ -1512,7 +1462,6 @@
"src/lib/log.test.ts",
"src/lib/logHooks.dom.test.ts",
"src/lib/logsUrlState.test.ts",
"src/lib/messagesSocket.test.ts",
"src/lib/moveInList.test.ts",
"src/lib/mutationFailures.contract.test.ts",
"src/lib/navPosition.wiring.contract.test.ts",
@@ -1588,7 +1537,6 @@
],
"lib/api/core.ts": ["src/lib/api/core.ts"],
"lib/http.ts": ["src/lib/http.ts"],
"lib/messagesSocket.ts": ["src/lib/messagesSocket.ts"],
"src": [
"src/App.tsx",
"src/components/About.tsx",
@@ -1613,8 +1561,6 @@
"src/components/ChannelTags.tsx",
"src/components/Channels.tsx",
"src/components/ChannelsProvider.tsx",
"src/components/ChatDock.tsx",
"src/components/ChatThread.tsx",
"src/components/ConfigPanel.tsx",
"src/components/ConfirmProvider.tsx",
"src/components/DateFilterSelect.tsx",
@@ -1635,12 +1581,10 @@
"src/components/Header.tsx",
"src/components/Home.tsx",
"src/components/IssueComposer.tsx",
"src/components/KeyGate.tsx",
"src/components/LanguageSwitcher.tsx",
"src/components/LibraryPlayer.tsx",
"src/components/LogLine.tsx",
"src/components/MediaResumeButtons.tsx",
"src/components/Messages.tsx",
"src/components/MyLogViewer.tsx",
"src/components/NavSidebar.tsx",
"src/components/NavigationProvider.tsx",
@@ -1800,7 +1744,6 @@
"src/components/Home.pin.dom.test.tsx",
"src/components/IssueComposer.dom.test.tsx",
"src/components/LibraryPlayer.dom.test.tsx",
"src/components/Messages.keyboard.dom.test.tsx",
"src/components/MyLogViewer.dom.test.tsx",
"src/components/NavigationProvider.dom.test.tsx",
"src/components/NotificationsPanel.menu.dom.test.tsx",
@@ -1914,7 +1857,6 @@
"src/lib/api/discover.ts",
"src/lib/api/downloads.ts",
"src/lib/api/feed.ts",
"src/lib/api/messaging.ts",
"src/lib/api/notifications.ts",
"src/lib/api/playlists.ts",
"src/lib/api/plex.ts",
@@ -1933,7 +1875,6 @@
"src/lib/channelLayout.ts",
"src/lib/channelOrder.ts",
"src/lib/channelsUrlState.ts",
"src/lib/chatDock.ts",
"src/lib/chipOverflow.ts",
"src/lib/clipboard.ts",
"src/lib/collate.ts",
@@ -1947,7 +1888,6 @@
"src/lib/downloadActions.ts",
"src/lib/downloadFailure.ts",
"src/lib/downloads.ts",
"src/lib/e2ee.ts",
"src/lib/entryUrl.ts",
"src/lib/errorDialog.ts",
"src/lib/feedSort.ts",
@@ -1973,7 +1913,6 @@
"src/lib/logsUrlState.ts",
"src/lib/mediaLog.ts",
"src/lib/mediaSource.ts",
"src/lib/messaging.ts",
"src/lib/modules.ts",
"src/lib/moveInList.ts",
"src/lib/nav.ts",
+1 -1
View File
@@ -3,7 +3,7 @@
//
// Two primitives share the existing page-history (App stamps `sfPage` in history.state):
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
// a module (e.g. Messages thread → list) returns to the parent view first.
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath.
-1
View File
@@ -116,7 +116,6 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications",
page: "notifications",
},
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
{
id: "goDownloads",
scope: "page",
+1 -1
View File
@@ -11,7 +11,7 @@
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
// A secret cannot leak by forgetting to scrub it at one call site, and a new call site is safe by
// default. Never kept, at any level: passwords, tokens, cookies, secret config, E2EE material, email
// default. Never kept, at any level: passwords, tokens, cookies, secret config, ciphertext, email
// 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.
//
-74
View File
@@ -1,74 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./api", () => ({ getActiveAccount: () => 7 }));
// A WebSocket double the test drives: each instance is recorded, and its events are fired by hand.
class FakeSocket {
static all: FakeSocket[] = [];
url: string;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev: { code: number; wasClean: boolean }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeSocket.all.push(this);
}
close() {
this.closed = true; // the real close event arrives later, on its own task — fired by the test
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
FakeSocket.all = [];
vi.stubGlobal("WebSocket", FakeSocket);
vi.stubGlobal("location", { protocol: "https:", host: "app.example" });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const frame = (sock: FakeSocket) =>
sock.onmessage?.({ data: JSON.stringify({ type: "message", message: { id: 1 } }) });
describe("messagesSocket", () => {
it("a socket closed on unsubscribe cannot take over from the one a re-subscribe opened", async () => {
const { onMessage } = await import("./messagesSocket");
const first = vi.fn();
onMessage(first)(); // subscribe, then leave (a remount / StrictMode's double effect)
const got = vi.fn();
onMessage(got);
const [old, current] = FakeSocket.all;
expect(old!.closed).toBe(true);
old!.onclose?.({ code: 1000, wasClean: true }); // the OLD socket's close lands late
vi.advanceTimersByTime(60_000);
expect(FakeSocket.all).toHaveLength(2); // no third socket
frame(current!);
expect(got).toHaveBeenCalledOnce();
});
it("reconnects when the current socket drops, and logs it without the account query", async () => {
// Imported after resetModules, like the socket module: the ring it writes to is this instance.
const { onMessage } = await import("./messagesSocket");
const { events } = await import("./log");
onMessage(() => {});
const [sock] = FakeSocket.all;
expect(sock!.url).toBe("wss://app.example/api/messages/ws?account=7");
sock!.onopen?.();
sock!.onclose?.({ code: 1006, wasClean: false });
vi.advanceTimersByTime(1000);
expect(FakeSocket.all).toHaveLength(2);
const logged = events().map((e) => [e.event, e.level, e.attrs?.path]);
expect(logged).toEqual([
["ws.open", "INFO", "/api/messages/ws"],
["ws.close", "WARNING", "/api/messages/ws"],
]);
expect(JSON.stringify(events())).not.toContain("account");
});
});
-137
View File
@@ -1,137 +0,0 @@
// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import { getActiveAccount, type Message, type MessageUser } from "./api";
import { log } from "./log";
// What the client log records of the socket: its lifecycle, and at DEBUG each frame's TYPE. Never a
// frame's content — chat plaintext stays out of every log structurally, not by redaction. The path
// is fixed, without the `?account=` query.
const WS_PATH = "/api/messages/ws";
// A pushed message plus both parties, so the dock can open/flash the right window.
interface IncomingMessage {
message: Message;
from?: MessageUser;
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}${WS_PATH}${qs}`;
}
function open() {
let sock: WebSocket;
try {
sock = new WebSocket(url());
} catch (e) {
log.warn("ws.failed", "could not open the socket", { path: WS_PATH, message: String(e) });
schedule();
return;
}
ws = sock;
// Every handler checks it still belongs to the CURRENT socket. A socket closed by the last
// subscriber leaving closes asynchronously: when a subscriber came back in between (a remount,
// StrictMode's double effect) a newer socket is already open, and the old one's close used to
// clear that newer reference and schedule a THIRD — two live sockets, every message delivered twice.
sock.onopen = () => {
if (ws !== sock) return;
log.info("ws.open", "socket open", { path: WS_PATH });
backoff = 1000;
};
sock.onmessage = (ev) => {
if (ws !== sock) return;
try {
const data = JSON.parse(ev.data);
log.debug("ws.frame", "socket frame", { path: WS_PATH, type: String(data?.type) });
if (data?.type === "message" && data.message) {
const e: IncomingMessage = {
message: data.message as Message,
from: data.from,
to: data.to,
};
for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
}
} catch {
/* ignore malformed frames */
}
};
sock.onclose = (ev) => {
const current = ws === sock;
// A socket we let go of (the last subscriber left) closing is routine; the current one closing
// is the server or the network going away, and the reconnect that follows is worth seeing.
(current ? log.warn : log.info)("ws.close", "socket closed", {
path: WS_PATH,
code: ev.code,
clean: ev.wasClean,
reconnect_ms: current && started ? backoff : null,
});
if (!current) return;
ws = null;
if (started) schedule();
};
sock.onerror = () => {
sock.close();
};
}
function schedule() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (started) open();
}, backoff);
backoff = Math.min(backoff * 2, 30000);
}
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
// last one leaves.
export function onMessage(h: Handler): () => void {
handlers.add(h);
if (!started) {
started = true;
open();
}
return () => {
handlers.delete(h);
if (handlers.size === 0) {
started = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
ws = null;
}
};
}
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}
-66
View File
@@ -1,66 +0,0 @@
// Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react";
import { qk } from "./queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
export const POLL_MS = 20000;
// Cache of partner public keys (the server is the directory). Keys are stable in normal use, but a
// partner can rotate theirs via a passphrase reset — refreshPartnerPub() evicts a stale entry (and
// its derived conversation key) so the next fetch picks up the new key.
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {
const cached = pubCache.get(partnerId);
if (cached) return cached;
const r = await api.messagePublicKey(partnerId);
pubCache.set(partnerId, r.public_key);
return r.public_key;
}
// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt
// fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching
// lets this device both read their new messages and encrypt future ones under the current key.
//
// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message
// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that
// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends
// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user
// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a
// send-time freshness check, which is out of the current no-forward-secrecy scope.
export async function refreshPartnerPub(partnerId: number): Promise<string> {
pubCache.delete(partnerId);
forgetConv(partnerId);
return partnerPub(partnerId);
}
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
}
// The SERVER is the source of truth for whether messaging is set up. Combining it with the
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
// but this device hasn't unlocked it, else "ready".
//
// KNOWN LIMITATION (multi-device, passphrase reset): this doesn't compare the locally-unlocked
// key's public half against the server's current public_key, so if the user resets their passphrase
// on ANOTHER device (new keypair), this already-unlocked device stays "ready" with the now-dead old
// key and silently fails to decrypt new messages until it's manually re-unlocked. Accepted for now:
// it fits the E2EE scope (static pairwise key, no forward secrecy) and self-heals once the device is
// re-unlocked with the new passphrase. Incoming-message decrypt failures on the CURRENT device DO
// self-heal via refreshPartnerPub() (a partner's rotation), which covers the common single-device case.
export type KeyState = "loading" | "setup" | "unlock" | "ready";
export function useKeyState(): KeyState {
const unlocked = useUnlocked();
const q = useQuery({ queryKey: qk.messageKey(), queryFn: api.messageMyKey, staleTime: 60_000 });
if (q.isLoading && !q.data) return "loading";
if (!(q.data?.configured ?? false)) return "setup";
if (!unlocked) return "unlock";
return "ready";
}
-9
View File
@@ -6,7 +6,6 @@ import {
Download,
Home,
ListVideo,
MessageSquare,
ScrollText,
FileSearch,
Settings,
@@ -90,14 +89,6 @@ const MODULES: readonly ModuleDef[] = [
icon: Bell,
hasBadge: true,
},
{
id: "messages",
labelKey: "messages.navLabel",
railKey: "nav.rail.messages",
icon: MessageSquare,
hasBadge: true,
gate: notDemo,
},
{
id: "downloads",
labelKey: "downloads.navLabel",
@@ -34,7 +34,6 @@ describe("network and console boundary", () => {
it.each([
["src/lib/http.ts", "fetch"],
["src/lib/api/core.ts", "XMLHttpRequest"],
["src/lib/messagesSocket.ts", "WebSocket"],
])("%s may use %s — and nothing else", async (owner, own) => {
const found = await flagged(ALL, owner);
// Its own global goes quiet; the other two globals, the two properties and console still fire.
-1
View File
@@ -11,7 +11,6 @@ const EXPECTED: Record<Page, string> = {
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
messages: "messages.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
-3
View File
@@ -26,8 +26,6 @@ describe("qk — bare vs parameterized branch", () => {
expect(qk.playlist(7)).toEqual(["playlist", 7]);
expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 3]);
expect(qk.ytSearch("cats", 3)).toEqual(["yt-search", "cats", 3]);
expect(qk.thread(null)).toEqual(["thread", null]);
expect(qk.thread(42)).toEqual(["thread", 42]);
});
});
@@ -50,7 +48,6 @@ describe("qk — single-id and scalar keys", () => {
expect(qk.myStatus()).toEqual(["my-status"]);
expect(qk.channels()).toEqual(["channels"]);
expect(qk.playlists()).toEqual(["playlists"]);
expect(qk.conversations()).toEqual(["conversations"]);
expect(qk.downloads()).toEqual(["downloads"]);
expect(qk.adminUsers()).toEqual(["admin-users"]);
expect(qk.plex()).toEqual(["plex"]);
+1 -7
View File
@@ -62,12 +62,7 @@ export const qk = {
notifications: () => ["notifications"] as const,
notifUnread: () => ["notif-unread"] as const,
// --- messaging ---
conversations: () => ["conversations"] as const,
thread: (partnerId: number | null) => ["thread", partnerId] as const,
messageKey: () => ["message-key"] as const,
messageUnread: () => ["message-unread"] as const,
messageUsers: () => ["message-users"] as const,
// --- sharing ---
shareRecipients: () => ["share-recipients"] as const,
// --- downloads ---
@@ -168,6 +163,5 @@ export const LANGUAGE_ORDERED = [
qk.discoveredChannels(),
qk.blockedChannels(),
qk.shareRecipients(),
qk.messageUsers(),
qk.plexCollections(),
] as const;
@@ -52,7 +52,6 @@ const COMPLEX: Record<string, number> = {
"components/Home.tsx::Home": 34,
"components/Home.tsx::useHomeCardMenu": 21,
"components/IssueComposer.tsx::ComposerBody": 34,
"components/KeyGate.tsx::KeyGate": 22,
"components/NotificationsPanel.tsx::NotificationRow": 28,
"components/OnboardingWizard.tsx::OnboardingWizard": 21,
"components/PersonalViewsModal.tsx::PersonalViewsModal": 22,
-1
View File
@@ -74,7 +74,6 @@ export const LS = {
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- per-account scoping -----------------------------------------------------------------
-1
View File
@@ -131,7 +131,6 @@ const PAGES = [
"audit",
"logs",
"notifications",
"messages",
"downloads",
"plex",
] as const;