diff --git a/.claude/skills/ci-check/SKILL.md b/.claude/skills/ci-check/SKILL.md index ab397b68..f7b7e5ad 100644 --- a/.claude/skills/ci-check/SKILL.md +++ b/.claude/skills/ci-check/SKILL.md @@ -34,7 +34,7 @@ fault, same verdict, especially when the diff touches no Go code. Rerun the job (`gh run rerun --job `); a job cannot be rerun while its parent run is still in progress. -## Client (from `Client/tauri-client/`) +## Client (from `Client/`) ```bash NODE_OPTIONS=--no-experimental-webstorage npm test @@ -47,7 +47,7 @@ The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md. `npm audit --audit-level=high` and `knip` also run in CI but are advisory. -## Rust (from `Client/tauri-client/src-tauri/`) +## Rust (from `Client/src-tauri/`) ```bash cargo test diff --git a/.claude/skills/protocol-change/SKILL.md b/.claude/skills/protocol-change/SKILL.md index 5db78b77..cbb45081 100644 --- a/.claude/skills/protocol-change/SKILL.md +++ b/.claude/skills/protocol-change/SKILL.md @@ -1,6 +1,6 @@ --- name: protocol-change -description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts. +description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/src/lib/protocolTypes.ts. --- # protocol-change @@ -11,7 +11,7 @@ generated from it by `Server/scripts/genprotocol/`. 1. Edit `docs/protocol-schema.json`. 2. Run `make protocol-generate` from `Server/`. 3. Commit **both** outputs — `Server/ws/message_types.go` and - `Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the + `Client/src/lib/protocolTypes.ts`. One run regenerates the pair; committing only the Go side is the usual mistake, and CI's `make protocol-verify` fails on either being stale. @@ -20,4 +20,4 @@ shapes, not behaviour. Adding a message type is not enough to make it work: a server handler must be registered in the `ws` V1/V2 dispatch tables, and the client needs a -`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`. +`ws.on(...)` subscription in `Client/src/lib/dispatcher.ts`. diff --git a/.claude/workflows/bughunt-fix.harness.mjs b/.claude/workflows/bughunt-fix.harness.mjs index 10db8ac0..c7f93372 100644 --- a/.claude/workflows/bughunt-fix.harness.mjs +++ b/.claude/workflows/bughunt-fix.harness.mjs @@ -46,7 +46,7 @@ export async function run({ agentStub, args = undefined, budget = undefined }) { export const rec = (id, over = {}) => ({ id, title: `bug ${id}`, - file: 'Client/tauri-client/src/lib/livekitE2EE.ts', + file: 'Client/src/lib/livekitE2EE.ts', line: 100, severity: 'high', why: 'w', @@ -78,7 +78,7 @@ scenarios.f1_clusters_by_file = async () => { assert.equal(result.branch, 'fix/test') assert.equal(result.clusters.length, 2) const byFile = Object.fromEntries(result.clusters.map((c) => [c.file, c.ids])) - assert.deepEqual(byFile['Client/tauri-client/src/lib/livekitE2EE.ts'], ['OC-0001', 'OC-0002']) + assert.deepEqual(byFile['Client/src/lib/livekitE2EE.ts'], ['OC-0001', 'OC-0002']) assert.deepEqual(byFile['Server/ws/hub_sweep.go'], ['OC-0003']) } @@ -580,7 +580,7 @@ scenarios.f14_disjoint_touched_paths_both_commit = async () => { if (opts.label.includes('livekitE2EE')) return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }], - touchedPaths: ['Client/tauri-client/src/lib/otherHelper.ts'], + touchedPaths: ['Client/src/lib/otherHelper.ts'], } return { results: [{ id: 'OC-0003', outcome: 'fixed', testPath: 'Server/ws/hub_sweep_test.go', rationale: '' }], @@ -616,7 +616,7 @@ scenarios.f15_prove_prompt_names_every_touched_path = async () => { if (String(opts.label).startsWith('fix:')) return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }], - touchedPaths: ['Client/tauri-client/src/lib/sharedCrypto.ts'], + touchedPaths: ['Client/src/lib/sharedCrypto.ts'], } if (String(opts.label).startsWith('prove:')) { provePromptText = prompt @@ -651,8 +651,8 @@ scenarios.f15_prove_prompt_names_every_touched_path = async () => { const FOUR_FILES = [ rec('OC-0001'), rec('OC-0002', { file: 'Server/ws/hub_sweep.go' }), - rec('OC-0003', { file: 'Client/tauri-client/src/lib/livekitSession.ts' }), - rec('OC-0004', { file: 'Client/tauri-client/src/components/VoiceWidget.ts' }), + rec('OC-0003', { file: 'Client/src/lib/livekitSession.ts' }), + rec('OC-0004', { file: 'Client/src/components/VoiceWidget.ts' }), ] // A fix stub that reports every id in its prompt as fixed. Ids go through a Set because rec() // puts each id in both `id` and `title`, so the raw matchAll yields every id twice. diff --git a/.claude/workflows/bughunt-fix.js b/.claude/workflows/bughunt-fix.js index ee3f6506..1aa00e25 100644 --- a/.claude/workflows/bughunt-fix.js +++ b/.claude/workflows/bughunt-fix.js @@ -150,7 +150,7 @@ function fixPrompt(cluster) { `alone, return outcome "declined" with a rationale. Do not invent a fix you do not believe in.\n` + ` 8. If you cannot fix it for a mechanical reason (missing fixture, unclear repro), return "blocked" ` + `with a rationale.\n\n` + - `Client tests run from Client/tauri-client with:\n` + + `Client tests run from Client with:\n` + ` NODE_OPTIONS=--no-experimental-webstorage npx vitest run \n` + `Server tests run from Server with:\n` + ` go test .// -run \n\n` + @@ -336,7 +336,7 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { ` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` + `Co-Authored-By trailer.\n` + ` 9. Return the short sha.\n\n` + - `Client tests run from Client/tauri-client with:\n` + + `Client tests run from Client with:\n` + ` NODE_OPTIONS=--no-experimental-webstorage npx vitest run \n` + `Server tests run from Server with:\n` + ` go test .// -run ` @@ -433,7 +433,7 @@ function stacksFor(files) { const s = new Set() for (const f of files) { if (f.startsWith('Server/')) s.add('server') - else if (f.startsWith('Client/tauri-client/src-tauri/')) s.add('rust') + else if (f.startsWith('Client/src-tauri/')) s.add('rust') else if (f.startsWith('Client/')) s.add('client') } return [...s] @@ -441,7 +441,7 @@ function stacksFor(files) { const GATE_COMMANDS = { client: - `From Client/tauri-client:\n` + + `From Client:\n` + ` NODE_OPTIONS=--no-experimental-webstorage npm test\n` + ` npm run typecheck\n` + ` npm run lint\n` + @@ -456,10 +456,10 @@ const GATE_COMMANDS = { ` make sqlc-verify protocol-verify # generated output must not be stale. If make is not on PATH, ` + `run the equivalent commands directly instead: ` + `"sqlc generate && git diff --exit-code db/dbgen" and ` + - `"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/tauri-client/src/lib/protocolTypes.ts" ` + + `"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` + `- a non-empty diff in either means generated code is stale and the gate fails`, rust: - `From Client/tauri-client/src-tauri:\n` + + `From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`, } diff --git a/.claude/workflows/bughunt.harness.mjs b/.claude/workflows/bughunt.harness.mjs index e9b03773..a0768fea 100644 --- a/.claude/workflows/bughunt.harness.mjs +++ b/.claude/workflows/bughunt.harness.mjs @@ -58,7 +58,7 @@ export function makeStub({ hunt, verify, recon = defaultRecon }) { } } export function defaultRecon() { - return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8' + return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/src/lib/dispatcher.ts 8' } export const none = { findings: [] } export const finding = (n, over = {}) => ({ @@ -363,7 +363,7 @@ scenarios.s_graph_missing_fallback = async () => { scenarios.s7_adaptive_lenses = async () => { const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' }) const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' }) - const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' }) + const C = finding(3, { file: 'Client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' }) const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key) => { @@ -380,7 +380,7 @@ scenarios.s7_adaptive_lenses = async () => { assert.equal(result.rounds[3].family, 'adaptive') const r4Keys = [...new Set(calls.filter((c) => /^r4:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2]))] assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`) - assert.ok(r4Keys.includes('hotspot-client-tauri-client-src-lib'), `r4 keys: ${r4Keys}`) + assert.ok(r4Keys.includes('hotspot-client-src-lib'), `r4 keys: ${r4Keys}`) assert.ok(r4Keys.includes('explore-1'), `r4 keys: ${r4Keys}`) const hotspot = calls.find((c) => (c.opts.label || '').includes('hotspot-server-ws')) assert.match(hotspot.prompt, /Server\/ws\/hub\.go/) diff --git a/.claude/workflows/bughunt.js b/.claude/workflows/bughunt.js index df39649a..0200cf40 100644 --- a/.claude/workflows/bughunt.js +++ b/.claude/workflows/bughunt.js @@ -87,7 +87,7 @@ const VERDICTS = { // ---------- rules ---------- const RULES = ` Repo: OwnCord, checked out at your current working directory (the repo root - do not assume any absolute -path; run every command from there and use repo-relative paths). Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/ +path; run every command from there and use repo-relative paths). Go 1.26 server in Server/, Tauri v2 client in Client/ (Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/). You are hunting REAL BUGS: wrong behavior, not style. In scope: @@ -132,8 +132,8 @@ const SURFACE_LENSES = [ key: 'voice-e2ee', prompt: `Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` + - `any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` + - `livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` + + `any Server/ws/*voice*.go or *e2ee*.go; Client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` + + `livekitSession.ts, identity.ts; Client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` + `Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` + `TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` + `joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` + @@ -167,7 +167,7 @@ const SURFACE_LENSES = [ { key: 'tauri-rust', prompt: - `Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` + + `Surface: the Tauri Rust backend. Files: Client/src-tauri/src/*.rs.\n\n` + `Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` + `environment-controlled input) - a panic here can take down the app; a lock held across .await; ` + `state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` + @@ -178,7 +178,7 @@ const SURFACE_LENSES = [ { key: 'client-state', prompt: - `Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` + + `Surface: TypeScript client state and event handling. Files: Client/src/lib/*.ts and ` + `src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` + `navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` + `Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` + @@ -505,8 +505,8 @@ const recon = await parallel([ `${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` + `Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` + ` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` + - ` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` + - ` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` + + ` (b) every Client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` + + ` (c) every Client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` + `For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`, { label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'xhigh' }, ), diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e2e91d2c..67afdae2 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -51,13 +51,13 @@ if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scrip fi # ---------- Client (TypeScript) ---------- -ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/tauri-client/(src|tests)/.*\.ts$' | grep -v '/generated/') +ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/') if [ -n "$ts_staged" ]; then - if [ ! -d Client/tauri-client/node_modules ]; then - printf 'pre-commit: WARNING: node_modules missing in Client/tauri-client; skipping client checks (run npm install there).\n' >&2 + if [ ! -d Client/node_modules ]; then + printf 'pre-commit: WARNING: node_modules missing in Client; skipping client checks (run npm install there).\n' >&2 else - rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/tauri-client/||') - cd Client/tauri-client || exit 1 + rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/||') + cd Client || exit 1 # shellcheck disable=SC2086 npx oxlint $rel || fail "oxlint" # shellcheck disable=SC2086 diff --git a/.githooks/pre-push b/.githooks/pre-push index e6f668c7..90d06bf6 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -28,7 +28,7 @@ if [ "$changed" = "__all__" ]; then client_changed=1 else if printf '%s\n' "$changed" | grep -q '^Server/'; then server_changed=1; fi - if printf '%s\n' "$changed" | grep -q '^Client/tauri-client/'; then client_changed=1; fi + if printf '%s\n' "$changed" | grep -q '^Client/'; then client_changed=1; fi if printf '%s\n' "$changed" | grep -q '^docs/protocol-schema\.json'; then server_changed=1 client_changed=1 @@ -49,12 +49,12 @@ if [ "$server_changed" = 1 ] && command -v go >/dev/null 2>&1; then fi if [ "$client_changed" = 1 ]; then - if [ -d Client/tauri-client/node_modules ]; then + if [ -d Client/node_modules ]; then echo "pre-push: client typecheck + eslint..." - (cd Client/tauri-client && npm run -s typecheck) || fail "tsc --noEmit" - (cd Client/tauri-client && npx eslint src/) || fail "eslint" + (cd Client && npm run -s typecheck) || fail "tsc --noEmit" + (cd Client && npx eslint src/) || fail "eslint" else - printf 'pre-push: WARNING: node_modules missing in Client/tauri-client; skipping client checks.\n' >&2 + printf 'pre-push: WARNING: node_modules missing in Client; skipping client checks.\n' >&2 fi fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1968779f..a15ed460 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,7 +38,7 @@ updates: # Tauri client npm dependencies - package-ecosystem: npm - directory: /Client/tauri-client + directory: /Client schedule: interval: weekly day: monday @@ -58,7 +58,7 @@ updates: # Tauri Rust/Cargo dependencies - package-ecosystem: cargo - directory: /Client/tauri-client/src-tauri + directory: /Client/src-tauri schedule: interval: weekly day: monday diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e52234ad..83920885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -127,7 +127,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install npm dependencies run: npm ci @@ -177,7 +177,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -185,7 +185,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install npm dependencies run: npm ci @@ -198,7 +198,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: client-coverage - path: Client/tauri-client/coverage/ + path: Client/coverage/ retention-days: 7 # Rust unit tests used to live inside tauri-build, which only runs on PRs to @@ -211,7 +211,7 @@ jobs: timeout-minutes: 30 defaults: run: - working-directory: Client/tauri-client/src-tauri/ + working-directory: Client/src-tauri/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -236,7 +236,7 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: Client/tauri-client/src-tauri + workspaces: Client/src-tauri - name: Clippy lint (including test targets) run: cargo clippy --all-targets -- -D warnings @@ -264,7 +264,7 @@ jobs: timeout-minutes: 25 defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -272,7 +272,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install npm dependencies run: npm ci @@ -295,8 +295,8 @@ jobs: with: name: playwright-report path: | - Client/tauri-client/playwright-report/ - Client/tauri-client/test-results/ + Client/playwright-report/ + Client/test-results/ retention-days: 7 # Admin-panel journey against a REAL server (no mocks): start-server.sh @@ -318,7 +318,7 @@ jobs: timeout-minutes: 20 defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -331,7 +331,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install npm dependencies run: npm ci @@ -348,8 +348,8 @@ jobs: with: name: admin-e2e-report path: | - Client/tauri-client/playwright-report/ - Client/tauri-client/test-results/ + Client/playwright-report/ + Client/test-results/ retention-days: 7 # Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering @@ -365,7 +365,7 @@ jobs: timeout-minutes: 15 defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -373,7 +373,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install npm dependencies run: npm ci @@ -390,8 +390,8 @@ jobs: with: name: playwright-report-parity path: | - Client/tauri-client/playwright-report/ - Client/tauri-client/test-results/ + Client/playwright-report/ + Client/test-results/ retention-days: 7 # Image build is verification only, so it is skipped on dev to keep day-to-day @@ -457,7 +457,7 @@ jobs: runs-on: ${{ matrix.os }} defaults: run: - working-directory: Client/tauri-client/ + working-directory: Client/ steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -465,7 +465,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install Linux system dependencies if: startsWith(matrix.os, 'ubuntu') @@ -491,20 +491,20 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: Client/tauri-client/src-tauri + workspaces: Client/src-tauri - name: Install npm dependencies run: npm ci - name: Clippy lint (Rust) - working-directory: Client/tauri-client/src-tauri/ + working-directory: Client/src-tauri/ run: cargo clippy -- -D warnings # Rust unit tests moved to the standalone `rust-tests` job so they run on # every event, not just PRs to main. - name: Security audit (Rust dependencies) - working-directory: Client/tauri-client/src-tauri/ + working-directory: Client/src-tauri/ run: | cargo install cargo-audit@0.22.1 --quiet cargo audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80da1d81..a32eb321 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,9 @@ jobs: shell: bash run: | TAG_VERSION="${GITHUB_REF_NAME#v}" - TAURI_VERSION=$(node -p "require('./Client/tauri-client/src-tauri/tauri.conf.json').version") - NPM_VERSION=$(node -p "require('./Client/tauri-client/package.json').version") - CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/tauri-client/src-tauri/Cargo.toml | head -1) + TAURI_VERSION=$(node -p "require('./Client/src-tauri/tauri.conf.json').version") + NPM_VERSION=$(node -p "require('./Client/package.json').version") + CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/src-tauri/Cargo.toml | head -1) fail=0 for pair in "tauri.conf.json:$TAURI_VERSION" "package.json:$NPM_VERSION" "Cargo.toml:$CARGO_VERSION"; do file="${pair%%:*}"; ver="${pair#*:}" @@ -56,7 +56,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -64,14 +64,14 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: Client/tauri-client/src-tauri + workspaces: Client/src-tauri - name: Install npm dependencies - working-directory: Client/tauri-client + working-directory: Client run: npm ci - name: Build Tauri app - working-directory: Client/tauri-client + working-directory: Client env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -81,7 +81,7 @@ jobs: shell: bash run: | mkdir -p release-staging - NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis" + NSIS_DIR="Client/src-tauri/target/release/bundle/nsis" INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1) cp "$INSTALLER" release-staging/ NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1) @@ -108,7 +108,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install Linux system dependencies run: | @@ -131,14 +131,14 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: Client/tauri-client/src-tauri + workspaces: Client/src-tauri - name: Install npm dependencies - working-directory: Client/tauri-client + working-directory: Client run: npm ci - name: Build Tauri app (AppImage + deb) - working-directory: Client/tauri-client + working-directory: Client env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -149,7 +149,7 @@ jobs: # EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact + # signatures for the patched image. - name: Strip host-incompatible libs from AppImage and re-sign - working-directory: Client/tauri-client + working-directory: Client shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -173,7 +173,7 @@ jobs: shell: bash run: | mkdir -p linux-staging - BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle" + BUNDLE_DIR="Client/src-tauri/target/release/bundle" # AppImage APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" ! -name "*.sig" | head -1) if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then cp "$APPIMAGE" linux-staging/; fi @@ -298,7 +298,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Install Linux system dependencies run: | @@ -321,14 +321,14 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: Client/tauri-client/src-tauri + workspaces: Client/src-tauri - name: Install npm dependencies - working-directory: Client/tauri-client + working-directory: Client run: npm ci - name: Build Tauri app (AppImage + deb) - working-directory: Client/tauri-client + working-directory: Client env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -336,7 +336,7 @@ jobs: # Same strip + re-sign as the x86_64 job — see the comment there. - name: Strip host-incompatible libs from AppImage and re-sign - working-directory: Client/tauri-client + working-directory: Client shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -360,7 +360,7 @@ jobs: shell: bash run: | mkdir -p linux-arm64-staging - BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle" + BUNDLE_DIR="Client/src-tauri/target/release/bundle" # AppImage + updater artifact (.tar.gz) + signatures. Every filename # must carry the arch: FindClientAssets matches on the # _aarch64.AppImage.tar.gz suffix, and arch-less names would collide @@ -465,7 +465,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: Client/tauri-client/package-lock.json + cache-dependency-path: Client/package-lock.json - name: Download Windows client assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -532,7 +532,7 @@ jobs: "$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json - name: Sign server update assets - working-directory: Client/tauri-client + working-directory: Client shell: bash env: SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }} @@ -542,8 +542,8 @@ jobs: printf '%s' "$SERVER_UPDATE_SIGNING_PRIVATE_KEY" > "$KEY_PATH" trap 'rm -f "$KEY_PATH"' EXIT npm ci - npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/chatserver.exe - npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/server-update-manifest.json + npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/chatserver.exe + npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/server-update-manifest.json # Fail closed before publishing: prove the freshly signed assets verify # against the pinned public key that ships inside the server binary. diff --git a/.gitignore b/.gitignore index 24689899..96080150 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,8 @@ docs/security-findings/ # Mutation-testing output (npm run test:mutate). Local-only by design: a # surviving-mutant report maps exactly which behaviour nothing tests. -Client/tauri-client/.stryker-tmp/ -Client/tauri-client/reports/ +Client/.stryker-tmp/ +Client/reports/ # Server runtime artifacts Server/chatserver.exe @@ -65,7 +65,7 @@ Client/login-mockup.html Client/ui-mockup.html # Tauri typegen (auto-generated IPC bindings) -Client/tauri-client/src/generated/ +Client/src/generated/ .typecache # Node modules @@ -103,7 +103,7 @@ Client/CLIENT-REVIEW.md .serena/ # Client env (holds API keys - never commit) -Client/tauri-client/.env +Client/.env # Rust review output .rust-review-results/ diff --git a/.superpowers/FINDINGS.md b/.superpowers/FINDINGS.md index 408a9823..e4d70def 100644 --- a/.superpowers/FINDINGS.md +++ b/.superpowers/FINDINGS.md @@ -8,7 +8,7 @@ Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.jso ### OC-0311 — medium — handleParticipantLeft is channel-blind: a voice_leave for any readable channel mutates this session's E2EE peer state -`Client/tauri-client/src/lib/dispatcher.ts:1077` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `voice-e2ee` +`Client/src/lib/dispatcher.ts:1077` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `voice-e2ee` The VOICE_LEAVE handler has payload.channel_id in hand (it uses it two lines above for shouldTeardownSession) but calls handleParticipantLeft(payload.user_id) with no channel. E2EEManager.handleParticipantLeft then unconditionally deletes that user from _peerPublicKeys/_peerOfferEpochs, clears their verification badge, retires their key, and re-runs the key-holder election against the client's OWN voice channel — even though the leave was for a different channel entirely. voice_leave is broadcast to channelReadAudience(thatChannel), i.e. every client with READ_MESSAGES on it, not just the room's participants. @@ -37,7 +37,7 @@ Reordering proof (already documented in-tree): voice_leave goes through the asyn ### OC-0312 — medium — Binding a push-to-talk key mid-call leaves PTT permanently dead — the `pttOwnsMute` latch is cleared by the store subscriber before the deferred `setMuted(true)` lands -`Client/tauri-client/src/lib/ptt.ts:286` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `client-state` +`Client/src/lib/ptt.ts:286` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `client-state` The mid-call PTT arming block writes `pttOwnsMute = true` synchronously, but the mute it is describing (`setMuted(true)`) is applied inside a `void import(...).then(...)` callback that runs a microtask *later*. `setPttGated(true)` on the line above schedules voiceStore's notification microtask first, and the subscriber registered in `initPtt` (`if (!s.localMuted) pttOwnsMute = false`) still observes `localMuted === false` and resets the latch. When `setMuted(true)` finally runs, the mic is PTT-muted while `pttOwnsMute` is false, so every subsequent press takes the "never lift a mute the user asked for" early return. @@ -84,7 +84,7 @@ Contrast with the ptt-state release handler (ptt.ts:215-216), which is correct b ### OC-0313 — medium — Pre-scoping per-user volume is copied into every server the user connects to — the legacy key is read through but never consumed -`Client/tauri-client/src/lib/audioElements.ts:57` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `client-state` +`Client/src/lib/audioElements.ts:57` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `client-state` `getSavedUserVolume` falls back to the unscoped `userVolume_{id}` key on a miss at the host-scoped key and persists the result under the scoped key, but unlike the identical migration in channel-mutes.ts it never removes the legacy key. Every subsequent brand-new host also misses its own scoped key, reads through to the same legacy value, and adopts it — so a volume (including a 0 = silenced) set for user id N on one server is silently applied to the unrelated user id N on every other server. @@ -126,7 +126,7 @@ channel-mutes.ts:106-111 (the corrected sibling) ### OC-0314 — medium — The server's "password changed but other sessions could NOT be revoked" partial-success warning is discarded by the client, which reports an unqualified success -`Client/tauri-client/src/pages/MainPage.ts:500` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `error-paths` +`Client/src/pages/MainPage.ts:500` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `error-paths` PUT /users/me/password, POST /users/me/totp/confirm and DELETE /users/me/totp are deliberately built as partial-success endpoints: when the credential/2FA change commits but `DeleteOtherSessions` fails (twice — there is one bounded retry), the server returns HTTP 200 with `{"warning": "...other sessions could not be revoked; revoke them from the sessions list", "sessions_revoked": N}` instead of 204. `service.ChangePasswordResult.RevokeFailed`'s own doc comment says this is "a partial success the caller must surface as a warning". The client throws the whole body away — `api.changePassword` / `confirmTotp` / `disableTotp` are all typed `Promise` and every call site shows an unconditional green success toast — so the one signal the server designed for this case never reaches a human. (The admin SPA does render the analogous `warnings` array from the setup wizard at Server/admin/static/index.html:621, which is what the design intends.) @@ -145,28 +145,28 @@ PUT /users/me/password, POST /users/me/totp/confirm and DELETE /users/me/totp ar "sessions_revoked": res.SessionsRevoked, }) -Client/tauri-client/src/lib/api.ts:372 +Client/src/lib/api.ts:372 changePassword(currentPassword, newPassword, signal?): Promise { return request("PUT", "/users/me/password", { old_password: currentPassword, new_password: newPassword }, signal); }, -Client/tauri-client/src/pages/MainPage.ts:498 +Client/src/pages/MainPage.ts:498 await api.changePassword(oldPassword, newPassword); showToast("Password changed successfully", "success"); Same shape at MainPage.ts:558-560 (confirmTotp -> "Two-factor authentication enabled") and MainPage.ts:569-571 (disableTotp -> "Two-factor authentication disabled"), against Server/api/totp_handler.go:359-370 and :460-471. doFetch does receive the body — only 204 short-circuits: -Client/tauri-client/src/lib/api.ts:173 +Client/src/lib/api.ts:173 if (res.status === 204) { return undefined as T; } return res.json() as Promise; -**Suggested fix:** Give the three partial-success endpoints a body type instead of void and surface it in one place. In Client/tauri-client/src/lib/api.ts, declare `type PartialSuccess = { warning?: string; sessions_revoked?: number } | undefined;` and change changePassword/confirmTotp/disableTotp to `request(...)`. Then in the three MainPage.ts handlers use the returned value for the toast, e.g. `const res = await api.changePassword(old, new_); showToast(res?.warning ?? "Password changed successfully", res?.warning ? "warning" : "success");` (and the analogous two lines at :558 and :569). No server change is needed. +**Suggested fix:** Give the three partial-success endpoints a body type instead of void and surface it in one place. In Client/src/lib/api.ts, declare `type PartialSuccess = { warning?: string; sessions_revoked?: number } | undefined;` and change changePassword/confirmTotp/disableTotp to `request(...)`. Then in the three MainPage.ts handlers use the returned value for the toast, e.g. `const res = await api.changePassword(old, new_); showToast(res?.warning ?? "Password changed successfully", res?.warning ? "warning" : "success");` (and the analogous two lines at :558 and :569). No server change is needed. ### OC-0315 — medium — Replay-gate boundary compares a naive-UTC server timestamp parsed as LOCAL time against a local wall-clock anchor -`Client/tauri-client/src/lib/dispatcher.ts:688` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` +`Client/src/lib/dispatcher.ts:688` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` `payload.timestamp` is the raw SQLite `datetime('now')` string ("2026-08-22 12:00:01" — UTC, no zone designator; Server/migrations/001_initial_schema.sql:81, passed through verbatim by service/message_crud.go:79). `Date.parse` treats it as LOCAL time, so the parsed epoch is off by the viewer's UTC offset. The codebase already has `parseTimestamp()` (components/message-list/formatting.ts:25-33) that exists solely to append the missing "Z"; this comparison bypasses it. The bias cancels once `serverClockSkewMs` has been sampled (line 765 uses the same biased parse), but it is 0 until the first accepted live message — so the very first reconnect of a session is decided by the viewer's timezone instead of by the timestamp. @@ -210,8 +210,8 @@ The offer path drops silently while the socket is down (Server/ws/voice_e2ee.go: (and while the dead old *Client is still registered, sendMsg queues into a send buffer that registerNow's old.closeSend() then discards). Client side, the only two re-announce entry points are LiveKit-driven: - Client/tauri-client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup) - Client/tauri-client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect) + Client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup) + Client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect) Neither is reachable from a WS resume: dispatcher.ts's AUTH_OK handler (lines 270-290) does exactly setAuth() + one channel_focus send, and the READY handler's E2EE work (OC-0201, dispatcher.ts:360-384) runs only on the full-resync tier, which a successful replay resume never takes. reannounceForReconnect's own comment states the assumption that is unmet here: "the key holder will send a fresh offer if the key was rotated during our absence" (livekitE2EE.ts:343-344) — true only because that path re-announces; the WS-resume path does not. @@ -234,7 +234,7 @@ This needs no client change: handleAnnounceInner's dedup branch (livekitE2EE.ts: ### OC-0317 — medium — updateDmLastMessage writes a regressing lastMessageId on the replay branch, defeating its own OC-0242 double-count guard -`Client/tauri-client/src/stores/dm.store.ts:153` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/stores/dm.store.ts:153` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` The replay branch suppresses the unread/mention increment but still writes `lastMessageId: messageId` unconditionally. When the redelivered id is *lower* than the stored watermark, the watermark is rolled backwards, so the very next frame in the same replay burst no longer looks like a replay and is counted as new. Its sibling `updateDmLastMessagePreview` (lines 186-190) documents this exact hazard (OC-0301) and returns `prev` instead — `updateDmLastMessage` never got the same treatment. @@ -297,7 +297,7 @@ Then replace registry.go:422-427 with `manifest, err := loadManifestFromDir(stag ### OC-0319 — medium — "Large Font" accessibility toggle is inert — the inline --font-size written on outranks the .large-font class rule -`Client/tauri-client/src/styles/app.css:5248` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-1` +`Client/src/styles/app.css:5248` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-1` `.large-font { --font-size: 18px }` targets `document.documentElement`, but that same element permanently carries an *inline* `--font-size` written by `applyStoredAppearance()` and `buildAppearanceTab()`. An inline declaration beats an author class rule without `!important` on the same element, so the class can never take effect. The sibling `.high-contrast` rule six lines above (app.css:5239-5243) carries a comment describing exactly this hazard and was given `!important`; `.reduced-motion` (app.css:5228) also uses `!important`. `.large-font` is the one that was left out. @@ -402,7 +402,7 @@ Step 2 only handles the success branch (`if data, err := os.ReadFile(keyPath); e ### OC-0322 — low — isValidHost accepts an underscore in a hostname that every Rust proxy rejects, so the client saves and accepts a server it can never reach -`Client/tauri-client/src/lib/hostValidation.ts:33` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `tauri-rust` +`Client/src/lib/hostValidation.ts:33` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `tauri-rust` The DNS-name branch uses `[\w.-]+`, and JS `\w` is `[A-Za-z0-9_]` — so a host containing `_` passes. Both `http_proxy::validate_remote_host` and `livekit_proxy::validate_remote_host` allow only `is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']'` and reject `_`. Since every REST call routes through `ensureHttpProxy` (api.ts:88), an underscore host is accepted by the Add Server modal and by `api.setConfig`, then fails 100% of REST traffic. The file's own header comment and ServerPanel.ts:310-313 both state the invariant that this validator mirrors the Rust one ("an address accepted here is also accepted by the actual connection path, and vice versa"). @@ -500,7 +500,7 @@ The comment above line 471 states the intent explicitly — "canonicalize the us ### OC-0325 — low — Search overlay parses the server's naive-UTC timestamp as local time, so a search hit shows a different clock time than the same message in the message list -`Client/tauri-client/src/components/SearchOverlay.ts:57` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` +`Client/src/components/SearchOverlay.ts:57` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` `r.timestamp` is `messages.timestamp` verbatim (db/models.go:168, populated by the raw column in SearchMessages), i.e. "2026-08-22 15:00:00" — UTC with no zone designator. `new Date(ts)` interprets that as local wall-clock, shifting the rendered time by the viewer's UTC offset. The message list renders the identical string through `parseTimestamp()` (message-list/formatting.ts:25), which appends "Z" — so the two surfaces disagree about the same message. @@ -518,7 +518,7 @@ The comment above line 471 states the intent explicitly — "canonicalize the us ### OC-0326 — low — Pinned-messages list parses the naive-UTC timestamp as local time, mislabelling the date by a full day near the UTC day boundary -`Client/tauri-client/src/components/PinnedMessages.ts:27` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` +`Client/src/components/PinnedMessages.ts:27` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `ordering-boundary` `msg.timestamp` comes from `GetPinnedMessages` → `MessageAPIResponse.Timestamp`, the raw SQLite "YYYY-MM-DD HH:MM:SS" UTC string. `new Date(iso)` reads it as local wall-clock rather than UTC (the project's `parseTimestamp()` helper exists for exactly this), and because only the *date* is rendered, any message whose UTC time and local time fall on different calendar days is labelled with the wrong day. @@ -535,7 +535,7 @@ The comment above line 471 states the intent explicitly — "canonicalize the us `Server/ws/livekit.go:205` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `flow-voice` -MuteParticipantAudio documents itself as muting "every microphone track the participant publishes", but its filter is `t.Type != livekit.TrackType_AUDIO` — which admits screen-share audio as well as the microphone. livekit.TrackInfo carries a Source field (TrackSource_MICROPHONE = 2 vs TrackSource_SCREEN_SHARE_AUDIO = 4) that is never consulted, and the client publishes screen-share audio under exactly that source (Client/tauri-client/src/lib/screenShare.ts:373, `source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio`). The client's own mute/deafen implementation states and enforces the opposite rule in two places — audioElements.ts:121-127 (`publication.source !== Track.Source.ScreenShareAudio` before refusing to attach while deafened) and audioElements.ts:216 (`if (publication.source === Track.Source.ScreenShareAudio) continue;`), both commented "Screen-share/stream audio is exempt: muting or deafening yourself gates voices, not the content someone is streaming". So the server's moderator mute silently reaches past the voice channel it is scoped to and kills a live screen share's audio for every viewer. +MuteParticipantAudio documents itself as muting "every microphone track the participant publishes", but its filter is `t.Type != livekit.TrackType_AUDIO` — which admits screen-share audio as well as the microphone. livekit.TrackInfo carries a Source field (TrackSource_MICROPHONE = 2 vs TrackSource_SCREEN_SHARE_AUDIO = 4) that is never consulted, and the client publishes screen-share audio under exactly that source (Client/src/lib/screenShare.ts:373, `source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio`). The client's own mute/deafen implementation states and enforces the opposite rule in two places — audioElements.ts:121-127 (`publication.source !== Track.Source.ScreenShareAudio` before refusing to attach while deafened) and audioElements.ts:216 (`if (publication.source === Track.Source.ScreenShareAudio) continue;`), both commented "Screen-share/stream audio is exempt: muting or deafening yourself gates voices, not the content someone is streaming". So the server's moderator mute silently reaches past the voice channel it is scoped to and kills a live screen share's audio for every viewer. **Repro:** User B joins voice channel #10 and starts a screen share with audio (screenShare.ts publishes the tab/system audio track as Track.Source.ScreenShareAudio). Users A and C are listening to the shared content. A moderator sends {"type":"voice_mod_mute","payload":{"channel_id":10,"user_id":B,"muted":true}} intending to silence B's microphone. Server: ApplyVoiceServerMute sets server_muted=1/muted=1, then MuteParticipantAudio iterates p.Tracks and calls MutePublishedTrack on BOTH B's microphone SID and B's screen-share-audio SID (both are TrackType_AUDIO). B's client receives RemoteMute for the screen-share-audio publication and calls pub.mute() — A and C abruptly lose the shared application's audio while the video keeps playing, and nothing in the OwnCord UI shows why (B's screen-share tile has no publisher-side audio mute control). Symmetrically, when the moderator clears the mute, MuteParticipantAudio(muted=false) force-unmutes that screen-share-audio publication again. The same happens via voice_mod_deafen, which passes c.Deafened() into the identical call. Expected per the function's own doc comment and the client's stated policy (audioElements.ts:118-120): only the microphone track (t.Source == livekit.TrackSource_MICROPHONE) should be affected. @@ -568,7 +568,7 @@ Delivery on the target's side: node_modules/livekit-client/src/room/participant/ ### OC-0328 — low — Channel unread/mention badges have no message-id replay guard at all, while the DM path has one and Channel.lastMessageId is populated for exactly that purpose but never read -`Client/tauri-client/src/stores/channels.store.ts:346` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-lib` +`Client/src/stores/channels.store.ts:346` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-lib` incrementUnread/incrementMention bump unconditionally. `Channel.lastMessageId` is declared (line 30) and filled from `ready`'s `last_message_id` (line 100), but no call site anywhere in the client reads it — the identical registerNow->buildReady double-delivery window that OC-0242 fixed for DMs (dm.store.ts) is unguarded for server channels. @@ -599,7 +599,7 @@ caller: dispatcher.ts:706-715 ### OC-0329 — low — DM profile note falls back to the pre-scoping unscoped key on every miss and never consumes it, so one server's private note about user N is shown for user N on every other server, forever -`Client/tauri-client/src/components/DmProfileSidebar.ts:115` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-lib` +`Client/src/components/DmProfileSidebar.ts:115` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-lib` loadNote reads the host-scoped key, and on a miss reads through to the legacy unscoped `owncord:dm-note:{userId}` key — but it neither migrates the value into the scoped key nor removes the legacy one. Because user ids are per-server SQLite autoincrement integers, that read-through fires on every server the user connects to. The two other modules with the same shape got this right: channel-mutes.ts:106-111 writes the scoped key and then `localStorage.removeItem` on the legacy one, citing OC-0288 for why leaving it in place lets every subsequent host inherit server A's data; identity.ts::migrateLegacyIdentityKey saves-then-deletes for the same reason. @@ -628,7 +628,7 @@ contrast channel-mutes.ts:106-111 ### OC-0330 — low — Pinned-messages panel prints the raw username and drops the author's user id, so a nickname can never be shown -`Client/tauri-client/src/pages/main-page/OverlayManagers.ts:77` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/pages/main-page/OverlayManagers.ts:77` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` mapToPinnedMessage flattens the pinned message's author to `msg.user.username` before handing it to PinnedMessages, which renders that string verbatim (PinnedMessages.ts:54). Because the user id is discarded in the mapping, the panel cannot resolve the live membersStore nickname the way every other author surface does, so a renamed/nicknamed user is labelled differently in the pinned panel than in the message list the panel jumps into. The avatar colour is also hashed from the raw username (line 80), so the letter and colour disagree with the message row's avatar too. @@ -671,7 +671,7 @@ Server/db/apitoken_queries.go:27 s := expiresAt.UTC().Format("2006-01-02T15:04: ### OC-0332 — low — Client auto-update is permanently and silently dead on a bare-IPv6 server — MainPage builds the updater URL without bracketing the host -`Client/tauri-client/src/pages/MainPage.ts:845` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-2` +`Client/src/pages/MainPage.ts:845` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-2` `https://${apiConfig.host}` splices a possibly-bare IPv6 literal straight into a URL authority with no `bracketBareIPv6Host` call, unlike every other URL-building site in the client (ws.ts:544, admin-panel.ts:27, attachments.ts:60). The resulting string is not a parseable URL, so `build_updater` rejects it before any network call, and `checkForUpdate` swallows the rejection and reports "no update available". @@ -695,11 +695,11 @@ src/lib/updater.ts:35-38 The helper that exists for exactly this (`bracketBareIPv6Host`, ws.ts:146) is used by ws.ts:544, admin-panel.ts:27 and attachments.ts:60 but not here. -**Suggested fix:** Use the existing shared helper at the one construction site — in Client/tauri-client/src/pages/MainPage.ts add `import { bracketBareIPv6Host } from "@lib/ws";` (ws.ts:146) and change line 845 to `const serverUrl = `https://${bracketBareIPv6Host(apiConfig.host)}`;`. That mirrors ws.ts:544 / admin-panel.ts:27 / attachments.ts:60, leaves DNS, IPv4 and already-bracketed hosts byte-identical, and keeps the Rust TOFU key aligned (url::Url::host_str returns the bracketed form and tofu::cert_store_key strips the brackets back off). +**Suggested fix:** Use the existing shared helper at the one construction site — in Client/src/pages/MainPage.ts add `import { bracketBareIPv6Host } from "@lib/ws";` (ws.ts:146) and change line 845 to `const serverUrl = `https://${bracketBareIPv6Host(apiConfig.host)}`;`. That mirrors ws.ts:544 / admin-panel.ts:27 / attachments.ts:60, leaves DNS, IPv4 and already-bracketed hosts byte-identical, and keeps the Rust TOFU key aligned (url::Url::host_str returns the bracketed form and tofu::cert_store_key strips the brackets back off). ### OC-0333 — low — ChannelSidebar's voice re-render signature carries no identity, so a rename mid-call leaves the voice roster stale for the whole session -`Client/tauri-client/src/components/ChannelSidebar.ts:1010` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/components/ChannelSidebar.ts:1010` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` The sidebar's only voice repaint trigger is `unsubVoiceStructure`, whose signature string covers channel id, mute/deafen/camera/screenshare/serverMuted/serverDeafened and E2EE verification — but not the participant's username or display name. The row label at line 431 is resolved from `membersStore` via `memberDisplayName`, and ChannelSidebar subscribes to `membersStore` nowhere at all (it imports it only for one-shot `getState()` reads). So the store writes that a rename performs — `updateMemberProfile` (bumps `roleRevision`, which only MessageList watches) and `updateVoiceUserProfile` (voice.store.ts:262, whose doc comment says it exists so "a rename [doesn't] leave the voice roster showing the old name for the rest of the call") — produce no re-render here. The same defect shape as the already-fixed OC finding at ChannelSidebar.ts:943 (signature omitted sessionFingerprint), on the field the roster is actually named by. @@ -714,7 +714,7 @@ const label = (member !== undefined ? memberDisplayName(member) : user.username) ### OC-0334 — low — Escape is both "close Settings" and a capturable push-to-talk key, so backing out of the PTT capture silently binds Escape as PTT -`Client/tauri-client/src/components/settings/KeybindsTab.ts:49` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` +`Client/src/components/settings/KeybindsTab.ts:49` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` `ptt_listen_for_key`'s allowlist includes VK 0x1B (Escape), and the capture UI has no cancel path and no aborted-signal guard — while the Rust poll is blocking for up to 10s, pressing Escape closes the Settings overlay (SettingsOverlay.ts:362-369, which even labels ESC as the close affordance) *and* resolves the capture with 0x1B, so `updatePttKey(0x1B)` runs against a torn-down tab and permanently rebinds push-to-talk to Escape without the user ever seeing the result. @@ -725,11 +725,11 @@ src-tauri/src/ptt.rs:34-63 `is_allowed_ptt_capture_vk` — `matches!(vk, 0x1B | SettingsOverlay.ts:360-369 — `document.addEventListener("keydown", (e) => { if (e.key === "Escape" && root?.classList.contains("open")) options.onClose(); })`. lib/ptt.ts:262-289 `updatePttKey` — persists `pttVk`, calls `ptt_set_key`, starts the poller and, when a call is live, immediately `setMuted(true)` + `setPttGated(true)`. -**Suggested fix:** Treat Escape as cancel in one place, in Rust: drop `0x1B` from `is_allowed_ptt_capture_vk` (Client/tauri-client/src-tauri/src/ptt.rs:48) and, in both capture loops of `ptt_listen_for_key` (~ptt.rs:477 and ~ptt.rs:502), `return 0` when the detected vk is 0x1B. The existing `vk === 0` branch in KeybindsTab.ts:53-57 then restores the previous binding with no client change and no per-caller guard. +**Suggested fix:** Treat Escape as cancel in one place, in Rust: drop `0x1B` from `is_allowed_ptt_capture_vk` (Client/src-tauri/src/ptt.rs:48) and, in both capture loops of `ptt_listen_for_key` (~ptt.rs:477 and ~ptt.rs:502), `return 0` when the detected vk is 0x1B. The existing `vk === 0` branch in KeybindsTab.ts:53-57 then restores the previous binding with no client change and no per-caller guard. ### OC-0335 — low — ServerPanel's "Add Server" modal registers its listeners on the page-lifetime AbortSignal, so every open/close cycle permanently retains a discarded modal subtree -`Client/tauri-client/src/pages/connect-page/ServerPanel.ts:325` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` +`Client/src/pages/connect-page/ServerPanel.ts:325` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` `handleAddServer` builds a fresh overlay on every click and wires six listeners to the ConnectPage-lifetime `signal`. `closeModal()` only does `overlay.remove()` — it never aborts anything — so each abandoned modal stays reachable from that signal's abort-listener list for the life of the connect page, along with its inputs and the closure state. @@ -742,7 +742,7 @@ lib/ptt.ts:262-289 `updatePttKey` — persists `pttVk`, calls `ptt_set_key`, sta ### OC-0336 — low — ServerPanel re-registers every profile row's listeners on the page-lifetime AbortSignal on each re-render -`Client/tauri-client/src/pages/connect-page/ServerPanel.ts:122` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` +`Client/src/pages/connect-page/ServerPanel.ts:122` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-pages` `renderServerProfiles` clears `serverListEl` and rebuilds every row, attaching up to three listeners per row to the ConnectPage-lifetime `signal`. Nothing aborts the previous generation, so each re-render — and it is re-rendered on profile load, add, delete and every auto-login toggle — permanently pins the whole discarded row set. Same defect class as the already-fixed MemberList/SearchOverlay/QuickSwitcher/EmojiPicker re-render leaks. @@ -931,7 +931,7 @@ Server/token_cli.go:74 Revoke a token by numeric id or by label. ### OC-0342 — low — Voice-roster avatar letter and colour are derived from the username while the name rendered beside them is the nickname -`Client/tauri-client/src/components/ChannelSidebar.ts:420` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/components/ChannelSidebar.ts:420` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` The voice participant row builds its avatar initial and background colour from `user.username`, then eleven lines later resolves the label through `memberDisplayName`. Every other avatar in the app goes through `createAvatarElement`/`avatarInitial`, which prefer `displayName` (lib/avatar.ts:50-60), so a nicknamed user's voice row shows a letter that matches no other avatar of that user anywhere in the client — including the member list rendered directly above it. @@ -953,7 +953,7 @@ lib/avatar.ts:50-60 ### OC-0343 — low — Tray-icon click hides a minimized window instead of restoring it — `is_visible()` is true while minimized and `unminimize()` is never called -`Client/tauri-client/src-tauri/src/tray.rs:67` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-2` +`Client/src-tauri/src/tray.rs:67` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `explore-2` `toggle_window_visibility` decides show-vs-hide purely from `window.is_visible()`, but on both shipped platforms a minimized window still reports visible (tao Windows: `IsWindowVisible`, which stays TRUE for an iconified window because WS_VISIBLE is not cleared; tao GTK: `gtk_widget_get_visible`, TRUE for an iconified GtkWindow). So the branch taken for a minimized window is the *hide* branch, and neither branch ever calls `unminimize()`. The same file's sibling recovery path in `lib.rs:52-58` (the single-instance handler) gets this right — it does `unminimize(); show(); set_focus();` — which is direct in-repo evidence of the intended sequence. @@ -980,7 +980,7 @@ contrast — lib.rs:52-58 (single-instance handler, same app, same window): Reachability: tauri.conf.json declares the main window with `"decorations": true, "resizable": true`, so the OS minimize button exists; bundle targets are nsis (Windows) + appimage/deb (Linux), i.e. exactly the two platforms where `is_visible()` is true while minimized. No Rust test covers tray.rs (src-tauri/tests does not exist, and tray.rs has no `mod tests`). -**Suggested fix:** Consult is_minimized() in the shared toggle so a minimized window takes the restore arm, and mirror lib.rs's sequence there. In Client/tauri-client/src-tauri/src/tray.rs:65-74: +**Suggested fix:** Consult is_minimized() in the shared toggle so a minimized window takes the restore arm, and mirror lib.rs's sequence there. In Client/src-tauri/src/tray.rs:65-74: fn toggle_window_visibility(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { @@ -1092,7 +1092,7 @@ Build-tag dependency: telemetry_otel.go's TraceIDFromContext is the only impleme ### OC-0347 — low — VoiceWidget's DM call label is read from dmStore but the widget never subscribes to dmStore, so it goes stale for the whole call -`Client/tauri-client/src/components/VoiceWidget.ts:255` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/components/VoiceWidget.ts:255` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` render() resolves a DM call's header name with `dmStore.getState().channels.find(...)` + `dmDisplayName(dm)`, but mount() registers exactly three subscriptions — voiceStore (a fixed-field selector that excludes voiceUsers), uiStore.connectionStatus and channelsStore.channels. None of them fires when dmStore changes, and a DM rename/nickname change only ever touches dmStore (`updateDmParticipant`, dm.store.ts:246) plus voiceStore.voiceUsers (excluded from the selector). The label is therefore painted once at join and can never be corrected. @@ -1110,7 +1110,7 @@ setText( ### OC-0348 — low — The sidebar "N online" header counts an invisible user as online, contradicting the member list right below it -`Client/tauri-client/src/stores/members.store.ts:247` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` +`Client/src/stores/members.store.ts:247` · found 2026-08-22 · hunt `general-2026-08-22-b` · lens `hotspot-client-tauri-client-src-components` `getOnlineMembers()` filters on `status !== "offline"`, so "invisible" counts as online. Every other surface deliberately groups invisible with offline — MemberList's `statusPriority` (line 129-135) and `isAwayStatus` (line 155-157) both fold invisible into offline, with a comment saying that is where the user appears "to everybody — including, in this list, to themselves". Only the signed-in user can ever carry "invisible" locally (the server maps it to offline for everyone else), so the header over-counts by exactly one whenever you go invisible. @@ -1128,7 +1128,7 @@ setText( ### OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever -`Client/tauri-client/src/lib/livekitE2EE.ts:772` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `crypto-primitives` +`Client/src/lib/livekitE2EE.ts:772` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `crypto-primitives` The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts. @@ -1136,11 +1136,11 @@ The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and rean **Evidence:** e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install -**Fixed:** `84033139` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `84033139` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it -`Client/tauri-client/src/components/VoiceWidget.ts:196` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability` +`Client/src/components/VoiceWidget.ts:196` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability` The badge is derived purely from voiceStatus === 'connected', never from the SDK's live encryption state. livekit-client emits EncryptionEvent.EncryptionError from E2eeManager.onWorkerError, and src/ subscribes to none of it (zero grep hits for EncryptionEvent, ParticipantEncryptionStatusChanged, EncryptionError, isE2EEEnabled). @@ -1148,11 +1148,11 @@ The badge is derived purely from voiceStatus === 'connected', never from the SDK **Evidence:** VoiceWidget.ts:196 display toggle; livekitSession.ts:376-427 createRoom; E2eeManager.ts:242-245 emits EncryptionError -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/voice-widget.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/voice-widget.test.ts` · revert-proof pass ### OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch -`Client/tauri-client/src/lib/livekitE2EE.ts:477` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain` +`Client/src/lib/livekitE2EE.ts:477` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain` The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare. @@ -1160,11 +1160,11 @@ The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: **Evidence:** livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers -**Fixed:** `bf7612fb` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `bf7612fb` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived -`Client/tauri-client/src/lib/livekitE2EE.ts:864` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election` +`Client/src/lib/livekitE2EE.ts:864` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election` handleParticipantLeft early-returns when voiceUsers.get(channelId) is missing or empty. That roster is populated only by voice_state broadcasts, including the client's own. The server sends voice_token directly at voice_join.go:312 but enqueues the joiner's own voice_state on the hub broadcast queue at :337, with a GetChannelVoiceStates query in between. @@ -1172,11 +1172,11 @@ handleParticipantLeft early-returns when voiceUsers.get(channelId) is missing or **Evidence:** livekitE2EE.ts:859-864; Server/ws/voice_join.go:312 vs :337 -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0005 — medium — Rotation offers exceed the server rate limit in large channels, permanently starving the same peers -`Client/tauri-client/src/lib/livekitE2EE.ts:817` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `rotation-forward-secrecy` +`Client/src/lib/livekitE2EE.ts:817` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `rotation-forward-secrecy` voiceE2EEOfferRateLimit is 64 per (sender, channel) per second, but voice_max_users defaults to 0 (unlimited) and admins may set up to maxVoiceLimit 99. distributeRoomKey loops over every peer with no pacing, awaiting only a fast WebCrypto wrap, so all sends land in one window. ws.send is fire-and-forget; onSendFailure covers local transport failures only, never a server ErrCodeRateLimited. @@ -1184,11 +1184,11 @@ voiceE2EEOfferRateLimit is 64 per (sender, channel) per second, but voice_max_us **Evidence:** Server/ws/voice_e2ee.go:23, :213-216; migrations/004_voice_optimization.sql:6; Server/admin/handlers_channels.go:148 -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0006 — medium — Both rotation paths call keyProvider.setKey with no session-generation guard -`Client/tauri-client/src/lib/livekitE2EE.ts:900` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `rotation-forward-secrecy` +`Client/src/lib/livekitE2EE.ts:900` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `rotation-forward-secrecy` handleParticipantLeft (:900) and rotateKeyPeriodically (:998) never capture or re-check _sessionGeneration around their setKey await. Every other destructive write in the file does; setupKeyExchange does it three times (:156, :174, :209). clearState bumps _sessionGeneration but does not touch keyProvider, which is one instance shared across Room objects. @@ -1196,11 +1196,11 @@ handleParticipantLeft (:900) and rotateKeyPeriodically (:998) never capture or r **Evidence:** livekitE2EE.ts:900, :998, :990 entry-only guard, :1038-1051 clearState -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0007 — medium — Reconnect reaches the Secured state without confirming the room key is current -`Client/tauri-client/src/lib/livekitE2EE.ts:329` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability` +`Client/src/lib/livekitE2EE.ts:329` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability` reannounceForReconnect re-applies the pre-disconnect room key and fires a single voice_e2ee_announce with no wait, no timeout, and no retry. The join path blocks on a confirmed key with a 10s attempt plus a 5s retry and aborts if it never arrives. @@ -1208,11 +1208,11 @@ reannounceForReconnect re-applies the pre-disconnect room key and fires a single **Evidence:** livekitE2EE.ts:309-342; livekitSession.ts:535 awaited before :549/556 set connected -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0008 — medium — restoreLocalVoiceState has no internal supersession guard -`Client/tauri-client/src/lib/livekitSession.ts:834` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` +`Client/src/lib/livekitSession.ts:834` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` await room.localParticipant.setMicrophoneEnabled can block for seconds on the mic-permission prompt. applyMicMuteState re-reads this._room fresh, so it acts on whichever room is live at resume time. connectAndSetup's checkpoint 3 (:1110) runs after the call returns and cannot prevent writes that happen mid-call. @@ -1220,11 +1220,11 @@ await room.localParticipant.setMicrophoneEnabled can block for seconds on the mi **Evidence:** livekitSession.ts:834 await, unguarded writes at :845, :866-868, :871 -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0009 — low — attemptAutoReconnect's tail has no supersession checkpoints after connected -`Client/tauri-client/src/lib/livekitSession.ts:564` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` +`Client/src/lib/livekitSession.ts:564` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` reconnectSuperseded is used exhaustively before newRoom.connect and never called again after the success setState. The tail runs unguarded, and startTokenRefreshTimer clobbers a single shared timer field that a newer session may have armed. @@ -1232,11 +1232,11 @@ reconnectSuperseded is used exhaustively before newRoom.connect and never called **Evidence:** livekitSession.ts:564-589, no reconnectSuperseded call after :548 -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0010 — low — handleOfferInner and handleAnnounceInner re-check generation before their final await, not after -`Client/tauri-client/src/lib/livekitE2EE.ts:784` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` +`Client/src/lib/livekitE2EE.ts:784` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `reconnect-stale-continuations` handleOfferInner's guard at :763 precedes the setKey await; the writes at :784 and :793 follow it. A teardown-and-rejoin-as-holder landing inside that await means :784 reads the new session's _isKeyHolder and stands it down. handleAnnounceInner has the same shape at :643 versus the write at :668. @@ -1244,11 +1244,11 @@ handleOfferInner's guard at :763 precedes the setKey await; the writes at :784 a **Evidence:** livekitE2EE.ts:763 guard, :773 await, :784/:793 writes; :643 guard, :655-665 awaits, :668 write. The dangerous announce variant is blocked server-side by sendToUserIfInVoiceChannel's atomic same-channel check. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0011 — low — A replayed announce overwrites a peer's live ephemeral key -`Client/tauri-client/src/lib/livekitE2EE.ts:651` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain` +`Client/src/lib/livekitE2EE.ts:651` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain` The signed announce message is domain || userId || ephemeralPubRaw with no channel, epoch, or nonce, so an old validly-signed announce replays cleanly. handleAnnounceInner sees a changed key and overwrites the live one, logging 'peer public key changed (reconnect?)'. @@ -1256,7 +1256,7 @@ The signed announce message is domain || userId || ephemeralPubRaw with no chann **Evidence:** livekitE2EE.ts:651-670; e2eeCrypto.ts:41-45, :101-117 buildAnnounceMessage -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0012 — low — CleanupVoiceForChannel never clears voiceKeyHolders @@ -1295,13 +1295,13 @@ var _ dmVoiceEvictor = (*ws.Hub)(nil) // sibling capability IS compile-time as ### OC-0014 — high — Client refreshes the LiveKit token every 23 hours while the server mints it with a 5-minute TTL, so auto-reconnect fails for any voice session older than 5 minutes -`Client/tauri-client/src/lib/livekitSession.ts:714` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` +`Client/src/lib/livekitSession.ts:714` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` Two sources of truth for the same credential disagree by three orders of magnitude. `Server/ws/livekit.go` sets `tokenTTL = 5 * time.Minute` and documents "The client requests a refresh via voice_token_refresh before expiry"; the client's only periodic refresh is `TOKEN_REFRESH_MS = 23h`. Nothing else re-requests a token: `requestTokenRefresh()` is called only from that timer and once right after a successful reconnect, and it early-returns when `this._room === null` (which is the case throughout "reconnecting"). `handleDisconnected` hands `deps.getLatestToken()` straight to `attemptAutoReconnect`, which passes it to `newRoom.connect(resolvedUrl, token)`. **Repro:** Join voice, stay connected for >5 minutes, then drop the SFU connection (Wi-Fi blip, laptop sleep, SFU restart). `handleDisconnected` (roomEventHandlers.ts:161-197) starts `attemptAutoReconnect` with the join-time token, which expired at T+5min. Both attempts fail JWT validation at LiveKit, the loop exhausts, and line 640 runs `this.leaveVoice(true); leaveVoiceChannel(); onErrorCallback("Voice connection lost — failed to reconnect")`. The user is ejected from the call for a blip that the reconnect path exists to absorb. The stale comment at livekitSession.ts:780-786 ("Sessions longer than the 4h TTL…", "The 23h refresh timer ensures a fresh token is always ready *before* the original expires") describes a TTL the server no longer uses. Note tests/unit/livekit-session.test.ts:2817 hardcodes the 23h advance, so it locks the constant but asserts nothing about the interop contract. -**Evidence:** Client/tauri-client/src/lib/livekitSession.ts:714 +**Evidence:** Client/src/lib/livekitSession.ts:714 private static readonly TOKEN_REFRESH_MS = 23 * 60 * 60 * 1000; Server/ws/livekit.go:28 @@ -1311,11 +1311,11 @@ Server/ws/livekit.go:28 **Suggested fix:** Lower LiveKitSession.TOKEN_REFRESH_MS below the server TTL — e.g. 4 * 60 * 1000 (refresh 1 min before the 5-min expiry) — and update the stale KNOWN LIMITATION comment (livekitSession.ts:777-786) plus the three test constants that advance the timer by 23h. Optionally also have handleDisconnected request a refresh before starting attemptAutoReconnect, but the timer change alone restores the invariant the server comment documents. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0015 — high — A failed voice channel-switch leaves the client live in a voice call (mic hot, audio flowing) with the voice UI completely hidden and no way to leave -`Client/tauri-client/src/lib/dispatcher.ts:812` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` +`Client/src/lib/dispatcher.ts:812` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` The VOICE_LEAVE handler unconditionally calls the store's leaveVoiceChannel() whenever the event is about the local user (`if (isSelf) leaveVoiceChannel();`), even though the sibling effect three lines above — actually tearing down the LiveKit session — is correctly gated on `shouldTeardownSession` (payload.channel_id matching the store's *current* channel). During a channel switch the store's currentChannelId has already been optimistically set to the NEW channel by VoiceCallbacks.onVoiceJoin before the server responds, so the self voice_leave broadcast for the OLD channel (which the server always sends first, per voice_join.go's `h.handleVoiceLeave(ctx, c)` call before minting a token for the new channel) makes shouldTeardownSession false — but leaveVoiceChannel() still runs and blanks voiceStore.currentChannelId to null. In the normal success path this is harmless because a voice_state broadcast for the new channel (VOICE_STATE handler, dispatcher.ts:745, `joinVoiceChannel(payload.channel_id)`) arrives shortly after and restores currentChannelId. But when the switch fails server-side — e.g. voice_join.go:158-179's `LeaveVoiceChannelIfMatch` retry for the old channel's DB row fails, so GetVoiceState still returns the old row and the join is aborted — the server never sends a voice_token or a voice_state for either channel; it only sends a generic ErrCodeInternal error ('voice channel switch failed — please try again'), which dispatcher.ts's S.ERROR handler does not specially handle for this code (it falls through to the generic setTransientError toast at dispatcher.ts:1002, with no voiceStore write). So voiceStore.currentChannelId is permanently stuck at null with nothing left to restore it. Meanwhile voice_leave.go's finishVoiceLeave() unconditionally calls `h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken)` (voice_leave.go:108-114) regardless of whether the DB delete succeeded, forcibly kicking the client's still-live LiveKit Room object (the client never ran connectAndSetup()/leaveVoice() for this failed switch, since it never got a voice_token) out of the SFU. That kick fires roomEventHandlers.ts's handleDisconnected with a non-CLIENT_INITIATED reason; its auto-reconnect branch (roomEventHandlers.ts:172-198) decides whether to reconnect using `deps.getCurrentChannelId()`, which is LiveKitSession's own internal `_currentChannelId` getter (livekitSession.ts:207-211, derived from `_state`) — completely independent of voiceStore.currentChannelId. Since `_state` was never touched by the failed switch, `_currentChannelId` still points at the old channel with a valid cached token/URL, so attemptAutoReconnect silently reconnects the client back into the old channel's LiveKit room, republishes the microphone (restoreLocalVoiceState), and sets voiceStatus to 'connected' (livekitSession.ts:548-556) — all without ever calling joinVoiceChannel() to resync voiceStore.currentChannelId. @@ -1323,28 +1323,28 @@ The VOICE_LEAVE handler unconditionally calls the store's leaveVoiceChannel() wh **Suggested fix:** Gate the teardown on the SESSION's live channel rather than only the store's: in the VOICE_LEAVE handler, tear down when isSelf and the LiveKit session's current channel id equals payload.channel_id (expose it from livekitSession alongside leaveVoice). The stale-leave protection test still holds (after a completed rejoin the session's channel is the new one), and every failed-switch variant then converges to a clean idle state instead of a hidden live session. Server-side hardening (send a voice_state resync in the voice_join abort branch) can follow, but the client guard alone removes the hot-mic state. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof pass ### OC-0016 — high — Re-opening a channel visited earlier in the session renders a permanently stale message window — loadMessages short-circuits on isChannelLoaded and nothing invalidates on switch -`Client/tauri-client/src/pages/main-page/MessageController.ts:76` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-message` +`Client/src/pages/main-page/MessageController.ts:76` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-message` `loadMessages` returns immediately when the channel is already in `loadedChannels`, and `loadedChannels` is only ever cleared by `invalidateLoadedMessageWindows()` (dispatcher's second-`ready` full-resync path) and `clearChannelMessages()` — which has no caller anywhere in `src/`. Combined with the focus-scoped fan-out above, every message posted in a channel while the user was viewing a different one is absent from the store, never delivered live, and never refetched. The stale window is what MessageList renders on the way back, with no gap indicator and no way for the user to force a refresh short of restarting the app. **Repro:** Open channel A (50 messages fetched, `loadedChannels` = {A}). Switch to channel B — the server unsubscribes the socket from `channel:A`. Ten messages are posted in A; none reach this client. Switch back to A: `MainPage`'s activeChannelId subscriber calls `mountChannel(A)` → `loadMessages(A)` → `isChannelLoaded(A)` is true → early return. MessageList renders the 50-message snapshot from the first visit; the 10 new messages are missing with no "has more below" affordance, and stay missing for the rest of the session (scroll-up only calls `loadOlderMessages`, which prepends). -**Evidence:** Client/tauri-client/src/pages/main-page/MessageController.ts:76-79 `if (isChannelLoaded(channelId)) { log.debug("Messages already loaded", { channelId }); return; }` -Client/tauri-client/src/pages/main-page/ChannelController.ts:245 `void msgCtrl.loadMessages(channelId, signal);` — the only load on mount; `mountChannel` does not clear the window -Client/tauri-client/src/stores/messages.store.ts:746 `clearChannelMessages` — `grep -rn "clearChannelMessages" src/` matches only its own definition -Client/tauri-client/src/lib/dispatcher.ts:311 `invalidateLoadedMessageWindows();` — reached only when `hasReceivedReadyBefore` (a full-ready resync) +**Evidence:** Client/src/pages/main-page/MessageController.ts:76-79 `if (isChannelLoaded(channelId)) { log.debug("Messages already loaded", { channelId }); return; }` +Client/src/pages/main-page/ChannelController.ts:245 `void msgCtrl.loadMessages(channelId, signal);` — the only load on mount; `mountChannel` does not clear the window +Client/src/stores/messages.store.ts:746 `clearChannelMessages` — `grep -rn "clearChannelMessages" src/` matches only its own definition +Client/src/lib/dispatcher.ts:311 `invalidateLoadedMessageWindows();` — reached only when `hasReceivedReadyBefore` (a full-ready resync) **Suggested fix:** In ChannelController.mountChannel, when previousChannelId !== null, drop that channel from loadedChannels (export a store helper mirroring reattachToPresent's Set-delete, without requiring the detached flag) so the next visit refetches the live tail; setMessages' existing merge already preserves pending/failed rows and newer live rows, so the refetch cannot clobber in-flight state. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/channel-controller.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/channel-controller.test.ts` · revert-proof self-reported ### OC-0017 — high — Virtual scroll window never follows the scroll position — rows outside the initial ±20-item overscan render as blank space -`Client/tauri-client/src/components/MessageList.ts:536` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/MessageList.ts:536` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` renderWindow() only rebuilds DOM when `renderedStart < 0`, and the only two callers that set that sentinel are renderAll() and scrollToMessage(). Every scroll-driven call therefore lands in the `else` branch, which is a pure no-op — it does not even update the spacers its own comment on line 496-497 claims it updates. The rendered window is frozen wherever the last data-change rebuild left it, so scrolling into the top/bottom spacer shows an empty region with no rows, and nothing ever fills it. @@ -1358,7 +1358,7 @@ renderWindow() only rebuilds DOM when `renderedStart < 0`, and the only two call **Suggested fix:** In renderWindow's else branch, detect that the freshly computed [start,end) range is not contained in [renderedStart,renderedEnd) and take the rebuild path in that case (the existing >30-rebuilds-per-2s renderWindowCount breaker already guards the image-height oscillation loop the no-op was written to avoid); keep the no-op only when the target range is already fully rendered. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/message-list.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/message-list.test.ts` · revert-proof self-reported ### OC-0018 — high — voice_join into a 1:1 DM has no block gate — a blocked user can enter the blocker's DM voice room and publish audio to them @@ -1395,7 +1395,7 @@ readPump's defer samples `replaced := hub.unregisterNow(c)` at line 148 and then ### OC-0020 — medium — Stale `_isKeyHolder` survives a voice-channel switch made while the SFU is reconnecting, so the client joins the new channel as a phantom key holder -`Client/tauri-client/src/lib/livekitE2EE.ts:188` · found 2026-08-12 · hunt `general-2026-08-12` · lens `voice-e2ee` +`Client/src/lib/livekitE2EE.ts:188` · found 2026-08-12 · hunt `general-2026-08-12` · lens `voice-e2ee` `setupKeyExchange` ORs the server-authoritative `is_key_holder` with whatever `_isKeyHolder` already holds. Its stated justification is that `clearState()` always runs between sessions (so a non-false residue can only be an in-window `handleParticipantLeft` promotion). That invariant is broken by `connectAndSetup`, which only tears E2EE state down via `if (this._room !== null) this.leaveVoice(false);` (livekitSession.ts:933) — and `_room` (livekitSession.ts:202) is null in the `reconnecting` state. A join issued while the LiveKit auto-reconnect loop is running therefore reaches `setupKeyExchange` with `_isKeyHolder` still true from the previous channel, and the server's `false` is discarded. @@ -1416,7 +1416,7 @@ livekitSession.ts:1370-1379 `leaveVoice()` is the sole caller of `this._e2ee.cl **Suggested fix:** In connectAndSetup, treat superseding an in-flight reconnect the same as superseding a live room: change livekitSession.ts:933 to `if (this._room !== null || this._state.type === "reconnecting") this.leaveVoice(false);`. leaveVoice(false) aborts the stale reconnect AbortController and runs _e2ee.clearState(), bumping _sessionGeneration so line 188's OR can only preserve promotions that land during THIS setupKeyExchange call (the B3-2 behavior), never residue from a prior session. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0021 — medium — Login builds a rate-limiter key from the unvalidated username, so an unauthenticated caller pins ~1 MiB of heap per request for 6 hours @@ -1530,7 +1530,7 @@ hub_broadcast.go RefreshChannelVisibility (the revoke, line 350-356): ### OC-0025 — medium — enableCamera has no supersession re-check after publishTrack, so a concurrent disableCamera leaves the server and every peer believing the camera is on -`Client/tauri-client/src/lib/screenShare.ts:243` · found 2026-08-12 · hunt `general-2026-08-12` · lens `concurrency` +`Client/src/lib/screenShare.ts:243` · found 2026-08-12 · hunt `general-2026-08-12` · lens `concurrency` The generation guard is checked only after device acquisition (line 235), not after the awaited publishTrack. A disableCamera that runs during the publish round-trip bumps the generation, unpublishes/stops the track and sends voice_camera{enabled:false}; the superseded enableCamera then resumes and sends voice_camera{enabled:true}, so the last frame the server sees says the camera is on while the local store says off and no track exists. @@ -1554,11 +1554,11 @@ export async function disableCamera(state, deps) { **Suggested fix:** After the awaited publishTrack (and before the ws.send at 251), re-check (state.generation ?? 0) !== generation; on supersession, unpublish and stop videoTrack, clear state.manualCameraTrack if it still points at it, and return without sending voice_camera(true). -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/screen-share-tracks.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/screen-share-tracks.test.ts` · revert-proof self-reported ### OC-0026 — medium — enableScreenshare has no supersession re-check across its publish loop, so a concurrent stop still announces the share as on -`Client/tauri-client/src/lib/screenShare.ts:345` · found 2026-08-12 · hunt `general-2026-08-12` · lens `concurrency` +`Client/src/lib/screenShare.ts:345` · found 2026-08-12 · hunt `general-2026-08-12` · lens `concurrency` Same missing post-await guard as enableCamera, but worse: the loop publishes tracks from the local `screenTracks` array while disableScreenshare has already emptied state.manualScreenTracks, so the remaining publishes are made against tracks the disable path can no longer reach, and the final ws.send announces enabled:true after the disable already announced enabled:false. @@ -1584,7 +1584,7 @@ export async function disableScreenshare(state, deps) { **Suggested fix:** Re-check (state.generation ?? 0) !== generation after each awaited publishTrack in the loop (and before the ws.send at 370); on supersession, unpublish/stop every track in the local screenTracks array, clear state.manualScreenTracks if it still references them, and return without sending voice_screenshare(true). -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/screen-share-tracks.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/screen-share-tracks.test.ts` · revert-proof self-reported ### OC-0027 — medium — HTTP listen failure returns from run() without hub.GracefulStop(), orphaning the companion livekit-server process and leaving the maintenance goroutine's stop channel unclosed @@ -1614,7 +1614,7 @@ if startErr := proc.Start(); startErr != nil { ... } else { hub.SetLiveKitProces buildReady filters every voice_state through visibleSet = READ-visible non-DM channels ∪ the user's *open* DM channels. Voice membership is gated on CONNECT_VOICE alone (voice_join.go:64) and DM visibility comes from dm_open_state, so the room the user is currently in can be absent from visibleSet — the exact hole handleReconnect patches on the replay tier via liveVoiceEventsSince (serve.go:341-343, whose comment names 'a DM voice call after the DM was closed' as the stock case). The full-ready tier has no equivalent supplement, so the ready payload asserts the user is in no voice channel while the server's voice_states row, the hub's c.voiceChID and the LiveKit session all say otherwise. -**Repro:** Alice and Bob are in a 1:1 DM voice call. Alice closes the DM from the sidebar (DELETE /api/v1/dms/{id}); CloseDM's non-group branch only deletes her dm_open_state row and leaves result.Left false, so no voice eviction runs — she stays in the call. Alice's socket then drops and her resume takes the full-ready path (mustFullResync, or a buffer/cold-tier miss). buildReady's dmChannels comes from GetUserDMChannels (dm_open_state), so the DM id is not in visibleSet and BOTH voice_state rows are filtered out; payload.voice_states is empty. Client-side setVoiceStates (Client/tauri-client/src/stores/voice.store.ts:185) then does `voiceUsers: channelMap` — a full replacement with an empty map — while `currentChannelId: autoJoinChannel ?? prev.currentChannelId` keeps her in the channel, and selfState is undefined so localServerMuted/localServerDeafened are reset to false. Result: a live, audible call rendering zero participants (including herself), setLocalSpeaking permanently a no-op (it early-returns when voiceUsers.get(channelId) is undefined), and any moderator server-mute gate silently lifted in the UI. Nothing repopulates her own row until she toggles mute herself. The same happens for any voice channel where an override grants CONNECT_VOICE but denies READ_MESSAGES. +**Repro:** Alice and Bob are in a 1:1 DM voice call. Alice closes the DM from the sidebar (DELETE /api/v1/dms/{id}); CloseDM's non-group branch only deletes her dm_open_state row and leaves result.Left false, so no voice eviction runs — she stays in the call. Alice's socket then drops and her resume takes the full-ready path (mustFullResync, or a buffer/cold-tier miss). buildReady's dmChannels comes from GetUserDMChannels (dm_open_state), so the DM id is not in visibleSet and BOTH voice_state rows are filtered out; payload.voice_states is empty. Client-side setVoiceStates (Client/src/stores/voice.store.ts:185) then does `voiceUsers: channelMap` — a full replacement with an empty map — while `currentChannelId: autoJoinChannel ?? prev.currentChannelId` keeps her in the channel, and selfState is undefined so localServerMuted/localServerDeafened are reset to false. Result: a live, audible call rendering zero participants (including herself), setLocalSpeaking permanently a no-op (it early-returns when voiceUsers.get(channelId) is undefined), and any moderator server-mute gate silently lifted in the UI. Nothing repopulates her own row until she toggles mute herself. The same happens for any voice channel where an override grants CONNECT_VOICE but denies READ_MESSAGES. **Evidence:** visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels)) for i := range visibleChannels { visibleSet[visibleChannels[i].ID] = struct{}{} } @@ -1636,7 +1636,7 @@ for i := range allVoiceStates { Inside one function, `ListChannels`/`ListRoles`/`GetChannelOverridesFor` failures abort the handshake (`return nil, err`), but `ListMembers` (l.153), `GetChannelUnreadCounts` (l.188) and `GetUserDMChannels` (l.242) failures are downgraded to `slog.Warn` plus an empty value, and the `ready` frame is then built and sent as if it succeeded. `ready` is the protocol's full-state snapshot, so the client cannot distinguish "the query failed" from "you genuinely have none" — the error is mapped to success on the wire. -**Repro:** A server restart makes every client reconnect at once and take the full-ready path; under that load one `GetUserDMChannels` read returns SQLITE_BUSY (or hits the request ctx deadline). The server logs a warning and sends `ready` with `dm_channels: []`. Client/tauri-client/src/lib/dispatcher.ts:331-335 documents the exact opposite contract — "the server always sends the field, so an empty array is an authoritative 'no open DMs' ... and must clear ghosts from dmStore" — so `setDmChannels([])` wipes the user's whole DM list, and the reconcile loop at dispatcher.ts:347-366 then deletes every dm-typed mirror row from channelsStore. If the user was viewing a DM, `stillPresent` at dispatcher.ts:285-292 is false, so `setActiveChannel(null)` tears down the open conversation. Every DM is unreachable for the rest of the session: a `ready` is only re-sent on a fresh connect or a full resync, and successful seq-replay reconnects never send one. The same interleaving on `ListMembers` empties the member sidebar (removing the "Message" affordance that is the only way back to a DM), and on `GetChannelUnreadCounts` zeroes every channel's unread_count/mention_count/last_message_id. +**Repro:** A server restart makes every client reconnect at once and take the full-ready path; under that load one `GetUserDMChannels` read returns SQLITE_BUSY (or hits the request ctx deadline). The server logs a warning and sends `ready` with `dm_channels: []`. Client/src/lib/dispatcher.ts:331-335 documents the exact opposite contract — "the server always sends the field, so an empty array is an authoritative 'no open DMs' ... and must clear ghosts from dmStore" — so `setDmChannels([])` wipes the user's whole DM list, and the reconcile loop at dispatcher.ts:347-366 then deletes every dm-typed mirror row from channelsStore. If the user was viewing a DM, `stillPresent` at dispatcher.ts:285-292 is false, so `setActiveChannel(null)` tears down the open conversation. Every DM is unreachable for the rest of the session: a `ready` is only re-sent on a fresh connect or a full resync, and successful seq-replay reconnects never send one. The same interleaving on `ListMembers` empties the member sidebar (removing the "Message" affordance that is the only way back to a DM), and on `GetChannelUnreadCounts` zeroes every channel's unread_count/mention_count/last_message_id. **Evidence:** members, err := database.ListMembers(ctx) if err != nil { @@ -1666,7 +1666,7 @@ if err != nil { return nil, fmt.Errorf("buildReady ListChannels: %w", err) } ### OC-0030 — medium — prependMessages trims the tail at the 500-row cap, silently destroying the user's pending/failed optimistic rows -`Client/tauri-client/src/stores/messages.store.ts:602` · found 2026-08-12 · hunt `general-2026-08-12` · lens `ordering-boundary` +`Client/src/stores/messages.store.ts:602` · found 2026-08-12 · hunt `general-2026-08-12` · lens `ordering-boundary` Optimistic rows (status "pending"/"failed") are appended at the END of a channel's array by addOptimisticMessage, and prependMessages trims with `combined.slice(0, MAX_MESSAGES_PER_CHANNEL)` — i.e. it drops the tail. Every other writer that replaces a channel window (setMessages L415-424, setAroundMessages L492, invalidateLoadedMessageWindows L545) deliberately carries non-"sent" rows across, with the comment "they are the only copy of the user's composed text". prependMessages is the one path that does not, so an unsent/failed message and its Retry draft are deleted with no server copy to restore them (the comment at L597-599 claims the dropped tail is "restored via the detached-window machinery", which is only true for rows the server actually has). @@ -1685,11 +1685,11 @@ if (wasTrimmed) { **Suggested fix:** In prependMessages' trim branch, carry non-'sent' rows out of the dropped tail: `if (wasTrimmed) { const kept = combined.slice(0, MAX_MESSAGES_PER_CHANNEL); const carried = combined.slice(MAX_MESSAGES_PER_CHANNEL).filter((m) => m.status !== "sent"); combined = carried.length > 0 ? [...kept, ...carried] : kept; }` — mirroring the carry every other window-replacing writer already performs. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/messages-store-detached.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/messages-store-detached.test.ts` · revert-proof self-reported ### OC-0031 — medium — Channel drag-reorder assumes distinct positions; tied positions make the drop a silent no-op or land the channel in the wrong slot -`Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts:151` · found 2026-08-12 · hunt `general-2026-08-12` · lens `ordering-boundary` +`Client/src/components/channel-sidebar/drag-reorder.ts:151` · found 2026-08-12 · hunt `general-2026-08-12` · lens `ordering-boundary` The mouseup handler reassigns "the group's own existing position slots" by sorting the category's position values ascending and zipping them onto the new id order. That is only correct when the positions are distinct. `channels.position` has no uniqueness constraint (Server/db/queries/sqlite/channels.sql has no unique index, AdminUpdateChannel/CreateChannel store whatever is given, and the admin panel's Create Channel modal ships `value="0"` for the Position field — Server/admin/static/index.html:925). When every channel in a category shares position 0, slots is [0,0,0,...] and `ch.position !== newPosition` is false for every row, so `reorders` stays empty, `drag.onReorder` is never called, no PATCH is sent, and updateChannelPosition is never applied — the drag silently does nothing and the row snaps back. With partial ties the zip assigns the wrong slot to the wrong channel, so the dragged channel lands somewhere other than where it was dropped. @@ -1719,11 +1719,11 @@ if (reorders.length > 0) { **Suggested fix:** After sorting, make the slot list strictly increasing before zipping: `for (let i = 1; i < slots.length; i++) { if (slots[i]! <= slots[i - 1]!) slots[i] = slots[i - 1]! + 1; }` — tied groups then get distinct positions, the reorder fires, and subsequent renders order deterministically, while categories with already-distinct slots keep their exact existing range (the behavior the offset test at drag-reorder.test.ts:360 locks). -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/drag-reorder.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/drag-reorder.test.ts` · revert-proof self-reported ### OC-0032 — medium — Client's `lastSeq` watermark is never reset by a full-ready resync, so it desyncs permanently from the server's seq counter (and then silently skips events) -`Client/tauri-client/src/lib/ws.ts:307` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-reconnect` +`Client/src/lib/ws.ts:307` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-reconnect` `lastSeq` is monotone-increasing (`if (seq > lastSeq) lastSeq = seq`) and is only ever zeroed by `disconnect()` (logout). The server answers an unusable `last_seq` by sending a full `ready` and stamps `replay_source: "none"` into auth_ok, but the client ignores that field and keeps the stale watermark forever. Once the server's counter is *below* the client's watermark (any restart where `MAX(events.seq)` is 0 — `event_persistence.enabled=false`, an events table emptied by the 24h pruner, a restored DB), the two counters never re-converge, and while the server's counter climbs back through the stale value the client asks for a range the server happily answers as a complete resume. @@ -1737,7 +1737,7 @@ Server side: Server/ws/ringbuffer.go:66 `if afterSeq > rb.newestSeqLocked() { re **Suggested fix:** In ws.ts's auth_ok branch (line ~307), reset the watermark when the server declares a full resync: `if ((msg.payload as { replay_source?: string }).replay_source === "none") lastSeq = 0;` before setState("connected"). The next sequenced frame then adopts the server's current epoch via the existing seq > lastSeq update. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/ws-reconnect.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/ws-reconnect.test.ts` · revert-proof pass ### OC-0033 — medium — A DM send survives a transient GetDMParticipantIDs failure by silently dropping live fan-out to everyone, including the sender @@ -1790,7 +1790,7 @@ voice_leave.go:129-134 (an empty join token makes the delete a silent no-op, gua return nil } -Client/tauri-client/src/lib/dispatcher.ts:790-815 (a self voice_leave tears the session down) +Client/src/lib/dispatcher.ts:790-815 (a self voice_leave tears the session down) const shouldTeardownSession = isSelf && voiceStore.getState().currentChannelId === payload.channel_id; ... if (shouldTeardownSession) void leaveVoice(false); if (isSelf) { leaveVoiceChannel(); } @@ -1856,7 +1856,7 @@ Server/admin/handlers_channels.go:147 — maxSlowModeSeconds = 21600. ### OC-0037 — medium — Tray Status menu bypasses the client's own status state, so a tray-set Do Not Disturb neither silences notifications nor survives the idle timer or a reconnect -`Client/tauri-client/src/main.ts:251` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/main.ts:251` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` The `status-change` listener only puts a `presence_update` on the wire. Unlike both in-app status surfaces (UserBar.ts:150-157 and settings/AccountTab.ts:883-884, which call `saveUserStatus(status)` and `applyPresence(status)`), it never writes the `userStatus` preference and never calls `updatePresence()`. `lib/userStatus.ts` is documented as "the single client-side source of truth" for the chosen status, and three separate consumers read it — so after a tray selection the server and the client disagree permanently, and two independent code paths then silently undo the user's choice. @@ -1884,7 +1884,7 @@ consumers of the pref the tray path never writes: **Suggested fix:** In the main.ts status-change listener, mirror UserBar's path instead of raw-sending: map the tray's legacy "offline" to "invisible" (matching userStatus.ts's migration), call saveUserStatus(mapped) (origin "manual") before ws.send({type:"presence_update",payload:{status: mapped}}). Persisting via saveUserStatus makes notifications, autoIdle, restoreSavedPresence, and the UserBar/Account-tab renders (via onUserStatusChange) all agree with the wire state in one place. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/main.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/main.test.ts` · revert-proof pass ### OC-0038 — medium — The LiveKit participant_left teardown never tells the leaver, unlike every sibling eviction path @@ -1927,7 +1927,7 @@ if _, ok := seen[c.userID]; !ok { ### OC-0040 — medium — Scroll-to-bottom button and "Jump to Present" pill are absolutely positioned inside the scroll container, so they scroll out of view exactly when they are shown -`Client/tauri-client/src/components/MessageList.ts:810` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/MessageList.ts:810` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` Both controls are appended to `root` (.messages-container), which is itself the `overflow-y: auto` scroller, and are styled `position: absolute; bottom: 8px`. Per CSS Overflow, boxes whose containing block is the scroll container are part of its scrollable overflow region, so they translate with the scrolled content: the button sits at the viewport bottom only at scrollTop ≈ 0 and is painted scrollTop px above the visible area otherwise. Both controls are only made visible when the user is NOT at the bottom, i.e. precisely when scrollTop is large and they are off-screen. @@ -1941,11 +1941,11 @@ root.appendChild(jumpToPresentPill); **Suggested fix:** Give the controls a non-scrolling positioned ancestor: in mount(), wrap the scroller in a position:relative wrapper div and append scrollToBottomBtn and jumpToPresentPill to the wrapper instead of root (root keeps the scroll listener and children; the wrapper becomes what is appended to parentContainer). -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/message-list.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/message-list.test.ts` · revert-proof self-reported ### OC-0041 — medium — Any user whose username is exactly "System" has every message rendered as a server system notice, with no author and no moderation controls -`Client/tauri-client/src/components/message-list/renderers.ts:182` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/message-list/renderers.ts:182` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` renderMessage dispatches to renderSystemMessage purely on `msg.user.username === "System"` — it never checks user.id (the tests use id 0) and the server never emits such messages, so the only way to reach that branch in production is a real account named "System". Server-side ValidateUsername (Server/auth/helpers.go:19) only rejects control/invisible characters and the "[deleted-…]" namespace, so the name is registrable. @@ -1960,11 +1960,11 @@ renderMessage dispatches to renderSystemMessage purely on `msg.user.username === **Suggested fix:** Reserve the name server-side in auth.ValidateUsername: reject strings.EqualFold(strings.TrimSpace(username), "System") alongside the existing "[deleted-" reservation (covers both register and rename since both funnel through it). Per docs/security.md, route the fix through a GitHub Security Advisory rather than a public issue. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/renderers.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/renderers.test.ts` · revert-proof pass ### OC-0042 — medium — leaveVoice() stops manual camera/screen tracks without bumping the enable/disable race-guard generation, so a camera/screenshare enable that is mid-flight (awaiting the OS permission prompt / device picker) when the user leaves voice resurrects a track after the room is gone -`Client/tauri-client/src/lib/livekitSession.ts:1361` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/lib/livekitSession.ts:1361` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` The `generation` counter on CameraTrackState/ScreenTrackState exists specifically so a concurrent enable() that captured its value before a multi-second device-acquisition await (getUserMedia/getDisplayMedia) can detect it was superseded by a disable and discard the track instead of publishing over it (see the doc comment at screenShare.ts:152-159). doDisableCamera/doDisableScreenshare (screenShare.ts:276, screenShare.ts:399) correctly call bumpGeneration(state) before stopping tracks. leaveVoice() performs the exact same operation — it calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 1361-1362) and even resets setLocalCamera(false)/setLocalScreenshare(false) (lines 1386-1387) — but never touches state.generation. Because `_cameraState`/`_screenState` are single per-session fields never reinitialized across join/leave cycles (declared once at lines 259-260), a stale enableCamera()/enableScreenshare() continuation that resumes after leaveVoice() ran will pass the `(state.generation ?? 0) !== generation` check at screenShare.ts:235/334, believe it is still current, set state.manualCameraTrack/manualScreenTracks to the newly created track(s), and attempt room.localParticipant.publishTrack() against `room` — a reference captured before the leave, i.e. a room that leaveVoice() has already called room.disconnect() on (line 1373). If that publish does not synchronously throw, the store is left saying camera/screenshare is whatever enableCamera set (or, worse, a mismatched state: the track object sits in state.manualCameraTrack referencing a track published to an already-disconnected room), and the physical camera/mic-capture device stays open. Nothing frees it: the next enableCamera()/disableCamera() call only calls stopManualCameraTrack when `deps.getRoom()` is non-null (screenShare.ts:206, :220), i.e. only once the user has rejoined a voice channel — until then the camera hardware (LED) stays active after the user has already left the call. @@ -1972,11 +1972,11 @@ The `generation` counter on CameraTrackState/ScreenTrackState exists specificall **Suggested fix:** Export a supersede helper from screenShare.ts (e.g. `export function supersedeVideoEnable(state: GenerationGuarded): void { state.generation = (state.generation ?? 0) + 1; }` — reuse it inside bumpGeneration) and call it on this._cameraState and this._screenState in leaveVoice immediately before the stopManualCameraTrack/stopManualScreenTracks calls at livekitSession.ts:1361-1362, so the stale enable discards its track at the existing screenShare.ts:235/334 check. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0043 — medium — The built-in "light" theme overrides only 4 of the ~45 design tokens and has no stylesheet, so the message composer and every form input render near-invisible dark-on-dark -`Client/tauri-client/src/components/settings/helpers.ts:37` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/settings/helpers.ts:37` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` `applyThemeByName` applies built-in themes by adding a `body.theme-` class, but `src/styles/` contains a rule for `body.theme-neon-glow` only — there is no `body.theme-light` (or `body.theme-midnight`) block anywhere. The entire "light" theme is therefore the 4 inline custom properties `applyTheme` writes onto `document.documentElement`. `--text-normal` flips to the dark `#313338` while `--bg-input` keeps the dark-theme `#383a40` from `tokens.css:11`, so every surface painted with `var(--bg-input)` ends up carrying dark text on a dark box (contrast ≈1.1:1). @@ -1993,7 +1993,7 @@ app.css:2173-2183 — .reply-bar-inner { background: var(--bg-input); } / .reply **Suggested fix:** Give the light theme a complete palette: add a body.theme-light block (new theme-light.css, mirroring theme-neon-glow.css) that overrides every dark token used against --text-normal — at minimum --bg-input, --bg-hover, --bg-active, --bg-modifier-*, --border, --border-strong, --text-muted, --text-faint, --text-micro, --header-primary, --header-secondary, --interactive-* — with light-mode values. (Extending the THEMES.light map works too, but the CSS block matches how neon-glow already ships its extra tokens.) -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/settings-helpers.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/settings-helpers.test.ts` · revert-proof pass ### OC-0044 — medium — rollbackVoiceJoin deletes voice_states by userID alone, letting a stale/failed join's rollback destroy a concurrently-established newer voice membership @@ -2037,7 +2037,7 @@ func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { ### OC-0046 — medium — The Font Size slider and the "Large Font" accessibility toggle are no-ops — `--font-size` is written but no stylesheet ever reads it -`Client/tauri-client/src/styles/base.css:22` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/styles/base.css:22` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` Three separate code paths write the `--font-size` custom property (`applyStoredAppearance` at startup, the Appearance-tab slider on input, and the `.large-font` rule in app.css), but `var(--font-size)` appears nowhere in the repository. `base.css:22` sets `body { font-size: 14px }` as a hard literal, and every other rule uses the fixed `--font-size-xxs … --font-size-xxl` scale from tokens.css. The token is a dead end, so both user-facing font-size controls change persisted state and nothing else. @@ -2053,11 +2053,11 @@ Verification: `grep -rn "var(--font-size)" . --include=*.css --include=*.html -- **Suggested fix:** Make the variable actually feed the type scale: in tokens.css derive the scale from it (e.g. --font-size-md: var(--font-size, 14px) and the other steps via calc() multipliers of --font-size), and change base.css:22 to `font-size: var(--font-size-md)`. Align the appearance.ts default (16) with the actual base (14) so the slider's initial position matches what is rendered. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/base-font-size-css.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/base-font-size-css.test.ts` · revert-proof pass ### OC-0047 — medium — Attachment/avatar fetches lose their bearer token and their cert-pinned proxy when the server host is stored with an explicit :443 -`Client/tauri-client/src/components/message-list/attachments.ts:158` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/components/message-list/attachments.ts:158` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` `isServerUrl` compares `new URL(url).host` against the raw `_serverHost` string without the ":443"-stripping normalization the rest of the client applies (`normalizeHostForCertCompare` in lib/ws.ts, `cert_store_key` in src-tauri/src/tofu.rs). WHATWG `URL` drops the default port for `https:`, so a host stored as `example.com:443` never matches, `fetchServerFile` takes the "external host" branch, and every server file is fetched with no `Authorization` header and outside the TOFU-pinned loopback proxy. @@ -2094,7 +2094,7 @@ Server side, the endpoint is auth-gated — Server/api/upload_handler.go:123 **Suggested fix:** Normalize once at the single entry point: in setServerHost (attachments.ts:37), store `_serverHost = host.replace(/:443$/, "").toLowerCase();` (mirroring normalizeHostForCertCompare). resolveServerUrl then emits the port-less form (same effective https origin), isServerUrl's parsed.host comparison matches for both port-less and explicit-:443 input URLs, and ensureHttpProxy(parsed.host) resolves the same TOFU pin because cert_store_key strips :443 anyway. Non-default ports are preserved on both sides. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/attachments-auth.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/attachments-auth.test.ts` · revert-proof pass ### OC-0048 — medium — Self-account-deletion emits no member_ban: router.go never supplies the optional AuthBroadcaster, so every other client keeps the deleted user and the deleted user's own socket survives @@ -2117,7 +2117,7 @@ Grep for `MountAuthRoutes` shows the only non-test call site is router.go:104; ` ### OC-0049 — medium — High Contrast accessibility toggle's main effect is dead: `.high-contrast { --text-normal }` is on , which already carries an inline --text-normal written by applyTheme() -`Client/tauri-client/src/lib/appearance.ts:21` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/lib/appearance.ts:21` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` `applyStoredAppearance()` calls `applyTheme(name)`, which writes the theme's four tokens — including `--text-normal` — as an *inline* style on `document.documentElement`, and then toggles the `high-contrast` class on the *same* element. An inline declaration always beats a class rule on the same element, so `.high-contrast { --text-normal: #ffffff }` (app.css:5204) can never take effect. The toggle's headline promise (pure-white body text) is silently a no-op; only `--text-muted` and `--bg-active`, which `THEMES` does not set inline, actually change. @@ -2131,7 +2131,7 @@ styles/app.css:5203-5207 `.high-contrast { --text-normal: #ffffff; --text-muted **Suggested fix:** In styles/app.css, make the high-contrast tokens important and cover the body-level custom-theme case: `.high-contrast, .high-contrast body { --text-normal: #ffffff !important; --text-muted: #cccccc !important; --bg-active: rgba(255,255,255,0.15) !important; }` — important author declarations beat normal inline styles, which is exactly the relationship needed here. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/appearance-high-contrast.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/appearance-high-contrast.test.ts` · revert-proof pass ### OC-0050 — low — CleanupVoiceForChannel's check-then-clear is not atomic, so a concurrent voice_join is silently wiped from the hub while its DB row survives @@ -2211,7 +2211,7 @@ api/channel_handler.go:373 ### OC-0053 — low — Quick-switch overlay's teardown guard never fires — an orphaned modal is mounted on document.body after MainPage is destroyed -`Client/tauri-client/src/pages/main-page/SidebarArea.ts:722` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` +`Client/src/pages/main-page/SidebarArea.ts:722` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` `openQuickSwitch` awaits `profileManager.loadProfiles()` and then checks `sidebarWrapper.parentElement === null` as its "were we torn down while awaiting?" test. That check can never be true: MainPage tears down by removing an *ancestor* (`root.remove()` in MainPage.destroy) and never removes `sidebarWrapper` from its parent `app` div, so `sidebarWrapper.parentElement` stays non-null forever. The overlay is then created and mounted to `document.body` — outside the removed subtree — after every reference to it (`quickSwitchInstance`, still `null` when `closeQuickSwitch` ran during teardown) is gone. @@ -2245,11 +2245,11 @@ Result: a full-screen `.quick-switch-backdrop` modal plus its document-level `ke **Suggested fix:** In createSidebarArea, add `let tornDown = false;` and change the pushed unsubscriber to `unsubscribers.push(() => { tornDown = true; closeQuickSwitch(); });`, then replace the dead guard at line 722 with `if (tornDown) return;`. (A flag beats `isConnected`, which would change behavior for unit tests that mount into a detached container.) -**Fixed:** `db0275a2` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass +**Fixed:** `db0275a2` · test `Client/tests/unit/sidebar-area.test.ts` · revert-proof pass ### OC-0054 — low — blocksStore survives clearAuth(), so a previous server's block list can gate DM composers on the next server -`Client/tauri-client/src/stores/auth.store.ts:93` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` +`Client/src/stores/auth.store.ts:93` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` `clearAuth()` deliberately resets voiceStore, messagesStore and channelsStore because their ids are per-server, but leaves `blocksStore.blockedByMe` untouched. Block state is keyed by *user id*, which is also only unique per server. The only thing that restates it on the next session is dispatcher.ts's fire-and-forget `api.listBlocks()`, whose failure is swallowed with a `log.warn` — so a single failed request leaves the previous server's blocked-user ids applied for the whole new session. @@ -2278,23 +2278,23 @@ Result: a full-screen `.quick-switch-backdrop` modal plus its document-level `ke **Suggested fix:** Add `export function resetBlocksStore(): void { blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); }` to blocks.store.ts and call it in clearAuth alongside resetChannelsStore() (auth.store.ts:93). Same-server reconnects don't go through clearAuth, so the keep-until-refetch behavior there is preserved. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/auth-store.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/auth-store.test.ts` · revert-proof pass ### OC-0055 — low — InviteManagerController.open() re-uses a pre-await root reference; a page teardown during the getInvites() fetch resurrects the overlay with a live document-level keydown listener that outlives the page -`Client/tauri-client/src/pages/main-page/OverlayManagers.ts:166` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` +`Client/src/pages/main-page/OverlayManagers.ts:166` · found 2026-08-12 · hunt `general-2026-08-12` · lens `client-state` -open() captures `const root = opts.getRoot()` and checks `instance !== null` BEFORE `await opts.api.getInvites()` (line 167-171), but never re-checks getRoot()/liveness after the await. If MainPage.destroy() runs while the fetch is in flight, its unsubscribers call headerInviteCtrl.cleanup(), but `instance` is still null (createInviteManager hasn't run yet) so cleanup() no-ops; destroy() then nulls its own `root` variable and detaches the DOM node, but the closure-local `root` const in open() still references the now-detached node. When the fetch resolves after teardown, open()'s continuation runs unconditionally: it creates a new InviteManager instance and mounts it on the stale, detached `root` (the `if (root !== null)` check on line 201 only checks the stale local, never re-derives liveness). createInviteManager's mount() (Client/tauri-client/src/components/InviteManager.ts:218) registers `document.addEventListener('keydown', ...)` for Escape-to-close, scoped to that instance's own AbortController. Because InviteManagerController.cleanup() already fired and is never invoked again for this newly-created instance, that global keydown listener is never torn down — it lives on `document` indefinitely, closing over the destroyed page's `api`/`getToast`, and will fire options.onClose() the next time Escape is pressed anywhere in the app (e.g. after the user has navigated back to the connect/login page). SidebarArea.ts's own openQuickSwitch() (same file family, lines ~703-745) demonstrates the intended fix: it re-checks `sidebarWrapper.parentElement === null` AFTER the await before mounting, exactly the guard missing here (and in PinnedPanelController.toggle at OverlayManagers.ts:252-298, which has the identical pattern though its component has no document-level listener so the blast radius is smaller — a detached, un-destroyable component instance rather than a global listener leak). +open() captures `const root = opts.getRoot()` and checks `instance !== null` BEFORE `await opts.api.getInvites()` (line 167-171), but never re-checks getRoot()/liveness after the await. If MainPage.destroy() runs while the fetch is in flight, its unsubscribers call headerInviteCtrl.cleanup(), but `instance` is still null (createInviteManager hasn't run yet) so cleanup() no-ops; destroy() then nulls its own `root` variable and detaches the DOM node, but the closure-local `root` const in open() still references the now-detached node. When the fetch resolves after teardown, open()'s continuation runs unconditionally: it creates a new InviteManager instance and mounts it on the stale, detached `root` (the `if (root !== null)` check on line 201 only checks the stale local, never re-derives liveness). createInviteManager's mount() (Client/src/components/InviteManager.ts:218) registers `document.addEventListener('keydown', ...)` for Escape-to-close, scoped to that instance's own AbortController. Because InviteManagerController.cleanup() already fired and is never invoked again for this newly-created instance, that global keydown listener is never torn down — it lives on `document` indefinitely, closing over the destroyed page's `api`/`getToast`, and will fire options.onClose() the next time Escape is pressed anywhere in the app (e.g. after the user has navigated back to the connect/login page). SidebarArea.ts's own openQuickSwitch() (same file family, lines ~703-745) demonstrates the intended fix: it re-checks `sidebarWrapper.parentElement === null` AFTER the await before mounting, exactly the guard missing here (and in PinnedPanelController.toggle at OverlayManagers.ts:252-298, which has the identical pattern though its component has no document-level listener so the blast radius is smaller — a detached, un-destroyable component instance rather than a global listener leak). **Repro:** 1) Open the sidebar, click the Invite button (SidebarArea.ts headerInviteBtn) while the network is slow, so `opts.api.getInvites()` is pending. 2) Before it resolves, log out / get banned / server-shutdown-kick (any path that calls MainPage.destroy()). destroy() runs headerInviteCtrl.cleanup() while `instance` is still null, so nothing happens; destroy() proceeds to null/remove `root`. 3) The pending getInvites() promise resolves; open()'s continuation creates a fresh InviteManager instance and mounts it onto the now-detached root, registering a document-level 'keydown' listener via createInviteManager's own AbortController. 4) The user is now on ConnectPage (or a new MainPage from re-login). Pressing Escape anywhere triggers the zombie instance's onClose→close(), which is the only thing that will ever call its destroy() — until then this dangling document listener is a real leak that nothing in MainPage's teardown chain can reach. **Suggested fix:** In open(), after the await re-derive the mount target: `const liveRoot = opts.getRoot(); if (liveRoot === null) return;` and mount on liveRoot instead of the pre-await const (delete the dead `if (root !== null)` check). getRoot() returns MainPage's `root`, which destroy() nulls, so this is an exact liveness signal. Apply the same two-line change in PinnedPanelController.toggle. -**Fixed:** `db0275a2` · test `Client/tauri-client/tests/unit/overlay-managers.test.ts` · revert-proof pass +**Fixed:** `db0275a2` · test `Client/tests/unit/overlay-managers.test.ts` · revert-proof pass ### OC-0056 — low — ws.disconnect() cannot cancel an in-flight connect(), so a cancelled session still opens its WebSocket and re-registers Tauri listeners after teardown -`Client/tauri-client/src/lib/ws.ts:585` · found 2026-08-12 · hunt `general-2026-08-12` · lens `lifecycle` +`Client/src/lib/ws.ts:585` · found 2026-08-12 · hunt `general-2026-08-12` · lens `lifecycle` connect() is async and has three await points (ensureTauriApis, setupEventListeners' 3 tauriListen IPC round-trips, then ws_connect) after it has already bumped wsGeneration. disconnect() is fully synchronous and does NOT bump wsGeneration, so it has no way to invalidate an attempt that is mid-await: it drains eventUnsubs while that array is still partially filled, nulls config, and returns — then the suspended connect() resumes, pushes fresh (never-cleaned) unsub handles into eventUnsubs, and calls invoke("ws_connect"), opening the very socket the teardown was meant to prevent. @@ -2304,11 +2304,11 @@ connect() is async and has three await points (ensureTauriApis, setupEventListen **Suggested fix:** In disconnect(), add `wsGeneration++;`. In connect(), capture `const gen = wsGeneration;` after the initial increment and bail (`if (gen !== wsGeneration) return;`) after `await ensureTauriApis()` and after `await setupEventListeners()`, before invoking ws_connect. -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/ws-lifecycle.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/ws-lifecycle.test.ts` · revert-proof self-reported ### OC-0057 — low — showContextMenu registers a permanent "abort" listener on the caller's component-lifetime signal on every open, pinning each removed menu subtree -`Client/tauri-client/src/lib/context-menu.ts:88` · found 2026-08-12 · hunt `general-2026-08-12` · lens `lifecycle` +`Client/src/lib/context-menu.ts:88` · found 2026-08-12 · hunt `general-2026-08-12` · lens `lifecycle` The teardown hook is attached with `signal.addEventListener("abort", ...)` with no `{ once: true }` and no removal path — and, unlike the per-menu `dismissAc`, the caller's `signal` is the component's whole lifetime. Each invocation therefore adds one more listener to that signal, and each listener's closure retains its `menu` element, so menus removed from the DOM (by dismissal, by the `querySelectorAll(...).remove()` sweep at line 36, or by an item click) stay reachable until the component is destroyed. @@ -2326,7 +2326,7 @@ showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-cont **Suggested fix:** Register the teardown hook so menu dismissal releases it: `signal.addEventListener("abort", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal })`, and abort dismissAc whenever the menu is removed (item click and outside-click already do). -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/context-menu.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/context-menu.test.ts` · revert-proof self-reported ### OC-0058 — low — Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster @@ -2349,7 +2349,7 @@ case !*req.Banned && hub != nil: ### OC-0059 — low — Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to -`Client/tauri-client/src/pages/main-page/ChannelController.ts:450` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync` +`Client/src/pages/main-page/ChannelController.ts:450` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync` The `chat_send_ok` and `error`/SLOW_MODE listeners registered in mountChannel are global ws.on subscriptions (no per-channel filter is possible: ChatSendOkPayload has only message_id/timestamp, ErrorPayload has only code/message — neither carries channel_id). They are torn down and re-registered on every channel switch, so a frame that was actually produced by a send in the *previous* channel gets delivered to the *newly mounted* channel's handler, which unconditionally calls startSlowMode(ch.slowMode) for the channel currently mounted — desyncing the client's local slow-mode countdown (source: WS-listener side effect) from the server's actual per-channel rate-limit state (source of truth: the server's limiter, correctly scoped by channel_id there). @@ -2357,11 +2357,11 @@ The `chat_send_ok` and `error`/SLOW_MODE listeners registered in mountChannel ar **Suggested fix:** Record the originating channel per correlation id (the send path already keys draftByCorrelation by correlation id — add a channelId field, or keep a controller-scoped Map). In both handlers, gate startSlowMode on that recorded channel equaling the mounted channelId; the server echoes the request id on SLOW_MODE errors too (buildErrorMsgWithID, Server/ws/handlers_chat.go:165), so the error handler can use the same correlation check via the second listener argument. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/channel-controller.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/channel-controller.test.ts` · revert-proof pass ### OC-0060 — low — A single malformed stored server profile makes the client discard all saved profiles, and the next login overwrites the on-disk list with that empty set -`Client/tauri-client/src/lib/profiles.ts:112` · found 2026-08-12 · hunt `general-2026-08-12` · lens `error-paths` +`Client/src/lib/profiles.ts:112` · found 2026-08-12 · hunt `general-2026-08-12` · lens `error-paths` `createTauriBackend().load()` returns `null` both for "nothing stored" and "stored payload failed validation", and `isValidStoredData` is all-or-nothing over the whole array — one bad entry rejects every profile. `loadProfiles` then does nothing (`if (data !== null)`), leaving the store empty rather than surfacing a read failure, and `saveProfiles` unconditionally writes that empty in-memory list back over the stored record. `importProfiles` on the same file shows the intended tolerance (it counts `skipped` per item); the load path has none. @@ -2384,7 +2384,7 @@ async saveProfiles(): Promise { **Suggested fix:** Make load() salvage instead of discard: when the envelope shape is valid, return { schemaVersion, profiles: obj.profiles.filter(isValidProfileShape) } (mirroring importProfiles' per-item tolerance) so one corrupt entry drops only itself rather than nulling the whole store that the next save then overwrites. -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/profiles.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/profiles.test.ts` · revert-proof self-reported ### OC-0061 — low — NewPersistentRateLimiter silently discards LoadActiveLockouts errors, dropping all active login lockouts with no log line @@ -2417,7 +2417,7 @@ Compare serve.go:188-197, whose own comment says "Accepting it as-is would prese ### OC-0063 — low — Connected overlay reads authStore before the auth_ok payload is dispatched, so server_name and motd are always the pre-handshake values -`Client/tauri-client/src/main.ts:388` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-reconnect` +`Client/src/main.ts:388` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-reconnect` `ws.onStateChange` listeners are invoked synchronously inside `setState("connected")`, which ws.ts runs *before* `dispatch(msg)` — and `setAuth(token, payload.user, payload.server_name, payload.motd)` only runs inside the dispatcher's auth_ok handler during that later `dispatch`. So `authStore.getState()` at main.ts:388 still holds the pre-auth_ok state. On a first login `wirePostAuth` has written only `token`, leaving `serverName`/`motd` at their `INITIAL_STATE` value of `null`, so both `?? ` fallbacks fire and the overlay renders the raw host string and a blank MOTD even though auth_ok carried both. Nothing later repairs it: the `ws.on("ready", ...)` handler at main.ts:403 only calls `markReady()`, and `createConnectedOverlay` captures its options by value at construction. @@ -2431,11 +2431,11 @@ main.ts:388-394 `const auth = authStore.getState(); ... serverName: auth.serverN **Suggested fix:** Create the overlay from the auth_ok payload instead of the store: in wirePostAuth, replace the onStateChange("connected") trigger with a one-shot ws.on("auth_ok", (payload) => { ... serverName: payload.server_name ?? host, motd: payload.motd ?? "" ... }) (registered after wireDispatcher), keeping the same self-unsubscribe and destroy-before-create logic. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/main.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/main.test.ts` · revert-proof pass ### OC-0064 — low — The dispatcher's catch-all server-error branch writes to `transientError`, which only ConnectPage renders — every unhandled error is invisible in-app and then resurfaces stale on the login screen -`Client/tauri-client/src/lib/dispatcher.ts:1002` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` +`Client/src/lib/dispatcher.ts:1002` · found 2026-08-12 · hunt `general-2026-08-12` · lens `flow-voice` `setTransientError` has exactly one consumer in the whole client: ConnectPage's subscription/mount read (ConnectPage.ts:267 and :276), which pushes it into the login form. MainPage never subscribes and never clears it. So the branch that the code comments call "the one place every remaining server error lands ... so it must not be silently dropped" does in fact drop it while the user is in the app, and leaves it latched in the store until the login page next mounts. @@ -2455,7 +2455,7 @@ ConnectPage.ts:274-280 **Suggested fix:** In the catch-all branch (dispatcher.ts:1002), surface in-app errors the same way the sibling CHANNEL_FULL/VIDEO_LIMIT branches do — showToast(payload.message || "Server error", "error") — keeping setTransientError only for flows that also leave the session (BANNED, shutdown), and update the dispatcher tests that assert the store write for the catch-all codes. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof pass ### OC-0065 — low — participant_joined webhook treats a GetVoiceState read error as proof of a rogue participant and ejects a legitimate one from the SFU @@ -2617,7 +2617,7 @@ ws/handlers.go:158-172 (a successful focus subscribes the socket to ChannelTopic ### OC-0071 — low — A sidebar re-render during a channel drag detaches the drop container, so the reorder silently no-ops -`Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts:111` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/channel-sidebar/drag-reorder.ts:111` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` `activeDrag` captures the `channelsContainer` element that existed when the row was rendered. `ChannelSidebar.renderChannels()` does `clearChildren(channelList)` and rebuilds every category group, so any re-render while the mouse button is down leaves `drag.containerEl` (and `drag.sourceEl`, and the stale `drag.channels` snapshot) detached from the document. The global `mouseup` hit-test then queries the detached subtree, where `getBoundingClientRect()` returns an all-zero DOMRect for every row, so no row can ever satisfy `e.clientY >= rect.top && e.clientY <= rect.bottom` and `dropTargetId` stays null. The handler returns at the `dropTargetId === null` guard, and the drag is discarded with no error, no toast and no visual trace. The same staleness silently kills the drop indicator during `mousemove` (line 74-89 also queries the detached container), so the user watches the indicator disappear and then the drop does nothing. @@ -2654,7 +2654,7 @@ message delivered to a non-active channel, so the selector above fires. **Suggested fix:** In the global mousemove/mouseup handlers, when !drag.containerEl.isConnected, re-resolve the live container via document.querySelector(`[data-drag-channel-id="${drag.channelId}"]`)?.closest('.category-channels-container') (and rebuild the channel snapshot for that group from channelsStore) before hit-testing; or equivalently have renderChannels() re-target activeDrag's containerEl/sourceEl/channels when it rebuilds while a drag it owns is in flight. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/drag-reorder.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/drag-reorder.test.ts` · revert-proof self-reported ### OC-0072 — low — voice_mod_move's pre-flight omits the archived-channel gate that voice_join enforces, so the move drops the target out of voice for nothing @@ -2767,7 +2767,7 @@ SendMessage was fixed to refuse writes into an archived channel (message_crud.go ### OC-0078 — low — renderAll's rapid-fire breaker discards the update instead of deferring it, leaving the message list permanently stale -`Client/tauri-client/src/components/MessageList.ts:658` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/MessageList.ts:658` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` When more than 20 renderAll calls occur inside the 2s window the function returns before rebuildItems(), so the store change that triggered it is simply dropped. Nothing re-schedules a render when renderAllResetTimer clears the counter, so the DOM keeps showing pre-burst state until some later, unrelated store event happens to arrive. @@ -2784,11 +2784,11 @@ if (renderAllResetTimer === 0) { **Suggested fix:** When the breaker trips, remember it (e.g. renderAllSuppressed = true) and have the 2s reset timeout call renderAll() once if the flag is set, so the final state of a burst is always rendered. -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/message-list.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/message-list.test.ts` · revert-proof self-reported ### OC-0079 — low — An emptied message edit is submitted (and edit mode torn down) when an attachment is queued in the composer -`Client/tauri-client/src/components/MessageInput.ts:472` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/components/MessageInput.ts:472` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` The empty-content early return is disabled by `hasAttachments`, but `pendingAttachments` is only meaningful for a new message — the edit branch never reads it. So with a file queued, an edit whose text the user cleared reaches `onEditMessage(id, "")`, which the host rejects with a toast, and `cancelEdit()` then runs unconditionally, dropping the user out of edit mode and wiping the textarea. The identical keystroke with no attachment queued is a harmless no-op that preserves edit state. @@ -2816,11 +2816,11 @@ The empty-content early return is disabled by `hasAttachments`, but `pendingAtta **Suggested fix:** In handleSend, make the empty-content guard ignore attachments when editing (edits are text-only): change line 472 to `if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;`. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/message-input.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/message-input.test.ts` · revert-proof self-reported ### OC-0080 — low — teardownForReconnect() has the same generation-guard gap as leaveVoice(), so a camera/screenshare enable racing an unexpected LiveKit disconnect can publish a track to the room being torn down for auto-reconnect -`Client/tauri-client/src/lib/livekitSession.ts:348` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/lib/livekitSession.ts:348` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForReconnect() on every unexpected disconnect that is eligible for auto-reconnect, before nulling the room and calling room.disconnect() (roomEventHandlers.ts:187-193). teardownForReconnect (livekitSession.ts:329-352) mirrors leaveVoice: it tells the server camera/screenshare are off, then calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 348-349) and resets setLocalCamera(false)/setLocalScreenshare(false) (lines 350-351) — again without bumping state.generation, unlike doDisableCamera/doDisableScreenshare. An enableCamera()/enableScreenshare() call that is awaiting device acquisition when an unexpected disconnect fires will, on resuming, pass the stale-generation check and attempt to publish onto the room object that is about to be (or already was) disconnected and replaced by attemptAutoReconnect's fresh Room, leaving a leaked/orphaned local track and a store state that can disagree with what is actually being sent once the new room comes up. @@ -2828,7 +2828,7 @@ roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForRecon **Suggested fix:** Same one-line-per-state fix as the leaveVoice finding: call the shared supersede/bumpGeneration helper on this._cameraState and this._screenState at the top of the teardownForReconnect callback (before livekitSession.ts:348-349). -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0081 — low — voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable @@ -2836,7 +2836,7 @@ roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForRecon EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from "no voice_states row for this channel", and handleVoiceCameraV2 maps both to VIDEO_LIMIT "maximum N video streams reached". -**Repro:** Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/tauri-client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT "maximum 1 video streams reached" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off. +**Repro:** Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT "maximum 1 video streams reached" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off. **Evidence:** Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit. @@ -2872,7 +2872,7 @@ api/channel_handler.go:407-426 (`writeServiceError`) has no `db.ErrNotFound` arm ### OC-0083 — low — ConnectPage.destroy() never clears uiStore.settingsOpen, so the settings panel pops open over MainPage immediately after login -`Client/tauri-client/src/pages/ConnectPage.ts:286` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/pages/ConnectPage.ts:286` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` MainPage.destroy() explicitly calls `closeSettings()` for exactly this reason ("the next page to mount an (initially hidden) SettingsOverlay off that flag — ConnectPage, after logout — would show it over the login screen"). ConnectPage.destroy() only destroys its own lazily-created overlay and leaves `settingsOpen === true` in the store. MainPage eagerly mounts a SettingsOverlay whose `mount()` ends with `if (uiStore.getState().settingsOpen) show()`, so the stale flag opens the full settings panel on top of the freshly loaded app. @@ -2886,11 +2886,11 @@ ConnectPage.ts:78 — onSettingsOpen: () => openSettings() // the gear is neve **Suggested fix:** In ConnectPage.destroy() (ConnectPage.ts:286), call closeSettings() alongside the existing setTransientError(null), mirroring MainPage.destroy(). -**Fixed:** `db0275a2` · test `Client/tauri-client/tests/unit/connect-page.test.ts` · revert-proof pass +**Fixed:** `db0275a2` · test `Client/tests/unit/connect-page.test.ts` · revert-proof pass ### OC-0084 — low — VideoGrid's track-mute handler adds a `track-muted` class that no stylesheet defines, so a stalled remote camera keeps showing a frozen frame -`Client/tauri-client/src/components/VideoGrid.ts:151` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/components/VideoGrid.ts:151` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` The handler's stated job is to hide the tile's video while the remote track is muted, but the hiding is expressed purely by toggling `track-muted`, and there is no `.track-muted` rule in app.css, base.css, login.css, tokens.css or theme-neon-glow.css. Nothing else in the mute path touches the element's visibility, so the branch is a no-op. @@ -2907,11 +2907,11 @@ The handler's stated job is to hide the tile's video while the remote track is m **Suggested fix:** Add a rule to app.css alongside the other .video-cell styles, e.g. `.video-cell.track-muted video { visibility: hidden; }` (optionally with a background on .video-cell.track-muted), so the tile blanks while the track is muted and reappears on unmute. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/video-grid-track-muted-css.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/video-grid-track-muted-css.test.ts` · revert-proof pass ### OC-0085 — low — Collapsed sidebar categories are persisted under the server's display name, not its host, so two default-named servers share one localStorage entry -`Client/tauri-client/src/pages/main-page/SidebarArea.ts:230` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` +`Client/src/pages/main-page/SidebarArea.ts:230` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-client-tauri-client-src` `loadCollapsedCategories(serverHost)` builds the localStorage key `owncord:collapsed:` and `saveCollapsedCategories` writes back under the same `currentServerHost`. The one caller passes `authStore.getState().serverName` — the operator-configured display name from `auth_ok` — not `api.getConfig().host`. Every sibling per-server preference in this client is host-scoped for exactly this reason (`setChannelMutesHost`, `setNsfwGateHost`, `setAudioVolumeHost`, all wired from `MainPage.ts:105-107` with `apiConfig.host`), and channel-mutes.ts's own doc comment names `ui.store.ts`'s `loadCollapsedCategories` as following that per-connection host convention. The server's default name is `"OwnCord Server"` (Server/config/config.go:212), so unmodified instances all collide on one key. @@ -2936,7 +2936,7 @@ setAudioVolumeHost(apiConfig.host ?? null); **Suggested fix:** Scope the key to the connected host like the sibling prefs: in MainPage.ts next to setChannelMutesHost/setNsfwGateHost/setAudioVolumeHost (lines 105-107), call loadCollapsedCategories(apiConfig.host ?? "") and delete the loadCollapsedCategories(initialServerName) call at SidebarArea.ts:228-230 (matching the per-connection wiring channel-mutes.ts:55-57 already describes). -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/sidebar-area.test.ts` · revert-proof pass ### OC-0086 — low — sweepStaleVoiceStates classifies ghost rows from a snapshot and then deletes them without re-checking, so an in-flight voice_join is ejected from the SFU while the hub still believes the user is in the room @@ -3049,22 +3049,22 @@ if platform == nil { ### OC-0089 — low — `additionalBrowserArgs` silently drops wry's default `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` on Windows -`Client/tauri-client/src-tauri/tauri.conf.json:22` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src-tauri/tauri.conf.json:22` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` Tauri/wry pass `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` to the WebView2 browser process by default; setting `additionalBrowserArgs` REPLACES that default rather than appending to it (documented on `WindowConfig::additional_browser_args` / `WebViewBuilder::with_additional_browser_args`: "you also need to disable these components by yourself"). This config supplies only the two media flags, so the three suppressed WebView2 features are re-enabled in every Windows build — including SmartScreen, which performs URL-reputation lookups against a Microsoft service for navigations and downloads inside the webview. For a client whose entire threat model is a self-hosted, TOFU-pinned server, that leaks the operator's server and attachment URLs off-box, and the msWebOOUI overlays reappear in the chrome-less window. **Repro:** Build the Windows (nsis) bundle and run it. Because the config's `additionalBrowserArgs` overrides wry's default argument string, the WebView2 process starts without `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection`: SmartScreen is active for in-webview navigations/downloads (attachment and OG-preview URLs from the self-hosted server are submitted for reputation checks), and the msWebOOUI out-of-process UI surfaces render over the custom window. Fix is to prepend `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` to the same string. (Confidence note: verified against the documented Tauri/wry contract and the absence of the flag anywhere in the repo, not by running a Windows build.) -**Evidence:** Client/tauri-client/src-tauri/src-tauri/tauri.conf.json:22 (app.windows[0]): +**Evidence:** Client/src-tauri/src-tauri/tauri.conf.json:22 (app.windows[0]): "additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream" No occurrence of `--disable-features` anywhere in the repo: - grep -rn "disable-features" Client/tauri-client -> no hits + grep -rn "disable-features" Client -> no hits tauri = 2.11.5 (src-tauri/Cargo.lock:4809-4810), which carries the replace-not-append semantics. **Suggested fix:** Prepend the dropped default to the same string in tauri.conf.json:22: "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream". -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/tauri-conf-webview2-args.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/tauri-conf-webview2-args.test.ts` · revert-proof pass ### OC-0090 — low — channelReadAudience falls through to a server-wide role scan when the channel row is gone, leaking a private group-DM's voice_leave to every connected member @@ -3148,7 +3148,7 @@ middleware.go:274-276 `if len(trustedNets) == 0 { return remoteHost }` ### OC-0094 — low — "Back" from DM sidebar can silently jump the user to an unrelated channel when DM mode was entered via "View all messages" -`Client/tauri-client/src/pages/main-page/SidebarArea.ts:523` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` +`Client/src/pages/main-page/SidebarArea.ts:523` · found 2026-08-12 · hunt `general-2026-08-12` · lens `fresh-eyes` `channelBeforeDm` (declared line 90, only ever written at line 393 inside `selectDmConversation`'s `setChannelBeforeDm` callback) is meant to remember which channel to restore when leaving the DM sidebar. But `SidebarDmSection.ts`'s "View all messages" button (line 66-68) switches to DM mode via a bare `setSidebarMode("dms")`, bypassing `selectDmConversation` entirely, so `channelBeforeDm` is never recorded for that entry path. When the user then clicks "Back" without selecting a specific DM, `onBack` (lines 523-536) sees `channelBeforeDm === null` and falls into the `else` branch, which activates the *first* `type === "text"` channel found in `channelsStore` Map-iteration order — not the channel that was actually on screen. If that first channel differs from the one the user was viewing, `setActiveChannel` (line 531) silently switches them to a different channel/conversation they never asked to leave. @@ -3156,11 +3156,11 @@ middleware.go:274-276 `if len(trustedNets) == 0 { return remoteHost }` **Suggested fix:** In onBack's else branch (SidebarArea.ts:528-535), keep the current channel when it is already a non-DM channel and only fall back when it is null or a DM: `const st = channelsStore.getState(); const cur = st.activeChannelId !== null ? st.channels.get(st.activeChannelId) : undefined; if (cur === undefined || cur.type === "dm") { /* existing first-text-channel loop */ }`. (fallBackFromDm at line 437 has the same pattern but is only reached after the active DM was closed, where the fallback is the intended behavior.) -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/sidebar-area.test.ts` · revert-proof pass ### OC-0095 — critical — Voice E2EE is never actually enabled — room.setE2EEEnabled(true) is never called, so every frame reaches the SFU in plaintext while the UI shows "🔒 Secured" -`Client/tauri-client/src/lib/livekitSession.ts:410` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `voice-e2ee` +`Client/src/lib/livekitSession.ts:410` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `voice-e2ee` createRoom() passes `e2ee: { keyProvider, worker }` to the livekit-client Room, but nothing in the client ever calls `room.setE2EEEnabled(true)`. In livekit-client 2.21.0 the Room constructor's `setupE2EE()` only wires the manager; it never enables encryption. `LocalParticipant.encryptionType` therefore stays `Encryption_Type.NONE`, so on `SignalConnected` the E2EEManager calls `setParticipantCryptorEnabled(localParticipant.isE2EEEnabled /* false */, localIdentity)` and the worker's `encodeFunction` takes the `if (!this.isEnabled()) { ... return controller.enqueue(encodedFrame); }` branch — the frame is forwarded unencrypted. Published tracks are also advertised with `encryption: NONE`, so every remote peer's `setParticipantCryptorEnabled(pub.trackInfo.encryption !== Encryption_Type.NONE, ...)` is false too and their decode transforms pass through as well. The entire ECDH/HKDF/AES-GCM key exchange, the TOFU identity layer, key-holder election and 5-minute rotation distribute a room key that is never used to encrypt or decrypt a single media frame. The call still works end to end, which is why this has never surfaced as a symptom. @@ -3174,7 +3174,7 @@ createRoom() passes `e2ee: { keyProvider, worker }` to the livekit-client Room, worker: this._e2eeWorker, }, -(no `setE2EEEnabled` / `isE2EEEnabled` anywhere under Client/tauri-client/src or tests/) +(no `setE2EEEnabled` / `isE2EEEnabled` anywhere under Client/src or tests/) node_modules/livekit-client/dist/livekit-client.esm.mjs:29027 this.encryptionType = Encryption_Type.NONE; // LocalParticipant default @@ -3192,12 +3192,12 @@ node_modules/livekit-client/dist/livekit-client.e2ee.worker.mjs:6741-6751 (Crypt return controller.enqueue(encodedFrame); // <-- plaintext passthrough } -Client/tauri-client/src/components/VoiceWidget.ts:196 +Client/src/components/VoiceWidget.ts:196 securedBadge.style.display = status === "connected" ? "inline-flex" : "none"; // "🔒 Secured" shown purely on connection state **Suggested fix:** Enable E2EE on the one room construction site so both connect paths get it: make createRoom() async and `await newRoom.setE2EEEnabled(true);` immediately after `new Room({...})` (livekitSession.ts:414), then `await this.createRoom()` at the two call sites (486, 950 — both already async). Pre-connect this only sets localParticipant.encryptionType = GCM (identity is still '' so the manager call is skipped); the manager's SignalConnected handler then posts enable=true with the real identity, and published tracks advertise encryption: GCM so peers enable their decode cryptors. The key is already set before connect (setupKeyExchange at 977 runs ahead of room.connect at 1009), so there is no encode-before-key window. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-session.test.ts` · revert-proof pass ### OC-0096 — high — Message search 500s on any query containing a hyphen — sanitizeFTSQuery allowlists the one character that is an FTS5 operator @@ -3255,7 +3255,7 @@ writeJSON(w, http.StatusOK, map[string]string{"message": "database restored — ### OC-0098 — high — A joining key holder sends its room-key offers before its own announce, so every existing participant drops them as "unknown peer" -`Client/tauri-client/src/lib/livekitE2EE.ts:230` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `flow-voice` +`Client/src/lib/livekitE2EE.ts:230` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `flow-voice` setupKeyExchange drains the queued announces (which, for a key holder, emit a voice_e2ee_offer per peer) at lines 223-228, and only afterwards sends the holder's own voice_e2ee_announce at line 232. The receiver needs the sender's ephemeral ECDH public key to unwrap, so handleOfferInner's `_peerPublicKeys.get(fromUserId)` guard (line 737-741) discards every one of those offers. Nothing re-requests them: handleAnnounce answers an announce with an offer, never a counter-announce, and mid-call peers never re-announce on their own. @@ -3281,7 +3281,7 @@ setupKeyExchange drains the queued announces (which, for a key holder, emit a vo **Suggested fix:** Hoist the key holder's announce above the drain: send `{type:"voice_e2ee_announce", payload: announcePayload}` immediately after `this._ecdhKeyPair = ecdhKeyPair;` (:217) when `this._isKeyHolder`, and delete the send at :232. Do NOT hoist the non-holder send at :244 — it must stay after the _roomKeyResolver is installed at :237-240, or an immediate offer resolves nothing and setupKeyExchange stalls to timeout. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass ### OC-0099 — high — Registration HTML-escapes the username but login does not, so any account whose name contains ' " & is permanently unloggable @@ -3425,7 +3425,7 @@ api/plugins_handler.go:118 (list) ### OC-0105 — medium — secret_store::delete reports success while the fallback credential survives on disk and resurrects on next launch -`Client/tauri-client/src-tauri/src/secret_store.rs:407` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `error-paths` +`Client/src-tauri/src/secret_store.rs:407` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `error-paths` `clear_fallback` removes the entry from the tauri-plugin-store's in-memory map and then flushes with `store.save()`. A `save()` failure is only `log::warn!`-ed, never propagated, so `delete()` (line 215–219) still returns the keyring result — `Ok(())`. The on-disk fallback file still holds the sealed secret, so the "deleted" login token/password or voice-E2EE identity key comes back the next time the app starts. This is exactly the failure `delete`'s own doc comment claims to prevent ("a delete that left the fallback copy behind would resurrect a 'deleted' secret on the next read"). @@ -3448,7 +3448,7 @@ pub fn delete(app: &AppHandle, account: &str) -> Result<(), String> { **Suggested fix:** Make clear_fallback return Result<(), String> (propagate the save() error) and have delete() return `keyring_result.and(clear_fallback(app, account))`; keep set_with's fallback_clear closure best-effort with `let _ = ...`, since there the keyring copy is authoritative. -**Fixed:** `8579cb5d` · test `Client/tauri-client/src-tauri/src/secret_store.rs` · revert-proof pass (manual hunk-level: behavioral line reverted, propagation test red, green at HEAD) +**Fixed:** `8579cb5d` · test `Client/src-tauri/src/secret_store.rs` · revert-proof pass (manual hunk-level: behavioral line reverted, propagation test red, green at HEAD) ### OC-0106 — medium — Every DM message re-emits dm_channel_open and bumps the global visibility watermark, so ordinary DM traffic forces every other client's next reconnect onto the full-resync tier @@ -3472,7 +3472,7 @@ Server/ws/hub_events.go:84-87 func (h *Hub) mustFullResync(lastSeq uint64) bool ### OC-0107 — medium — Session termination before MainPage mounts never tears down the WS client, so a banned/revoked token reconnects forever -`Client/tauri-client/src/main.ts:729` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `flow-session` +`Client/src/main.ts:729` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `flow-session` The only place that calls `ws.disconnect()`, `deleteCredential(host)` and `router.navigate("connect")` on logout is the `authStore` subscriber, and its entire body is gated on `router.getCurrentPage() === "main"`. MainPage is not mounted until the connected overlay's `onReady` fires, so every session that dies during login / auto-login / the connected-overlay window skips the teardown: `intentionalClose` stays false in `ws.ts`, and `rustState === "closed"` therefore calls `scheduleReconnect()` (bounded delay 30s, no attempt cap) with the same dead token, forever. The server-side ban rejection makes this the default path: `authenticateConn` answers a banned user with `buildErrorMsg(ErrCodeBanned, ...)` — a generic `error` frame — not `auth_error`, and `ws.ts` only sets `intentionalClose = true` for `auth_error`. @@ -3507,11 +3507,11 @@ lib/store.ts subscribeSelector fires only when the slice changes, so false->fals **Suggested fix:** In dispatcher.ts's S.ERROR BANNED branch (line ~930), call `ws.disconnect()` before `clearAuth()` — `ws` is already in wireDispatcher's scope and disconnect() sets intentionalClose/cancels the reconnect timer, so it is idempotent with the main-page subscriber's own ws.disconnect(). One guard in the shared handler covers every router state instead of adding a third hand-copied teardown block. -**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass +**Fixed:** `8579cb5d` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof pass ### OC-0108 — medium — A username/nickname/avatar change never repaints the open message list — updateMemberProfile is the one members.store mutator that does not bump roleRevision, and MessageList subscribes to nothing else -`Client/tauri-client/src/stores/members.store.ts:152` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` +`Client/src/stores/members.store.ts:152` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` `updateMemberProfile` returns `{ ...prev, members: next }` while every sibling mutator (`setMembers`:78, `addMember`:102, `removeMember`:111, `updateMemberRole`:122) returns `roleRevision: (prev.roleRevision ?? 0) + 1`. `MessageList` holds exactly one members subscription — `membersStore.subscribeSelector((s) => s.roleRevision ?? 0, () => renderAll())` at MessageList.ts:897-903 — so the profile patch mutates the store without ever waking the only renderer that reads it. That renderer does read it live: `renderers.ts:200` calls `resolveAuthor(msg.user)`, and `formatting.ts:143` resolves the name/avatar out of `membersStore.getState().members`, with a doc comment (formatting.ts:131-135) stating the store is preferred precisely because "a rename or an avatar change arrives as a user_update and patches every member". The store is patched; the pixels are not. @@ -3537,7 +3537,7 @@ dispatcher.ts:698-705 — the event that lands here: **Suggested fix:** One line in the shared mutator: make updateMemberProfile return `{ ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 }` (members.store.ts:152), and update the MembersState doc comment at line 30-34 (and MessageList.ts:894) to say the counter covers membership, role, and profile changes — still excluding presence/typing, which is the exclusion the counter exists for. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/members.store.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/members.store.test.ts` · revert-proof pass ### OC-0109 — medium — GIF proxy's log redaction misses the percent-encoded API key, so an upstream failure writes the Klipy credential to stdout and the admin log stream @@ -3590,7 +3590,7 @@ Consumer: Server/admin/static/index.html:1540 renders `entry.attrs` inline, and ### OC-0111 — medium — Auto-idle's return-to-online presence_update is always swallowed by the 1-per-10s presence limiter, so every user shows as Idle to everyone else after returning to the keyboard -`Client/tauri-client/src/pages/MainPage.ts:200` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-1` +`Client/src/pages/MainPage.ts:200` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-1` applyPresence() writes the local presence store unconditionally but only sends the wire frame if limiters.presence.tryConsume() succeeds, and the presence limiter is 1 token per 10 000 ms (rate-limiter.ts:154). autoIdle fires apply(true)->onStatusChange("idle") and then, on the very first mouse event (unthrottled by design, autoIdle.ts:119-124), apply(false)->onStatusChange("online") milliseconds later. The second call always loses the token, so the local store/localStorage say "online" while the server and every other client keep the user at "idle", with no retry and no user feedback. @@ -3609,7 +3609,7 @@ autoIdle.ts:119-124 if (idleByTimer) { apply(false); ... } **Suggested fix:** In applyPresence (the shared function all three call sites route through), stop dropping the frame silently — on a failed tryConsume, schedule one retry: keep a module-level `let presenceRetry: ReturnType | null`, and in the else branch clearTimeout(presenceRetry) then presenceRetry = setTimeout(() => applyPresence(loadUserStatus()), limiters.presence.getRemainingMs()); clear it in the page teardown next to autoIdle.destroy(). Coalescing on the latest status means a burst still costs one frame, and restoreSavedPresence should call applyPresence rather than duplicating the same unguarded shape. -**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass +**Fixed:** `7be9ccd2` · test `Client/tests/unit/main-page.test.ts` · revert-proof pass ### OC-0112 — medium — handleServeFile's admin bypass covers the DM branch, so an ADMINISTRATOR can download attachments from private DMs they are not a participant in @@ -3637,7 +3637,7 @@ vs Server/service/message_query.go:25-30 (no admin arm) ### OC-0113 — medium — Selecting "Default" microphone never changes the capture device — the mute/unmute cycle re-acquires nothing -`Client/tauri-client/src/lib/deviceManager.ts:71` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` +`Client/src/lib/deviceManager.ts:71` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` `cycleMicForDeviceSwitch` is the entire implementation of "switch back to the default input device", but it only calls `setMicrophoneEnabled(false)` then `(true)`. On an already-published mic that is livekit's `track.mute()` → `track.unmute()`, and with the default `stopMicTrackOnMute: false` neither call touches the device: `mute()` skips the `_mediaStreamTrack.stop()` branch, and `unmute()` only restarts when `stopOnMute || readyState === 'ended' || pendingDeviceChange`. Nothing ever clears `Room.options.audioCaptureDefaults.deviceId` or `LocalAudioTrack._constraints.deviceId`, which a previous `switchActiveDevice('audioinput', id)` left as `{exact: }`. So the non-empty branch (`room.switchActiveDevice`) really switches, and the empty/"Default" branch silently does not. @@ -3667,13 +3667,13 @@ livekitSession.ts:1119-1122 on (re)connect the saved input is only applied when **Suggested fix:** Fix it once in the shared helper rather than in both callers: make cycleMicForDeviceSwitch reset the pinned constraint before the cycle by adding `await room.switchActiveDevice("audioinput", "default", false);` as its first statement (exact=false yields a plain 'default' string, i.e. an `ideal` constraint, matching livekit's own audioDefaults `{ideal:'default'}`, so it degrades gracefully where no 'default' id exists). That clears room.options.audioCaptureDefaults.deviceId and drives setDeviceId -> restartTrack on the live publication, so both switchInputDevice('') and the hot-swap fallback actually reach the default device; the existing mute/PTT gate at deviceManager.ts:74 and its two tests stay untouched. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/device-manager.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/device-manager.test.ts` · revert-proof self-reported ### OC-0114 — medium — In a group DM, one participant's decline silences every other participant's incoming ring — and never reaches the actual caller `Server/ws/handlers_call.go:84` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-1` -handleCallDeclineV2 addresses call_declined to every OTHER participant of the DM (RingTargets excludes only the sender), so fellow callees receive it alongside the ringer. The client's handler `ws.on("call_declined", p => ringCtrl?.cancel(p.channel_id))` (Client/tauri-client/src/pages/MainPage.ts:579-581) cancels purely on channel_id and ignores `from_user`, so it cannot tell "the caller hung up" from "another callee declined". Meanwhile the caller itself never received call_incoming (RingTargets excludes the sender at handlers_call.go:52 / dm.go:377-380), so `ringCtrl.current()` is null on the caller and cancel() is a no-op there — the decline's stated purpose ("so a ringing client can stop ringing before the 30s timeout") is unreachable, and its only observable effect is to kill the other callees' rings. +handleCallDeclineV2 addresses call_declined to every OTHER participant of the DM (RingTargets excludes only the sender), so fellow callees receive it alongside the ringer. The client's handler `ws.on("call_declined", p => ringCtrl?.cancel(p.channel_id))` (Client/src/pages/MainPage.ts:579-581) cancels purely on channel_id and ignores `from_user`, so it cannot tell "the caller hung up" from "another callee declined". Meanwhile the caller itself never received call_incoming (RingTargets excludes the sender at handlers_call.go:52 / dm.go:377-380), so `ringCtrl.current()` is null on the caller and cancel() is a no-op there — the decline's stated purpose ("so a ringing client can stop ringing before the 30s timeout") is unreachable, and its only observable effect is to kill the other callees' rings. **Repro:** Group DM C with A, B, D. A opens C and clicks the call button → voice_join(C) + call_ring(C). Server sends call_incoming{channel_id:C, from_user:A} to B and D; both banners appear and both chimes start. B clicks Decline → call_decline{channel_id:C}. Server RingTargets(B,C)=[A,D] and sends call_declined{channel_id:C, from_user:B} to A and D. D's ring state is {channelId:C, fromUserId:A}, so cancel(C) matches → D's banner is torn down and the chime stops; D can no longer answer a call A is still sitting in. A, having no ring state, ignores the frame entirely — so the one client the decline was meant for is the one it does nothing to. @@ -3692,11 +3692,11 @@ The payload carries `from_user` (messages.go:310-314, types.ts:571-575) but the **Suggested fix:** Guard the client handler by ringer identity instead of channel alone: in MainPage.ts:579-581, `const r = ringCtrl?.current(); if (r && payload.from_user === r.fromUserId) ringCtrl?.cancel(payload.channel_id);`. That keeps the legitimate glare case (both users ringing each other, the peer declines) working and stops a fellow callee's decline from cancelling an unrelated ring. Server fan-out can stay as-is — it has no call state to target with. -**Fixed:** `db0275a2` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass +**Fixed:** `db0275a2` · test `Client/tests/unit/main-page.test.ts` · revert-proof pass ### OC-0115 — medium — Link-preview fetch buffers the whole response body with no timeout and no size cap — the documented 50 KB bound is applied after the bytes are already in memory -`Client/tauri-client/src/components/message-list/embeds.ts:202` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` +`Client/src/components/message-list/embeds.ts:202` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-2` The 5 s AbortController timer is cleared at line 182, the moment response *headers* arrive, so `await res.text()` at line 202 runs with the abort signal already disarmed and reads the body to completion before `html.slice(0, 50_000)` is applied. The slice is therefore not a memory bound at all (the comment at line 49 and line 203 both claim it is), and there is no timeout covering the body phase. @@ -3712,11 +3712,11 @@ const meta = parseOgTags(html.slice(0, 50_000)); // "memory bound" applied too **Suggested fix:** Move the disarm past the body read instead of adding a second timer: drop `clearTimeout(timer)` at line 182 and wrap the response handling so it runs after `const html = await res.text();` (or put `clearTimeout(timer)` in a `finally` on the async IIFE). That makes the existing 5 s AbortController cover the body phase, and the stream's abort listener errors res.text() and drops the body on the Rust side. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/embeds.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/embeds.test.ts` · revert-proof self-reported ### OC-0116 — medium — A failed TOTP verify tears down the TOTP overlay, so the code cannot be re-entered -`Client/tauri-client/src/pages/connect-page/LoginForm.ts:667` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-3` +`Client/src/pages/connect-page/LoginForm.ts:667` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-3` handleTotpSubmit's catch calls transitionTo("error", …). transitionTo runs updateTotpOverlay(), which adds `totp-overlay--hidden` for every state that is not "totp". A rejected verification therefore closes the code prompt and drops the user back on the login form; there is no showTotp() call on any error path, so the second factor is unreachable without re-submitting the password. @@ -3739,11 +3739,11 @@ The `finally` re-enables the Verify button on an overlay that is already hidden. **Suggested fix:** Keep the overlay up while a challenge is outstanding instead of tying it to formState alone: add a `totpPending` flag set in showTotp() and cleared in handleTotpCancel() (and on successful auth), then change updateTotpOverlay's condition to `if (formState === "totp" || (formState === "error" && totpPending))`, and only clear/focus totpInput on the fresh `formState === "totp"` entry so a retry keeps its context. This is the single shared point both the click and Enter paths route through. Pair it with retaining pendingTotpPartialToken in main.ts (move the clear out of `finally` into the success branch plus cancel), otherwise the re-opened prompt hits the empty-token guard at main.ts:520. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/login-form-totp-retry.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/login-form-totp-retry.test.ts` · revert-proof pass ### OC-0117 — medium — Channel create/edit/delete modals lock up permanently on an API failure — the caller swallows the rejection the modal needs to re-enable its button -`Client/tauri-client/src/pages/main-page/SidebarArea.ts:280` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-3` +`Client/src/pages/main-page/SidebarArea.ts:280` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `explore-3` CreateChannelModal (and EditChannelModal/DeleteChannelModal) disable their submit button, relabel it "Creating…"/"Saving…"/"Deleting…", and rely on `await onCreate(...)` REJECTING to restore the button and render the inline error. SidebarArea's callbacks catch the API error themselves and only show a toast, so the promise resolves normally: the modal's own catch never runs, the button stays disabled with the in-flight label forever, and the modal's `errorEl` (data-testid="channel-create-error") is dead code on every real code path. The user cannot retry after a recoverable error (duplicate name, 403, transient network) without cancelling and losing everything typed. @@ -3769,33 +3769,33 @@ tests/unit/sidebar-area.test.ts:1761 mocks createCreateChannelModal, so nothing **Suggested fix:** Preserve the modals' reject-to-recover contract in the shared caller: in SidebarArea.ts's three catch blocks, keep the toast and add `throw err;` (or drop the try/catch entirely and let the modal render the inline error). One line per callback; the six sidebar-area.test.ts cases that `await modalCallArgs.onCreate/onSave/onConfirm(...)` must then be updated to `await expect(...).rejects.toThrow(...)`. Restoring the button in a `finally` inside each modal is the alternative but leaves errorEl dead code and touches three files instead of one. -**Fixed:** `b1fb565` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof self-reported +**Fixed:** `b1fb565` · test `Client/tests/unit/sidebar-area.test.ts` · revert-proof self-reported ### OC-0118 — low — Identity-keyring account namespace collides between a legacy host-only entry and a scoped host+userId entry, letting one server's voice identity private key be adopted on another server -`Client/tauri-client/src/lib/identity.ts:211` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `voice-e2ee` +`Client/src/lib/identity.ts:211` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `voice-e2ee` The B3-3 per-user scope is folded into the single opaque `host` field as `${host}:${userId}`, and the Rust side turns that into the keyring account `identity:{host}`. A host that carries an explicit port therefore produces exactly the same account string as the pre-B3-3 legacy (host-only) entry of a different server: `identityScopeKey("chat.example", 8443)` -> account `identity:chat.example:8443`, which is byte-identical to `identity_account("chat.example:8443")`. `loadOrGenerateIdentityKeyPair` reads that account directly, so it silently adopts the other server's long-term identity private key instead of minting a fresh one — and `ensureIdentityKeyPublished` then publishes that key's public half to the second server, linking the two identities. Host scoping exists precisely to prevent one host's identity key ever being used on another. **Repro:** Same desktop install. (1) Sign in to a self-hosted server reachable as `chat.example:8443` on a build that predates the host+user scoping — the identity private key is stored under keyring account `identity:chat.example:8443`. (2) Sign in to a different server reachable as `chat.example` (port 443) as the user whose id is 8443. `loadOrGenerateIdentityKeyPair("chat.example", 8443)` computes scope `chat.example:8443`, `loadIdentityKey` reads account `identity:chat.example:8443`, `importIdentityKeyPair` succeeds on the first server's blob, and the client signs its voice announces on `chat.example` with `chat.example:8443`'s identity key while `ensureIdentityKeyPublished` PATCHes that public key onto `chat.example`. -**Evidence:** Client/tauri-client/src/lib/identity.ts:211-213 +**Evidence:** Client/src/lib/identity.ts:211-213 function identityScopeKey(host: string, userId: number): string { return `${host}:${userId}`; } -Client/tauri-client/src/lib/identity.ts:298-299 +Client/src/lib/identity.ts:298-299 const scope = identityScopeKey(host, userId); const stored = await loadIdentityKey(scope); // -> invoke("load_identity_key", { host: scope }) -Client/tauri-client/src-tauri/src/credentials.rs:46-48 +Client/src-tauri/src/credentials.rs:46-48 fn identity_account(host: &str) -> String { format!("identity:{host}") } -Client/tauri-client/src-tauri/src/credentials.rs:227-232 (load_identity_key -> secret_store::get(&app, &identity_account(&host))) +Client/src-tauri/src/credentials.rs:227-232 (load_identity_key -> secret_store::get(&app, &identity_account(&host))) **Suggested fix:** One line in identityScopeKey (identity.ts:211-213): use a delimiter that cannot appear in a valid host, e.g. `return `${userId}@${host}`;` (isValidHost forbids '@' and '/'), which makes a scoped key structurally unable to equal any legacy host-only account. Existing scoped accounts re-mint once — the same one-time re-verify the B3-3 comment at 187-189 already accepts; add a scope-to-scope migration only if that churn matters. -**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/identity.test.ts` · revert-proof pass +**Fixed:** `8787b906` · test `Client/tests/unit/identity.test.ts` · revert-proof pass ### OC-0119 — low — Group-DM creation only block-checks the creator against each recipient, so a third party can force two users who blocked each other into a shared room @@ -3854,7 +3854,7 @@ service/message_query.go:151-153 (GetMessagesAround) ### OC-0121 — low — Message jumps and permalinks into a DM fail with "That channel isn't available" until the DM has been opened once this session -`Client/tauri-client/src/lib/channel-navigation.ts:36` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` +`Client/src/lib/channel-navigation.ts:36` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` `findChannelById` and `navigateToChannel` gate on `channelsStore.channels`, but a DM's row in that store is *synthesized on open* by `addDmToChannelsStore` — the `ready` payload never carries DM rows, and dmStore is the client's only record of DM membership until the user clicks the conversation. Every jump affordance (global search hit, `owncord://message/...` permalink, pinned/reply jump) routes through `MessageJump.jumpTo`, whose very first check is `findChannelById(channelId) === null`, so a legitimate, visible DM is rejected as invisible. @@ -3885,11 +3885,11 @@ Server/service/message_perms.go:42-46 (search covers DMs) **Suggested fix:** Make the shared resolver DM-aware instead of patching each affordance: in channel-navigation.ts, when channelsStore has no row for channelId, look the id up in dmStore — findChannelById returns { id, name: dmDisplayName(dm) }, and navigateToChannel calls addDmToChannelsStore(dm) before setActiveChannel so ChannelController can resolve it (dispatcher.ts:449 already crosses the same lib->pages import boundary for exactly this reason). -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/channel-navigation.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/channel-navigation.test.ts` · revert-proof self-reported ### OC-0122 — low — dm_channel_close fallback activates a DM without clearing its dmStore unread badge, leaving a permanent phantom badge on the conversation being read -`Client/tauri-client/src/lib/dispatcher.ts:450` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` +`Client/src/lib/dispatcher.ts:450` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` Every other "open this DM" path pairs activation with `clearDmUnread` (`selectDmConversation`, `navigateToChannel`, `markChannelRead`), because a DM's badge lives in dmStore while `setActiveChannel` only clears the channelsStore mirror. The dispatcher's close-fallback calls bare `setActiveChannel`, so dmStore's `unreadCount`/`mentionCount` survive — and because the dispatcher then treats that DM as the active one (`isDmActive` → `updateDmLastMessagePreview`, no increment), the stale count is frozen and never clears for the rest of the session. @@ -3912,11 +3912,11 @@ dispatcher.ts:502-518 (why the count then freezes) **Suggested fix:** Replace the two lines with `addDmToChannelsStore(remaining[0]!); navigateToChannel(remaining[0]!.channelId);` — navigateToChannel is already the shared 'open this channel' entry point and does setActiveChannel + clearUnread + clearDmUnread in one call (a bare `clearDmUnread(remaining[0]!.channelId)` before line 450 is the even smaller equivalent). -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof self-reported ### OC-0123 — low — markAllRead's paced tail marks messages that arrived after the click as read, destroying a genuinely-new unread badge -`Client/tauri-client/src/lib/read-state.ts:118` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` +`Client/src/lib/read-state.ts:118` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `client-state` `markAllRead` snapshots the unread set at click time but defers most of the sends by up to `ceil(n/4) * 1100 ms`. Each deferred timer calls `markChannelRead(id)` unconditionally, which both sends `mark_read` (advancing the server's read state to the channel's current tail) and calls `clearUnread`/`clearDmUnread` locally. A message that arrives in one of those channels during the pacing window is therefore marked read and its badge wiped, even though the user never saw it and it postdates the action they took. @@ -3944,11 +3944,11 @@ read-state.ts:103-105 (the stated intent this breaks) **Suggested fix:** In markAllRead, snapshot each id's current lastMessageId alongside the id, and in the deferred callback skip markChannelRead when the channel's lastMessageId has moved (the new message is genuinely unread and keeping its badge is the safer side of the tradeoff). ~4 lines, entirely inside markAllRead; do not change markChannelRead, whose synchronous callers want the unconditional advance. -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/read-state.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/read-state.test.ts` · revert-proof self-reported ### OC-0124 — low — hidePreview overwrites the pending animation timer without clearing it, orphaning timers that later tear down a freshly reopened preview and strand its media-track listeners -`Client/tauri-client/src/lib/streamPreview.ts:273` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `concurrency` +`Client/src/lib/streamPreview.ts:273` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `concurrency` `hidePreview` clears only `state.debounce` (line 249) and then blindly assigns `state.animation = animTimer` (line 273). The `animation` slot may already hold a live timer — either `stopPreviewDelayed`'s 150 ms grace timer (line 315) or a prior `hidePreview`'s 200 ms removal timer — and that timer is never cancelled, so it keeps running with no handle. When an orphaned removal timer fires it runs `previewTimers.delete(row)` (line 269) against whatever state a *later* hover installed, which both destroys that state's `trackCleanup` handle and makes the abort handler's `clearPreviewState` (lines 79-80) a no-op for the row. @@ -3979,11 +3979,11 @@ Both other entry points call hidePreview directly without touching state.animati **Suggested fix:** In hidePreview, cancel the pending animation timer before overwriting it: add `clearTimeout(state.animation);` immediately after `clearTimeout(state.debounce);` at line 249. One line in the shared function covers every entry path (scroll collapse, focusout, delayed stop, preview mouseleave), guarantees at most one live animation timer per state, and keeps that timer cancellable by clearPreviewState/startPreview. clearTimeout(0) on the initial value is a harmless no-op. -**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/stream-preview.test.ts` · revert-proof self-reported +**Fixed:** `c3837fa` · test `Client/tests/unit/stream-preview.test.ts` · revert-proof self-reported ### OC-0125 — low — Voice & Audio settings tab registers a new permanent abort listener on the overlay-lifetime AbortSignal on every build, pinning every previously built tab -`Client/tauri-client/src/components/settings/VoiceAudioTab.ts:490` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `lifecycle` +`Client/src/components/settings/VoiceAudioTab.ts:490` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `lifecycle` buildVoiceAudioTabInner adds an "abort" listener to the SettingsOverlay-lifetime AbortSignal each time the tab is (re)built, with no {once}, no removeEventListener, and no dedupe. The signal only fires when the whole overlay is destroyed, so the listeners accumulate for the MainPage session and each closure retains that build's entire DOM subtree (selects, sliders, toggles, preview