From 5202e3fe1e8aa392f5ec26f7de8f546555418a67 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:45:30 +0200 Subject: [PATCH] fix: correctness fixes from the 2026-08-20 bug hunt (#1398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(identity): 2 defect(s) (OC-0192, OC-0197) OC-0192: bound raw display_name/about/avatar bytes before the quadratic fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile. OC-0197: sanitize display_name before validateDisplayName so an HTML-entity-encoded bidi override (e.g. "‮") can no longer pass validation as ASCII and be decoded into the real character on the way to storage. * fix(ws): 1 defect(s) (OC-0196) A transient DB error during WebSocket auth (session or user lookup) was collapsed into the terminal auth_error frame, which the client treats as non-recoverable: it stops reconnecting and clears stored credentials. A sub-second SQLite hiccup therefore force-logged-out every reconnecting client with a perfectly valid session. Send a non-terminal INTERNAL error frame instead so normal backoff/reconnect retries. * fix(api): 1 defect(s) (OC-0198) * fix(ws): 1 defect(s) (OC-0200) normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's normalization order. Without the unwrap, every cert-tofu host equality guard took the "unrelated host" branch for bracketed-IPv6 servers. * fix(api): 1 defect(s) (OC-0202) * fix(admin): 1 defect(s) (OC-0203) Channel permission override handlers applied requireGrantableOverride only to the bits being written, so an all-zero PUT or a DELETE could clear a deny bit the actor's own role does not hold — EffectivePerms = (rolePerm &^ deny) | allow makes removing a deny an escalation. Both the role-layer and per-user handlers now check the guard against the bits already on the row. * fix(client): 1 defect(s) (OC-0205) * fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235) * fix(client): 1 defect(s) (OC-0208) * fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213) OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce runs, so the replay cannot overwrite the peer's displayed verification status/session fingerprint with the retired key's before being rejected. OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it after a successful rePinPeerIdentity, so re-pinning actually restores the peer for the live call instead of clearing the badge and leaving them un-keyed (a mid-call peer never re-announces on its own). OC-0213: skip retiring a departing peer's key when the local voice roster still lists them as present — a rejoin announce published straight into the send queue can overtake the buffered, stale voice_leave, and retiring a still-live key would reject every later genuine re-announce as a replay. * fix(ws): 1 defect(s) (OC-0211) * fix(identity): 1 defect(s) (OC-0214) The delete-account admin guard counted remaining admins with a raw `banned = 0` filter, so an admin whose temporary ban had already lapsed was treated as unusable. Use the shared notBannedClause, appended outside the Sprintf format string because its strftime verbs (%Y, %H) would otherwise be parsed as fmt directives. * fix(client): 1 defect(s) (OC-0215) * fix(voice): 1 defect(s) (OC-0216) * fix(client): 1 defect(s) (OC-0217) * fix(voice): 1 defect(s) (OC-0219) rollbackVoiceJoin cleared the client's in-memory voiceChID but left its VoiceTopic subscription in place, so a socket whose join failed after voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching every other path that takes a client out of voice while its WS stays up. * fix(client): 2 defect(s) (OC-0220, OC-0224) dmDisplayName: a group DM whose other members have all left keeps a live is_group row, but the server leaves `recipient` zero-valued, so the empty username fell through as a blank label. Fall back to a non-empty placeholder. updateDmLastMessage: a queued chat_message redelivered for an id already reflected in the `ready` snapshot double-counted the unread badge. Only increment when the message id advances past lastMessageId. * fix(client): 1 defect(s) (OC-0221) Cap queued attachments at the server's 10-attachment limit in the message composer. Past that the server rejects the whole chat_send frame as a generic parse error, orphaning already-uploaded attachments; refusing before the upload starts keeps composer state and the send in sync. * fix(ws): 1 defect(s) (OC-0222) handleReconnect built the resume auth_ok before applyConnectStatus settled c.user.Status, so a resumed client was told its disconnect-time status (routinely "offline") instead of the status it was coming online as. Move applyConnectStatus ahead of reconnectWriteReplay, matching handleFreshConnect's ordering. * fix(mentions): 1 defect(s) (OC-0223) * fix(admin): 1 defect(s) (OC-0225) * fix(client): 1 defect(s) (OC-0226) * fix(client): 1 defect(s) (OC-0228) * fix(client): 1 defect(s) (OC-0230) Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list. * fix(voice): 1 defect(s) (OC-0231) * fix(client): 1 defect(s) (OC-0232) Reduce Motion toggle wrote the reduced-motion class directly, fighting the OS-sync media-query listener that owns it when Sync with OS is on. Route the side effect through syncOsMotionListener so whichever source owns the class re-derives it. * fix(client): 1 defect(s) (OC-0233) notifyIncomingMessage titled the desktop notification with the raw payload username, so the popup named the sender differently from the message row it points at. Resolve the author the same way the message list does (resolveAuthor over the live membersStore, then resolveDisplayName). * fix(client): 1 defect(s) (OC-0234) * fix(client): 1 defect(s) (OC-0236) * fix(ws): 1 defect(s) (OC-0237) * fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218) * fix(identity): 1 defect(s) (OC-0195) Bound free-text profile fields by raw byte length before cleanText's quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into cleanTextBounded and applying it to HandlePresenceUpdate's custom_status, SetCustomStatus, and group DM names. * fix(dm): 1 defect(s) (OC-0199) handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed. * fix(voice): 1 defect(s) (OC-0206) vad-worklet.js gate timing constants were copied from the setTimeout fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext). The mic gate therefore closed ~6x faster than intended (~32ms of silence instead of ~200ms), with the startup grace and RMS post interval off by the same factor. Scale the frame counts to render quanta. * fix(client): 1 defect(s) (OC-0229) * test(client): assert the real TOFU re-pin outcome and make the pin mock faithful The e2e journey test asserted that "Trust New Key" makes the peer's verify badge disappear. That is the behaviour OC-0212 identifies as the defect: a mid-call peer never re-announces, so clearing the badge left the peer un-keyed for the rest of the call with nothing on screen. Re-pinning now replays the announce that was blocked as a mismatch and re-verifies it against the pin just stored, so assert the peer actually lands verified. The mock's store_identity_pin was a no-op recorder while get_identity_pin served a static seed map, so the replayed announce re-read the stale pin and re-failed — a mismatch the real keyring never produces. Back the pins with a mutable map so a write is visible to the next read. The unreadable-store (DC-08) and reject-keeps-blocked paths are unchanged and still pass. * fix(dm): 1 defect(s) (OC-0194) Add regression tests pinning the raw-byte bound on group DM names, for both CreateGroupDM and RenameGroupDM. The Server/service/dm.go source fix for OC-0194 already landed in bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the guard into cleanTextBounded and applied it to the group DM name paths alongside the profile fields. This commit therefore carries the OC-0194 tests only; dm.go is unchanged. Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the rune-count check) both new tests fail — CreateGroupDM returns "recipient not found" after 222ms and RenameGroupDM accepts the name after 251ms, against a 150ms budget. With the fix in place both pass in 0.03s. * fix(ws): 1 defect(s) (OC-0210) * chore(findings): record the 2026-08-20 hunt's 46 findings as fixed Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each fixed with its commit and the test that pins it. Pre-existing records are byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with these ids. Every fix was independently revert-proofed: the commit's own source diff is reverse-applied, its test must go red, and must return green once restored. 43 of 46 carry revertProof "pass" from that mechanical run. Three could not be checked at file level and were proved by hand at hunk level instead, recorded as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies because the merge kept main's equivalent implementation; OC-0215, whose Rust tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper introduced by an earlier commit. No fix was found to rest on a vacuous test. OC-0200 additionally carries a note: main fixed that same normalizer independently while this branch was in flight, so the branch is no longer the only thing closing it. * docs: record the dm_channel_open emission on 1:1 DM creation POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a channel (it previously emitted nothing on that path), so api.md states it the way the sibling DM endpoints already state theirs. The channels/members/DMs UX spec claimed the server broadcast the event "to both parties" on this flow. That was never true — nothing was broadcast before, and now only the recipient is sent it; the creator learns the channel from the response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts, service/channel.go and dm.go among its sources of truth, all touched here, so it is corrected in the same change per its maintenance rule. --------- Co-authored-by: Claude --- .superpowers/FINDINGS.md | 1315 ++++++++++++++++- .superpowers/findings-ledger.json | 1061 ++++++++++++- Client/tauri-client/public/vad-worklet.js | 15 +- Client/tauri-client/src-tauri/src/tofu.rs | 32 +- .../src/components/ChannelSidebar.ts | 30 +- .../src/components/MessageInput.ts | 13 + .../src/components/MessageList.ts | 24 +- .../tauri-client/src/components/VideoGrid.ts | 18 + .../components/message-list/content-parser.ts | 35 +- .../src/components/message-list/media.ts | 6 +- .../message-list/syntax-highlight.ts | 9 +- .../components/settings/AccessibilityTab.ts | 12 +- .../src/components/settings/LogsTab.ts | 14 +- Client/tauri-client/src/lib/audioPipeline.ts | 7 + Client/tauri-client/src/lib/autoIdle.ts | 13 + Client/tauri-client/src/lib/dispatcher.ts | 111 +- Client/tauri-client/src/lib/livekitE2EE.ts | 92 +- Client/tauri-client/src/lib/mentions.ts | 6 + Client/tauri-client/src/lib/notifications.ts | 31 +- Client/tauri-client/src/pages/MainPage.ts | 78 +- .../src/pages/main-page/ChannelController.ts | 12 + .../pages/main-page/VideoModeController.ts | 22 +- .../src/pages/main-page/VoiceCallbacks.ts | 5 +- Client/tauri-client/src/stores/auth.store.ts | 9 +- .../tauri-client/src/stores/blocks.store.ts | 33 +- .../tauri-client/src/stores/channels.store.ts | 21 +- Client/tauri-client/src/stores/dm.store.ts | 13 +- Client/tauri-client/tests/e2e/helpers.ts | 15 +- .../tests/e2e/voice-e2ee-verify.spec.ts | 12 +- .../tests/unit/AccessibilityTab.test.ts | 85 ++ .../tests/unit/accessibility-tab.test.ts | 11 +- ...udio-pipeline-vad-worklet-teardown.test.ts | 153 ++ .../tests/unit/auth.store.test.ts | 21 + .../tauri-client/tests/unit/auto-idle.test.ts | 58 + .../tests/unit/blocks-store.test.ts | 43 + .../tests/unit/channel-controller.test.ts | 10 + .../tests/unit/channel-sidebar.test.ts | 89 ++ .../tests/unit/content-markdown.test.ts | 11 + .../tests/unit/dispatcher.test.ts | 219 ++- .../tauri-client/tests/unit/dm-groups.test.ts | 17 + .../tauri-client/tests/unit/dm-store.test.ts | 22 + .../tests/unit/livekit-e2ee.test.ts | 131 ++ .../tauri-client/tests/unit/logs-tab.test.ts | 41 + .../tauri-client/tests/unit/main-page.test.ts | 267 +++- Client/tauri-client/tests/unit/media.test.ts | 20 + .../tests/unit/mentions-render.test.ts | 11 + .../tests/unit/message-input.test.ts | 30 + .../tests/unit/message-list.test.ts | 25 + .../tests/unit/notifications.test.ts | 113 ++ .../tests/unit/vad-worklet-timing.test.ts | 152 ++ .../tests/unit/voice-callbacks.test.ts | 21 + Server/admin/handlers_channel_perms.go | 52 +- Server/admin/handlers_channel_perms_test.go | 88 ++ .../admin/handlers_channel_user_perms_test.go | 69 + Server/admin/middleware.go | 14 +- Server/admin/middleware_db_error_test.go | 66 + Server/api/dm_handler.go | 29 +- Server/api/dm_handler_block_context_test.go | 88 ++ Server/api/dm_handler_create_notify_test.go | 72 + Server/api/dm_handler_test.go | 16 + Server/api/middleware.go | 19 +- Server/api/middleware_test.go | 37 + Server/api/profile_handler.go | 215 +-- Server/api/profile_handler_test.go | 75 + Server/api/upload_handler_test.go | 16 + Server/db/account.go | 10 +- Server/db/account_test.go | 27 + Server/main.go | 118 +- Server/main_test.go | 171 +++ Server/service/channel.go | 12 +- Server/service/dm.go | 17 +- Server/service/dm_test.go | 65 + Server/service/mentions.go | 11 +- Server/service/mentions_test.go | 31 + Server/service/message.go | 20 + Server/service/profile_fields_test.go | 81 + Server/service/user.go | 50 +- Server/ws/handlers.go | 13 +- Server/ws/handlers_chat.go | 10 +- Server/ws/hub.go | 5 + .../ws/oc_0211_session_recheck_dberr_test.go | 61 + ...19_voice_join_rollback_unsubscribe_test.go | 100 ++ .../ws/oc_0222_reconnect_status_order_test.go | 145 ++ .../ws/oc_0237_service_error_internal_test.go | 64 + Server/ws/ringbuffer.go | 10 + Server/ws/serve.go | 11 +- Server/ws/serve_auth.go | 26 +- Server/ws/voice_join.go | 11 +- Server/ws/ws_integration_test.go | 83 ++ docs/api.md | 5 + docs/architecture/ux/channels-members-dms.md | 2 +- 91 files changed, 6345 insertions(+), 284 deletions(-) create mode 100644 Client/tauri-client/tests/unit/AccessibilityTab.test.ts create mode 100644 Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts create mode 100644 Client/tauri-client/tests/unit/vad-worklet-timing.test.ts create mode 100644 Server/admin/middleware_db_error_test.go create mode 100644 Server/api/dm_handler_block_context_test.go create mode 100644 Server/api/dm_handler_create_notify_test.go create mode 100644 Server/ws/oc_0211_session_recheck_dberr_test.go create mode 100644 Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go create mode 100644 Server/ws/oc_0222_reconnect_status_order_test.go create mode 100644 Server/ws/oc_0237_service_error_internal_test.go diff --git a/.superpowers/FINDINGS.md b/.superpowers/FINDINGS.md index fdd1fb47..0d048043 100644 --- a/.superpowers/FINDINGS.md +++ b/.superpowers/FINDINGS.md @@ -2,7 +2,7 @@ Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`. -**0 open** · 0 blocked · 188 fixed · 2 declined · 0 refuted · 1 duplicate +**0 open** · 0 blocked · 234 fixed · 2 declined · 0 refuted · 1 duplicate ## Fixed @@ -4594,6 +4594,1319 @@ CreateChannelModal.ts:163 createBtn.setAttribute("disabled", "true"); **Fixed:** `a84e2f5a` · test `Client/tauri-client/tests/unit/a11y.test.ts` · revert-proof pass +### OC-0192 — high — PATCH /users/me runs the quadratic fixpoint sanitizer on avatar/display_name/about with no raw-length bound — one 1 MiB request burns ~11 minutes of CPU + +`Server/api/profile_handler.go:220` · found 2026-08-20 · hunt `2026-08-20-general` · lens `api-authz` + +The handler explicitly bounds `req.Username` at `maxLoginUsernameLen*4` before handing it to `service.SanitizeText` (OC-0151: "sanitizeToFixpoint's cost is quadratic in input length, and nothing bounds this field before it runs"), but its three sibling fields in the same handler reach the same sanitizer with no bound at all: `req.Avatar` is sanitized at line 220 and only length-checked afterwards by `validateAvatarURL` (maxAvatarURLLen=512), and `display_name`/`about` are passed through to `UserService.UpdateProfile`, which calls `cleanText` (= `sanitizeToFixpoint`) at user.go:140/143 *before* the MaxDisplayNameLen / MaxAboutLen checks. `sanitizeToFixpoint` loops `len(raw)+1` times, and each pass is `html.UnescapeString(bluemonday.Sanitize(html.UnescapeString(s)))`; bluemonday re-escapes `&` on every pass, so a nested-entity payload peels exactly one level per pass and the whole thing is O(n^2) over the request body. + +**Repro:** Measured on this repo (temporary test through service.SanitizeText, since removed) with payload `"&" + strings.Repeat("amp;", k)`: 2 KB -> 4.5 ms, 4 KB -> 12 ms, 8 KB -> 43 ms, 16 KB -> 168 ms — clean quadratic. PATCH /api/v1/users/me is NOT in `bodyCapExemptPrefixes`, so the body cap is defaultMaxBodySize = 1 MiB. Extrapolating the measured curve, `PATCH /api/v1/users/me` with `{"username":"bob","avatar":"&ampamp…;"}` where the avatar string is ~1 MiB of nested `amp;` levels costs ~690 s (~11.5 min) of one core before `validateAvatarURL` ever sees the string and rejects it as >512 chars. `display_name` and `about` take the same path via `cleanText`. The route's only limiter is per-IP `"profile:"` at 10/min, so one authenticated user can keep ~10 cores saturated indefinitely; the request is answered with a 400 either way, so nothing in the logs attributes the load. + +**Evidence:** // profile_handler.go +185: if len(req.Username) > maxLoginUsernameLen*4 { // <- the guard, username only +... +219: if req.Avatar != nil { +220: trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar)) // <- unbounded +221: if err := validateAvatarURL(trimmed); err != nil { // len check AFTER +... +234: if req.DisplayName != nil { +235: if err := validateDisplayName(*req.DisplayName); err != nil { // char check only, no length +... +256: updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{ +257: Username: req.Username, Avatar: req.Avatar, +259: DisplayName: req.DisplayName, About: req.About, + +// service/user.go +140: if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen { +143: if patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen { + +// service/message.go +195:func sanitizeToFixpoint(raw string) string { +197: for i := 0; i <= len(raw); i++ { +198: next := sanitizePass(s) + +**Suggested fix:** Bound the raw bytes before they reach the fixpoint sanitizer, at the two chokepoints rather than per field: (1) in handleUpdateProfile, before line 220, `if req.Avatar != nil && len(*req.Avatar) > maxAvatarURLLen*4 { 400 }`; (2) in UserService.UpdateProfile, before the cleanText calls at user.go:140/143, reject on raw byte length — `if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 { ErrBadRequest }` and the same for About with MaxAboutLen*4 — which also covers any non-REST caller. Same shape as the existing guard at profile_handler.go:185 and service/message.go:220. + +**Fixed:** `c688fc20eec6379545be2c1bc8ce1ecd946d8bb0` · test `Server/api/profile_handler_test.go (TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing); Server/service/profile_fields_test.go (TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing)` · revert-proof pass + +### OC-0193 — high — A refused voice-channel *switch* nulls currentChannelId while the old LiveKit session is still live — widget disappears, mic stays published, and the recovery click re-triggers the same clear + +`Client/tauri-client/src/lib/dispatcher.ts:1117` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-voice` + +The catch-all error handler rolls back an optimistic join with `if (voiceStore.getState().voiceStatus === "joining") leaveVoiceChannel();`. Its stated invariant — "An already-established session is never in 'joining', so this never touches a live voice call" — is false during a channel switch: `VoiceCallbacks.onVoiceJoin` calls `joinVoiceChannel(newCh)`, which sets `currentChannelId=newCh, voiceStatus="joining"` while `LiveKitSession._state` is still `connected(oldCh)` with the mic track published. `leaveVoiceChannel()` is store-only — it never calls `LiveKitSession.leaveVoice()` — so the store and the live media session desynchronise. + +**Repro:** User is connected to voice channel A (voiceStatus "connected", mic published). They click voice channel B in the sidebar, where B is refused at precheck — e.g. B has a channel_overrides deny on CONNECT_VOICE (FORBIDDEN), B was just archived (BAD_REQUEST), or the user tripped voiceJoinRateLimit=5/s by clicking several voice channels quickly (RATE_LIMITED). +1. onVoiceJoin(B): store -> currentChannelId=B, voiceStatus="joining"; LiveKitSession still connected(A). +2. Server refuses in voiceJoinPrecheck. No voice_leave is broadcast; the user remains in A in voice_states and on the SFU. +3. dispatcher.ts:1117 sees voiceStatus==="joining" -> leaveVoiceChannel() -> currentChannelId=null. +4. VoiceWidget.render hides the whole widget (no leave/mute button). LiveKitSession._state is still connected(A): the mic track is still published and audio still flows to every peer in A, and every other client still shows the user in A. +5. Recovery attempt: the user clicks channel A again -> joinVoiceChannel(A) (prev is null, so it sets voiceStatus="joining") -> voice_join A -> server answers ALREADY_JOINED (voice_join.go voiceJoinLeaveCurrent, currentChID==channelID) -> the same guard fires and hides the widget again. The user cannot leave the call from the UI at all; only an app restart or WS drop ends it. +The existing test that supposedly covers this (tests/unit/dispatcher.test.ts:3770 "does not touch an already-established voice session on CHANNEL_FULL") seeds voiceStatus="connected", a state a switch never passes through, so nothing locks the real behaviour. + +**Evidence:** dispatcher.ts:1117-1119 + if (voiceStore.getState().voiceStatus === "joining") { + leaveVoiceChannel(); + } + +VoiceCallbacks.ts:179-184 onVoiceJoin: joinVoiceChannel(channelId); ws.send({type:"voice_join",...}) +voice.store.ts:300-315 joinVoiceChannel: currentChannelId=channelId, voiceStatus="joining" (only short-circuits when prev.currentChannelId === channelId) +voice.store.ts:319-330 leaveVoiceChannel: currentChannelId=null, voiceStatus="idle" (no LiveKit teardown) +VoiceWidget.ts:236-242 if (channelId === null) { root.classList.remove("visible"); return; } -> no disconnect/mute buttons at all +Server/ws/voice_join.go:57-65 voiceJoinPrecheck (RATE_LIMITED / FORBIDDEN / NOT_FOUND / BAD_REQUEST-archived / VOICE_ERROR) runs BEFORE voiceJoinLeaveCurrent, so a precheck refusal emits no voice_leave at all and the user stays in the old channel server-side. + +**Suggested fix:** Make the rollback tear down the media session, not just the store, in the one shared guard at dispatcher.ts:1117: `if (voiceStore.getState().voiceStatus === "joining") { void livekitSession().then(({ isVoiceConnected, leaveVoice }) => { if (isVoiceConnected()) leaveVoice(true); }); leaveVoiceChannel(); }` — isVoiceConnected is already exported (livekitSession.ts:1803) and leaveVoice(true) both disconnects room A and sends voice_leave so the server/SFU state matches the cleared store. A first-time-join refusal has no live session, so isVoiceConnected() is false and behavior there is unchanged (the existing tests at dispatcher.test.ts:3757/3789/3798 stay green). + +**Fixed:** `4bab1b4b4b8b74baac7a3eb153dd313bb1d02932` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass + +### OC-0194 — high — Group-DM create/rename feed the quadratic fixpoint sanitizer an unbounded body field, on the only REST route group with no rate limiter + +`Server/service/dm.go:234` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-api` + +`cleanText` -> `sanitizeToFixpoint` costs roughly O(entity-nesting-depth x length); every other caller of that pipeline bounds its raw input first (`sanitizeContent` at `maxMessageLen*4`, the register path and PATCH /users/me at `maxLoginUsernameLen*4`). `CreateGroupDM` and `RenameGroupDM` do not: they run it on the raw `name` from the JSON body, bounded only by the global 1 MiB cap, and `MountDMRoutes` attaches no `RateLimitMiddleware` at all — so unlike the already-confirmed profile-handler instance (which at least sits behind `profile:` at `profileUpdateRateLimitPerMinute`), these two can be issued back-to-back. In `CreateGroupDM` the sanitizer also runs *before* the recipient-existence, ban and block checks, so the CPU is spent even when the request is going to 404. + +**Repro:** As any authenticated user: POST /api/v1/dms/group with body {"recipient_ids":[999999,999998],"name":"&"+"amp;"*200000+"lt;"} (~1 MiB, well under the 1 MiB MaxBodySizeUnless cap). handleCreateGroupDM decodes it and calls CreateGroupDM: the dedup loop passes (two distinct positive ids), len(unique)==2 satisfies both the >=2 and <=MaxGroupDMParticipants checks, and control reaches `cleanName := cleanText(name)` at dm.go:234. sanitizeToFixpoint peels roughly one entity layer per pass over a ~1 MiB string, ~2x10^5 passes, ~10^11 bytes of work — minutes of pinned CPU — before `GetUserByID(999999)` is ever called and the request 404s. /api/v1/dms has no rate limiter, so N concurrent requests pin N cores; the server's SQLite writer and every other request path starve. PATCH /api/v1/dms/{channelId} with the same body reaches the identical call at dm.go:323 (after only an IsDMParticipant + IsGroupDM check). + +**Evidence:** dm.go:234 `cleanName := cleanText(name)` and dm.go:323 `cleanName := cleanText(name)`; user.go:113 `func cleanText(v string) string { return strings.TrimSpace(sanitizeToFixpoint(v)) }`; message.go:220 shows the bound the sibling path has (`if len(raw) > maxMessageLen*4 { return "", ... }`) before `sanitizeToFixpoint(raw)`; api/dm_handler.go:72 `r.Route("/api/v1/dms", func(r chi.Router) { r.Use(AuthMiddleware(database)); r.Post("/", ...); r.Post("/group", ...); r.Patch("/{channelId}", ...) })` — no RateLimitMiddleware. + +**Suggested fix:** Bound the raw bytes before sanitizing, exactly as OC-0151 did. Smallest shared form: add a bounded helper next to cleanText in Server/service/user.go, e.g. `func cleanTextBounded(v string, maxRunes int) (string, bool) { if len(v) > maxRunes*4 { return "", false }; return cleanText(v), true }`, then use it at dm.go:234 and dm.go:323 with MaxGroupDMNameLen (returning the existing `%w: name must be at most %d characters` ErrBadRequest on the false branch), and at the custom-status sites (service/channel.go:182, service/user.go:202) with MaxCustomStatusLen. *4 still admits any legitimate 100-rune UTF-8 name, so no valid input changes behavior. Separately, MountDMRoutes should carry a RateLimitMiddleware like its sibling route groups, but that is defense in depth, not the fix. + +**Fixed:** `fba75319a419477890944bd0218573cfb5d6333c` · test `Server/service/dm_test.go` · revert-proof pass (hand-proved) + +### OC-0195 — high — presence_update's custom_status runs the unbounded fixpoint sanitizer on a full 1 MiB WebSocket frame + +`Server/service/channel.go:182` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-api` + +`HandlePresenceUpdate` applies the 128-rune `MaxCustomStatusLen` cap to the *output* of `cleanText`, so the quadratic `sanitizeToFixpoint` runs on the raw client string first. The WS read limit is `config.MaxMessageBytes` (1 MiB), so a single `presence_update` frame can carry a 1 MiB nested-entity payload straight into it. This is the same defect already confirmed for PATCH /users/me, but on a different transport that the REST-side bound would not cover, and it executes on the connection's own readPump goroutine, so nothing bounds how many of these run at once. + +**Repro:** Authenticate a WebSocket, then send {"type":"presence_update","payload":{"status":"online","custom_status":"&"+"amp;"*200000+"lt;"}} — ~1 MiB, accepted because conn.SetReadLimit(wsReadLimitBytes) is 1<<20 (serve.go:27/80). readPump (serve_pumps.go:210) calls hub.handleMessage on the per-client goroutine; handlePresenceV2 calls HandlePresenceUpdate, whose limiter check (1 per 10s) passes, `db.ValidStatuses["online"]` passes, and channel.go:182 `text := cleanText(*customStatus)` then spins for minutes before the 128-rune check at channel.go:183 rejects it. Because each call outlives the 10 s limiter window, one account can start a new 1 MiB frame every 10 s and accumulate dozens of concurrently spinning CPU-bound goroutines from a single connection stream. + +**Evidence:** channel.go:180-186 `if customStatus != nil { text := cleanText(*customStatus); if utf8.RuneCountInString(text) > MaxCustomStatusLen { return ... } }` — the bound is on the sanitized output, not the raw input; ws/serve.go:27 `wsReadLimitBytes = config.MaxMessageBytes` (config/constants.go:7 `MaxMessageBytes = 1 << 20`); ws/serve_pumps.go:210 `hub.handleMessage(c, msg)` inside readPump. Same unguarded call also at service/user.go:202 (`SetCustomStatus`). + +**Suggested fix:** Same shared guard as the DM finding: bound the raw bytes before cleanText. In Server/service/channel.go:180, `if len(*customStatus) > MaxCustomStatusLen*4 { return nil, fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen) }` before `text := cleanText(*customStatus)`, and the identical pre-check at Server/service/user.go:202. Best done as the one `cleanTextBounded(v string, maxRunes int)` helper in service/user.go used by all four unguarded sites (channel.go:182, user.go:202, dm.go:234, dm.go:323) rather than four hand-rolled checks. + +**Fixed:** `bdbd5ac472ee73cdb566ac6348b21f9569b61598` · test `Server/service/profile_fields_test.go` · revert-proof pass + +### OC-0196 — medium — A transient DB read error during the WS auth handshake is reported as a terminal auth_error, logging the user out + +`Server/ws/serve_auth.go:62` · found 2026-08-20 · hunt `2026-08-20-general` · lens `ws-hub` + +authenticateConn collapses "session lookup failed" (DB error) into the same client-visible frame as "no such session" — buildAuthError, which the protocol defines as non-recoverable. The code comment two lines below explicitly recognises the distinction ("DB outage, not a bad token") but applies it only to the server log; the wire frame is unchanged, so a momentarily unreadable database signs valid users out. + +**Repro:** 1. Server is under SQLite reader contention (WAL checkpoint, admin backup/restore, a long write tx, or busy_timeout exceeded) so `database.GetSessionByTokenHash` returns an error rather than (nil, nil). +2. Any client whose socket drops in that window reconnects and sends its auth frame. +3. Server takes the `err != nil` branch at serve_auth.go:61 and writes `buildAuthError("invalid token")` (line 62). The identical hazard exists at line 78 for a `GetUserByID` error → `buildAuthError("user not found")`. +4. Client/tauri-client/src/lib/ws.ts:301 sets `intentionalClose = true`, calls `disconnectProxy()` and `setState("disconnected")` — the auto-reconnect loop stops permanently. +5. Client/tauri-client/src/lib/dispatcher.ts:259 runs `clearAuth()` on the same frame, which resets authStore (INITIAL_STATE), tears down voice, and drops messages/channels/blocks stores — a full logout back to the connect page. +Net effect: the session row in the DB is perfectly valid and unexpired, yet every client that happened to reconnect during a sub-second DB hiccup must sign in again. Contrast Server/ws/hub_sweep.go:137-143, where the same package refuses to treat a failed session lookup as evidence about any individual session ("kicking everyone on a transient DB error would be a mass disconnect"), and Server/ws/messages.go's `buildErrorMsg(ErrCodeInternal, ...)`, which the client does NOT treat as terminal. No test pins the DB-error case — Server/ws/ws_integration_test.go only covers malformed/absent/nonexistent tokens. + +**Evidence:** sess, err := database.GetSessionByTokenHash(ctx, hash) +if err != nil || sess == nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) + if err != nil { + // DB outage, not a bad token — carry the cause so the caller's log + // distinguishes it from an ordinary invalid-token rejection. + return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err) + } + +**Suggested fix:** Split the error branch from the not-found branch in authenticateConn so only a genuine miss produces the terminal frame. At Server/ws/serve_auth.go:60: `if err != nil { _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry")); return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err) }` then `if sess == nil { ...buildAuthError("invalid token")... }`. Apply the same split at line 76-83 for GetUserByID. ErrCodeInternal is not terminal on the client (dispatcher.ts treats only auth_error/BANNED as credential-clearing), so the socket simply closes and the normal backoff reconnect retries. + +**Fixed:** `ed0bfe03e014aa441c2dc118e09e97f54ce0e90f` · test `Server/ws/ws_integration_test.go` · revert-proof pass + +### OC-0197 — medium — validateDisplayName runs on the raw body, before the sanitizer's HTML-entity unescape — `‮` becomes a real bidi override in the stored display name + +`Server/api/profile_handler.go:235` · found 2026-08-20 · hunt `2026-08-20-general` · lens `api-authz` + +The username field in the same handler is sanitized first and validated second (line 199 then 206), which is the correct order; `display_name` is validated first (line 235, against the raw JSON string) and sanitized second (inside `UserService.UpdateProfile` -> `cleanText` -> `sanitizeToFixpoint`). `sanitizePass` ends with `html.UnescapeString(...)`, so an entity-encoded control or Cf character passes `validateDisplayName` as harmless ASCII and is then turned into the real character before storage — defeating the guard whose stated purpose is "it is rendered wherever a username is, so control characters and bidi overrides are exactly as unwelcome here". + +**Repro:** Verified against the real sanitizer in this repo (temporary test in Server/service, since removed): + SanitizeText("ada‮gnp.exe") == "ada‮gnp.exe" (runes: [... 8238 ...]) + SanitizeText("ada‮gnp.exe") == "ada‮gnp.exe" +So `PATCH /api/v1/users/me` with `{"username":"dn_user","display_name":"ada‮gnp.exe"}` returns 200 and stores a display name containing U+202E RIGHT-TO-LEFT OVERRIDE, while the existing test Server/api/avatar_handler_test.go:283 shows the literal form `"ada‮gnp.exe"` is (correctly) rejected with 400. The same bypass admits control characters: `"a b"` stores a real newline. `MaxDisplayNameLen` still holds, so the value is persisted and broadcast via user_update to every client, where it renders in place of the username in the member list, message rows and voice roster. + +**Evidence:** // profile_handler.go — username: sanitize THEN validate +199: req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) +206: if err := auth.ValidateUsername(req.Username); err != nil { + +// profile_handler.go — display_name: validate raw, sanitize later in the service +234: if req.DisplayName != nil { +235: if err := validateDisplayName(*req.DisplayName); err != nil { + +137:func validateDisplayName(name string) error { +138: for _, r := range name { +139: if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + +// service/message.go — the outer unescape that re-creates the character +166:func sanitizePass(s string) string { +167: return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) + +**Suggested fix:** Validate the sanitized value, matching the username path in the same handler: at profile_handler.go:234, first bound and sanitize — `trimmed := strings.TrimSpace(service.SanitizeText(*req.DisplayName))` (after the byte bound from the previous finding) — then `validateDisplayName(trimmed)`, and set `req.DisplayName = &trimmed` before the UpdateProfile call. cleanText's fixpoint output is stable, so the service's re-sanitize is a no-op. + +**Fixed:** `c688fc20eec6379545be2c1bc8ce1ecd946d8bb0` · test `Server/api/profile_handler_test.go (TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName)` · revert-proof pass + +### OC-0198 — medium — Block-then-evict: the shared-DM lookup that gates voice eviction uses the request context, so a client disconnect right after the block commits leaves the blocked user in the blocker's live 1:1 DM call forever + +`Server/api/dm_handler.go:410` · found 2026-08-20 · hunt `2026-08-20-general` · lens `api-authz` + +`svc.Blocks.BlockUser` has already committed by line 409. The eviction call itself is correctly detached with `context.WithoutCancel(r.Context())` (line 414), but the `SharedOneToOneDM` lookup that decides whether to evict is not — it runs on `r.Context()`. A canceled request context makes that lookup return a wrapped error, which is logged and skipped, so the eviction never happens. As the handler's own comment states, no later gate compensates: the block is otherwise only enforced at `voice_join` and voluntary `voice_token_refresh`, both driven by the blocked client, so the blocked user keeps speaking and listening in the blocker's DM call indefinitely. + +**Repro:** A and B are in the voice room of their 1:1 DM channel. A sends `PUT /api/v1/blocks/{B}` and the TCP connection is torn down (client navigates away / app quits / reverse proxy read timeout) between `BlockUser` returning at line 397 and `SharedOneToOneDM` returning at line 410. `r.Context()` is done, `FindDMChannelIDBetween` fails with context.Canceled, the handler logs "shared-DM lookup for voice eviction failed" and returns. The block row is durable — A's client shows B as blocked and the DM composer is gated — but B is still connected to the SFU room for that channel with a live mic, and nothing re-runs the gate for the life of B's session. Contrast handleCloseDM (line 227-241), which detaches every post-commit step. + +**Evidence:** 397: if err := svc.Blocks.BlockUser(r.Context(), user.ID, targetID); err != nil { // commits here +... +409: if ve, evictable := broadcaster.(dmVoiceEvictor); evictable { +410: if chID, exists, err := svc.DMs.SharedOneToOneDM(r.Context(), user.ID, targetID); err != nil { +411: slog.Warn("block: shared-DM lookup for voice eviction failed", ...) +413: } else if exists { +414: ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), targetID, chID) + +// service/dm.go:369 — any ctx error becomes ErrInternal, i.e. the skip branch +371: id, ok, err := s.st.FindDMChannelIDBetween(ctx, userA, userB) +372: if err != nil { +373: return 0, false, fmt.Errorf("%w: failed to look up shared DM: %v", ErrInternal, err) + +**Suggested fix:** Take one detached context immediately after the block commits and use it for the whole post-commit tail: at dm_handler.go:398 add `bgCtx := context.WithoutCancel(r.Context())`, then use bgCtx for both svc.DMs.SharedOneToOneDM (line 410) and ve.DisconnectFromVoiceInChannel (line 414) — same shape as bgCtx in handleRenameGroupDM. + +**Fixed:** `d652b237a7c5b6f77b51b48ef96b5005a11f0b22` · test `Server/api/dm_handler_block_context_test.go` · revert-proof pass + +### OC-0199 — medium — REST-created 1:1 DM pre-opens dm_open_state for the recipient, so the first message never emits dm_channel_open and the DM never appears in their sidebar + +`Server/db/dm_queries.go:171` · found 2026-08-20 · hunt `2026-08-20-general` · lens `db-storage` + +GetOrCreateDMChannel inserts dm_open_state rows for BOTH users at creation time, but SendMessage only reports a recipient in OpenedDMFor when its own INSERT OR IGNORE actually inserted a row (OpenDM is :execrows). Because the row already exists, `opened` is false, ws/handlers_chat.go emits no DMChannelOpenEvent for the recipient, and no visibility-watermark bump happens either — so neither a live event nor a warm reconnect ever tells the recipient the DM exists. handleCreateDM (api/dm_handler.go:75, 120) is wired with no DMBroadcaster at all, unlike handleCreateGroupDM/handleRenameGroupDM/handleCloseDM, so nothing else covers the gap. + +**Repro:** Alice POSTs /api/v1/dms {recipient_id: bob} (the client's api.createDm). GetOrCreateDMChannel creates channel 50 and inserts dm_open_state for BOTH alice(1) and bob(2). Alice then sends the first message. sendMessageDMSideEffects calls OpenDM(bob, 50) -> INSERT OR IGNORE affects 0 rows -> opened=false -> result.OpenedDMFor is empty -> handleChatSendV2 emits only the sequenced chat_message, no dm_channel_open for bob. On bob's client, dispatcher.ts CHAT_MESSAGE finds `isDm === false` (channel 50 is not in dmStore) and `incrementUnread` no-ops (DM ids are absent from channelsStore), so bob gets a desktop notification for a message with no sidebar entry, no unread badge, and no way to open the conversation — until he fully restarts and receives a `ready` payload. Group DMs do not have this bug: handleCreateGroupDM explicitly calls broadcastDMOpen for every participant. The existing regression test (service/message_crud_test.go:232 TestSendMessage_DoesNotReopenAlreadyOpenDM) misses it because newDMFixture seeds dm_participants only, never dm_open_state, so it never reproduces the REST-created state. + +**Evidence:** Server/db/dm_queries.go:170-174 + // Open the DM for both users. + _, err = tx.Exec( + `INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?)`, + user1ID, channelID, user2ID, channelID, + ) + +Server/service/message_crud.go:274-281 + opened, openErr := s.st.OpenDM(bgCtx, pid, p.ChannelID) + ... + if opened { + result.OpenedDMFor = append(result.OpenedDMFor, pid) + } + +Server/api/dm_handler.go:75 r.Post("/", handleCreateDM(svc)) // no broadcaster, unlike every sibling DM route + +**Suggested fix:** Wire the broadcaster into the create route the same way its siblings are: MountDMRoutes -> r.Post("/", handleCreateDM(svc, broadcaster)), and in handleCreateDM after a successful CreateDM add `if result.Created { broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID}) }`. broadcastDMOpen already does the context.WithoutCancel detach and the markDMVisibilityChanged watermark bump, so this one call covers both the live event and the warm-reconnect path. (The alternative — dropping user2 from the create-time dm_open_state insert so the first message's OpenDM reports opened=true — also works but changes GetUserDMChannelIDs visibility for the recipient before the first message and breaks db/dm_queries_test.go:113-121.) + +**Fixed:** `c6873380625cd99a4f7544de0047739c47d07af1` · test `Server/api/dm_handler_create_notify_test.go` · revert-proof pass + +### OC-0200 — medium — Cert-mismatch "Reject" is a no-op for any bracketed-IPv6 server: normalizeHostForCertCompare never mirrors cert_store_key's bracket unwrap + +`Client/tauri-client/src/lib/ws.ts:127` · found 2026-08-20 · hunt `2026-08-20-general` · lens `tauri-rust` + +`tofu::cert_store_key` (src-tauri/src/tofu.rs:302-309) unwraps the brackets off a portless/`:443` bracketed IPv6 literal, so every `cert-tofu` event carries the BARE address. Its documented JS mirror `normalizeHostForCertCompare` only strips `:443` and lowercases — it never unwraps brackets — so for a profile saved as `[2001:db8::1]` the two strings can never be equal. Every guard that gates a security action on that equality silently takes the "unrelated host" branch. + +**Repro:** Save/log into a server whose host is a bracketed IPv6 literal with no port or with `:443` (accepted by `isValidHost`, src/lib/hostValidation.ts:27 — `/^\[[0-9A-Fa-f:.]+\](:\d+)?$/`), e.g. `[2001:db8::1]`. `lastConnectHost` (main.ts:363) and `config.host` are stored verbatim as `"[2001:db8::1]"`. ws.ts:538 builds `wss://[2001:db8::1]/api/v1/ws`; Rust `extract_host` → `cert_store_key("[2001:db8::1]")` → strip_suffix(":443") misses → strip_prefix('[')+strip_suffix(']') → emits `host: "2001:db8::1"`. JS computes `normalizeHostForCertCompare("[2001:db8::1]")` = `"[2001:db8::1]"`. Now rotate/replace the server certificate. (1) ws.ts:381 `raw.host === normalizeHostForCertCompare(config.host)` is false → `certMismatchBlock` stays false and `cancelReconnect()`/`setState("disconnected")` never run, so the reconnect loop keeps re-arming and re-firing a mismatch modal instead of latching once. (2) main.ts:233 `if (evt.host === normalizeHostForCertCompare(lastConnectHost))` is false → clicking **Reject** on the "certificate changed — possible MITM" modal does NOT call `ws.disconnect()`, `clearAuth()` or `router.navigate("connect")`; the user stays authenticated and connected to the server whose certificate they just rejected. (3) main.ts:218 → after **Accept**, `reconnectAfterCertAccept` never runs. (4) main.ts:185 → after confirming a FIRST-USE certificate the pending `ws.connect` is never resumed, so first login to such a server hangs on the connect page. tests/unit/ws-cert.test.ts:484-497 pins only the lowercase half of this parity contract and its own comment names consequence (2) as the worst case. + +**Evidence:** ws.ts:126-128 export function normalizeHostForCertCompare(host: string): string { return host.replace(/:443$/, "").toLowerCase(); } + +tofu.rs:302-309 pub(crate) fn cert_store_key(host: &str) -> String { + let stripped = host.strip_suffix(":443").unwrap_or(host); + let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped); + unbracketed.to_ascii_lowercase() +} + +main.ts:233 if (evt.host === normalizeHostForCertCompare(lastConnectHost)) { ws.disconnect(); clearAuth(); router.navigate("connect"); } + +**Suggested fix:** Make the JS mirror the Rust key exactly, in the one shared helper (ws.ts:126): `export function normalizeHostForCertCompare(host: string): string { const stripped = host.replace(/:443$/, ""); const unbracketed = stripped.startsWith("[") && stripped.endsWith("]") ? stripped.slice(1, -1) : stripped; return unbracketed.toLowerCase(); }` - same order as cert_store_key (strip :443, then unwrap brackets, then lowercase), so "[2001:db8::1]" and "[2001:db8::1]:443" both normalize to "2001:db8::1" while "[2001:db8::1]:8443" keeps its brackets. All four call sites are fixed by that single change. + +**Fixed:** `80f96f83403d9518a0c8d5f0e0b9654254449d4b` · test `Client/tauri-client/tests/unit/ws-cert.test.ts` · revert-proof pass (hand-proved) + +### OC-0201 — medium — A full-resync `ready` that preserves a live voice session does no E2EE reconciliation: departed peers keep a working room key and a client elected key holder during the outage never learns it + +`Client/tauri-client/src/lib/dispatcher.ts:273` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-voice` + +On the full-ready reconnect tier the client rebuilds `voiceUsers` wholesale from `payload.voice_states` and never notifies `E2EEManager` about anyone who left while the socket was down — `handleParticipantLeft` is only ever driven by live `voice_leave` frames, which this tier does not replay. `ready` also carries no ECDH keys, so nothing re-derives `_isKeyHolder` or prunes `_peerPublicKeys`. The same handler already patches the sibling symptom for moderator mute/deafen (lines 295-306, "this full resync is the only place a moderator mute/deafen issued while we were disconnected ever reaches us"), so the gap is a known-shape hole left open for E2EE state. + +**Repro:** Voice channel 5 holds A(uid 1, key holder), B(uid 2), C(uid 3). B's TCP connection goes silent without FIN/RST (wifi handoff / laptop sleep / NAT rebind), so B's server-side readPump never errors and B stays in h.clients (sweepStaleClients only kicks after staleClientTimeout = 90s). B's client detects its own ping timeout and reconnects on a new socket with last_seq>0; registerNow sees the old entry, reports replaced=true, transfers B's voice state, and re-runs updateKeyHolder. B's LiveKit/SFU connection was never dropped, so the media session and B's room key are still live. +The resume takes the full-ready tier (replay buffer no longer covers last_seq, or mustFullResync was tripped by a visibility bump), so handleFreshConnect sends `ready` instead of replaying events. +Case 1 — forward secrecy: C left during the 40s outage. B never receives C's voice_leave, so handleParticipantLeft(3) never runs, C stays in B's _peerPublicKeys and, if B is the holder, B never rotates. C's captured room key keeps decrypting the room's SFrames until B's 5-minute periodic timer happens to fire. +Case 2 — holder stall: A (the holder, lowest uid) left during the outage. Server-side updateKeyHolder on B's re-registration elects B (uid 2, now lowest). B's E2EEManager._isKeyHolder is still false and no voice_leave for A ever arrives, so B never self-elects and never rotates. Every subsequent joiner announces, is offered nothing (B won't offer; C's offers are refused with NOT_KEY_HOLDER by voice_e2ee.go:198), times out after 10s+5s in setupKeyExchange and is ejected from voice with "e2ee_timeout". The channel stays in that state until the next voice_leave happens to run an election on B. + +**Evidence:** dispatcher.ts:269-306 + ws.on(S.READY, (payload) => { + ... + setVoiceStates(payload.voice_states); // wholesale roster replace + ... + } else if (selfVoiceState !== undefined) { // live voice session survived the WS drop + enforceModeratorAudioState(...); // <- only mute/deafen is reconciled + } +(no livekitSession() call anywhere in the READY handler — grep of dispatcher.ts shows livekitSession() only at 857/894/917/950/966/974/1150/1187) + +livekitE2EE.ts:1197 handleParticipantLeft() is the ONLY path that deletes _peerPublicKeys entries, rotates for membership forward secrecy, and self-elects a new key holder. +Server/ws/hub.go:486-499 registerNow transfers the old connection's voice state on lastSeq>0, and +Server/ws/hub.go:593-595 then runs updateKeyHolder(replacedVoiceChID) — so the server can elect the reconnecting client key holder. +Server/ws/serve.go:882-886 freshConnectCleanStaleVoice deliberately KEEPS the voice_states row on the replay-failure fallback (lastSeq>0, old client still registered), which is what makes the full-ready-with-live-voice case reachable. +Server/ws/serve_ready.go:356-365 the ready payload carries voice_states only — no e2ee public keys, no key-holder field. + +**Suggested fix:** Reconcile voice membership in the same branch that already reconciles moderator audio state (dispatcher.ts:296-306). Snapshot the roster BEFORE the wholesale replace at line 273 — `const prevPeers = voiceStore.getState().currentChannelId !== null ? new Set(voiceStore.getState().voiceUsers.get(voiceStore.getState().currentChannelId)?.keys() ?? []) : new Set()` — then, in the `else if (selfVoiceState !== undefined)` branch, for every uid in prevPeers that is absent from payload.voice_states for selfVoiceState.channel_id (and is not our own id), call `void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid))`. handleParticipantLeft already prunes/retires the peer key, re-runs the lowest-uid election (self-electing and rotating when appropriate), so one call site covers both the departed-peer rotation and the missed key-holder promotion. + +**Fixed:** `4bab1b4b4b8b74baac7a3eb153dd313bb1d02932` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass + +### OC-0202 — medium — A transient DB read error in AuthMiddleware is reported as 401, which makes the client log the user out and permanently delete their saved credential + +`Server/api/middleware.go:117` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-session` + +ResolveTokenHash deliberately returns DB errors *wrapped* (never a sentinel) so callers can tell an outage from a bad token — the middleware even logs it as "auth: token resolution failed" — but then falls into the same `default` arm as ErrTokenNotFound and answers 401 UNAUTHORIZED. The desktop client treats every 401 as "session expired": it calls the global onUnauthorized sink, which runs clearAuth(), and the authStore subscriber then tears down the WS and calls deleteCredential(host) plus sets `owncord:skip-auto-login`. So one transient SQLite read failure on any authenticated REST call signs a live user out and destroys their stored credential, even though the session token is still perfectly valid and the WebSocket was healthy. + +**Repro:** 1. User is signed in on MainPage with a remembered host (credential in the OS keyring) and a healthy WS. +2. The SQLite reader momentarily fails one query — e.g. `database is locked` / `disk I/O error` during the scheduled backup or a restore, or the DB file is briefly replaced (admin/handlers_backup.go's restore path swaps the file under the running server). +3. Any authenticated REST call in flight (GET /api/v1/channels/{id}/messages, /dms, /blocks, an avatar fetch, search…) hits AuthMiddleware; GetSessionByTokenHash/GetUserByID/GetRoleByID returns the wrapped DB error. +4. Middleware writes 401 UNAUTHORIZED instead of 500/503. +5. api.ts fires onUnauthorized -> clearAuth() -> main.ts subscriber runs ws.disconnect(), deleteCredential(host), sets skip-auto-login, navigates to the connect page with "Your session expired — sign in again." +6. The session row was never revoked and the token is still valid, but the user must retype their password and auto-login is disabled for that host. No test pins this (Server/api/middleware_test.go covers missing/invalid/expired/revoked tokens and a dangling role, never a DB error). + +**Evidence:** Server/api/middleware.go:117-128 + case err != nil: + // ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad + // token — log it so it's distinguishable from ordinary 401s. + if !errors.Is(err, auth.ErrTokenNotFound) { + slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err) + } + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid or expired session", + }) + return + +Server/auth/resolve.go:41-77 — every store error is returned raw/wrapped, matching none of the sentinels: + sess, err := store.GetSessionByTokenHash(ctx, hash); if err != nil { return nil,nil,nil, err } + user, err := store.GetUserByID(ctx, userID); if err != nil { return nil,nil,nil, err } + role, err := store.GetRoleByID(ctx, user.RoleID); if err != nil { return nil,nil,nil, err } + +Client/tauri-client/src/lib/api.ts:141-155 + if (res.status === 401) { + if (!opts?.skipUnauthorized) { + onUnauthorized?.(); + } + +Client/tauri-client/src/main.ts:121-130 +const api = createApiClient({ host: "" }, () => { + ... + clearAuth(); +}); + +Client/tauri-client/src/main.ts:807-815 + const host = api.getConfig().host; + if (host && authStore.getState().logoutReason !== "server_shutdown") { + void deleteCredential(host); + sessionStorage.setItem("owncord:skip-auto-login", "1"); + } + +Contrast: the ws revoked-session sweep refuses to do this on the identical signal — Server/ws/hub_sweep.go:137-143 "A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep." + +**Suggested fix:** Split the default arm in AuthMiddleware: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other (wrapped) error write 503 SERVICE_UNAVAILABLE (or 500) alongside the existing slog.ErrorContext, so the client's 401 sink never fires on a server-side fault. + +**Fixed:** `394ea9ccc7f8222ef5b4d019c3fd846dc62a7c52` · test `Server/api/middleware_test.go` · revert-proof pass + +### OC-0203 — medium — The channel-override escalation guard is missing on every clear path, so a MANAGE_CHANNELS holder can hand a lower-ranked role a permission their own role lacks + +`Server/admin/handlers_channel_perms.go:222` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-session` + +requireGrantableOverride exists to stop a non-ADMINISTRATOR MANAGE_CHANNELS holder granting a bit their own role does not hold, and handleDeleteChannelPermission's own comment says clearing an override "restores exactly the access the PUT path refuses to grant, so gate it identically to handlePutChannelPermission" — but it only adds the hierarchy guard and never calls requireGrantableOverride. handleDeleteChannelUserPermission has the same omission, and the guard is also defeated on the PUT paths themselves because it only inspects the *new* allow|deny mask: PUT {allow:0, deny:0} passes trivially while wiping an existing deny row. Clearing a deny is a grant (EffectivePerms = (rolePerm &^ deny) | allow), so the documented invariant does not hold on any of the four endpoints. + +**Repro:** Setup: role "Helper" (position 5) whose base permissions include MANAGE_MESSAGES; channel #general carries channel_overrides(channel=#general, role=Helper, allow=0, deny=MANAGE_MESSAGES). Actor "Mod" holds MANAGE_CHANNELS but NOT MANAGE_MESSAGES and NOT ADMINISTRATOR, at position 10. + +1. Mod sends PUT /admin/api/channels/{general}/permissions/{helper} with {"allow": MANAGE_MESSAGES, "deny": 0} -> 403 "cannot grant a permission your own role lacks (MANAGE_MESSAGES)" (handlers_channel_perms.go:141). +2. Mod instead sends DELETE /admin/api/channels/{general}/permissions/{helper} (or PUT with {"allow":0,"deny":0}) -> 204/200. Only the position check runs, and 5 < 10 passes. +3. The deny row is gone, so EffectivePerms(Helper.base, 0, 0) now yields MANAGE_MESSAGES in #general: every Helper can delete other members' messages there — a power Mod does not have and was explicitly refused in step 1. permInvalidator + RefreshChannelVisibility even push the widened grant out immediately. + +Same two steps work against a single member through PUT/DELETE /channels/{id}/user-permissions/{userId} (handlers_channel_perms.go:342 vs 397). + +**Evidence:** Server/admin/handlers_channel_perms.go:88-102 (the invariant) +// requireGrantableOverride refuses to write a channel override whose allow or +// deny mask contains a bit the actor's own role does not hold. Without this, +// any MANAGE_CHANNELS holder could grant themselves or another user a +// permission (e.g. MANAGE_SERVER) they were never assigned ... +func requireGrantableOverride(actorRole *db.Role, allow, deny int64) error { + if permissions.HasAdmin(actorRole.Permissions) { return nil } + if escalated := (allow | deny) &^ actorRole.Permissions; escalated != 0 { ... } + +Server/admin/handlers_channel_perms.go:141-150 (PUT role — both guards) + if err := requireGrantableOverride(actorRole, allow, deny); err != nil { ...403... } + if role.Position >= actorRole.Position { ...403... } + +Server/admin/handlers_channel_perms.go:218-230 (DELETE role — hierarchy ONLY) + // Hierarchy guard: deleting an override is a permission mutation with the + // same authority as writing one (removing a deny row restores exactly the + // access the PUT path refuses to grant), so gate it identically to + // handlePutChannelPermission. + if role.Position >= actorRole.Position { ...403... } + if err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { ... } + +Server/admin/handlers_channel_perms.go:395-404 (DELETE per-user — hierarchy ONLY, no requireGrantableOverride) + +Server/permissions/permissions.go:138-140 +func EffectivePerms(rolePerm, allow, deny int64) int64 { return (rolePerm &^ deny) | allow } + +**Suggested fix:** Do the escalation check against the masks being REMOVED, not the ones being written: in both DELETE handlers load the current row (database.GetChannelPermissions / GetUserChannelPermissions) and call requireGrantableOverride(actorRole, curAllow, curDeny) before deleting; in both PUT handlers pass (curAllow|allow, curDeny|deny) so a clear-by-zero-mask is covered by the same guard. Note a bare requireGrantableOverride(actorRole, 0, 0) on the DELETE paths would be a no-op. + +**Fixed:** `c978a348bbdd58b7c26e848f52f65a4f8c11c487` · test `Server/admin/handlers_channel_perms_test.go` · revert-proof pass + +### OC-0204 — medium — A message or @mention arriving while the user reads back-history in the same channel is silently dropped: no row, no badge, no notification + +`Client/tauri-client/src/lib/dispatcher.ts:581` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +Three independently-correct guards compose into a hole. messages.store's addMessage refuses to append to a detached around-window, dispatcher skips incrementUnread/incrementMention because the channel is activeChannelId, and notifyIncomingMessage bails because the window is focused and the channel is active. Nothing else records that the message arrived, so an @mention of the user vanishes with zero indication. + +**Repro:** 1. Open #general (a channel with more than ~100 messages of history). 2. Click a reply-reference, a search result, or a permalink pointing at an old message IN #general. MessageJump.jumpTo (pages/main-page/MessageJump.ts:95-102) calls api.getMessagesAround and passes has_more_after=true to setAroundMessages, which adds #general to detachedChannels (messages.store.ts:517-524). #general is still activeChannelId. 3. Leave the app window focused. Another user posts "@you ping" in #general. 4. dispatcher CHAT_MESSAGE: addMessage(payload) hits messages.store.ts:255 and returns prev unchanged -> the row is never stored or rendered. payload.channel_id !== activeId is FALSE, so incrementUnread (dispatcher.ts:582) and incrementMention (dispatcher.ts:585) are both skipped. notifyIncomingMessage hits notifications.ts:56 (isWindowFocused() && channel_id === activeChannelId) and returns -> no desktop popup, no chime, no taskbar flash. RESULT: nothing at all reaches the user. The server did increment read_states.mention_count, but dispatcher's ready handler calls markChannelRead(currentActive) (dispatcher.ts:397) on the next full resync, erasing it permanently. Recovery requires the user to guess and click "Jump to Present". + +**Evidence:** messages.store.ts:252-255 // 3. Append as a new message — unless the channel is showing a detached +// around-window ... +if (prev.detachedChannels.has(channelId)) return prev; + +dispatcher.ts:581-586 +if (payload.channel_id !== activeId && !isOwnMessage) { + incrementUnread(payload.channel_id); + if (isMention) { + incrementMention(payload.channel_id); + } +} + +notifications.ts:54-56 +// Don't notify if the window is focused AND the message is in the active channel +const activeChannelId = channelsStore.getState().activeChannelId; +if (isWindowFocused() && payload.channel_id === activeChannelId) return; + +**Suggested fix:** Stop using "channel is active" as a proxy for "the user is looking at the live tail". Smallest change: in notifications.ts:56 use the already-exported detached selector — `if (isWindowFocused() && payload.channel_id === activeChannelId && !isWindowDetached(payload.channel_id)) return;` — so a detached active channel still notifies. Mirror it at dispatcher.ts:581 (`if ((payload.channel_id !== activeId || isWindowDetached(payload.channel_id)) && !isOwnMessage)`) if the badge is wanted too; that path additionally needs the badge cleared when the channel reattaches (reattachToPresent / setMessages) or the count will linger. + +**Fixed:** `4bab1b4b4b8b74baac7a3eb153dd313bb1d02932` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass + +### OC-0205 — medium — Embed pipeline never strips the trailing punctuation the linkifier strips, so a URL at the end of a sentence gets no embed (and YouTube links render as a duplicate broken bare link) + +`Client/tauri-client/src/components/message-list/media.ts:515` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-2` + +`extractUrls` returns raw `URL_REGEX` matches. `URL_REGEX = /https?:\/\/[^\s<>"']+/g` swallows any trailing `.`, `,`, `)`, `!`, `?`. `renderMentions` in content-parser.ts:171 explicitly strips exactly those characters (with a paren-balance give-back) before building the anchor, so the linkified href and the URL handed to the embed pipeline disagree on every URL that is followed by sentence punctuation. The embed path then either mis-classifies the URL or fetches an address that does not exist. + +**Repro:** Post `Nice pic https://cdn.example.com/a.png.` (sentence-ending period). extractUrls yields `https://cdn.example.com/a.png.`; `isDirectImageUrl` tests `new URL(...).pathname` = `/a.png.` against `/\.(gif|png|jpg|jpeg|webp)$/`, which fails, so the inline image is never rendered and a generic link-preview card is fetched for a 404 address instead. Same for `(https://cdn.example.com/a.png)`. For YouTube: post `Check https://youtu.be/dQw4w9WgXcQ.` — extractYouTubeId returns `dQw4w9WgXcQ.` (non-null), so renderYouTubeEmbed is entered, YOUTUBE_ID_RE `^[\w-]{1,20}$` rejects the `.`, and the message gets a second, plain `` "embed" pointing at the trailing-dot URL underneath the correctly-linkified one — no player. No test covers punctuation: tests/unit/media.test.ts:1211-1246 only exercises clean URLs. + +**Evidence:** media.ts:511-516 + const withoutCodeBlocks = content + .replace(CODE_BLOCK_REGEX, "") + .replace(INLINE_CODE_REGEX, "") + .replace(MASKED_LINK_REGEX, ""); + const matches = withoutCodeBlocks.match(URL_REGEX); + return matches ?? []; + +vs content-parser.ts:170-179 + const rawUrl = match[0]; + let stripped = rawUrl.replace(/[.,;:!?)]+$/, ""); + ... if (opens > closes) stripped = stripped + ")"; + +**Suggested fix:** Export the trailing-punctuation strip from content-parser.ts (factor renderMentions:171-179 into e.g. `stripUrlTrailingPunctuation(raw)`) and apply it once in extractUrls (media.ts:515) before returning, so the embed pipeline and the anchor agree on the same URL. One change in the shared extractor covers YouTube, direct-image and generic-preview branches. + +**Fixed:** `0b665f3c7da336b95beb7c63f3c3eb23b634e173` · test `Client/tauri-client/tests/unit/media.test.ts` · revert-proof pass + +### OC-0206 — medium — VAD AudioWorklet counts 128-sample render quanta as if they were ~16 ms frames, so the mic gate closes after 32 ms of quiet instead of the intended ~200 ms + +`Client/tauri-client/public/vad-worklet.js:19` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-ws` + +AudioWorkletProcessor.process() is invoked once per render quantum — 128 sample frames — and AudioPipeline creates its context with `new AudioContext({ sampleRate: 48000 })` (audioPipeline.ts:120), so one process() call is exactly 128/48000 = 2.667 ms. The worklet's frame counters were sized for ~16 ms frames (the setTimeout fallback's poll interval): _gateOnFrames = 12 is annotated "~200ms of silence before gating" but is 32 ms; _startupGrace = 30 is annotated "~500ms grace period" but is 80 ms; the RMS throttle `_frameCounter >= 6` is annotated "~50ms" but is 16 ms. audioPipeline.ts:415 calls the fallback's logic "identical to the setTimeout version", where the same 12/30 constants at 16 ms per poll do give ~192 ms and ~480 ms. The two VAD implementations therefore disagree by ~6x on the only tuning parameter that matters, and the primary (worklet) path is the wrong one. + +**Repro:** Join voice with the default voiceSensitivity of 50 (threshold RMS 0.05) on a build where /vad-worklet.js loads successfully (the logged "VAD AudioWorklet started" path). Speak a normal sentence. Inter-word pauses of 40-150 ms drop RMS below threshold for longer than 12 render quanta (32 ms), so the worklet posts {type:"gate",gated:true}, AudioPipeline sets the GainNode target to 0 with setTargetAtTime(tau=0.015) and the outgoing mic level collapses mid-sentence; the ungate needs only 2 quanta (5.3 ms) so the gain immediately ramps back. The result is continuous level pumping / clipped word onsets heard by every other participant. Force the fallback instead (make addModule reject, e.g. remove /vad-worklet.js) and the identical constants gate only after ~192 ms, so the same speech passes through cleanly — the two paths produce audibly different behaviour from the same tuning values. + +**Evidence:** vad-worklet.js:19,26,71 + this._gateOnFrames = 12; // ~200ms of silence before gating + this._startupGrace = 30; // ~500ms grace period + if (this._frameCounter >= 6) { // "~50ms at 128 samples/frame @ 48kHz" + +audioPipeline.ts:120 const ctx = new AudioContext({ sampleRate: 48000 }); +audioPipeline.ts:364-367 (fallback, polled at 16 ms) + const GATE_ON_FRAMES = 12; + const GATE_OFF_FRAMES = 2; + const STARTUP_GRACE = 30; +audioPipeline.ts:390,410 this.vadTimer = setTimeout(poll, 16); + +**Suggested fix:** Fix the constants in Client/tauri-client/public/vad-worklet.js to be counts of 128-sample render quanta rather than 16 ms polls: _gateOnFrames = 75 (~200 ms), _gateOffFrames = 12 (~32 ms), _startupGrace = 188 (~500 ms), and the RMS-throttle test at line 71 to _frameCounter >= 19 (~50 ms). That is the single-place fix, since _startupGrace and the RMS throttle are not overridable through the config message. If you would rather keep the timing on the main thread, derive them there instead — in startVadWorklet (audioPipeline.ts:326) post gateOnFrames: Math.round(0.2 * ctx.sampleRate / 128) and gateOffFrames: Math.round(0.033 * ctx.sampleRate / 128) — but the grace period and RMS throttle still have to be corrected in the worklet. + +**Fixed:** `e0ed40e4d96c1757522e8d69b47863c2c65f92eb` · test `Client/tauri-client/tests/unit/vad-worklet-timing.test.ts` · revert-proof pass + +### OC-0207 — medium — The video-mode wake-up signature omits currentChannelId, so VideoModeController's lastChannelId goes stale and clearStreams() deletes the remote tile onRemoteVideo just added + +`Client/tauri-client/src/pages/MainPage.ts:710` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-2` + +MainPage's voiceStore subscriber only calls videoModeCtrl.checkVideoMode() when the camera/screenshare signature changes (MainPage.ts:710-724), and that signature is built from localCamera/localScreenshare plus per-user camera/screenshare flags — currentChannelId is read (line 711) only to pick which roster to scan, and never contributes to `sig`. checkVideoMode() is the ONLY writer of VideoModeController's `lastChannelId` (VideoModeController.ts:121-126), so a voice-channel switch that leaves the signature unchanged never advances it. The next call to checkVideoMode() is then the one made by setOnRemoteVideo (MainPage.ts:695) immediately after it added a tile — and it fires `videoGrid.clearStreams()` for the now-stale channel change, destroying that tile one line after it was created. + +**Repro:** 1. Alice is in voice channel A. Nobody in A or B has a camera or screenshare on, so MainPage's prevVideoSignature is "" and VideoModeController's lastChannelId is A. +2. Alice clicks voice channel B in the sidebar. VoiceCallbacks.onVoiceJoin -> joinVoiceChannel(B) sets currentChannelId = B (voice.store.ts:300-315) and touches nothing else. MainPage's subscriber recomputes sig: still "" (no local flags, B's roster has no camera/screenshare flags), so line 721's guard fails and checkVideoMode() is NOT called. lastChannelId is still A. +3. Bob, already in B, starts a screenshare. LiveKit's TrackSubscribed reaches Alice before the server's voice_state broadcast does — the exact race VideoModeController.ts:142-143 documents. +4. setOnRemoteVideo runs: videoGrid.addStream(bobId + 1_000_000, "Bob (Screen)", stream, ...) at MainPage.ts:690, then videoModeCtrl.checkVideoMode() at MainPage.ts:695. +5. checkVideoMode sees channelId (B) !== lastChannelId (A) and calls videoGrid.clearStreams() (VideoModeController.ts:123), deleting the tile added in step 4. It then reads channelUsers.get(bobId).screenshare === false (WS still lagging) and videoGrid.hasStreams() === false (just cleared), so anyVideoOn is false and it calls closeVideoGrid(). +6. The voice_state broadcast finally arrives; sig changes to ":s" and checkVideoMode runs again — but channelId now equals lastChannelId, and remote tiles are only ever added by onRemoteVideo, which has already fired for this track and will not fire again. +Result: Bob's screenshare is permanently invisible to Alice. Clicking "Watch stream" on Bob (ChannelSidebar.ts:539-541 -> MainPage.ts:398-401) opens the grid and calls setFocus(bobId + 1_000_000) for a tile that no longer exists, so VideoGrid.rebuildFocusLayout renders an empty .video-focus-main with no thumbnails. Only Bob stopping and restarting the share recovers it. + +**Evidence:** MainPage.ts:710-724 — `let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : ""); const channelId = state.currentChannelId; if (channelId !== null) { const users = state.voiceUsers.get(channelId); if (users) { for (const [uid, u] of users) { if (u.camera) sig += `:c${uid}`; if (u.screenshare) sig += `:s${uid}`; } } } if (sig !== prevVideoSignature) { prevVideoSignature = sig; videoModeCtrl?.checkVideoMode(); }` — no channelId term in `sig`. + +VideoModeController.ts:112-126 — `function checkVideoMode(): void { const voice = voiceStore.getState(); const channelId = voice.currentChannelId; if (channelId !== lastChannelId) { if (lastChannelId !== null) { videoGrid.clearStreams(); } lastChannelId = channelId; }` — `lastChannelId` is assigned nowhere else except `destroy()`. + +MainPage.ts:690-695 — `videoGrid.addStream(tileId, username, stream, { isSelf: false, audioUserId: userId, isScreenshare }); videoModeCtrl?.checkVideoMode();` — add, then clear. + +VideoModeController.ts:141-143 states the premise of the race: "Check both voice store state AND whether the grid has tiles, because LiveKit track delivery can race ahead of the WS voice_state update." + +VideoModeController.ts:213-217 confirms nothing else re-adds the tile: "Remote video tiles are managed exclusively by the onRemoteVideo / onRemoteVideoRemoved callbacks (driven by LiveKit TrackSubscribed / TrackUnsubscribed)." + +Grep confirms only three checkVideoMode call sites exist: MainPage.ts:695, :700, :723. + +**Suggested fix:** Include the voice channel id in the signature so any channel switch immediately advances lastChannelId. In MainPage.ts:710-711, seed the signature with the channel id, e.g. `const channelId = state.currentChannelId; let sig = `${String(channelId)}|` + (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : "");` (moving the existing line 711 above line 710). One line at the single wake-up site; the controller's tested clear-on-change behavior is untouched. + +**Fixed:** `0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass + +### OC-0208 — medium — Voice sidebar's re-render signature omits sessionFingerprint, so an unverified peer's session fingerprint goes permanently stale after their LiveKit reconnect + +`Client/tauri-client/src/components/ChannelSidebar.ts:943` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-ws` + +`unsubVoiceStructure` is the only voiceStore subscription that calls `renderChannels()` (the sibling `unsubSpeaking` only toggles CSS classes), and its structural signature folds in `verif.status` but never `verif.sessionFingerprint`. The unverified badge's tooltip is built from `v.sessionFingerprint` (ChannelSidebar.ts:81-83), so a peer whose ephemeral key changes mid-call at an unchanged status produces a new fingerprint in the store that the DOM never picks up — the badge keeps advertising the superseded value, which for a peer with no identity key is the only out-of-band comparison value the feature (OC-0003) exists to provide. + +**Repro:** Local user A and legacy peer B (B has no published identity key) are in the same voice channel. A's sidebar shows B's muted shield with tooltip "Session fingerprint …: FP1". B's LiveKit room connection drops and auto-reconnects: `E2EEManager.reannounceForReconnect()` (livekitE2EE.ts:337-375) generates a fresh ECDH keypair and re-announces it. A's `handleAnnounceInner` -> `verifyPeerAnnounce` writes `setPeerVerification({userId: B, status: "unverified", safetyNumber: null, sessionFingerprint: FP2})` (livekitE2EE.ts:553, 557-565). B never left `voiceUsers` and B's status is still "unverified", so the structural signature at line 943 is byte-identical to before, `subscribeSelector` fires no callback, `renderChannels()` never runs, and A's tooltip keeps showing FP1 while B's live session key hashes to FP2. It stays wrong until some unrelated structural change (someone toggles mute/camera, or a join/leave) happens to force a re-render. Reading FP1 out of band against B's screen (which shows FP2 via `localSessionFingerprint`, correctly refreshed because line 936 includes it) reports a false mismatch; the reverse ordering hides a real one. + +**Evidence:** ChannelSidebar.ts:943 — `structSig += `:${uid}${u.muted ? "m" : ""}...${verif ? `@${verif.status}` : ""}`;` (only `verif.status`, no `verif.sessionFingerprint`) +ChannelSidebar.ts:81-83 — `(v.sessionFingerprint !== null ? ` Session fingerprint (changes every call — not an identity): ${v.sessionFingerprint}` : "")` +ChannelSidebar.ts:936 — the local half IS covered: `let structSig = `${state.currentChannelId ?? ""}#${state.localSessionFingerprint ?? ""}`;` + +**Suggested fix:** Include the fingerprint (and safety number) in the structural signature at ChannelSidebar.ts:943 — replace `${verif ? `@${verif.status}` : ""}` with `${verif ? `@${verif.status}/${verif.safetyNumber ?? ""}/${verif.sessionFingerprint ?? ""}` : ""}`. One change in the shared selector covers every badge field the tooltip reads. + +**Fixed:** `05287f67725f44eb4933e29ff604481a1ad7baab` · test `Client/tauri-client/tests/unit/channel-sidebar.test.ts` · revert-proof pass + +### OC-0209 — medium — A replayed retired-key announce overwrites the peer's displayed verification and session fingerprint before the replay guard rejects it + +`Client/tauri-client/src/lib/livekitE2EE.ts:748` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-ws` + +`handleAnnounceInner` runs `verifyPeerAnnounce` — which calls `setPeerVerificationIfCurrent` on every branch, including the success branches that publish `sessionFingerprint` computed from the announced key — before the `isRetiredPeerKey` replay guard at lines 779-783 / 790-793. An announce that is then rejected as a retired-key replay has already rewritten the voice store's verification entry for that peer, so the UI advertises the fingerprint of a key that is provably no longer the peer's live ECDH key (`_peerPublicKeys` is left untouched), and a red "mismatch" badge raised by a preceding forged announce is reset to green by the replay. + +**Repro:** Peer P announces ephemeral key K1; A stores it and publishes verification {status, sessionFingerprint: FP(K1)}. P reconnects and announces K2; A retires K1 (`retirePeerKey`, line 785), stores K2, and publishes {status, sessionFingerprint: FP(K2)}. The relay now re-emits P's original, still-validly-signed K1 announce (the exact replay OC-0011's retired-key guard was added for — the announce message carries no channel/epoch/nonce). `handleAnnounceInner` calls `verifyPeerAnnounce(P, K1, sig1)`: the signature verifies against P's pinned identity key, so it returns true after writing `setPeerVerification({userId: P, status: "verified"|"unverified", sessionFingerprint: FP(K1)})`. Only then does line 780 reject the announce and return, leaving `_peerPublicKeys[P] === K2`. The badge now reports FP(K1) — a key A itself already retired — as P's current session fingerprint. If the replay is preceded by a forged/unsigned announce that set status "mismatch", the replay also clears that red badge back to verified. + +**Evidence:** livekitE2EE.ts:747-751 — `if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) { return; }` runs first +livekitE2EE.ts:553 — `const sessionFingerprint = await computeRawKeyFingerprint(this.rawFromBase64(publicKeyBase64));` then `setPeerVerificationIfCurrent(..., sessionFingerprint)` at 557-565 / 613-622 +livekitE2EE.ts:779-783 — `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId }); return; }` (same guard again at 790-793) — reached only *after* the store write + +**Suggested fix:** Hoist the replay check ahead of verification: in handleAnnounceInner, immediately after the `_ecdhKeyPair` queue check / generation capture (~line 741), add `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error(...); return; }` and delete the two later duplicates at 779-783 and 790-793. Safe because a retired key is never the live key — retirePeerKey is only called for a key being replaced (785) or for a departing peer whose entry is deleted (1200/1214) — so the dedupe branch at 764-769 cannot be starved. + +**Fixed:** `ccd9f39b69b202dc2858c8b02e97104e67f81aec` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass + +### OC-0210 — medium — Ring-buffer-only mode has no cross-restart seq-epoch guard: a partial replay from a fresh seq epoch is presented as a clean resume + +`Server/main.go:431` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-2` + +`seedHubReplayState` is the only thing that re-seeds `h.seq` from persisted events AND calls `hub.MarkVisibilityChanged()` at boot, and it lives *behind* `runStartEventPersistence`'s early return. With `event_persistence.enabled: false` (an explicitly supported mode — config.go:81-83 "falls back to ring-buffer-only behaviour (Phase A semantics)") the hub restarts with `h.seq == 0`, `visibilityChangeSeq == 0`, and an empty ring, so a reconnecting client carrying a stale pre-restart `last_seq` is matched against seq numbers belonging to a completely different epoch. `EventRingBuffer.EventsSinceFiltered` (ringbuffer.go:101/107) only refuses when `afterSeq <= oldestSeq` or `afterSeq > newestSeq`; a stale watermark that happens to land inside the new epoch's live window passes both checks and yields a partial replay. `mustFullResync` (hub_events.go:84-87) is inert because the watermark is 0. The client-side mitigation (ws.ts:326-329, OC-0032) only resets `lastSeq` when `replay_source === "none"`, so this path — `replay_source: "buffer"` — bypasses it, and the client tracks only `max(seq)` so the skipped events can never be requested again. + +**Repro:** Config `event_persistence.enabled: false`. Server runs briefly; hub seq reaches 40; client A's in-memory `lastSeq` = 40. Restart the server (admin Restart / update / supervisor). On boot `runStartEventPersistence` returns at main.go:431, so `h.seq = 0`, `visibilityChangeSeq = 0`, ring empty. Other clients reconnect first; each connect fans out a sequenced global presence/member frame, pushing the NEW epoch's seq to 60 (ring holds new-epoch seq 1..60). Client A now reconnects with `last_seq: 40`: `mustFullResync(40)` is false (w==0); `EventsSinceFiltered(40, allowed)` sees oldest=1, newest=60, so 40 > 1 and 40 <= 60 → it returns the 20 frames with seq 41..60. handleReconnect writes `auth_ok` with `replay_source: "buffer"` plus those 20 frames. Client A never receives new-epoch events 1..40 (the other users' presence/member/channel frames), its `lastSeq` is never reset because `replay_source != "none"`, and since it only reports `max(seq)` the hole is unrecoverable for the life of the connection — its member list and presence state stay silently wrong while the UI reports a successful resume. + +**Evidence:** Server/main.go:431 `if !cfg.EventPersistence.Enabled || hub == nil { return nil, nil }` — line 435 `seedHubReplayState(bgCtx, hub, database, log)` and its `hub.MarkVisibilityChanged()` (main.go:846) are unreachable in ring-only mode. +Server/ws/ringbuffer.go:101-108 `if afterSeq <= oldestSeq { return nil }` / `if afterSeq > rb.newestSeqLocked() { return nil }` — nothing rejects an in-window afterSeq from a previous epoch. +Server/ws/hub_events.go:84-87 `func (h *Hub) mustFullResync(lastSeq uint64) bool { w := h.visibilityChangeSeq.Load(); return w > 0 && lastSeq <= w }` — `w == 0` on this boot, so always false. + +**Suggested fix:** Add a per-process epoch nonce to the resume handshake and reject a mismatched one in the single shared guard. Concretely: generate a random `bootEpoch uint64` in ws.NewHub, emit it in buildAuthOK/buildReady, have serve_auth.go's authPayload accept an `epoch` field alongside `last_seq`, and in reconnectPrecheck (Server/ws/serve.go:298-310, next to the existing mustFullResync check) force the full-ready path whenever the echoed epoch is absent or != h.bootEpoch. That is one guard covering both tiers and both the disabled-persistence and empty-events-table cases. A server-only stopgap, if the protocol change is too big: set a `h.freshEpoch` flag whenever the boot did not seed from persistence (main.go:431 disabled branch and main.go:843 maxSeq<=0 branch) and make mustFullResync return true for any lastSeq > 0 while it is set — correct, at the cost of degrading every ring-only reconnect to a full ready. + +**Fixed:** `3bd29b5d9fea466025209d837c1036952c28f55b` · test `Server/main_test.go` · revert-proof pass + +### OC-0211 — low — Periodic session recheck disconnects the client on a transient DB error, unlike its sibling sweep which explicitly refuses to + +`Server/ws/handlers.go:122` · found 2026-08-20 · hunt `2026-08-20-general` · lens `ws-hub` + +handleMessageSessionRecheck treats `dbErr != nil` identically to "session row is gone" and "session expired", kicking the connection and logging the misleading reason "ws session expired". The sibling backstop in the same package (sweepRevokedSessions) documents and implements the opposite rule for exactly this case, so the two authorization paths disagree about what a failed read means. + +**Repro:** 1. A client sends its 10th message since the last check (SessionCheckInterval, client.go:21), so `shouldCheck` is true. +2. At that instant `h.db.GetSessionWithBanStatus` fails transiently — SQLITE_BUSY past busy_timeout, an I/O error, a maintenance window. It returns (nil, err). +3. handlers.go:122 takes the branch and calls `h.kickClient(c)` at line 124: the client is deleted from h.clients, its send channels are closed and it is unsubscribed from every topic — with no error frame explaining why. +4. Because the trigger is a server-wide DB condition, every connected client that crosses its 10-message boundary in that window is dropped simultaneously, and they all reconnect at once — adding load to the already-contended DB and feeding straight into the handshake path above (serve_auth.go:62), where the same failing query now produces a terminal auth_error and a logout. +Compare Server/ws/hub_sweep.go:136-143, which on the identical failure logs and skips: "A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep; the next tick retries." No test covers the dbErr path; Server/ws/handlers_test.go only exercises a genuinely deleted session. + +**Evidence:** result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash) +if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { + slog.Info("ws session expired, closing connection", "user_id", c.userID) + h.kickClient(c) + return true +} + +**Suggested fix:** Treat a failed read as no evidence, matching the sibling sweep. In Server/ws/handlers.go:121, before the combined check: `if dbErr != nil { slog.Warn("ws session recheck: lookup failed, skipping", "user_id", c.userID, "err", dbErr); return false }` — the next recheck and sweepRevokedSessions remain the enforcement backstops. + +**Fixed:** `59ac14a78b39b192950cc4fa770225c8cd7fb923` · test `Server/ws/oc_0211_session_recheck_dberr_test.go` · revert-proof pass + +### OC-0212 — low — TOFU re-pin recovery is a no-op for the live call, and it deletes the only badge that showed the peer was blocked + +`Client/tauri-client/src/lib/livekitE2EE.ts:674` · found 2026-08-20 · hunt `2026-08-20-general` · lens `voice-e2ee` + +`rePinPeerIdentity` writes the new pin and then calls `clearPeerVerification(userId)`, but nothing re-runs the announce that was rejected. `handleAnnounceInner` returns before `this._peerPublicKeys.set(...)` on a failed `verifyPeerAnnounce` (line 750), and a mid-call peer never re-announces (announce is only sent from `setupKeyExchange` and `reannounceForReconnect`), so the peer stays out of `_peerPublicKeys` — and therefore out of every offer and every rotation — for the rest of the call. Meanwhile `clearPeerVerification` removes the map entry entirely, so `ChannelSidebar.ts:480` (`if (verification !== null)`) renders no shield at all: the user sees the red shield-alert vanish and reads that as "fixed". The method's own doc comment ("the next announce re-verifies against the new pin") assumes an announce that a live call never produces. + +**Repro:** Users A, B, C in one voice call; C is the key holder, A has B pinned to identity key K_old. B reinstalls (new identity key K_new) and rejoins the channel. B's `voice_e2ee_announce` reaches A: `verifyPeerAnnounce` sees `pin(K_old) !== publishedIdentity(K_new)` → status "mismatch", returns false, so A never stores B's ECDH key. C has no pin for B, accepts, and offers B the room key, so B is a normal participant for everyone but A. A clicks the red shield on B's row, confirms the fingerprint out of band, clicks "Trust New Key": `rePinPeerIdentity` succeeds, `clearPeerVerification(B)` runs, B's badge disappears. B is still absent from A's `_peerPublicKeys`. When C leaves and A is elected key holder, A's `distributeRoomKey` iterates `_peerPublicKeys` — B gets no offer, is stranded on the retired key, and goes permanently silent/undecryptable for the rest of the call, with no badge or error anywhere in A's UI. + +**Evidence:** livekitE2EE.ts:663-676 + const result = await storeIdentityPin(host, String(userId), verifiedKey); + if (result === "failed") { ...; return false; } + clearPeerVerification(userId); + log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); + return true; + +livekitE2EE.ts:747-751 (the rejection that is never retried) + if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) { + return; // rejected/blocked — do not store or wrap + } + +ChannelSidebar.ts:480-503 + const verification = getPeerVerification(user.userId); + if (verification !== null) { ...render badge... } + +**Suggested fix:** Buffer the rejected announce instead of discarding it: in verifyPeerAnnounce's mismatch branch (livekitE2EE.ts:536-547) record `{userId -> {publicKeyBase64, signatureBase64}}` in a new `_blockedAnnounces` map (cleared in clearState alongside _pendingAnnounces), and in rePinPeerIdentity, after the successful storeIdentityPin and before clearPeerVerification, replay it: `const pending = this._blockedAnnounces.get(userId); if (pending) { this._blockedAnnounces.delete(userId); await this.handleAnnounce(userId, pending.publicKeyBase64, pending.signatureBase64); }`. That re-runs the normal verifying path against the new pin, re-populates _peerPublicKeys, sends the offer if we are the holder, and lets setPeerVerification write the real "verified" badge rather than leaving the row blank. + +**Fixed:** `ccd9f39b69b202dc2858c8b02e97104e67f81aec` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass + +### OC-0213 — low — A stale voice_leave retires a rejoined peer's LIVE ephemeral key, permanently locking them out of the room key + +`Client/tauri-client/src/lib/livekitE2EE.ts:1214` · found 2026-08-20 · hunt `2026-08-20-general` · lens `voice-e2ee` + +`voice_leave` carries only `{channel_id, user_id}` (messages.go:704) — no join token — and it travels through the buffered `h.broadcast` queue plus the hub dispatch goroutine, while `voice_e2ee_announce` is published straight into the recipient's send queue from the sender's read-pump (`sendToVoiceChannelExcept` → `pubsub.Publish`). Hub_broadcast.go:64-72 explicitly names this asymmetry as a reordering hazard. So a peer's rejoin announce can be delivered ahead of the voice_leave for the join instance it superseded. `handleParticipantLeft` then reads the peer's CURRENT key as `departingKey`, deletes it from `_peerPublicKeys`, and feeds it to `retirePeerKey` — which `handleAnnounceInner` (lines 779-784 / 790-793) uses to reject any later announce carrying that key as a replay. The peer is both un-keyed and un-re-announceable. + +**Repro:** A (key holder) and P are in voice channel X; A holds P's ephemeral key K2. P leaves X — `finishVoiceLeave` enqueues voice_leave(X,P) onto `h.broadcast` (hub.go:150, capacity 1024). Under a broadcast burst the hub dispatch goroutine lags. P rejoins X ~100 ms later; `setupKeyExchange` mints K4 and sends `voice_e2ee_announce`, which `sendToVoiceChannelExcept` publishes directly into A's send queue, overtaking the still-queued voice_leave. At A: the announce applies first — K2 retired, K4 stored, offer sent. Then the stale voice_leave(X,P) arrives: `handleParticipantLeft(P)` reads `departingKey = K4`, deletes P from `_peerPublicKeys`, retires K4, and (since `wasKeyHolder && hadPeerKey`) rotates the room key excluding P. P now decrypts nothing and is decrypted by nobody; P's reconnect-confirm re-announce of K4 (line 405) is rejected by `isRetiredPeerKey`, and P is never offered a key again for the remainder of the call unless P's SFU connection drops and mints a fresh keypair. A channel filter does not fix this — the stale leave names the same channel P rejoined. + +**Evidence:** livekitE2EE.ts:1198-1215 + const departingKey = this._peerPublicKeys.get(userId); + const hadPeerKey = departingKey !== undefined; + this._peerPublicKeys.delete(userId); + this._peerOfferEpochs.delete(userId); + clearPeerVerification(userId); + if (departingKey) { + this.retirePeerKey(userId, await exportPublicKey(departingKey)); + } + +livekitE2EE.ts:790-793 (the resulting permanent rejection) + if (this.isRetiredPeerKey(userId, publicKeyBase64)) { + log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId }); + return; + } + +Server/ws/voice_e2ee.go:270-272 (direct publish, bypasses h.broadcast) + func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) { + h.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID) + +Server/ws/hub_broadcast.go:64-72 documents that publishing straight to pub/sub "would reintroduce exactly that kind of reordering". + +**Suggested fix:** Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked. + +**Fixed:** `ccd9f39b69b202dc2858c8b02e97104e67f81aec` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass + +### OC-0214 — low — DeleteAccount's last-admin guard counts admins with `banned = 0`, so a lapsed temporary ban on any admin permanently blocks another admin's self-deletion + +`Server/db/account.go:202` · found 2026-08-20 · hunt `2026-08-20-general` · lens `db-storage` + +Same lapsed-ban split as above, in the opposite direction: the guard's "is there another usable admin left" count excludes an admin whose temporary ban has expired, even though that admin can log in and administer normally (auth.IsEffectivelyBanned returns false for them). The guard then reports ErrLastAdmin for a server that in fact still has a working administrator, and there is no way for the caller to clear it short of an explicit unban. + +**Repro:** Server has exactly two admin-class accounts, alice and bob. Alice is temp-banned for 1h at some point; the hour lapses (users.banned stays 1, ban_expires in the past) and alice keeps logging in and administering fine. Bob now calls DELETE /api/v1/users/me. deleteAccountAdminGuard resolves the admin role ids, sees bob is admin-class, and runs `SELECT COUNT(*) FROM users WHERE role_id IN (...) AND id != bob AND banned = 0` -> alice is excluded -> adminCount == 0 -> ErrLastAdmin. Bob can never delete his account while alice's stale banned flag stands, even though alice is a fully functional administrator. + +**Evidence:** Server/db/account.go:200-210 + var adminCount int + if err := tx.QueryRowContext(ctx, + fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`, + strings.Join(placeholders, ",")), + args..., + ).Scan(&adminCount); err != nil { ... } + if adminCount == 0 { + return ErrLastAdmin + } + +(the same file's anonymiseUser comment, account.go:110-113, explicitly documents that a stale lapsed ban_expires means banned=1 does NOT mean banned) + +**Suggested fix:** Reuse the package's canonical predicate instead of the raw column: in deleteAccountAdminGuard replace `AND banned = 0` with `AND ` + notBannedClause (db/mention_queries.go:40, same package). A permanently banned admin (ban_expires NULL) and a deleted/anonymised account still fail that clause, so the guard keeps excluding them; only the lapsed-temp-ban admin is counted again. + +**Fixed:** `d793d3e1f3b932e48b0165756bc7db5ce99f9f01` · test `Server/db/account_test.go` · revert-proof pass + +### OC-0215 — low — cert_store_key strips ":443" off a bare IPv6 literal ending in hextet 443, pinning one server under two different keys + +`Client/tauri-client/src-tauri/src/tofu.rs:303` · found 2026-08-20 · hunt `2026-08-20-general` · lens `tauri-rust` + +`strip_suffix(":443")` runs before any bracket/IPv6 awareness, so a bare (unbracketed) IPv6 address whose final hextet is `443` has its last group eaten as if it were a port. `http_proxy` passes the bare host verbatim while `ws_proxy` passes the bracketed authority from the wss:// URL, so the same server resolves to two different cert-store keys. The sibling parser `http_proxy::split_host_port` (http_proxy.rs:285-298) explicitly guards this case (`Some((host, port)) if !host.contains(':')`); `cert_store_key` has no such guard. + +**Repro:** Configure a server at a bare IPv6 address ending in :443, e.g. host = `fd00::443` (accepted by isValidHost's bare-IPv6 branch, and dialled correctly by http_proxy::resolve_remote_target as `[fd00::443]:443`). + +- REST path: `ensureHttpProxy("fd00::443")` → http_proxy.rs:386 `tofu::cert_store_key("fd00::443")` → strip_suffix(":443") matches → `"fd00:"`. The first-use prompt is emitted with `host: "fd00:"` and `accept_cert_fingerprint` pins under `"fd00:"`. +- WS path: ws.ts:538 builds `wss://[fd00::443]/api/v1/ws` (bracketBareIPv6Host); Rust `extract_host` → `cert_store_key("[fd00::443]")` → the string ends in `443]`, so strip_suffix(":443") misses → brackets stripped → `"fd00::443"`. `evaluate` finds no pin → a SECOND first-use prompt for the same certificate. +- LiveKit path: `ensureLiveKitProxy` sends `[fd00::443]:443` → key `"fd00::443"`, agreeing with WS and disagreeing with HTTP. + +Net effect: the user is asked to confirm one server's fingerprint twice, one prompt shows the meaningless host string `fd00:`, and the REST tunnel's pin lives under a key no other surface ever reads or re-validates. On a later certificate rotation the same split produces two independent mismatch prompts. No existing test covers a bare IPv6 whose last hextet is 443 (tofu.rs:421-432 only covers `2001:db8::1`). + +**Evidence:** tofu.rs:302-309 + pub(crate) fn cert_store_key(host: &str) -> String { + let stripped = host.strip_suffix(":443").unwrap_or(host); + let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped); + unbracketed.to_ascii_lowercase() + } + +contrast http_proxy.rs:293-296 (the guard cert_store_key lacks): + match remote_host.rsplit_once(':') { + Some((host, port)) if !host.contains(':') => Ok((host, port)), + _ => Ok((remote_host, "443")), + } + +**Suggested fix:** Give cert_store_key the same bracket/IPv6 guard split_host_port already has, in the one shared function (tofu.rs:303): `let stripped = match host.strip_suffix(":443") { Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest, _ => host };` then keep the existing bracket-unwrap and lowercase. That leaves "example.com:443" -> "example.com" and "[2001:db8::1]:443" -> "2001:db8::1" unchanged, while "fd00::443" stays whole and matches the ws/livekit key. + +**Fixed:** `0fe64f2f18cfad3ffc90ff4252923569a9c37904` · test `Client/tauri-client/src-tauri/src/tofu.rs` · revert-proof pass (hand-proved) + +### OC-0216 — low — Unmuting while server-deafened fires a voice_deafen the server always refuses — an error toast on every unmute + +`Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:80` · found 2026-08-20 · hunt `2026-08-20-general` · lens `client-state` + +`onMuteToggle`'s unmute branch auto-undeafens without checking `localServerDeafened`, while its sibling `onDeafenToggle` does carry the mirror-image `localServerMuted` guard (line 99). The server refuses a self-undeafen while `server_deafened` is set (Server/ws/voice_controls.go `refuseIfServerSilenced`, ErrCodeServerDeafened), and livekitSession.setDeafened(false) already refuses locally — so the frame is pure waste that lands in the dispatcher's generic error branch as a user-facing toast. + +**Repro:** 1) User A joins a voice channel, not muted, not deafened. 2) A moderator server-deafens A (`voice_mod_deafen`) → dispatcher's enforceModeratorAudioState sets localDeafened=true, localServerDeafened=true; VoiceWidget disables only the deafen button (VoiceWidget.ts:281-287), the mic button stays enabled. 3) A clicks the mic button (or presses Ctrl+M) to self-mute → localMuted=true. 4) A clicks the mic button again to unmute → `state.localMuted` is true, `state.localServerMuted` is false, so the branch runs: `voiceSessionSetMuted(false)` + `voice_mute{false}` (fine), then because `state.localDeafened` is true it calls `voiceSessionSetDeafened(false)` (silently refused by livekitSession.ts:1607) and sends `voice_deafen{deafened:false}`. The server answers SERVER_DEAFENED, and dispatcher.ts's catch-all error branch (line 1172) pops a red "you were deafened by a moderator" toast. Every subsequent mute/unmute cycle repeats it. + +**Evidence:** if (state.localMuted) { + voiceSessionSetMuted(false); + ws.send({ type: "voice_mute", payload: { muted: false } }); + if (state.localDeafened) { + voiceSessionSetDeafened(false); + ws.send({ type: "voice_deafen", payload: { deafened: false } }); + } + } else { + +// vs. the guarded sibling at line 99: +// if (state.localServerMuted !== true) { +// voiceSessionSetMuted(false); +// ws.send({ type: "voice_mute", payload: { muted: false } }); +// } + +**Suggested fix:** Mirror the sibling guard in Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:80 — change `if (state.localDeafened) {` to `if (state.localDeafened && state.localServerDeafened !== true) {` so no voice_deafen{deafened:false} frame is sent while the moderator deafen stands (the unmute half at 78-79 still goes through). + +**Fixed:** `cfb8168eadbf82563ff9c7a90a521783f20cb7d1` · test `Client/tauri-client/tests/unit/voice-callbacks.test.ts` · revert-proof pass + +### OC-0217 — low — scrollToMessage registers a new permanent abort listener (pinning a message row) on the component-lifetime AbortSignal on every jump + +`Client/tauri-client/src/components/MessageList.ts:1021` · found 2026-08-20 · hunt `2026-08-20-general` · lens `client-state` + +The highlight-flash cleanup is attached to `ac.signal` — the MessageList's whole-lifetime controller — once per `scrollToMessage` call, and `{ once: true }` only removes it when abort actually fires (i.e. at destroy). Each closure captures the target row's `HTMLElement`, so every jump adds one listener and pins one (usually already re-rendered away) DOM subtree until the channel is unmounted. + +**Repro:** Open a channel and jump repeatedly within it — click a reply bar's jump arrow, a search hit, or a pinned entry — N times. Each successful `scrollToMessage` reaches line 1017-1021 and calls `ac.signal.addEventListener("abort", …)`. After 200 jumps the single AbortSignal carries 200 listeners and 200 detached message-row elements are still strongly reachable through their closures; none are released until `destroy()` aborts the controller. `renderWindow()`/`renderAll()` rebuild `contentContainer`'s children on every store update, so the pinned nodes are dead DOM. Same defect shape as the already-fixed SearchOverlay.ts:96, context-menu.ts:88 and VoiceAudioTab.ts:490 findings. + +**Evidence:** el.classList.add("highlight-flash"); + const timer = window.setTimeout(() => { + el.classList.remove("highlight-flash"); + }, 1500); + // Unmounting mid-flash must not leave a timer pointing at a dead node. + ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + +**Suggested fix:** Drop the per-call registration: hoist one factory-scoped `let flashTimer = 0; let flashEl: HTMLElement | null = null;`, and in scrollToMessage do `if (flashTimer !== 0) { clearTimeout(flashTimer); flashEl?.classList.remove("highlight-flash"); } flashEl = el; flashTimer = window.setTimeout(() => { el.classList.remove("highlight-flash"); flashTimer = 0; flashEl = null; }, 1500);`, then clear the same pair inside the existing destroy() (next to `ac.abort()`), so no abort listener is registered at all. + +**Fixed:** `8f6b22708d2a17f349b117f74817174c1a349faf` · test `Client/tauri-client/tests/unit/message-list.test.ts` · revert-proof pass + +### OC-0218 — low — ready-time GET /blocks has no staleness guard, so it silently reverts a block/unblock the user performs while it is in flight + +`Client/tauri-client/src/lib/dispatcher.ts:467` · found 2026-08-20 · hunt `2026-08-20-general` · lens `concurrency` + +The `ready` handler fires `api.listBlocks()` and unconditionally applies the response with `setBlockedByMe(...)`, a whole-set replace. `onToggleBlock` writes the same store with a per-user delta (`setUserBlockedByMe`) only after its own `await api.blockUser/unblockUser`. Neither writer has a generation/epoch guard, so whichever network reply lands second wins — and the stale full-set reply can land after the fresh delta. + +**Repro:** 1. Client is in a session with user 42 in `blockedByMe`. 2. The socket reconnects onto the full-resync tier, so dispatcher's `ready` handler runs and issues GET /blocks at T0 (response body will contain 42). 3. At T0+50ms the user opens the member list and clicks "Unblock" on user 42; DELETE /blocks returns 204, `setUserBlockedByMe(42, false)` removes 42 from `blockedByMe`, and the toast says "Unblocked ". 4. At T0+150ms the GET issued in step 2 resolves and `setBlockedByMe([...,42,...])` replaces the whole set, re-adding 42. Result: the server has the user unblocked, but `dmComposerBlockReason` still returns "You've blocked this user. Unblock to send messages." and the DM composer stays disabled. Nothing else ever writes `blockedByMe`, so the contradiction persists until the next `ready`. The mirror case (user clicks Block inside the window) drops the block locally, un-gating a composer whose sends the server will refuse. + +**Evidence:** dispatcher.ts:464-470 + clearBlockedByThem(); + if (api !== undefined) { + api + .listBlocks() + .then((r) => setBlockedByMe(r.blocked_user_ids)) + .catch((err) => log.warn("Failed to load block list", { error: String(err) })); + } + +SidebarMemberSection.ts:176-185 + onToggleBlock: async (userId, username, block) => { + try { + if (block) { + await api.blockUser(userId); + } else { + await api.unblockUser(userId); + } + setUserBlockedByMe(userId, block); + getToast()?.show(block ? `Blocked ${username}` : `Unblocked ${username}`, "success"); + +**Suggested fix:** Put the guard in the store, not the caller, so both writers share it: give blocksStore a monotonically increasing `blockedByMeRev` that setUserBlockedByMe bumps on every accepted per-user delta, and change setBlockedByMe to take the revision observed before the fetch — `setBlockedByMe(userIds, rev)` returns without writing when `rev !== state.blockedByMeRev`. In dispatcher.ts:465-469 snapshot it before the call: `const rev = blocksStore.getState().blockedByMeRev; api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, rev))`. Existing callers of setBlockedByMe in tests pass the current revision (or make the parameter optional = force). + +**Fixed:** `4bab1b4b4b8b74baac7a3eb153dd313bb1d02932` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass + +### OC-0219 — low — rollbackVoiceJoin clears the client's voice state but never drops its VoiceTopic subscription, so the socket keeps receiving another room's E2EE announces for the rest of the connection + +`Server/ws/voice_join.go:643` · found 2026-08-20 · hunt `2026-08-20-general` · lens `state-desync` + +Every other path that takes a client out of voice while its WS stays up goes through `clearVoiceAndUnsubscribe` (voice_leave.go:15) or an explicit `h.pubsub.Unsubscribe(c, VoiceTopic(...))` (voice_leave.go:47, hub_sweep.go:405, livekit_webhook.go:300). `rollbackVoiceJoin` does only `c.clearVoiceChID()` — and it is reached from `voiceJoinComplete` *after* `h.pubsub.Subscribe(c, VoiceTopic(channelID))` has already run (voice_join.go:471 → 494). The client's in-memory voice state and the pubsub subscription registry therefore disagree for the lifetime of the socket, which is exactly the hazard `clearVoiceAndUnsubscribe`'s own doc comment says every leave path must avoid. + +**Repro:** 1. Alice joins voice channel 5. `voiceJoinComplete` runs: line 471 subscribes her socket to VoiceTopic(5), line 474 elects a key holder, line 476 broadcasts her voice_state. +2. The very next statement, `h.db.GetChannelVoiceStates(ctx, 5)` (line 491), fails (SQLITE_BUSY / I/O error / the ctx-free reader pool hiccup this branch was written for — see the OC-0172 test which fault-injects exactly this). +3. Line 494 calls `rollbackVoiceJoin(ctx, c, 5, state.JoinedAt, true)`: `c.clearVoiceChID()` zeroes her voiceChID, the row is deleted, a compensating voice_leave is broadcast, and her client tears the session down. Line 495 sends her an INTERNAL error. +4. But `ps.topics["voice:5"]` still maps her userID → her *Client. `sendToVoiceChannelExcept` (voice_e2ee.go:271) publishes every `voice_e2ee_announce` for channel 5 onto that topic, and `buildVoiceE2EEAnnounce(userID, pubKey, sig)` carries no channel_id. +5. Alice retries and joins voice channel 9. Bob (still in channel 5) reconnects to the SFU and re-announces. The relay reaches Alice's socket; `dispatcher.ts:965` calls `handleE2EEAnnounce(...)` with no channel filter, and `handleAnnounceInner` (livekitE2EE.ts:807) writes Bob into `_peerPublicKeys` for the channel-9 session. If Alice is channel 9's key holder, line 818 immediately wraps channel 9's room key for Bob and spends a `sendOfferPaced` slot plus one of the server's 64-offers/sec budget on an offer the server then drops. Every subsequent rotation re-includes Bob in `distributeRoomKey`'s peer snapshot, permanently taxing the rotation budget that OC-0167 was fixed to keep inside the server's cap. + +**Evidence:** voice_join.go:471 h.pubsub.Subscribe(c, VoiceTopic(channelID)) +voice_join.go:491-496 + existing, err := h.db.GetChannelVoiceStates(ctx, channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + h.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) + return + } +voice_join.go:642-643 + func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) { + c.clearVoiceChID() // <- no h.pubsub.Unsubscribe(c, VoiceTopic(channelID)) + +contrast, voice_leave.go:14-20 + func (h *Hub) clearVoiceAndUnsubscribe(c *Client) (int64, string) { + oldChID, oldJoinToken := c.clearVoiceState() + if oldChID != 0 { h.pubsub.Unsubscribe(c, VoiceTopic(oldChID)) } + +**Suggested fix:** In Server/ws/voice_join.go:643 replace `c.clearVoiceChID()` with `h.clearVoiceAndUnsubscribe(c)`. It performs the same clear (clearVoiceChID already delegates to clearVoiceState, client.go:143-144) and additionally drops VoiceTopic(oldChID). This is safe for the other two call sites: at line 286 the client's voice state is still 0 (setVoiceState has not run), so no unsubscribe fires, and at line 396 oldChID == channelID, which is the subscription that should not exist yet and is a no-op. + +**Fixed:** `ee8c8214803b4d75833684c1a4701d0f31f8a4d9` · test `Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go` · revert-proof pass + +### OC-0220 — low — A group DM that loses all its other members renders with a completely blank name everywhere + +`Client/tauri-client/src/stores/dm.store.ts:197` · found 2026-08-20 · hunt `2026-08-20-general` · lens `ordering-boundary` + +`dmDisplayName` falls back to `dm.recipient.username` when `participants` is empty, but the server only populates `Recipient` when the "others" list is non-empty (`db.NewDMChannelInfo` / `GetUserDMChannels` both leave it as the zero-valued `DMUser`). A group DM legitimately reaches zero *other* participants — `LeaveGroupDM` only deletes the channel row when `remaining == 0`, so the last member keeps a live, is_group=1 channel — and `dm_participants` also CASCADEs when other members delete their accounts. The DmChannel doc comment ("Never empty for a live DM") and the DmSidebar avatar builder (which explicitly handles "An empty group (every other member has left)") both anticipate this state; `dmDisplayName` is the one place that does not, so it returns "". + +**Repro:** 1. A, B and C create an unnamed group DM (name is "", the default when the optional name is omitted). +2. B closes the DM (DELETE /dms/{id} -> DMService.CloseDM -> LeaveGroupDM); then C does the same. `remaining` is 1, not 0, so the channel row survives for A. +3. A reconnects (or just receives the dm_channel_open refresh). GetUserDMChannels returns the channel with Recipients=[] and Recipient={ID:0, Username:""}. +4. dmDisplayName returns "". A's DM sidebar row, the chat header (ChannelController.ts:593), the quick switcher, MainPage.ts:230 and DM desktop notifications (notifications.ts:35 -> NotificationsTab renders `@` + "") all render an empty label, and the avatar circle renders no letter. The conversation is unidentifiable and, in a list of several such groups, indistinguishable. +Note: the existing test `dm-groups.test.ts:86` ("falls back to the recipient when the participant list is empty") only covers the legacy pre-group case where `recipient` IS populated, so it does not lock this behavior. + +**Evidence:** dm.store.ts:194-201 + export function dmDisplayName(dm: DmChannel): string { + if (dm.name !== "") return dm.name; + const names = dm.participants.map((p) => (p.displayName ?? "") || p.username); + if (names.length === 0) return dm.recipient.username; // <-- zero-value DMUser -> "" + +Server/db/dm_queries.go:80-82 (NewDMChannelInfo) + if len(others) > 0 { + info.Recipient = others[0] + } // else Recipient stays the zero value (Username "") + +Server/db/dm_queries.go:246-249 (GetUserDMChannels) — same guard. +Server/ws/serve_ready.go:88 already codes around it: `if dmChannels[i].Recipient.ID != 0 && ...`. + +DmSidebar.ts:120-122 acknowledges the same state for avatars: + // An empty group (every other member has left) still needs a mark, so fall + // back to the row's own label rather than rendering an empty circle. + const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }]; +(convo.username is itself dmDisplayName's "", so the avatar letter is empty too.) + +**Suggested fix:** Give the shared function a terminal fallback instead of returning a possibly-empty recipient username — one guard covers all seven call sites: replace dm.store.ts:197 with `if (names.length === 0) return dm.recipient.username !== "" ? dm.recipient.username : (dm.isGroup ? "Empty group" : "Unknown user");` + +**Fixed:** `b7229b94fec6cd686cbd423ce2f431a590e1152a` · test `Client/tauri-client/tests/unit/dm-groups.test.ts` · revert-proof pass + +### OC-0221 — low — Composer has no attachment-count cap while the server hard-rejects >10, so an 11-attachment message can never be sent + +`Client/tauri-client/src/components/MessageInput.ts:612` · found 2026-08-20 · hunt `2026-08-20-general` · lens `ordering-boundary` + +`handlePasteFile` uploads and queues an attachment with no bound on `pendingAttachments.length`, and `handleSend` forwards every finished upload id. The server's chat_send constructor refuses the whole frame at exactly 11 (`if len(p.Attachments) > 10`), and that refusal is a *parse* error, so it comes back as the generic `BAD_REQUEST` / "invalid payload" with no mention of attachments. The composer has already cleared the preview bar by then, so the user is left with a permanently-failing message row and no way to learn what is wrong. + +**Repro:** 1. Open any channel with ATTACH_FILES. Paste (or pick, 11 times) 11 small images into the composer; all 11 upload successfully and show previews. +2. Type any text and press Send. +3. The server's chat_send parser returns BAD_REQUEST "invalid payload" carrying the send's correlation id; the optimistic row flips to "failed" with a generic error, the preview bar is already cleared, and the 11 uploads are orphaned server-side. +4. Retry fails identically every time. Nothing in the UI indicates that the attachment count (11 > 10) is the cause, and the composer never prevented queueing the 11th. + +**Evidence:** Client MessageInput.ts:536-612 (handlePasteFile) validates only `state.editing`, `file.size` and `file.type`, then: + pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item }); +(no length check anywhere in the file — `pendingAttachments` is only ever read for `.length > 0` / `.length === 0`) + +Client MessageInput.ts:489-496 (handleSend) + const attachmentIds = pendingAttachments + .filter((a) => !a.id.startsWith("pending-")) + .map((a) => a.id); + options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds); + clearReply(); + clearPendingAttachments(); // previews discarded regardless of outcome + +Server/ws/command.go:416 + if len(p.Attachments) > 10 { + return nil, fmt.Errorf("too many attachments (max 10)") + } + +Server/ws/handlers.go:51-54 — a constructor error yields: + c.sendMsg(buildErrorMsgWithID(ErrCodeBadRequest, "invalid payload", env.ID)) + +Client dispatcher.ts:1067-1086 routes that id to `markSendFailed(id, "BAD_REQUEST")`. + +**Suggested fix:** Add the bound at the single entry point rather than at each call site — in handlePasteFile, alongside the existing size/type guards (MessageInput.ts, just before the `const tempId = ...` line): `if (pendingAttachments.length >= 10) { showUploadError("You can attach at most 10 files to a message"); return; }`. Placing it before the upload also stops the 11th file from being uploaded and orphaned. + +**Fixed:** `dbf2867514ee50cf15b390246470ffaacb17c344` · test `Client/tauri-client/tests/unit/message-input.test.ts` · revert-proof pass + +### OC-0222 — low — Resume path builds auth_ok before applyConnectStatus, so every reconnect ships the disconnect-time status and makes the client fire a redundant presence_update + +`Server/ws/serve.go:289` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-reconnect` + +handleFreshConnect settles the session status (applyConnectStatus, serve.go:836) BEFORE it writes auth_ok (serve.go:840), so a fresh connect's auth_ok carries db.ConnectStatus(saved). handleReconnect inverts that order: reconnectWriteReplay writes buildAuthOK(ctx, c.user, ...) at serve.go:574 and applyConnectStatus only runs at serve.go:289, after the replay burst. On a resume, c.user.Status is whatever the previous connection's teardown left in the row — and MarkUserDisconnected (Server/db/queries/sqlite/users.sql:31, 'SET status = CASE WHEN status = ''online'' THEN ''offline'' ELSE status END') rewrites a plain online user to 'offline'. So every resumed auth_ok tells the client its own status is 'offline' while the server is simultaneously about to set and broadcast 'online'. The client's MainPage.restoreSavedPresence() (Client/tauri-client/src/pages/MainPage.ts:196-201) compares loadUserStatus() against exactly that auth_ok value and, on mismatch, sends a presence_update — the call its own doc comment says 'is a no-op in the normal case', and which tests/unit/main-page.test.ts:624 explicitly forces to be a no-op by pinning authStore.user.status to 'online'. Ordering is deterministic: ws.ts's setState('connected') schedules the uiStore notification in a microtask (lib/store.ts:134) while setAuth runs synchronously in the same dispatch, so restoreSavedPresence always reads the freshly-received (stale) auth_ok status. + +**Repro:** 1. User U (no chosen status; local pref userStatus = "online") is connected; users.status = 'online'. 2. Kill the socket (proxy blip). readPump's defer runs MarkUserDisconnected -> users.status = 'offline'. 3. ws.ts backs off ~1s and reconnects with last_seq > 0; the ring buffer still covers it, so handleReconnect takes the buffer tier. 4. reconnectPrecheck's refreshUserSnapshot reads status 'offline'; reconnectWriteReplay writes auth_ok with payload.user.status = "offline". 5. Client: setAuth stores status 'offline'; uiStore.connectionStatus flips back to "connected"; MainPage's subscriber calls restoreSavedPresence(), sees "online" != "offline", and sends presence_update{status:"online"} — consuming the session's single 1-per-10s presence token and triggering a second server-wide sequenced presence fan-out on top of the one applyConnectStatus/announceConnectPresence already produced. 6. Consequence: any genuine status change (manual pick, or auto-idle's idle/return-to-online transition) in the next 10 s is deferred to presenceSender's retry instead of being sent immediately; and in a reconnect storm every reconnecting client adds an extra O(connected-clients) global broadcast, defeating the QueuePresence coalescer. Contrast a fresh connect (F5), where auth_ok carries 'online' and restoreSavedPresence is correctly a no-op. + +**Evidence:** serve.go handleReconnect: + if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) { ... } // line 280 -> writes buildAuthOK(ctx, c.user, ...) at line 574 + // Update presence but skip member_join — user was already known. + applyConnectStatus(ctx, database, c) // line 289 <-- runs AFTER auth_ok + h.announceConnectPresence(c) // line 290 + +vs. handleFreshConnect: + applyConnectStatus(ctx, database, c) // line 836 <-- runs BEFORE auth_ok + if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, "none")); ... // line 840 + +Client/tauri-client/src/pages/MainPage.ts:196 + function restoreSavedPresence(): void { + const status = loadUserStatus(); // "online" by default + const serverStatus = authStore.getState().user?.status; // "offline" from the resumed auth_ok + if (serverStatus === status) return; + applyPresence(status); // spends the shared 1-per-10s token + } + +**Suggested fix:** Move the applyConnectStatus(ctx, database, c) call in handleReconnect from serve.go:289 to just before the reconnectWriteReplay call at serve.go:280 (i.e. immediately after reconnectRegister returns), leaving h.announceConnectPresence(c) where it is. That makes the resumed auth_ok carry db.ConnectStatus(saved), matching handleFreshConnect, with no change to the replay contents or the post-replay broadcast. + +**Fixed:** `7c05232817c4c87e31206a5c2b8a49a0380fa5d0` · test `Server/ws/oc_0222_reconnect_status_order_test.go` · revert-proof pass + +### OC-0223 — low — @here raises a mention badge for users who are offline but whose last chosen status was idle/dnd + +`Server/service/mentions.go:179` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-message` + +The @here narrowing reads `users.status`, but that column keeps a *chosen* idle/dnd across a disconnect by design — MarkUserDisconnected rewrites only 'online' to 'offline'. Every other read path compensates with the "no live connection is offline, whatever the row says" rule (ws/serve_ready.go presentableMembers/presentableDMChannels); this one does not, so @here reaches signed-out users whose last status was idle or dnd while correctly skipping signed-out users whose last status was online. + +**Repro:** User B sets status to Do Not Disturb (or Idle) and closes the client; readPump's teardown calls MarkUserDisconnected, which leaves users.status = 'dnd'. User C simply closes the client while online; their row becomes 'offline'. User A (holding MENTION_EVERYONE) posts "@here standup" in #general. applyMentionCounts -> mentionReaders returns both B and C; BroadcastStatus('dnd') != 'offline' so B is added to `recipients` and IncrementMentionCounts bumps B's read_states.mention_count, while C is correctly skipped. B — equally offline — comes back to a red @here mention badge, which is exactly what the @here/offline narrowing exists to prevent. No test in Server/service/mentions_test.go covers a disconnected idle/dnd reader (only the invisible-but-connected case, TestSendMessage_HereSkipsInvisibleUsers). + +**Evidence:** // Server/service/mentions.go:179 +if set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline { + continue +} + +// Server/db/queries/sqlite/users.sql:25-34 (MarkUserDisconnected) +// "It clears only 'online' ... A stale choice never renders as 'present' +// because the read path treats a member with no live connection as offline +// regardless." +UPDATE users +SET status = CASE WHEN status = 'online' THEN 'offline' ELSE status END, + last_seen = datetime('now') +WHERE id = ?; + +// r.Status comes straight from the column: db/mention_queries.go:351 +// SELECT id, status, role_id FROM users WHERE AND role_id IN (...) + +**Suggested fix:** Give applyMentionCounts the same live-connection rule the read path uses, in the one shared place rather than at each call site. Add an optional predicate to MessageService (e.g. `online func(int64) bool`, nil-safe) that the ws layer wires to the hub's connected-id lookup (Hub.GetClient / connectedUserIDs), then at Server/service/mentions.go:179 make the @here skip `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID)))`. Leaving s.online nil preserves today's behavior for tests and any caller that has no hub. + +**Fixed:** `0ad96147da2e50377b23e985d8f07dafc2953602` · test `Server/service/mentions_test.go` · revert-proof pass + +### OC-0224 — low — A DM message that lands between registerNow and buildReady is counted twice in the DM unread badge — updateDmLastMessage has no message-id monotonicity guard + +`Client/tauri-client/src/stores/dm.store.ts:142` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-message` + +updateDmLastMessage increments unreadCount unconditionally, with no check that `messageId` is newer than the row's `lastMessageId`. On a fresh connect the server registers the client (ws/serve.go handleFreshConnect, registerNow) BEFORE it snapshots unread counts in buildReady, so a DM delivered in that window is both included in ready's authoritative `unread_count` and queued for delivery after ready — the client applies both. + +**Repro:** 1) User A opens the client (fresh connect, last_seq = 0). 2) handleFreshConnect calls registerNow, which subscribes A to UserTopic; a DM sent by B at this instant is fanned out via EmitEvents -> sendSequencedToUsers -> SendToUser and lands in A's send buffer. 3) buildReady then runs GetUserDMChannels, which already counts that message: dm_channels[i].unread_count = 1. 4) A's client applies `ready` (setDmChannels sets unreadCount = 1), then the transport delivers the queued chat_message; the dispatcher's CHAT_MESSAGE handler (dispatcher.ts:601) calls updateDmLastMessage, which bumps unreadCount to 2. The DM sidebar shows a badge of 2 for one unread message, and it persists until the DM is opened or the next full `ready`. buildReady runs ~8 DB queries after registration, so the window is milliseconds-to-tens-of-milliseconds wide on a loaded server. + +**Evidence:** // Client/tauri-client/src/stores/dm.store.ts:136-143 +channels: [ + { + ...updated, + lastMessageId: messageId, + lastMessage: content, + lastMessageAt: timestamp, + unreadCount: updated.unreadCount + 1, // no `messageId > updated.lastMessageId` guard + }, + ...rest, +] + +// Server/ws/serve.go handleFreshConnect: registerNow(...) at ~line 805, +// buildReady(...) at ~line 846 — registration precedes the snapshot, and +// writePump (which drains the queued frame) only starts after the handshake +// writes, so the queued chat_message is delivered strictly after `ready`. + +**Suggested fix:** Guard the increment on message-id monotonicity inside the shared store function, not at the dispatcher call sites (message ids are globally monotonic rowids, so id <= lastMessageId can only mean a duplicate or an out-of-order re-delivery). In Client/tauri-client/src/stores/dm.store.ts:142 replace `unreadCount: updated.unreadCount + 1` with `unreadCount: updated.lastMessageId !== null && messageId <= updated.lastMessageId ? updated.unreadCount : updated.unreadCount + 1`, keeping the preview/reorder update unconditional. + +**Fixed:** `b7229b94fec6cd686cbd423ce2f431a590e1152a` · test `Client/tauri-client/tests/unit/dm-store.test.ts` · revert-proof pass + +### OC-0225 — low — A transient DB read error in the admin perimeter is reported as 401, ejecting an admin from the panel mid-session + +`Server/admin/middleware.go:56` · found 2026-08-20 · hunt `2026-08-20-general` · lens `flow-session` + +adminAuthMiddleware's error switch has the same shape as api.AuthMiddleware's: ResolveTokenHash's wrapped DB errors are non-sentinel, so they fall into the `default` arm and are answered as 401 "invalid or expired session" — indistinguishable from an unknown token. Unlike the api middleware this one does not even log the distinction, so a DB outage on the admin perimeter is silently reported to the operator as a dead session. The desktop client routes adminRequest() through the same doFetch 401 sink as ordinary API calls, so an admin acting from the app (kick/ban/role change/channel edit all go to /admin/api) is signed out and has their credential deleted by the same path as finding 1. + +**Repro:** 1. An admin has the panel open (or is using the desktop client's admin actions) with a valid session. +2. One SQLite read fails transiently — the scheduled backup's VACUUM INTO, a restore swapping the file, or plain lock contention — while a /admin/api/* request is in flight. +3. ResolveTokenHash returns the wrapped DB error; the default arm writes 401 "invalid or expired session". +4. The web panel shows the admin as logged out; from the desktop client, doFetch's 401 sink fires onUnauthorized -> clearAuth -> deleteCredential(host), ending the whole chat session and erasing the stored credential — all for a session that was never revoked. + +**Evidence:** Server/admin/middleware.go:47-61 + user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash) + if err != nil { + switch { + case errors.Is(err, auth.ErrTokenExpired): ... + case errors.Is(err, auth.ErrUserNotFound): ... + case errors.Is(err, auth.ErrRoleNotFound): ... + default: + // ErrTokenNotFound or a wrapped DB error. + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") + } + return + } + +Client/tauri-client/src/lib/api.ts:190-197 — adminRequest() uses the same doFetch, so it hits the 401 sink at api.ts:150 with no skipUnauthorized opt-out. + +**Suggested fix:** Mirror the same split here: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other error log it and writeErr 503 SERVICE_UNAVAILABLE. Best done once — have both middlewares share a helper that maps a ResolveTokenHash error to (status, code, message). + +**Fixed:** `b0ef1313f087bd19870ee237da5e7aa85bc3c59f` · test `Server/admin/middleware_db_error_test.go` · revert-proof pass + +### OC-0226 — low — uiStore is the only domain store clearAuth does not reset, so sidebarMode (and activeDmUserId) leak across a logout into the next server + +`Client/tauri-client/src/stores/ui.store.ts:36` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +ui.store.ts exports no reset function and clearAuth (auth.store.ts:96-99) resets voice, messages, channels and blocks but never the UI store. sidebarMode is not restated by any `ready` payload, so it survives sign-out as module-global state and SidebarArea mounts the next server's sidebar in whatever mode the previous session left it in. + +**Repro:** 1. Sign into server X. 2. Click a DM in the sidebar -> SidebarDmHelpers.ts:51-52 calls setActiveDmUser() then setSidebarMode("dms"). 3. Log out (lib/logout.ts:17 -> clearAuth). clearAuth resets voiceStore, messagesStore, channelsStore, blocksStore and authStore, but uiStore is untouched: sidebarMode is still "dms" and activeDmUserId still holds server X's user id. 4. Sign into a different server Y. MainPage mounts, createSidebarArea reads `const initialMode = uiStore.getState().sidebarMode;` (pages/main-page/SidebarArea.ts:705) and mounts the DM sidebar instead of Y's channel list, even though the chat pane shows a text channel the dispatcher auto-selected. The user must hit "Back" to reach the channel list. Same class as the already-fixed blocksStore-survives-clearAuth and settingsOpen-survives-ConnectPage.destroy leaks. + +**Evidence:** ui.store.ts:36 export const uiStore = createStore(INITIAL_STATE); // no resetUiStore export anywhere in the file +ui.store.ts:180-186 export function setSidebarMode(mode) { uiStore.setState((prev) => ({ ...prev, sidebarMode: mode, activeDmUserId: mode === "channels" ? null : prev.activeDmUserId })); } + +auth.store.ts:96-99 + resetVoiceStore(); + resetMessagesStore(); + resetChannelsStore(); + resetBlocksStore(); + +SidebarArea.ts:704-706 + // Initial mount based on current store state + const initialMode = uiStore.getState().sidebarMode; + mountSidebarContent(initialMode); + +**Suggested fix:** One line in clearAuth (auth.store.ts, alongside resetBlocksStore()): `setSidebarMode("channels")` imported from @stores/ui.store — it also nulls activeDmUserId (ui.store.ts:180-186). ui.store imports nothing from auth.store, so there is no import cycle. Prefer this over a blanket resetUiStore(), which would also clobber `theme` (a user preference, not session state). + +**Fixed:** `78c75ada02ea05601721336032a47b027924277a` · test `Client/tauri-client/tests/unit/auth.store.test.ts` · revert-proof pass + +### OC-0227 — low — Video-call tiles label participants with the raw username and never refresh it, so a nickname is ignored and a mid-call rename leaves the tile stale + +`Client/tauri-client/src/pages/MainPage.ts:685` · found 2026-08-20 · hunt `2026-08-20-general` · lens `hotspot-server-ws` + +The video grid's tile label is built from voiceStore's frozen `user.username` rather than the member's display name, and addStream is the only thing that writes the label (VideoGrid.ts:274-277). addStream is called exactly twice — from setOnRemoteVideo on LiveKit TrackSubscribed (MainPage.ts:690) and from VideoModeController's local-tile block, which is latched behind `if (!localTileAdded)` (VideoModeController.ts:174-185). Neither is re-driven by a profile change, so the label is fixed for the life of the tile. Every other identity surface was moved to memberDisplayName — ChannelSidebar's voice roster (ChannelSidebar.ts:421-422, "Render the same identity a rename shows everywhere else"), the member list, message rows, the DM sidebar and the typing indicator — with only the deliberately security-sensitive surfaces (E2EE mismatch modal, moderation menu) keeping the raw username. The video tile is neither, and it was missed. + +**Repro:** Two users in a voice channel; user B has a nickname/display_name set ("Bee") that differs from their username ("bob_1994"). B turns on their camera. A's sidebar voice roster shows "Bee" (memberDisplayName), while the video tile that opens shows "bob_1994" — the same person under two names in one screen. Then, with the camera still on, B renames themselves via Settings → Account: the user_update fan-out patches membersStore and voiceStore, the sidebar row and every message row repaint with the new name, but the video tile keeps the old string because nothing calls addStream again for that tile. + +**Evidence:** MainPage.ts:685-694 + const username = isScreenshare + ? user?.username ? `${user.username} (Screen)` : `User ${userId} (Screen)` + : (user?.username ?? `User ${userId}`); + videoGrid.addStream(tileId, username, stream, {...}); + +VideoModeController.ts:174-185 + if (!localTileAdded) { ... videoGrid.addStream(currentUserId, me?.username ? `${me.username} (You)` : "You", ...); localTileAdded = true; } + +VideoGrid.ts:274-277 (label is only rewritten by another addStream call) + +ChannelSidebar.ts:421-422 + const member = membersStore.getState().members.get(user.userId); + const label = (member !== undefined ? memberDisplayName(member) : user.username) || "Unknown"; + +**Suggested fix:** Route both call sites through one shared label helper that prefers memberDisplayName: in MainPage.ts:685-689 and VideoModeController.ts:178/199, look up membersStore.getState().members.get(userId) and use memberDisplayName(member) with user.username as fallback (the exact idiom at ChannelSidebar.ts:421-422). For the staleness half, add a VideoGrid.setLabel(tileId, text) and call it from the existing USER_UPDATE handling — cheapest hook is right after updateVoiceUserProfile in dispatcher.ts:815, relabelling the plain and SCREENSHARE_TILE_ID_OFFSET tiles for that user id — rather than un-latching localTileAdded, which would re-run addStream and churn srcObject. + +**Fixed:** `0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass + +### OC-0228 — low — Mention pills bypass the server's authoritative `mentions` list, so a token the server refused still renders as a live (yellow "you") mention + +`Client/tauri-client/src/lib/mentions.ts:77` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +`resolveMentionUserId` runs its member-list-by-username fallback unconditionally, not only when the server omitted `mentions`. The header comment promises "a token the server would not resolve must not be highlighted here either" and the row-level gate (`mentionsCurrentUser`, line 108) does honour the server, but the inline pill built by `buildMentionNode` (content-parser.ts:301) resolves locally and stamps `.mention-self` (the yellow "you were mentioned" styling, app.css:1298). Result: one and the same message can be un-highlighted at the row level and self-highlighted at the token level, and the sender sees a live pill for a ping that was never delivered. + +**Repro:** Three independent ways to make the server's `mentions` disagree with the local parse, all of which produce a false pill: +(1) Cap: post one message containing 21+ distinct @mentions of real members. Server resolves only the first 20 (maxMentionsPerMessage, mentions.go:17/142) and ships `mentions` with 20 ids; every client still renders all 21 as `.mention` pills, and the 21st user's own client renders theirs as `.mention-self` (yellow) while `highlightsCurrentUser` returns false — no row highlight, no unread mention badge, no notification. +(2) Case folding: user `élodie` exists. Post "hey @Élodie". Server: LowerASCII("Élodie") = "Élodie" != map key LowerASCII("élodie") = "élodie" -> `mentions: []`, no badge. Client: "Élodie".toLowerCase() === "élodie".toLowerCase() -> resolves, renders `.mention .mention-self` for élodie. +(3) The existing unit test tests/unit/mentions-render.test.ts:309 ("trusts the server list over the local name parse") sends content "hey @me" with mentions:[10] where the signed-in user is id 12, and asserts the row lacks `.mentioned`. Render the same message and query `.mention` — the span carries `mention-self` and data-user-id="12", i.e. the exact case the test declares the server wins is still self-highlighted inline. + +**Evidence:** mentions.ts:67-85 + for (const id of info?.mentions ?? []) { + const member = members.get(id); + if (member !== undefined && matches(member.username)) return id; + } + for (const member of members.values()) { // <- line 77, runs even when info.mentions was supplied + if (matches(member.username)) return member.id; + } + +vs the server, which is the sole authority for the wire field `mentions`: +Server/service/mentions.go:142 if len(set.UserIDs) >= maxMentionsPerMessage { break } // cap = 20 +Server/service/mentions.go:71 raw := db.LowerASCII(m[2]) // ASCII-only fold, deliberately (OC-0131) + +vs the client, which folds with full Unicode: +mentions.ts:51 const lower = token.toLowerCase(); +mentions.ts:71 const matches = (username) => spellings.includes(username.toLowerCase()); + +**Suggested fix:** Gate the two local fallbacks in the shared function rather than at each call site. In resolveMentionUserId, after the info.mentions loop, return null when the server supplied a list: `if (info?.mentions !== undefined) return null;` placed between line 76 and line 77. Callers that legitimately have no server list — resolveMentionsFromContent (line 94), renderers.ts:149's renderMentions(msg.content), and optimistic echoes whose msg.mentions is undefined (messages.store.ts:299-311) — pass info?.mentions === undefined and keep the fallback. This makes the token-level gate agree with mentionsCurrentUser (line 108) and with the already-documented rule at lines 63-65 that a server-listed id the member map cannot name stays unhighlighted. Verified against the suite: no existing assertion in tests/unit/mentions-render.test.ts changes. + +**Fixed:** `754ce6b5e70a3143bfd11729cee07168da7ca83e` · test `Client/tauri-client/tests/unit/mentions-render.test.ts` · revert-proof pass + +### OC-0229 — low — Channel rows register their context-menu listener on the sidebar-lifetime AbortSignal on every render, so every incoming message permanently retains a full set of detached rows + +`Client/tauri-client/src/components/channel-sidebar/context-menu.ts:53` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-3` + +`attachChannelContextMenu` does `el.addEventListener("contextmenu", …, { signal })` where `signal` is `ChannelSidebar`'s single factory-lifetime `ac.signal` (ChannelSidebar.ts:747, passed down at :806 → :610). Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal that holds a strong reference to the event target; it is only released when the signal aborts, which happens once, in `ChannelSidebar.destroy()`. `renderChannels()` (ChannelSidebar.ts:776) does `clearChildren(channelList)` and rebuilds every row from scratch, so each render leaks one retained detached row per channel. This is the same defect class the ledger already confirmed for SearchOverlay.ts:96 and MessageList.ts:1021 — but at far higher frequency, because `renderChannels()` is wired to `channelsStore.subscribeSelector((s) => s.channels, …)` (ChannelSidebar.ts:872) and `incrementUnread` builds a brand-new `channels` Map for every message delivered to a non-active channel (channels.store.ts:350). The same file also gets it right elsewhere: lines 185-188 explicitly tie the menu's bridge listener to `menuAc` "so this bridge listener is torn down with the menu itself", while the five menu-item listeners beside it (lines 82, 104, 135, 155, and the `signal` handed to `appendPurgeSection` at 172) stay on the long-lived `signal` and retain one detached `.channel-ctx-menu` subtree per right-click. + +**Repro:** Sign in to a server with 20 channels and leave the client open on one channel while traffic flows in the others. Every message posted to a non-active channel calls `incrementUnread`, which returns `{...prev, channels: new Map(...)}`, which fires the `s.channels` selector, which runs `renderChannels()`: `clearChildren(channelList)` detaches all 20 rows and 20 fresh rows are built, each calling `attachChannelContextMenu(el, channel, ac.signal, …)` at ChannelSidebar.ts:610. After 1,000 messages, `ac.signal` holds 20,000 abort algorithms, each pinning a detached `.channel-item` subtree (plus, for a MANAGE_CHANNELS holder, three more per row from drag-reorder.ts:256/268/304). None are collectable until MainPage is destroyed. Take a heap snapshot after 10 minutes on a busy server and filter for detached `.channel-item` — the count grows monotonically with message volume and never drops. Secondary repro for the per-open variant: right-click the same channel row 200 times, dismissing each menu with a left-click; 200 detached `.channel-ctx-menu` subtrees (each with up to five item divs and their closures) stay reachable from `ac.signal`. + +**Evidence:** context-menu.ts:53-56, 195 + el.addEventListener( + "contextmenu", + (e) => { … }, + { signal }, // ChannelSidebar's factory-lifetime ac.signal + ); + +context-menu.ts:82-89 (and 104, 135, 155) — per-open menu items on the same long-lived signal + markItem.addEventListener("click", () => { closeMenu(); markChannelRead(channel.id); }, { signal }); + +context-menu.ts:185-188 — the file's own statement of the hazard, applied to one listener only + // Tie this bridge listener's own lifetime to menuAc so it does not + // outlive the menu it belongs to … + signal.addEventListener("abort", closeMenu, { signal: menuAc.signal }); + +ChannelSidebar.ts:747 const ac = new AbortController(); +ChannelSidebar.ts:781 clearChildren(channelList); +ChannelSidebar.ts:806 ac.signal, +ChannelSidebar.ts:610 attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel); +ChannelSidebar.ts:872-875 + const unsubChannelsMap = channelsStore.subscribeSelector((s) => s.channels, () => renderChannels()); + +channels.store.ts:346-352 (incrementUnread) — new Channel object AND new Map per message + +**Suggested fix:** Do not hand rows the factory-lifetime signal. In createChannelSidebar add `let renderAc: AbortController | null = null;` and at the top of renderChannels() (before clearChildren) do `renderAc?.abort(); renderAc = new AbortController();`, then pass `renderAc.signal` instead of `ac.signal` into renderCategoryGroup at ChannelSidebar.ts:806, and add `renderAc?.abort(); renderAc = null;` beside `ac.abort()` in destroy(). One change in the shared render function covers the context menu, drag handlers, and every other per-row listener; the sidebar-lifetime `ac.signal` stays for header/root listeners (mount at :826-855) that are created once. + +**Fixed:** `aa963efa464fef4e2a5cece8706afd6d3072ee82` · test `Client/tauri-client/tests/unit/channel-sidebar.test.ts` · revert-proof pass + +### OC-0230 — low — "Clear Logs" empties the list but leaves the entry counter showing the pre-clear total + +`Client/tauri-client/src/components/settings/LogsTab.ts:241` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-3` + +`countEl` is built once at LogsTab.ts:317 from `getLogBuffer().length` and is only ever rewritten inside the `addLogListener` callback at line 341. The Clear button's handler calls `clearLogBuffer()` + `renderLogEntries()` — neither of which touches `countEl` — so the counter and the list it labels disagree. The Refresh button (line 249) has the same gap. The counter only self-corrects when the next log entry at or above the current minimum level happens to be emitted *while the Logs tab is the active tab*; at the production default level of `info`, no UI interaction in the settings panel emits one, so the wrong number can sit there indefinitely. + +**Repro:** Open Settings → Logs on a client that has been running a while (say 412 buffered entries). The panel shows the log rows and "412 entries". Click "Clear Logs". The list goes empty (renderLogEntries() re-runs against the now-empty buffer) but the line above it still reads "412 entries". Click "Refresh" — still "412 entries". It stays wrong until some component emits an info/warn/error log line while the Logs tab is still on screen. + +**Evidence:** LogsTab.ts:237-245 + const clearBtn = createElement("button", { class: "ac-btn" }, "Clear Logs"); + clearBtn.addEventListener("click", () => { + clearLogBuffer(); + renderLogEntries(); // no countEl update + }, { signal }); + +LogsTab.ts:317-323 + const countEl = createElement("div", {...}, `${getLogBuffer().length} entries`); + +LogsTab.ts:338-343 — the only place countEl is ever updated + unsubLogListener = addLogListener(() => { + if (getActiveTab() === "Logs") { + renderLogEntries(); + countEl.textContent = `${getLogBuffer().length} entries`; + } + }); + +LogsTab.ts:88-100 — renderLogEntries touches only logListEl + +**Suggested fix:** Extract the count refresh into a local `function updateCount(): void { countEl.textContent = `${getLogBuffer().length} entries`; }` declared after countEl and call it from renderLogEntries (or from both the Clear and Refresh handlers plus the log listener). Cleanest single-point version: move countEl's creation above renderLogEntries' use and have renderLogEntries itself update the count, so every render path — clear, refresh, filter change, live entry — stays consistent. + +**Fixed:** `bd007f675b3710b953cfbac14189abffee299c54` · test `Client/tauri-client/tests/unit/logs-tab.test.ts` · revert-proof pass + +### OC-0231 — low — stopVadPolling never detaches the VAD worklet's MessagePort handler, so a gate message posted before the worklet sees `stop` can re-gate the mic to zero after VAD has been turned off — with sensitivity 100 nothing ever un-gates it + +`Client/tauri-client/src/lib/audioPipeline.ts:418` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +`stopVadPolling()` posts `{type:"stop"}`, calls `workletNode.disconnect()` and nulls `this.vadWorkletNode`, but never sets `workletNode.port.onmessage = null` nor `port.close()`. The `onmessage` closure installed at line 332 stays live (the closure itself keeps the node alive, and `disconnect()` only drops the node's outgoing edges — the incoming `analyser.connect(workletNode)` from line 324 is never removed either). The `stop` message has to cross to the audio thread, so the processor keeps running for several render quanta and can post `{type:"gate", gated:true}` in that window. That message is still dispatched on the main thread and sets `this.vadGated = true` + `updatePipelineGain()`, which drives the still-live GainNode to 0. On the `setVoiceSensitivity(>=100)` path there is no VAD left to ever post `gated:false`, and `updatePipelineGain()` keeps returning 0 for every later call because it reads `this.vadGated`. + +**Repro:** In a voice call with VAD active (sensitivity < 100, the default 50), stop speaking so the worklet is about to gate, then drag the Input Sensitivity handle in Settings → Voice & Audio all the way to the far left (sensitivity 100, 'gate nothing'). VoiceAudioTab.ts:163-177 calls setVoiceSensitivity on every pointermove, so the last live worklet is stopped with no replacement. If the worklet emitted its silence→gated transition in the few quanta between the `stop` postMessage and the audio thread processing it, the late `{gate:true}` lands after stopVadPolling ungated, setting vadGated=true and driving the pipeline GainNode to 0. The mic is now permanently silent to every peer while the UI shows VAD disabled and unmuted; even moving the Input Volume slider does not help (setInputVolume → updatePipelineGain still multiplies by the stuck gate). Only a full pipeline rebuild — mute/unmute, a device change, or leaving and rejoining voice — clears it. + +**Evidence:** audioPipeline.ts:418-439 `stopVadPolling()` — `this.vadWorkletNode.port.postMessage({ type: "stop" }); this.vadWorkletNode.disconnect(); this.vadWorkletNode = null;` (no `port.onmessage = null`, no `port.close()`), then `if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }`. audioPipeline.ts:332-342 `workletNode.port.onmessage = (event) => { if (event.data.type === "gate") { … this.vadGated = gated; this.updatePipelineGain(); } }`. audioPipeline.ts:253-258 — the `clamped >= 100` branch of `setVoiceSensitivity` does NOT restart VAD. audioPipeline.ts:223-231 `updatePipelineGain` → `const effectiveGain = this.vadGated ? 0 : this.currentInputGain;`. public/vad-worklet.js:42-44 `else if (event.data.type === "stop") { this._active = false; }` — only observed at the next `process()` call, and :77-83 posts `{gate:true}` from that same still-running `process()`. + +**Suggested fix:** In stopVadPolling(), detach the handler before stopping the node: `this.vadWorkletNode.port.onmessage = null;` immediately before the existing `postMessage({type:"stop"})` / `disconnect()` at audioPipeline.ts:429-434. One guard in the shared teardown covers every caller (setVoiceSensitivity, startVadPolling's self-stop, teardownAudioPipeline). Equivalent alternative: capture `const vadGen = this._vadGeneration` in startVadWorklet and early-return from the onmessage closure when `vadGen !== this._vadGeneration`. + +**Fixed:** `9b0863967d3475e15d6600b2b9809b56921dbbef` · test `Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts` · revert-proof pass + +### OC-0232 — low — "Reduce Motion" and "Sync with OS" are two writers of one CSS class with no arbitration, so a manual toggle silently overrides the OS accessibility setting + +`Client/tauri-client/src/components/settings/AccessibilityTab.ts:24` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-2` + +The `reducedMotion` toggle's sideEffect writes `documentElement.classList.toggle("reduced-motion", nowOn)` directly, and `syncOsMotionListener` (os-motion.ts:34/38) writes the same class from the media query. Neither consults the other. `.reduced-motion` (app.css:5228) is the app-wide animation kill switch — the three `@media (prefers-reduced-motion: reduce)` blocks in the stylesheets only cover `.highlight-flash`, `.jump-to-present-pill` and `.upp-popup`, so removing the class genuinely re-enables every other animation and transition. The toggle's rendered state is also always `loadPref("reducedMotion", false)` (line 72), never the effective state, so with OS sync on the switch reads OFF while motion is in fact reduced. + +**Repro:** OS has "reduce motion" enabled. Settings → Accessibility → turn ON "Sync with OS": `syncOsMotionListener(true)` adds `reduced-motion`; animations stop. The "Reduce Motion" switch still renders OFF (its pref is false). Now click "Reduce Motion" ON, then OFF: the second click runs `classList.toggle("reduced-motion", false)`, removing the class. Animations are back app-wide even though "Sync with OS" is still ON and the OS still asks for reduced motion; nothing restores it until the OS setting itself changes or the app restarts. The mirror case also loses data: with OS sync ON and OS = no-reduce, a manual "Reduce Motion" ON survives to localStorage but is wiped on next launch, because applyStoredAppearance (appearance.ts:45-55) applies the manual pref first and then calls syncOsMotionListener, which re-derives the class from the OS. + +**Evidence:** // AccessibilityTab.ts:19-26 +{ key: "reducedMotion", ..., sideEffect: (nowOn) => { + document.documentElement.classList.toggle("reduced-motion", nowOn); + } }, +// os-motion.ts:32-41 +ac = new AbortController(); +const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); +document.documentElement.classList.toggle("reduced-motion", mq.matches); +mq.addEventListener("change", (e) => { + document.documentElement.classList.toggle("reduced-motion", e.matches); +}, { signal: ac.signal }); +// app.css:5228 +.reduced-motion, .reduced-motion * { animation-duration: 0s !important; transition-duration: 0s !important; } + +**Suggested fix:** Make os-motion the single writer: change the reducedMotion sideEffect in AccessibilityTab.ts:23-25 to `sideEffect: () => syncOsMotionListener(loadPref("syncOsMotion", false))`. savePref has already stored the new manual value, and syncOsMotionListener(false) re-reads it while syncOsMotionListener(true) re-derives the class from the media query, so whichever source owns the class wins consistently — matching applyStoredAppearance's startup ordering (appearance.ts:45-55). + +**Fixed:** `08e188135283b0883cd4aba261f3e30cb3769677` · test `Client/tauri-client/tests/unit/AccessibilityTab.test.ts` · revert-proof pass + +### OC-0233 — low — Desktop notification titles print the raw username, ignoring the nickname the message row beside them renders + +`Client/tauri-client/src/lib/notifications.ts:99` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-2` + +`notifyIncomingMessage` builds the title from `payload.user.username`, but `MessageUser` carries `display_name` (types.ts:97) and the message list renders it via `resolveAuthor` (message-list/formatting.ts:145-152). The popup that tells you who wrote to you names them differently from the row you click through to. + +**Repro:** User id 42 has username `a_martinez`, display_name `Alice`. She posts in #general while the window is unfocused. The desktop notification reads "a_martinez in #general"; opening the app shows the same message authored by "Alice". A user who only ever sees nicknames cannot tell who the notification is from. + +**Evidence:** const title = sanitizeNotif( + mentioned + ? `${payload.user.username} mentioned you in ${channelLabel}` + : `${payload.user.username} in ${channelLabel}`, + 80, +); + +**Suggested fix:** Resolve the author name once at notifications.ts:97 the way every other surface does — `const authorName = memberDisplayName(membersStore.getState().members.get(payload.user.id) ?? { username: payload.user.username, displayName: payload.user.display_name ?? null });` (or reuse resolveAuthor) — and interpolate authorName into both title branches, leaving the 80-char sanitizeNotif cap unchanged. + +**Fixed:** `f3eeaad2edff76a208b4a3d0bee4fb00458f29cc` · test `Client/tauri-client/tests/unit/notifications.test.ts` · revert-proof pass + +### OC-0234 — low — resolveLanguage resolves fence tags against Object.prototype, so ```constructor / ```toString return a function instead of null + +`Client/tauri-client/src/components/message-list/syntax-highlight.ts:196` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-3` + +ALIASES is a plain object literal, so the lookup walks the prototype chain. `ALIASES["constructor"]` is the Object constructor, `ALIASES["toString"]` / `["valueOf"]` / `["hasOwnProperty"]` / `["isPrototypeOf"]` / `["propertyIsEnumerable"]` / `["toLocaleString"]` are Function objects — none are null or undefined, so `?? null` never fires and the function returns a non-string in violation of its declared `string | null` type and its documented "null when unknown" contract. LANG_TAG_REGEX (`/^[A-Za-z][\w+#-]{0,19}$/`, content-parser.ts:435) accepts every one of those tags, so a message can reach it. The caller then writes the value straight into a DOM attribute. + +**Repro:** Post a message whose body is a fence tagged `constructor`: +```constructor +x = 1 +``` +content-parser.ts:477 calls resolveLanguage("constructor"), which returns the `Object` function rather than null; line 478's `canonical !== null` passes and block.setAttribute("data-lang", canonical) stringifies it, producing data-lang="function Object() { [native code] }" on the rendered
. The same input with the fix (an own-property guard, e.g. Object.hasOwn(ALIASES, tag) or a null-prototype map) yields no data-lang at all, which is what every other unknown tag does. Unit test content-markdown.test.ts:661 only pins `resolveLanguage("nope")`, so the prototype keys are uncovered. + +**Evidence:** syntax-highlight.ts:160-197: + const ALIASES: Readonly> = { js: "javascript", ... }; + export function resolveLanguage(tag: string | null): string | null { + if (tag === null) return null; + return ALIASES[tag.toLowerCase()] ?? null; // prototype chain, no own-property guard + } + +content-parser.ts:477-479: + const canonical = resolveLanguage(lang); + if (canonical !== null) block.setAttribute("data-lang", canonical); + for (const token of highlightCode(code, canonical)) { + +**Suggested fix:** Guard the lookup for own properties in the one shared function: `const hit = Object.hasOwn(ALIASES, key) ? ALIASES[key] : undefined; return hit ?? null;` (or declare ALIASES via Object.assign(Object.create(null), {...}) / a Map). No caller-side change needed. + +**Fixed:** `74d1ea6d3fa2897a46443158faf7820d29bacb32` · test `Client/tauri-client/tests/unit/content-markdown.test.ts` · revert-proof pass + +### OC-0235 — low — In a group DM, the ringer leaving voice cancels every other callee's ring even though the call is still live + +`Client/tauri-client/src/pages/MainPage.ts:629` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +The `voice_leave` handler treats "the user who rang me left the DM's voice channel" as "the call is over", but in a group DM the room can still hold other participants who already accepted. The condition tests only `payload.user_id === ringing.fromUserId`; it never asks whether the channel's voice roster is now empty, even though `voiceStore.voiceUsers` is a `Map>` that answers exactly that. + +**Repro:** Group DM with A, B, C (channels.type='dm', is_group=1 — Server/db/dm_queries.go:292, and service/dm.go RingTargets fans out to every other participant). (1) A clicks call: MainPage.startCall() (MainPage.ts:292-304) joins the DM voice channel and sends call_ring. (2) B and C both get call_incoming and both ring. (3) B clicks Accept and joins the voice channel. C is still ringing. (4) A leaves voice (hangs up or switches channel). The server broadcasts voice_leave for A to the DM's READ audience, which includes C (hub_broadcast.go channelReadAudience for a DM = its participants). (5) C's handler matches A's user_id and calls ringCtrl.cancel(channelId) -> stopRinging(): C's banner disappears and the chime stops, even though B is sitting in the call waiting. C loses the one-click Accept and gets no indication the call is still open. + +**Evidence:** MainPage.ts:626-631 + ws.on("voice_leave", (payload) => { + const ringing = ringCtrl?.current(); + if (ringing === null || ringing === undefined) return; + if (payload.user_id === ringing.fromUserId) { + ringCtrl?.cancel(payload.channel_id); + } + }), + +call-ring.ts:114-117 — cancel() only re-checks the channel id, not occupancy: + function cancel(channelId: number): void { + if (state === null || state.channelId !== channelId) return; + stopRinging(); + } + +The roster that would answer the real question exists: voice.store.ts:73 + readonly voiceUsers: ReadonlyMap>; // channelId -> userId -> VoiceUser + +**Suggested fix:** Guard the cancel on the channel's voice roster being empty of anyone but the leaver, in the single MainPage voice_leave handler (voiceStore is already imported at MainPage.ts:27): + +ws.on("voice_leave", (payload) => { + const ringing = ringCtrl?.current(); + if (ringing === null || ringing === undefined) return; + if (payload.user_id !== ringing.fromUserId) return; + const roster = voiceStore.getState().voiceUsers.get(payload.channel_id); + const othersStillIn = + roster !== undefined && [...roster.keys()].some((id) => id !== payload.user_id); + if (!othersStillIn) ringCtrl?.cancel(payload.channel_id); +}); + +(The dispatcher's removeVoiceUser may or may not have run first; excluding payload.user_id makes the check order-independent, and keeps main-page.test.ts:503-528 green since that test's roster is empty.) + +**Fixed:** `0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass + +### OC-0236 — low — Auto-idle's inactivity timer is never re-armed except by a DOM input event, so a tray status change leaves the watcher permanently disarmed + +`Client/tauri-client/src/lib/autoIdle.ts:107` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-1` + +`arm()` is called only at construction and from `onActivity`. The timeout callback sets `timer = null` and calls `apply(true)` without re-arming. Any status change made through a surface that produces no window input event (the OS tray menu, which calls `saveUserStatus` directly) therefore cannot restart the ten-minute clock, and the user stays broadcast as Online while away — the exact outcome auto-idle exists to prevent. + +**Repro:** (1) User is Online and walks away. At T+10 min the timer fires: apply(true) flips the pref to idle/auto, `timer` is left null. (2) User comes back and sets Online from the OS tray Status submenu. main.ts:270 calls saveUserStatus("online") — a native tray menu delivers no mousemove/keydown/mousedown into the webview, so onActivity never runs and arm() is never called. Status is now online/manual with no armed timer. (3) User walks away again without clicking inside the app window. The ten-minute watcher is gone: they show Online to every other member indefinitely. The same dead-timer state is also reached whenever the timer fires while the status is ineligible (dnd/invisible/manual-idle), where apply(true) returns null and nothing re-arms. tests/unit/auto-idle.test.ts:144-155 exercises the dnd case but never asserts re-arming, so nothing locks the current behaviour in. + +**Evidence:** autoIdle.ts:105-130 + function arm(): void { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; // <- fired; never re-armed here + if (destroyed) return; + apply(true); + }, delayMs); + } + + function onActivity(): void { // the ONLY other arm() caller + ... + if (now - lastActivityRun < ACTIVITY_THROTTLE_MS) return; + lastActivityRun = now; + arm(); + } + +main.ts:264-272 — the tray writes the status with no notifyActivity()/arm(): + void listen("status-change", (e) => { + ... + saveUserStatus(mapped); + getActivePresenceSender()?.send(mapped); + }); + +(The controller exposes notifyActivity() at autoIdle.ts:138 but nothing in src/ calls it.) + +**Suggested fix:** Re-arm inside the timeout callback so the watcher survives a firing that changed nothing: + + timer = setTimeout(() => { + timer = null; + if (destroyed) return; + apply(true); + arm(); // keep watching: the status may become eligible again + }, delayMs); + +One change in the shared arm(), rather than a notifyActivity() call bolted onto every external status-writing surface. Re-arming is harmless when already idle — nextAutoStatus(idle, *, true) returns null. + +**Fixed:** `e9686461682f8719591981d4fa9dcc61b1f02564` · test `Client/tauri-client/tests/unit/auto-idle.test.ts` · revert-proof pass + +### OC-0237 — low — WebSocket internal errors ship the raw wrapped error to the client and are never logged, unlike every sibling path + +`Server/ws/handlers_chat.go:185` · found 2026-08-20 · hunt `2026-08-20-general` · lens `explore-3` + +serviceErrorToResult's default branch puts err.Error() into the ClientError message for ErrCodeInternal. Service-layer ErrInternal wrappers embed the underlying driver error via %v, so the raw DB error text is sent to the requesting client. Its REST twin (writeServiceError, Server/api/channel_handler.go:418-422) deliberately does the opposite — it logs the error and replies with the fixed string "an internal error occurred" — and every other ErrCodeInternal site inside package ws uses a fixed message (deps.go:132/139, registry.go:59, voice_controls.go:58/161/249/262, serve.go:856). Worse, ws/handlers.go:85-92 only logs when result.Error is NOT a ClientError, so this path also produces zero server-side log output: the operator sees nothing while the client sees everything. + +**Repro:** An authenticated user sends a `call_ring` frame for a DM they participate in while the SQLite file is under write contention or otherwise erroring. handlers_call.go:45 calls DMSvc.RingTargets, which fails at Server/service/dm.go:404-406 and returns fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err). handlers_call.go:47 hands that to serviceErrorToResult, which falls to the default branch at handlers_chat.go:184-185 and builds ClientError{Code:"INTERNAL", Message: err.Error()}. handlers.go:86-87 writes that message verbatim onto the socket, so the client receives e.g. `{"type":"error","code":"INTERNAL","message":"internal error: failed to read DM participants: GetDMParticipantIDs: database is locked"}` — internal query names and driver state disclosed to an ordinary member — while nothing is written to the server log, so the operator has no record the failure happened. The identical REST call would have logged it and returned only "an internal error occurred". + +**Evidence:** Server/ws/handlers_chat.go:184-186 + default: + return Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}} + } + +contrast Server/api/channel_handler.go:418-422 + case errors.Is(err, service.ErrInternal): + slog.ErrorContext(ctx, "service error", "error", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "INTERNAL_ERROR", Message: "an internal error occurred"}) + +contrast Server/ws/handlers.go:85-92 (ClientError branch does not log) + if ce, ok := result.Error.(ClientError); ok { + c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID)) + } else { + slog.Error("ws handler internal error", ...) + +**Suggested fix:** In the default branch of serviceErrorToResult (Server/ws/handlers_chat.go:184-186) log and return a fixed string, matching writeServiceError: `default: slog.Error("ws service internal error", "err", err); return Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}}`. One change in the shared helper covers all seven call sites; no caller change needed. + +**Fixed:** `5dcf18f3466e76cc613c3a8a7a2d14c9c7fe1bb9` · test `Server/ws/oc_0237_service_error_internal_test.go` · revert-proof pass + ## Declined ### OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message diff --git a/.superpowers/findings-ledger.json b/.superpowers/findings-ledger.json index 664e8335..59609a99 100644 --- a/.superpowers/findings-ledger.json +++ b/.superpowers/findings-ledger.json @@ -1,5 +1,5 @@ { - "nextId": 192, + "nextId": 238, "findings": [ { "id": "OC-0001", @@ -4629,6 +4629,1065 @@ }, "suggestedFix": "Exclude disabled controls in the one shared place: `const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';` (or add `!el.hasAttribute(\"disabled\")` to isFocusable, a11y.ts:26).", "fixedDate": "2026-08-19" + }, + { + "id": "OC-0192", + "title": "PATCH /users/me runs the quadratic fixpoint sanitizer on avatar/display_name/about with no raw-length bound — one 1 MiB request burns ~11 minutes of CPU", + "file": "Server/api/profile_handler.go", + "line": 220, + "severity": "high", + "why": "The handler explicitly bounds `req.Username` at `maxLoginUsernameLen*4` before handing it to `service.SanitizeText` (OC-0151: \"sanitizeToFixpoint's cost is quadratic in input length, and nothing bounds this field before it runs\"), but its three sibling fields in the same handler reach the same sanitizer with no bound at all: `req.Avatar` is sanitized at line 220 and only length-checked afterwards by `validateAvatarURL` (maxAvatarURLLen=512), and `display_name`/`about` are passed through to `UserService.UpdateProfile`, which calls `cleanText` (= `sanitizeToFixpoint`) at user.go:140/143 *before* the MaxDisplayNameLen / MaxAboutLen checks. `sanitizeToFixpoint` loops `len(raw)+1` times, and each pass is `html.UnescapeString(bluemonday.Sanitize(html.UnescapeString(s)))`; bluemonday re-escapes `&` on every pass, so a nested-entity payload peels exactly one level per pass and the whole thing is O(n^2) over the request body.", + "repro": "Measured on this repo (temporary test through service.SanitizeText, since removed) with payload `\"&\" + strings.Repeat(\"amp;\", k)`: 2 KB -> 4.5 ms, 4 KB -> 12 ms, 8 KB -> 43 ms, 16 KB -> 168 ms — clean quadratic. PATCH /api/v1/users/me is NOT in `bodyCapExemptPrefixes`, so the body cap is defaultMaxBodySize = 1 MiB. Extrapolating the measured curve, `PATCH /api/v1/users/me` with `{\"username\":\"bob\",\"avatar\":\"&ampamp…;\"}` where the avatar string is ~1 MiB of nested `amp;` levels costs ~690 s (~11.5 min) of one core before `validateAvatarURL` ever sees the string and rejects it as >512 chars. `display_name` and `about` take the same path via `cleanText`. The route's only limiter is per-IP `\"profile:\"` at 10/min, so one authenticated user can keep ~10 cores saturated indefinitely; the request is answered with a 400 either way, so nothing in the logs attributes the load.", + "evidence": "// profile_handler.go\n185:\tif len(req.Username) > maxLoginUsernameLen*4 { // <- the guard, username only\n...\n219:\tif req.Avatar != nil {\n220:\t\ttrimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar)) // <- unbounded\n221:\t\tif err := validateAvatarURL(trimmed); err != nil { // len check AFTER\n...\n234:\tif req.DisplayName != nil {\n235:\t\tif err := validateDisplayName(*req.DisplayName); err != nil { // char check only, no length\n...\n256:\tupdated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{\n257:\t\tUsername: req.Username, Avatar: req.Avatar,\n259:\t\tDisplayName: req.DisplayName, About: req.About,\n\n// service/user.go\n140:\tif patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen {\n143:\tif patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen {\n\n// service/message.go\n195:func sanitizeToFixpoint(raw string) string {\n197:\tfor i := 0; i <= len(raw); i++ {\n198:\t\tnext := sanitizePass(s)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "api-authz", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "c688fc20eec6379545be2c1bc8ce1ecd946d8bb0", + "test": "Server/api/profile_handler_test.go (TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing); Server/service/profile_fields_test.go (TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing)", + "revertProof": "pass" + }, + "suggestedFix": "Bound the raw bytes before they reach the fixpoint sanitizer, at the two chokepoints rather than per field: (1) in handleUpdateProfile, before line 220, `if req.Avatar != nil && len(*req.Avatar) > maxAvatarURLLen*4 { 400 }`; (2) in UserService.UpdateProfile, before the cleanText calls at user.go:140/143, reject on raw byte length — `if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 { ErrBadRequest }` and the same for About with MaxAboutLen*4 — which also covers any non-REST caller. Same shape as the existing guard at profile_handler.go:185 and service/message.go:220.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0193", + "title": "A refused voice-channel *switch* nulls currentChannelId while the old LiveKit session is still live — widget disappears, mic stays published, and the recovery click re-triggers the same clear", + "file": "Client/tauri-client/src/lib/dispatcher.ts", + "line": 1117, + "severity": "high", + "why": "The catch-all error handler rolls back an optimistic join with `if (voiceStore.getState().voiceStatus === \"joining\") leaveVoiceChannel();`. Its stated invariant — \"An already-established session is never in 'joining', so this never touches a live voice call\" — is false during a channel switch: `VoiceCallbacks.onVoiceJoin` calls `joinVoiceChannel(newCh)`, which sets `currentChannelId=newCh, voiceStatus=\"joining\"` while `LiveKitSession._state` is still `connected(oldCh)` with the mic track published. `leaveVoiceChannel()` is store-only — it never calls `LiveKitSession.leaveVoice()` — so the store and the live media session desynchronise.", + "repro": "User is connected to voice channel A (voiceStatus \"connected\", mic published). They click voice channel B in the sidebar, where B is refused at precheck — e.g. B has a channel_overrides deny on CONNECT_VOICE (FORBIDDEN), B was just archived (BAD_REQUEST), or the user tripped voiceJoinRateLimit=5/s by clicking several voice channels quickly (RATE_LIMITED).\n1. onVoiceJoin(B): store -> currentChannelId=B, voiceStatus=\"joining\"; LiveKitSession still connected(A).\n2. Server refuses in voiceJoinPrecheck. No voice_leave is broadcast; the user remains in A in voice_states and on the SFU.\n3. dispatcher.ts:1117 sees voiceStatus===\"joining\" -> leaveVoiceChannel() -> currentChannelId=null.\n4. VoiceWidget.render hides the whole widget (no leave/mute button). LiveKitSession._state is still connected(A): the mic track is still published and audio still flows to every peer in A, and every other client still shows the user in A.\n5. Recovery attempt: the user clicks channel A again -> joinVoiceChannel(A) (prev is null, so it sets voiceStatus=\"joining\") -> voice_join A -> server answers ALREADY_JOINED (voice_join.go voiceJoinLeaveCurrent, currentChID==channelID) -> the same guard fires and hides the widget again. The user cannot leave the call from the UI at all; only an app restart or WS drop ends it.\nThe existing test that supposedly covers this (tests/unit/dispatcher.test.ts:3770 \"does not touch an already-established voice session on CHANNEL_FULL\") seeds voiceStatus=\"connected\", a state a switch never passes through, so nothing locks the real behaviour.", + "evidence": "dispatcher.ts:1117-1119\n if (voiceStore.getState().voiceStatus === \"joining\") {\n leaveVoiceChannel();\n }\n\nVoiceCallbacks.ts:179-184 onVoiceJoin: joinVoiceChannel(channelId); ws.send({type:\"voice_join\",...})\nvoice.store.ts:300-315 joinVoiceChannel: currentChannelId=channelId, voiceStatus=\"joining\" (only short-circuits when prev.currentChannelId === channelId)\nvoice.store.ts:319-330 leaveVoiceChannel: currentChannelId=null, voiceStatus=\"idle\" (no LiveKit teardown)\nVoiceWidget.ts:236-242 if (channelId === null) { root.classList.remove(\"visible\"); return; } -> no disconnect/mute buttons at all\nServer/ws/voice_join.go:57-65 voiceJoinPrecheck (RATE_LIMITED / FORBIDDEN / NOT_FOUND / BAD_REQUEST-archived / VOICE_ERROR) runs BEFORE voiceJoinLeaveCurrent, so a precheck refusal emits no voice_leave at all and the user stays in the old channel server-side.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-voice", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932", + "test": "Client/tauri-client/tests/unit/dispatcher.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Make the rollback tear down the media session, not just the store, in the one shared guard at dispatcher.ts:1117: `if (voiceStore.getState().voiceStatus === \"joining\") { void livekitSession().then(({ isVoiceConnected, leaveVoice }) => { if (isVoiceConnected()) leaveVoice(true); }); leaveVoiceChannel(); }` — isVoiceConnected is already exported (livekitSession.ts:1803) and leaveVoice(true) both disconnects room A and sends voice_leave so the server/SFU state matches the cleared store. A first-time-join refusal has no live session, so isVoiceConnected() is false and behavior there is unchanged (the existing tests at dispatcher.test.ts:3757/3789/3798 stay green).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0194", + "title": "Group-DM create/rename feed the quadratic fixpoint sanitizer an unbounded body field, on the only REST route group with no rate limiter", + "file": "Server/service/dm.go", + "line": 234, + "severity": "high", + "why": "`cleanText` -> `sanitizeToFixpoint` costs roughly O(entity-nesting-depth x length); every other caller of that pipeline bounds its raw input first (`sanitizeContent` at `maxMessageLen*4`, the register path and PATCH /users/me at `maxLoginUsernameLen*4`). `CreateGroupDM` and `RenameGroupDM` do not: they run it on the raw `name` from the JSON body, bounded only by the global 1 MiB cap, and `MountDMRoutes` attaches no `RateLimitMiddleware` at all — so unlike the already-confirmed profile-handler instance (which at least sits behind `profile:` at `profileUpdateRateLimitPerMinute`), these two can be issued back-to-back. In `CreateGroupDM` the sanitizer also runs *before* the recipient-existence, ban and block checks, so the CPU is spent even when the request is going to 404.", + "repro": "As any authenticated user: POST /api/v1/dms/group with body {\"recipient_ids\":[999999,999998],\"name\":\"&\"+\"amp;\"*200000+\"lt;\"} (~1 MiB, well under the 1 MiB MaxBodySizeUnless cap). handleCreateGroupDM decodes it and calls CreateGroupDM: the dedup loop passes (two distinct positive ids), len(unique)==2 satisfies both the >=2 and <=MaxGroupDMParticipants checks, and control reaches `cleanName := cleanText(name)` at dm.go:234. sanitizeToFixpoint peels roughly one entity layer per pass over a ~1 MiB string, ~2x10^5 passes, ~10^11 bytes of work — minutes of pinned CPU — before `GetUserByID(999999)` is ever called and the request 404s. /api/v1/dms has no rate limiter, so N concurrent requests pin N cores; the server's SQLite writer and every other request path starve. PATCH /api/v1/dms/{channelId} with the same body reaches the identical call at dm.go:323 (after only an IsDMParticipant + IsGroupDM check).", + "evidence": "dm.go:234 `cleanName := cleanText(name)` and dm.go:323 `cleanName := cleanText(name)`; user.go:113 `func cleanText(v string) string { return strings.TrimSpace(sanitizeToFixpoint(v)) }`; message.go:220 shows the bound the sibling path has (`if len(raw) > maxMessageLen*4 { return \"\", ... }`) before `sanitizeToFixpoint(raw)`; api/dm_handler.go:72 `r.Route(\"/api/v1/dms\", func(r chi.Router) { r.Use(AuthMiddleware(database)); r.Post(\"/\", ...); r.Post(\"/group\", ...); r.Patch(\"/{channelId}\", ...) })` — no RateLimitMiddleware.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-api", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "fba75319a419477890944bd0218573cfb5d6333c", + "test": "Server/service/dm_test.go", + "revertProof": "pass (hand-proved)" + }, + "suggestedFix": "Bound the raw bytes before sanitizing, exactly as OC-0151 did. Smallest shared form: add a bounded helper next to cleanText in Server/service/user.go, e.g. `func cleanTextBounded(v string, maxRunes int) (string, bool) { if len(v) > maxRunes*4 { return \"\", false }; return cleanText(v), true }`, then use it at dm.go:234 and dm.go:323 with MaxGroupDMNameLen (returning the existing `%w: name must be at most %d characters` ErrBadRequest on the false branch), and at the custom-status sites (service/channel.go:182, service/user.go:202) with MaxCustomStatusLen. *4 still admits any legitimate 100-rune UTF-8 name, so no valid input changes behavior. Separately, MountDMRoutes should carry a RateLimitMiddleware like its sibling route groups, but that is defense in depth, not the fix.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0195", + "title": "presence_update's custom_status runs the unbounded fixpoint sanitizer on a full 1 MiB WebSocket frame", + "file": "Server/service/channel.go", + "line": 182, + "severity": "high", + "why": "`HandlePresenceUpdate` applies the 128-rune `MaxCustomStatusLen` cap to the *output* of `cleanText`, so the quadratic `sanitizeToFixpoint` runs on the raw client string first. The WS read limit is `config.MaxMessageBytes` (1 MiB), so a single `presence_update` frame can carry a 1 MiB nested-entity payload straight into it. This is the same defect already confirmed for PATCH /users/me, but on a different transport that the REST-side bound would not cover, and it executes on the connection's own readPump goroutine, so nothing bounds how many of these run at once.", + "repro": "Authenticate a WebSocket, then send {\"type\":\"presence_update\",\"payload\":{\"status\":\"online\",\"custom_status\":\"&\"+\"amp;\"*200000+\"lt;\"}} — ~1 MiB, accepted because conn.SetReadLimit(wsReadLimitBytes) is 1<<20 (serve.go:27/80). readPump (serve_pumps.go:210) calls hub.handleMessage on the per-client goroutine; handlePresenceV2 calls HandlePresenceUpdate, whose limiter check (1 per 10s) passes, `db.ValidStatuses[\"online\"]` passes, and channel.go:182 `text := cleanText(*customStatus)` then spins for minutes before the 128-rune check at channel.go:183 rejects it. Because each call outlives the 10 s limiter window, one account can start a new 1 MiB frame every 10 s and accumulate dozens of concurrently spinning CPU-bound goroutines from a single connection stream.", + "evidence": "channel.go:180-186 `if customStatus != nil { text := cleanText(*customStatus); if utf8.RuneCountInString(text) > MaxCustomStatusLen { return ... } }` — the bound is on the sanitized output, not the raw input; ws/serve.go:27 `wsReadLimitBytes = config.MaxMessageBytes` (config/constants.go:7 `MaxMessageBytes = 1 << 20`); ws/serve_pumps.go:210 `hub.handleMessage(c, msg)` inside readPump. Same unguarded call also at service/user.go:202 (`SetCustomStatus`).", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-api", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "bdbd5ac472ee73cdb566ac6348b21f9569b61598", + "test": "Server/service/profile_fields_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Same shared guard as the DM finding: bound the raw bytes before cleanText. In Server/service/channel.go:180, `if len(*customStatus) > MaxCustomStatusLen*4 { return nil, fmt.Errorf(\"%w: custom_status must be at most %d characters\", ErrBadRequest, MaxCustomStatusLen) }` before `text := cleanText(*customStatus)`, and the identical pre-check at Server/service/user.go:202. Best done as the one `cleanTextBounded(v string, maxRunes int)` helper in service/user.go used by all four unguarded sites (channel.go:182, user.go:202, dm.go:234, dm.go:323) rather than four hand-rolled checks.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0196", + "title": "A transient DB read error during the WS auth handshake is reported as a terminal auth_error, logging the user out", + "file": "Server/ws/serve_auth.go", + "line": 62, + "severity": "medium", + "why": "authenticateConn collapses \"session lookup failed\" (DB error) into the same client-visible frame as \"no such session\" — buildAuthError, which the protocol defines as non-recoverable. The code comment two lines below explicitly recognises the distinction (\"DB outage, not a bad token\") but applies it only to the server log; the wire frame is unchanged, so a momentarily unreadable database signs valid users out.", + "repro": "1. Server is under SQLite reader contention (WAL checkpoint, admin backup/restore, a long write tx, or busy_timeout exceeded) so `database.GetSessionByTokenHash` returns an error rather than (nil, nil).\n2. Any client whose socket drops in that window reconnects and sends its auth frame.\n3. Server takes the `err != nil` branch at serve_auth.go:61 and writes `buildAuthError(\"invalid token\")` (line 62). The identical hazard exists at line 78 for a `GetUserByID` error → `buildAuthError(\"user not found\")`.\n4. Client/tauri-client/src/lib/ws.ts:301 sets `intentionalClose = true`, calls `disconnectProxy()` and `setState(\"disconnected\")` — the auto-reconnect loop stops permanently.\n5. Client/tauri-client/src/lib/dispatcher.ts:259 runs `clearAuth()` on the same frame, which resets authStore (INITIAL_STATE), tears down voice, and drops messages/channels/blocks stores — a full logout back to the connect page.\nNet effect: the session row in the DB is perfectly valid and unexpired, yet every client that happened to reconnect during a sub-second DB hiccup must sign in again. Contrast Server/ws/hub_sweep.go:137-143, where the same package refuses to treat a failed session lookup as evidence about any individual session (\"kicking everyone on a transient DB error would be a mass disconnect\"), and Server/ws/messages.go's `buildErrorMsg(ErrCodeInternal, ...)`, which the client does NOT treat as terminal. No test pins the DB-error case — Server/ws/ws_integration_test.go only covers malformed/absent/nonexistent tokens.", + "evidence": "sess, err := database.GetSessionByTokenHash(ctx, hash)\nif err != nil || sess == nil {\n\t_ = conn.Write(ctx, websocket.MessageText, buildAuthError(\"invalid token\"))\n\tif err != nil {\n\t\t// DB outage, not a bad token — carry the cause so the caller's log\n\t\t// distinguishes it from an ordinary invalid-token rejection.\n\t\treturn nil, \"\", resumeHint{}, fmt.Errorf(\"auth: session lookup failed: %w\", err)\n\t}", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "ws-hub", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "ed0bfe03e014aa441c2dc118e09e97f54ce0e90f", + "test": "Server/ws/ws_integration_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Split the error branch from the not-found branch in authenticateConn so only a genuine miss produces the terminal frame. At Server/ws/serve_auth.go:60: `if err != nil { _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, \"temporary failure, please retry\")); return nil, \"\", resumeHint{}, fmt.Errorf(\"auth: session lookup failed: %w\", err) }` then `if sess == nil { ...buildAuthError(\"invalid token\")... }`. Apply the same split at line 76-83 for GetUserByID. ErrCodeInternal is not terminal on the client (dispatcher.ts treats only auth_error/BANNED as credential-clearing), so the socket simply closes and the normal backoff reconnect retries.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0197", + "title": "validateDisplayName runs on the raw body, before the sanitizer's HTML-entity unescape — `‮` becomes a real bidi override in the stored display name", + "file": "Server/api/profile_handler.go", + "line": 235, + "severity": "medium", + "why": "The username field in the same handler is sanitized first and validated second (line 199 then 206), which is the correct order; `display_name` is validated first (line 235, against the raw JSON string) and sanitized second (inside `UserService.UpdateProfile` -> `cleanText` -> `sanitizeToFixpoint`). `sanitizePass` ends with `html.UnescapeString(...)`, so an entity-encoded control or Cf character passes `validateDisplayName` as harmless ASCII and is then turned into the real character before storage — defeating the guard whose stated purpose is \"it is rendered wherever a username is, so control characters and bidi overrides are exactly as unwelcome here\".", + "repro": "Verified against the real sanitizer in this repo (temporary test in Server/service, since removed):\n SanitizeText(\"ada‮gnp.exe\") == \"ada‮gnp.exe\" (runes: [... 8238 ...])\n SanitizeText(\"ada‮gnp.exe\") == \"ada‮gnp.exe\"\nSo `PATCH /api/v1/users/me` with `{\"username\":\"dn_user\",\"display_name\":\"ada‮gnp.exe\"}` returns 200 and stores a display name containing U+202E RIGHT-TO-LEFT OVERRIDE, while the existing test Server/api/avatar_handler_test.go:283 shows the literal form `\"ada‮gnp.exe\"` is (correctly) rejected with 400. The same bypass admits control characters: `\"a b\"` stores a real newline. `MaxDisplayNameLen` still holds, so the value is persisted and broadcast via user_update to every client, where it renders in place of the username in the member list, message rows and voice roster.", + "evidence": "// profile_handler.go — username: sanitize THEN validate\n199:\treq.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n206:\tif err := auth.ValidateUsername(req.Username); err != nil {\n\n// profile_handler.go — display_name: validate raw, sanitize later in the service\n234:\tif req.DisplayName != nil {\n235:\t\tif err := validateDisplayName(*req.DisplayName); err != nil {\n\n137:func validateDisplayName(name string) error {\n138:\tfor _, r := range name {\n139:\t\tif unicode.IsControl(r) || unicode.In(r, unicode.Cf) {\n\n// service/message.go — the outer unescape that re-creates the character\n166:func sanitizePass(s string) string {\n167:\treturn html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s)))", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "api-authz", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "c688fc20eec6379545be2c1bc8ce1ecd946d8bb0", + "test": "Server/api/profile_handler_test.go (TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName)", + "revertProof": "pass" + }, + "suggestedFix": "Validate the sanitized value, matching the username path in the same handler: at profile_handler.go:234, first bound and sanitize — `trimmed := strings.TrimSpace(service.SanitizeText(*req.DisplayName))` (after the byte bound from the previous finding) — then `validateDisplayName(trimmed)`, and set `req.DisplayName = &trimmed` before the UpdateProfile call. cleanText's fixpoint output is stable, so the service's re-sanitize is a no-op.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0198", + "title": "Block-then-evict: the shared-DM lookup that gates voice eviction uses the request context, so a client disconnect right after the block commits leaves the blocked user in the blocker's live 1:1 DM call forever", + "file": "Server/api/dm_handler.go", + "line": 410, + "severity": "medium", + "why": "`svc.Blocks.BlockUser` has already committed by line 409. The eviction call itself is correctly detached with `context.WithoutCancel(r.Context())` (line 414), but the `SharedOneToOneDM` lookup that decides whether to evict is not — it runs on `r.Context()`. A canceled request context makes that lookup return a wrapped error, which is logged and skipped, so the eviction never happens. As the handler's own comment states, no later gate compensates: the block is otherwise only enforced at `voice_join` and voluntary `voice_token_refresh`, both driven by the blocked client, so the blocked user keeps speaking and listening in the blocker's DM call indefinitely.", + "repro": "A and B are in the voice room of their 1:1 DM channel. A sends `PUT /api/v1/blocks/{B}` and the TCP connection is torn down (client navigates away / app quits / reverse proxy read timeout) between `BlockUser` returning at line 397 and `SharedOneToOneDM` returning at line 410. `r.Context()` is done, `FindDMChannelIDBetween` fails with context.Canceled, the handler logs \"shared-DM lookup for voice eviction failed\" and returns. The block row is durable — A's client shows B as blocked and the DM composer is gated — but B is still connected to the SFU room for that channel with a live mic, and nothing re-runs the gate for the life of B's session. Contrast handleCloseDM (line 227-241), which detaches every post-commit step.", + "evidence": "397:\t\tif err := svc.Blocks.BlockUser(r.Context(), user.ID, targetID); err != nil { // commits here\n...\n409:\t\tif ve, evictable := broadcaster.(dmVoiceEvictor); evictable {\n410:\t\t\tif chID, exists, err := svc.DMs.SharedOneToOneDM(r.Context(), user.ID, targetID); err != nil {\n411:\t\t\t\tslog.Warn(\"block: shared-DM lookup for voice eviction failed\", ...)\n413:\t\t\t} else if exists {\n414:\t\t\t\tve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), targetID, chID)\n\n// service/dm.go:369 — any ctx error becomes ErrInternal, i.e. the skip branch\n371:\tid, ok, err := s.st.FindDMChannelIDBetween(ctx, userA, userB)\n372:\tif err != nil {\n373:\t\treturn 0, false, fmt.Errorf(\"%w: failed to look up shared DM: %v\", ErrInternal, err)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "api-authz", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "d652b237a7c5b6f77b51b48ef96b5005a11f0b22", + "test": "Server/api/dm_handler_block_context_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Take one detached context immediately after the block commits and use it for the whole post-commit tail: at dm_handler.go:398 add `bgCtx := context.WithoutCancel(r.Context())`, then use bgCtx for both svc.DMs.SharedOneToOneDM (line 410) and ve.DisconnectFromVoiceInChannel (line 414) — same shape as bgCtx in handleRenameGroupDM.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0199", + "title": "REST-created 1:1 DM pre-opens dm_open_state for the recipient, so the first message never emits dm_channel_open and the DM never appears in their sidebar", + "file": "Server/db/dm_queries.go", + "line": 171, + "severity": "medium", + "why": "GetOrCreateDMChannel inserts dm_open_state rows for BOTH users at creation time, but SendMessage only reports a recipient in OpenedDMFor when its own INSERT OR IGNORE actually inserted a row (OpenDM is :execrows). Because the row already exists, `opened` is false, ws/handlers_chat.go emits no DMChannelOpenEvent for the recipient, and no visibility-watermark bump happens either — so neither a live event nor a warm reconnect ever tells the recipient the DM exists. handleCreateDM (api/dm_handler.go:75, 120) is wired with no DMBroadcaster at all, unlike handleCreateGroupDM/handleRenameGroupDM/handleCloseDM, so nothing else covers the gap.", + "repro": "Alice POSTs /api/v1/dms {recipient_id: bob} (the client's api.createDm). GetOrCreateDMChannel creates channel 50 and inserts dm_open_state for BOTH alice(1) and bob(2). Alice then sends the first message. sendMessageDMSideEffects calls OpenDM(bob, 50) -> INSERT OR IGNORE affects 0 rows -> opened=false -> result.OpenedDMFor is empty -> handleChatSendV2 emits only the sequenced chat_message, no dm_channel_open for bob. On bob's client, dispatcher.ts CHAT_MESSAGE finds `isDm === false` (channel 50 is not in dmStore) and `incrementUnread` no-ops (DM ids are absent from channelsStore), so bob gets a desktop notification for a message with no sidebar entry, no unread badge, and no way to open the conversation — until he fully restarts and receives a `ready` payload. Group DMs do not have this bug: handleCreateGroupDM explicitly calls broadcastDMOpen for every participant. The existing regression test (service/message_crud_test.go:232 TestSendMessage_DoesNotReopenAlreadyOpenDM) misses it because newDMFixture seeds dm_participants only, never dm_open_state, so it never reproduces the REST-created state.", + "evidence": "Server/db/dm_queries.go:170-174\n\t// Open the DM for both users.\n\t_, err = tx.Exec(\n\t\t`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?)`,\n\t\tuser1ID, channelID, user2ID, channelID,\n\t)\n\nServer/service/message_crud.go:274-281\n\t\topened, openErr := s.st.OpenDM(bgCtx, pid, p.ChannelID)\n\t\t...\n\t\tif opened {\n\t\t\tresult.OpenedDMFor = append(result.OpenedDMFor, pid)\n\t\t}\n\nServer/api/dm_handler.go:75 r.Post(\"/\", handleCreateDM(svc)) // no broadcaster, unlike every sibling DM route", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "db-storage", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "c6873380625cd99a4f7544de0047739c47d07af1", + "test": "Server/api/dm_handler_create_notify_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Wire the broadcaster into the create route the same way its siblings are: MountDMRoutes -> r.Post(\"/\", handleCreateDM(svc, broadcaster)), and in handleCreateDM after a successful CreateDM add `if result.Created { broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID}) }`. broadcastDMOpen already does the context.WithoutCancel detach and the markDMVisibilityChanged watermark bump, so this one call covers both the live event and the warm-reconnect path. (The alternative — dropping user2 from the create-time dm_open_state insert so the first message's OpenDM reports opened=true — also works but changes GetUserDMChannelIDs visibility for the recipient before the first message and breaks db/dm_queries_test.go:113-121.)", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0200", + "title": "Cert-mismatch \"Reject\" is a no-op for any bracketed-IPv6 server: normalizeHostForCertCompare never mirrors cert_store_key's bracket unwrap", + "file": "Client/tauri-client/src/lib/ws.ts", + "line": 127, + "severity": "medium", + "why": "`tofu::cert_store_key` (src-tauri/src/tofu.rs:302-309) unwraps the brackets off a portless/`:443` bracketed IPv6 literal, so every `cert-tofu` event carries the BARE address. Its documented JS mirror `normalizeHostForCertCompare` only strips `:443` and lowercases — it never unwraps brackets — so for a profile saved as `[2001:db8::1]` the two strings can never be equal. Every guard that gates a security action on that equality silently takes the \"unrelated host\" branch.", + "repro": "Save/log into a server whose host is a bracketed IPv6 literal with no port or with `:443` (accepted by `isValidHost`, src/lib/hostValidation.ts:27 — `/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/`), e.g. `[2001:db8::1]`. `lastConnectHost` (main.ts:363) and `config.host` are stored verbatim as `\"[2001:db8::1]\"`. ws.ts:538 builds `wss://[2001:db8::1]/api/v1/ws`; Rust `extract_host` → `cert_store_key(\"[2001:db8::1]\")` → strip_suffix(\":443\") misses → strip_prefix('[')+strip_suffix(']') → emits `host: \"2001:db8::1\"`. JS computes `normalizeHostForCertCompare(\"[2001:db8::1]\")` = `\"[2001:db8::1]\"`. Now rotate/replace the server certificate. (1) ws.ts:381 `raw.host === normalizeHostForCertCompare(config.host)` is false → `certMismatchBlock` stays false and `cancelReconnect()`/`setState(\"disconnected\")` never run, so the reconnect loop keeps re-arming and re-firing a mismatch modal instead of latching once. (2) main.ts:233 `if (evt.host === normalizeHostForCertCompare(lastConnectHost))` is false → clicking **Reject** on the \"certificate changed — possible MITM\" modal does NOT call `ws.disconnect()`, `clearAuth()` or `router.navigate(\"connect\")`; the user stays authenticated and connected to the server whose certificate they just rejected. (3) main.ts:218 → after **Accept**, `reconnectAfterCertAccept` never runs. (4) main.ts:185 → after confirming a FIRST-USE certificate the pending `ws.connect` is never resumed, so first login to such a server hangs on the connect page. tests/unit/ws-cert.test.ts:484-497 pins only the lowercase half of this parity contract and its own comment names consequence (2) as the worst case.", + "evidence": "ws.ts:126-128 export function normalizeHostForCertCompare(host: string): string { return host.replace(/:443$/, \"\").toLowerCase(); }\n\ntofu.rs:302-309 pub(crate) fn cert_store_key(host: &str) -> String {\n let stripped = host.strip_suffix(\":443\").unwrap_or(host);\n let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped);\n unbracketed.to_ascii_lowercase()\n}\n\nmain.ts:233 if (evt.host === normalizeHostForCertCompare(lastConnectHost)) { ws.disconnect(); clearAuth(); router.navigate(\"connect\"); }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "tauri-rust", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "80f96f83403d9518a0c8d5f0e0b9654254449d4b", + "test": "Client/tauri-client/tests/unit/ws-cert.test.ts", + "revertProof": "pass (hand-proved)" + }, + "suggestedFix": "Make the JS mirror the Rust key exactly, in the one shared helper (ws.ts:126): `export function normalizeHostForCertCompare(host: string): string { const stripped = host.replace(/:443$/, \"\"); const unbracketed = stripped.startsWith(\"[\") && stripped.endsWith(\"]\") ? stripped.slice(1, -1) : stripped; return unbracketed.toLowerCase(); }` - same order as cert_store_key (strip :443, then unwrap brackets, then lowercase), so \"[2001:db8::1]\" and \"[2001:db8::1]:443\" both normalize to \"2001:db8::1\" while \"[2001:db8::1]:8443\" keeps its brackets. All four call sites are fixed by that single change.", + "fixedDate": "2026-08-20", + "note": "Independently fixed on main by #1397 (labelled there as OC-0163's TS half) while this branch was in flight. The merge of origin/main kept main's equivalent implementation of normalizeHostForCertCompare, so this branch's own edit to that function is superseded; the behaviour is pinned by main's tests plus this branch's. Distinct from ledger OC-0163, which is the bare-IPv6 WebSocket-URL defect at ws.ts:540." + }, + { + "id": "OC-0201", + "title": "A full-resync `ready` that preserves a live voice session does no E2EE reconciliation: departed peers keep a working room key and a client elected key holder during the outage never learns it", + "file": "Client/tauri-client/src/lib/dispatcher.ts", + "line": 273, + "severity": "medium", + "why": "On the full-ready reconnect tier the client rebuilds `voiceUsers` wholesale from `payload.voice_states` and never notifies `E2EEManager` about anyone who left while the socket was down — `handleParticipantLeft` is only ever driven by live `voice_leave` frames, which this tier does not replay. `ready` also carries no ECDH keys, so nothing re-derives `_isKeyHolder` or prunes `_peerPublicKeys`. The same handler already patches the sibling symptom for moderator mute/deafen (lines 295-306, \"this full resync is the only place a moderator mute/deafen issued while we were disconnected ever reaches us\"), so the gap is a known-shape hole left open for E2EE state.", + "repro": "Voice channel 5 holds A(uid 1, key holder), B(uid 2), C(uid 3). B's TCP connection goes silent without FIN/RST (wifi handoff / laptop sleep / NAT rebind), so B's server-side readPump never errors and B stays in h.clients (sweepStaleClients only kicks after staleClientTimeout = 90s). B's client detects its own ping timeout and reconnects on a new socket with last_seq>0; registerNow sees the old entry, reports replaced=true, transfers B's voice state, and re-runs updateKeyHolder. B's LiveKit/SFU connection was never dropped, so the media session and B's room key are still live.\nThe resume takes the full-ready tier (replay buffer no longer covers last_seq, or mustFullResync was tripped by a visibility bump), so handleFreshConnect sends `ready` instead of replaying events.\nCase 1 — forward secrecy: C left during the 40s outage. B never receives C's voice_leave, so handleParticipantLeft(3) never runs, C stays in B's _peerPublicKeys and, if B is the holder, B never rotates. C's captured room key keeps decrypting the room's SFrames until B's 5-minute periodic timer happens to fire.\nCase 2 — holder stall: A (the holder, lowest uid) left during the outage. Server-side updateKeyHolder on B's re-registration elects B (uid 2, now lowest). B's E2EEManager._isKeyHolder is still false and no voice_leave for A ever arrives, so B never self-elects and never rotates. Every subsequent joiner announces, is offered nothing (B won't offer; C's offers are refused with NOT_KEY_HOLDER by voice_e2ee.go:198), times out after 10s+5s in setupKeyExchange and is ejected from voice with \"e2ee_timeout\". The channel stays in that state until the next voice_leave happens to run an election on B.", + "evidence": "dispatcher.ts:269-306\n ws.on(S.READY, (payload) => {\n ...\n setVoiceStates(payload.voice_states); // wholesale roster replace\n ...\n } else if (selfVoiceState !== undefined) { // live voice session survived the WS drop\n enforceModeratorAudioState(...); // <- only mute/deafen is reconciled\n }\n(no livekitSession() call anywhere in the READY handler — grep of dispatcher.ts shows livekitSession() only at 857/894/917/950/966/974/1150/1187)\n\nlivekitE2EE.ts:1197 handleParticipantLeft() is the ONLY path that deletes _peerPublicKeys entries, rotates for membership forward secrecy, and self-elects a new key holder.\nServer/ws/hub.go:486-499 registerNow transfers the old connection's voice state on lastSeq>0, and\nServer/ws/hub.go:593-595 then runs updateKeyHolder(replacedVoiceChID) — so the server can elect the reconnecting client key holder.\nServer/ws/serve.go:882-886 freshConnectCleanStaleVoice deliberately KEEPS the voice_states row on the replay-failure fallback (lastSeq>0, old client still registered), which is what makes the full-ready-with-live-voice case reachable.\nServer/ws/serve_ready.go:356-365 the ready payload carries voice_states only — no e2ee public keys, no key-holder field.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-voice", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932", + "test": "Client/tauri-client/tests/unit/dispatcher.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Reconcile voice membership in the same branch that already reconciles moderator audio state (dispatcher.ts:296-306). Snapshot the roster BEFORE the wholesale replace at line 273 — `const prevPeers = voiceStore.getState().currentChannelId !== null ? new Set(voiceStore.getState().voiceUsers.get(voiceStore.getState().currentChannelId)?.keys() ?? []) : new Set()` — then, in the `else if (selfVoiceState !== undefined)` branch, for every uid in prevPeers that is absent from payload.voice_states for selfVoiceState.channel_id (and is not our own id), call `void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid))`. handleParticipantLeft already prunes/retires the peer key, re-runs the lowest-uid election (self-electing and rotating when appropriate), so one call site covers both the departed-peer rotation and the missed key-holder promotion.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0202", + "title": "A transient DB read error in AuthMiddleware is reported as 401, which makes the client log the user out and permanently delete their saved credential", + "file": "Server/api/middleware.go", + "line": 117, + "severity": "medium", + "why": "ResolveTokenHash deliberately returns DB errors *wrapped* (never a sentinel) so callers can tell an outage from a bad token — the middleware even logs it as \"auth: token resolution failed\" — but then falls into the same `default` arm as ErrTokenNotFound and answers 401 UNAUTHORIZED. The desktop client treats every 401 as \"session expired\": it calls the global onUnauthorized sink, which runs clearAuth(), and the authStore subscriber then tears down the WS and calls deleteCredential(host) plus sets `owncord:skip-auto-login`. So one transient SQLite read failure on any authenticated REST call signs a live user out and destroys their stored credential, even though the session token is still perfectly valid and the WebSocket was healthy.", + "repro": "1. User is signed in on MainPage with a remembered host (credential in the OS keyring) and a healthy WS.\n2. The SQLite reader momentarily fails one query — e.g. `database is locked` / `disk I/O error` during the scheduled backup or a restore, or the DB file is briefly replaced (admin/handlers_backup.go's restore path swaps the file under the running server).\n3. Any authenticated REST call in flight (GET /api/v1/channels/{id}/messages, /dms, /blocks, an avatar fetch, search…) hits AuthMiddleware; GetSessionByTokenHash/GetUserByID/GetRoleByID returns the wrapped DB error.\n4. Middleware writes 401 UNAUTHORIZED instead of 500/503.\n5. api.ts fires onUnauthorized -> clearAuth() -> main.ts subscriber runs ws.disconnect(), deleteCredential(host), sets skip-auto-login, navigates to the connect page with \"Your session expired — sign in again.\"\n6. The session row was never revoked and the token is still valid, but the user must retype their password and auto-login is disabled for that host. No test pins this (Server/api/middleware_test.go covers missing/invalid/expired/revoked tokens and a dangling role, never a DB error).", + "evidence": "Server/api/middleware.go:117-128\n\t\t\tcase err != nil:\n\t\t\t\t// ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad\n\t\t\t\t// token — log it so it's distinguishable from ordinary 401s.\n\t\t\t\tif !errors.Is(err, auth.ErrTokenNotFound) {\n\t\t\t\t\tslog.ErrorContext(r.Context(), \"auth: token resolution failed\", \"error\", err)\n\t\t\t\t}\n\t\t\t\twriteJSON(w, http.StatusUnauthorized, errorResponse{\n\t\t\t\t\tError: \"UNAUTHORIZED\",\n\t\t\t\t\tMessage: \"invalid or expired session\",\n\t\t\t\t})\n\t\t\t\treturn\n\nServer/auth/resolve.go:41-77 — every store error is returned raw/wrapped, matching none of the sentinels:\n\tsess, err := store.GetSessionByTokenHash(ctx, hash); if err != nil { return nil,nil,nil, err }\n\tuser, err := store.GetUserByID(ctx, userID); if err != nil { return nil,nil,nil, err }\n\trole, err := store.GetRoleByID(ctx, user.RoleID); if err != nil { return nil,nil,nil, err }\n\nClient/tauri-client/src/lib/api.ts:141-155\n if (res.status === 401) {\n if (!opts?.skipUnauthorized) {\n onUnauthorized?.();\n }\n\nClient/tauri-client/src/main.ts:121-130\nconst api = createApiClient({ host: \"\" }, () => {\n ...\n clearAuth();\n});\n\nClient/tauri-client/src/main.ts:807-815\n const host = api.getConfig().host;\n if (host && authStore.getState().logoutReason !== \"server_shutdown\") {\n void deleteCredential(host);\n sessionStorage.setItem(\"owncord:skip-auto-login\", \"1\");\n }\n\nContrast: the ws revoked-session sweep refuses to do this on the identical signal — Server/ws/hub_sweep.go:137-143 \"A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep.\"", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-session", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "394ea9ccc7f8222ef5b4d019c3fd846dc62a7c52", + "test": "Server/api/middleware_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Split the default arm in AuthMiddleware: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other (wrapped) error write 503 SERVICE_UNAVAILABLE (or 500) alongside the existing slog.ErrorContext, so the client's 401 sink never fires on a server-side fault.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0203", + "title": "The channel-override escalation guard is missing on every clear path, so a MANAGE_CHANNELS holder can hand a lower-ranked role a permission their own role lacks", + "file": "Server/admin/handlers_channel_perms.go", + "line": 222, + "severity": "medium", + "why": "requireGrantableOverride exists to stop a non-ADMINISTRATOR MANAGE_CHANNELS holder granting a bit their own role does not hold, and handleDeleteChannelPermission's own comment says clearing an override \"restores exactly the access the PUT path refuses to grant, so gate it identically to handlePutChannelPermission\" — but it only adds the hierarchy guard and never calls requireGrantableOverride. handleDeleteChannelUserPermission has the same omission, and the guard is also defeated on the PUT paths themselves because it only inspects the *new* allow|deny mask: PUT {allow:0, deny:0} passes trivially while wiping an existing deny row. Clearing a deny is a grant (EffectivePerms = (rolePerm &^ deny) | allow), so the documented invariant does not hold on any of the four endpoints.", + "repro": "Setup: role \"Helper\" (position 5) whose base permissions include MANAGE_MESSAGES; channel #general carries channel_overrides(channel=#general, role=Helper, allow=0, deny=MANAGE_MESSAGES). Actor \"Mod\" holds MANAGE_CHANNELS but NOT MANAGE_MESSAGES and NOT ADMINISTRATOR, at position 10.\n\n1. Mod sends PUT /admin/api/channels/{general}/permissions/{helper} with {\"allow\": MANAGE_MESSAGES, \"deny\": 0} -> 403 \"cannot grant a permission your own role lacks (MANAGE_MESSAGES)\" (handlers_channel_perms.go:141).\n2. Mod instead sends DELETE /admin/api/channels/{general}/permissions/{helper} (or PUT with {\"allow\":0,\"deny\":0}) -> 204/200. Only the position check runs, and 5 < 10 passes.\n3. The deny row is gone, so EffectivePerms(Helper.base, 0, 0) now yields MANAGE_MESSAGES in #general: every Helper can delete other members' messages there — a power Mod does not have and was explicitly refused in step 1. permInvalidator + RefreshChannelVisibility even push the widened grant out immediately.\n\nSame two steps work against a single member through PUT/DELETE /channels/{id}/user-permissions/{userId} (handlers_channel_perms.go:342 vs 397).", + "evidence": "Server/admin/handlers_channel_perms.go:88-102 (the invariant)\n// requireGrantableOverride refuses to write a channel override whose allow or\n// deny mask contains a bit the actor's own role does not hold. Without this,\n// any MANAGE_CHANNELS holder could grant themselves or another user a\n// permission (e.g. MANAGE_SERVER) they were never assigned ...\nfunc requireGrantableOverride(actorRole *db.Role, allow, deny int64) error {\n\tif permissions.HasAdmin(actorRole.Permissions) { return nil }\n\tif escalated := (allow | deny) &^ actorRole.Permissions; escalated != 0 { ... }\n\nServer/admin/handlers_channel_perms.go:141-150 (PUT role — both guards)\n\t\tif err := requireGrantableOverride(actorRole, allow, deny); err != nil { ...403... }\n\t\tif role.Position >= actorRole.Position { ...403... }\n\nServer/admin/handlers_channel_perms.go:218-230 (DELETE role — hierarchy ONLY)\n\t\t// Hierarchy guard: deleting an override is a permission mutation with the\n\t\t// same authority as writing one (removing a deny row restores exactly the\n\t\t// access the PUT path refuses to grant), so gate it identically to\n\t\t// handlePutChannelPermission.\n\t\tif role.Position >= actorRole.Position { ...403... }\n\t\tif err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { ... }\n\nServer/admin/handlers_channel_perms.go:395-404 (DELETE per-user — hierarchy ONLY, no requireGrantableOverride)\n\nServer/permissions/permissions.go:138-140\nfunc EffectivePerms(rolePerm, allow, deny int64) int64 { return (rolePerm &^ deny) | allow }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-session", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "c978a348bbdd58b7c26e848f52f65a4f8c11c487", + "test": "Server/admin/handlers_channel_perms_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Do the escalation check against the masks being REMOVED, not the ones being written: in both DELETE handlers load the current row (database.GetChannelPermissions / GetUserChannelPermissions) and call requireGrantableOverride(actorRole, curAllow, curDeny) before deleting; in both PUT handlers pass (curAllow|allow, curDeny|deny) so a clear-by-zero-mask is covered by the same guard. Note a bare requireGrantableOverride(actorRole, 0, 0) on the DELETE paths would be a no-op.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0204", + "title": "A message or @mention arriving while the user reads back-history in the same channel is silently dropped: no row, no badge, no notification", + "file": "Client/tauri-client/src/lib/dispatcher.ts", + "line": 581, + "severity": "medium", + "why": "Three independently-correct guards compose into a hole. messages.store's addMessage refuses to append to a detached around-window, dispatcher skips incrementUnread/incrementMention because the channel is activeChannelId, and notifyIncomingMessage bails because the window is focused and the channel is active. Nothing else records that the message arrived, so an @mention of the user vanishes with zero indication.", + "repro": "1. Open #general (a channel with more than ~100 messages of history). 2. Click a reply-reference, a search result, or a permalink pointing at an old message IN #general. MessageJump.jumpTo (pages/main-page/MessageJump.ts:95-102) calls api.getMessagesAround and passes has_more_after=true to setAroundMessages, which adds #general to detachedChannels (messages.store.ts:517-524). #general is still activeChannelId. 3. Leave the app window focused. Another user posts \"@you ping\" in #general. 4. dispatcher CHAT_MESSAGE: addMessage(payload) hits messages.store.ts:255 and returns prev unchanged -> the row is never stored or rendered. payload.channel_id !== activeId is FALSE, so incrementUnread (dispatcher.ts:582) and incrementMention (dispatcher.ts:585) are both skipped. notifyIncomingMessage hits notifications.ts:56 (isWindowFocused() && channel_id === activeChannelId) and returns -> no desktop popup, no chime, no taskbar flash. RESULT: nothing at all reaches the user. The server did increment read_states.mention_count, but dispatcher's ready handler calls markChannelRead(currentActive) (dispatcher.ts:397) on the next full resync, erasing it permanently. Recovery requires the user to guess and click \"Jump to Present\".", + "evidence": "messages.store.ts:252-255 // 3. Append as a new message — unless the channel is showing a detached\n// around-window ...\nif (prev.detachedChannels.has(channelId)) return prev;\n\ndispatcher.ts:581-586\nif (payload.channel_id !== activeId && !isOwnMessage) {\n incrementUnread(payload.channel_id);\n if (isMention) {\n incrementMention(payload.channel_id);\n }\n}\n\nnotifications.ts:54-56\n// Don't notify if the window is focused AND the message is in the active channel\nconst activeChannelId = channelsStore.getState().activeChannelId;\nif (isWindowFocused() && payload.channel_id === activeChannelId) return;", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932", + "test": "Client/tauri-client/tests/unit/dispatcher.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Stop using \"channel is active\" as a proxy for \"the user is looking at the live tail\". Smallest change: in notifications.ts:56 use the already-exported detached selector — `if (isWindowFocused() && payload.channel_id === activeChannelId && !isWindowDetached(payload.channel_id)) return;` — so a detached active channel still notifies. Mirror it at dispatcher.ts:581 (`if ((payload.channel_id !== activeId || isWindowDetached(payload.channel_id)) && !isOwnMessage)`) if the badge is wanted too; that path additionally needs the badge cleared when the channel reattaches (reattachToPresent / setMessages) or the count will linger.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0205", + "title": "Embed pipeline never strips the trailing punctuation the linkifier strips, so a URL at the end of a sentence gets no embed (and YouTube links render as a duplicate broken bare link)", + "file": "Client/tauri-client/src/components/message-list/media.ts", + "line": 515, + "severity": "medium", + "why": "`extractUrls` returns raw `URL_REGEX` matches. `URL_REGEX = /https?:\\/\\/[^\\s<>\"']+/g` swallows any trailing `.`, `,`, `)`, `!`, `?`. `renderMentions` in content-parser.ts:171 explicitly strips exactly those characters (with a paren-balance give-back) before building the anchor, so the linkified href and the URL handed to the embed pipeline disagree on every URL that is followed by sentence punctuation. The embed path then either mis-classifies the URL or fetches an address that does not exist.", + "repro": "Post `Nice pic https://cdn.example.com/a.png.` (sentence-ending period). extractUrls yields `https://cdn.example.com/a.png.`; `isDirectImageUrl` tests `new URL(...).pathname` = `/a.png.` against `/\\.(gif|png|jpg|jpeg|webp)$/`, which fails, so the inline image is never rendered and a generic link-preview card is fetched for a 404 address instead. Same for `(https://cdn.example.com/a.png)`. For YouTube: post `Check https://youtu.be/dQw4w9WgXcQ.` — extractYouTubeId returns `dQw4w9WgXcQ.` (non-null), so renderYouTubeEmbed is entered, YOUTUBE_ID_RE `^[\\w-]{1,20}$` rejects the `.`, and the message gets a second, plain `` \"embed\" pointing at the trailing-dot URL underneath the correctly-linkified one — no player. No test covers punctuation: tests/unit/media.test.ts:1211-1246 only exercises clean URLs.", + "evidence": "media.ts:511-516\n const withoutCodeBlocks = content\n .replace(CODE_BLOCK_REGEX, \"\")\n .replace(INLINE_CODE_REGEX, \"\")\n .replace(MASKED_LINK_REGEX, \"\");\n const matches = withoutCodeBlocks.match(URL_REGEX);\n return matches ?? [];\n\nvs content-parser.ts:170-179\n const rawUrl = match[0];\n let stripped = rawUrl.replace(/[.,;:!?)]+$/, \"\");\n ... if (opens > closes) stripped = stripped + \")\";", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-2", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "0b665f3c7da336b95beb7c63f3c3eb23b634e173", + "test": "Client/tauri-client/tests/unit/media.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Export the trailing-punctuation strip from content-parser.ts (factor renderMentions:171-179 into e.g. `stripUrlTrailingPunctuation(raw)`) and apply it once in extractUrls (media.ts:515) before returning, so the embed pipeline and the anchor agree on the same URL. One change in the shared extractor covers YouTube, direct-image and generic-preview branches.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0206", + "title": "VAD AudioWorklet counts 128-sample render quanta as if they were ~16 ms frames, so the mic gate closes after 32 ms of quiet instead of the intended ~200 ms", + "file": "Client/tauri-client/public/vad-worklet.js", + "line": 19, + "severity": "medium", + "why": "AudioWorkletProcessor.process() is invoked once per render quantum — 128 sample frames — and AudioPipeline creates its context with `new AudioContext({ sampleRate: 48000 })` (audioPipeline.ts:120), so one process() call is exactly 128/48000 = 2.667 ms. The worklet's frame counters were sized for ~16 ms frames (the setTimeout fallback's poll interval): _gateOnFrames = 12 is annotated \"~200ms of silence before gating\" but is 32 ms; _startupGrace = 30 is annotated \"~500ms grace period\" but is 80 ms; the RMS throttle `_frameCounter >= 6` is annotated \"~50ms\" but is 16 ms. audioPipeline.ts:415 calls the fallback's logic \"identical to the setTimeout version\", where the same 12/30 constants at 16 ms per poll do give ~192 ms and ~480 ms. The two VAD implementations therefore disagree by ~6x on the only tuning parameter that matters, and the primary (worklet) path is the wrong one.", + "repro": "Join voice with the default voiceSensitivity of 50 (threshold RMS 0.05) on a build where /vad-worklet.js loads successfully (the logged \"VAD AudioWorklet started\" path). Speak a normal sentence. Inter-word pauses of 40-150 ms drop RMS below threshold for longer than 12 render quanta (32 ms), so the worklet posts {type:\"gate\",gated:true}, AudioPipeline sets the GainNode target to 0 with setTargetAtTime(tau=0.015) and the outgoing mic level collapses mid-sentence; the ungate needs only 2 quanta (5.3 ms) so the gain immediately ramps back. The result is continuous level pumping / clipped word onsets heard by every other participant. Force the fallback instead (make addModule reject, e.g. remove /vad-worklet.js) and the identical constants gate only after ~192 ms, so the same speech passes through cleanly — the two paths produce audibly different behaviour from the same tuning values.", + "evidence": "vad-worklet.js:19,26,71\n\tthis._gateOnFrames = 12; // ~200ms of silence before gating\n\tthis._startupGrace = 30; // ~500ms grace period\n\tif (this._frameCounter >= 6) { // \"~50ms at 128 samples/frame @ 48kHz\"\n\naudioPipeline.ts:120 const ctx = new AudioContext({ sampleRate: 48000 });\naudioPipeline.ts:364-367 (fallback, polled at 16 ms)\n\tconst GATE_ON_FRAMES = 12;\n\tconst GATE_OFF_FRAMES = 2;\n\tconst STARTUP_GRACE = 30;\naudioPipeline.ts:390,410 this.vadTimer = setTimeout(poll, 16);", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-ws", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "e0ed40e4d96c1757522e8d69b47863c2c65f92eb", + "test": "Client/tauri-client/tests/unit/vad-worklet-timing.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Fix the constants in Client/tauri-client/public/vad-worklet.js to be counts of 128-sample render quanta rather than 16 ms polls: _gateOnFrames = 75 (~200 ms), _gateOffFrames = 12 (~32 ms), _startupGrace = 188 (~500 ms), and the RMS-throttle test at line 71 to _frameCounter >= 19 (~50 ms). That is the single-place fix, since _startupGrace and the RMS throttle are not overridable through the config message. If you would rather keep the timing on the main thread, derive them there instead — in startVadWorklet (audioPipeline.ts:326) post gateOnFrames: Math.round(0.2 * ctx.sampleRate / 128) and gateOffFrames: Math.round(0.033 * ctx.sampleRate / 128) — but the grace period and RMS throttle still have to be corrected in the worklet.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0207", + "title": "The video-mode wake-up signature omits currentChannelId, so VideoModeController's lastChannelId goes stale and clearStreams() deletes the remote tile onRemoteVideo just added", + "file": "Client/tauri-client/src/pages/MainPage.ts", + "line": 710, + "severity": "medium", + "why": "MainPage's voiceStore subscriber only calls videoModeCtrl.checkVideoMode() when the camera/screenshare signature changes (MainPage.ts:710-724), and that signature is built from localCamera/localScreenshare plus per-user camera/screenshare flags — currentChannelId is read (line 711) only to pick which roster to scan, and never contributes to `sig`. checkVideoMode() is the ONLY writer of VideoModeController's `lastChannelId` (VideoModeController.ts:121-126), so a voice-channel switch that leaves the signature unchanged never advances it. The next call to checkVideoMode() is then the one made by setOnRemoteVideo (MainPage.ts:695) immediately after it added a tile — and it fires `videoGrid.clearStreams()` for the now-stale channel change, destroying that tile one line after it was created.", + "repro": "1. Alice is in voice channel A. Nobody in A or B has a camera or screenshare on, so MainPage's prevVideoSignature is \"\" and VideoModeController's lastChannelId is A.\n2. Alice clicks voice channel B in the sidebar. VoiceCallbacks.onVoiceJoin -> joinVoiceChannel(B) sets currentChannelId = B (voice.store.ts:300-315) and touches nothing else. MainPage's subscriber recomputes sig: still \"\" (no local flags, B's roster has no camera/screenshare flags), so line 721's guard fails and checkVideoMode() is NOT called. lastChannelId is still A.\n3. Bob, already in B, starts a screenshare. LiveKit's TrackSubscribed reaches Alice before the server's voice_state broadcast does — the exact race VideoModeController.ts:142-143 documents.\n4. setOnRemoteVideo runs: videoGrid.addStream(bobId + 1_000_000, \"Bob (Screen)\", stream, ...) at MainPage.ts:690, then videoModeCtrl.checkVideoMode() at MainPage.ts:695.\n5. checkVideoMode sees channelId (B) !== lastChannelId (A) and calls videoGrid.clearStreams() (VideoModeController.ts:123), deleting the tile added in step 4. It then reads channelUsers.get(bobId).screenshare === false (WS still lagging) and videoGrid.hasStreams() === false (just cleared), so anyVideoOn is false and it calls closeVideoGrid().\n6. The voice_state broadcast finally arrives; sig changes to \":s\" and checkVideoMode runs again — but channelId now equals lastChannelId, and remote tiles are only ever added by onRemoteVideo, which has already fired for this track and will not fire again.\nResult: Bob's screenshare is permanently invisible to Alice. Clicking \"Watch stream\" on Bob (ChannelSidebar.ts:539-541 -> MainPage.ts:398-401) opens the grid and calls setFocus(bobId + 1_000_000) for a tile that no longer exists, so VideoGrid.rebuildFocusLayout renders an empty .video-focus-main with no thumbnails. Only Bob stopping and restarting the share recovers it.", + "evidence": "MainPage.ts:710-724 — `let sig = (state.localCamera ? \"c\" : \"\") + (state.localScreenshare ? \"s\" : \"\"); const channelId = state.currentChannelId; if (channelId !== null) { const users = state.voiceUsers.get(channelId); if (users) { for (const [uid, u] of users) { if (u.camera) sig += `:c${uid}`; if (u.screenshare) sig += `:s${uid}`; } } } if (sig !== prevVideoSignature) { prevVideoSignature = sig; videoModeCtrl?.checkVideoMode(); }` — no channelId term in `sig`.\n\nVideoModeController.ts:112-126 — `function checkVideoMode(): void { const voice = voiceStore.getState(); const channelId = voice.currentChannelId; if (channelId !== lastChannelId) { if (lastChannelId !== null) { videoGrid.clearStreams(); } lastChannelId = channelId; }` — `lastChannelId` is assigned nowhere else except `destroy()`.\n\nMainPage.ts:690-695 — `videoGrid.addStream(tileId, username, stream, { isSelf: false, audioUserId: userId, isScreenshare }); videoModeCtrl?.checkVideoMode();` — add, then clear.\n\nVideoModeController.ts:141-143 states the premise of the race: \"Check both voice store state AND whether the grid has tiles, because LiveKit track delivery can race ahead of the WS voice_state update.\"\n\nVideoModeController.ts:213-217 confirms nothing else re-adds the tile: \"Remote video tiles are managed exclusively by the onRemoteVideo / onRemoteVideoRemoved callbacks (driven by LiveKit TrackSubscribed / TrackUnsubscribed).\"\n\nGrep confirms only three checkVideoMode call sites exist: MainPage.ts:695, :700, :723.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-2", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85", + "test": "Client/tauri-client/tests/unit/main-page.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Include the voice channel id in the signature so any channel switch immediately advances lastChannelId. In MainPage.ts:710-711, seed the signature with the channel id, e.g. `const channelId = state.currentChannelId; let sig = `${String(channelId)}|` + (state.localCamera ? \"c\" : \"\") + (state.localScreenshare ? \"s\" : \"\");` (moving the existing line 711 above line 710). One line at the single wake-up site; the controller's tested clear-on-change behavior is untouched.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0208", + "title": "Voice sidebar's re-render signature omits sessionFingerprint, so an unverified peer's session fingerprint goes permanently stale after their LiveKit reconnect", + "file": "Client/tauri-client/src/components/ChannelSidebar.ts", + "line": 943, + "severity": "medium", + "why": "`unsubVoiceStructure` is the only voiceStore subscription that calls `renderChannels()` (the sibling `unsubSpeaking` only toggles CSS classes), and its structural signature folds in `verif.status` but never `verif.sessionFingerprint`. The unverified badge's tooltip is built from `v.sessionFingerprint` (ChannelSidebar.ts:81-83), so a peer whose ephemeral key changes mid-call at an unchanged status produces a new fingerprint in the store that the DOM never picks up — the badge keeps advertising the superseded value, which for a peer with no identity key is the only out-of-band comparison value the feature (OC-0003) exists to provide.", + "repro": "Local user A and legacy peer B (B has no published identity key) are in the same voice channel. A's sidebar shows B's muted shield with tooltip \"Session fingerprint …: FP1\". B's LiveKit room connection drops and auto-reconnects: `E2EEManager.reannounceForReconnect()` (livekitE2EE.ts:337-375) generates a fresh ECDH keypair and re-announces it. A's `handleAnnounceInner` -> `verifyPeerAnnounce` writes `setPeerVerification({userId: B, status: \"unverified\", safetyNumber: null, sessionFingerprint: FP2})` (livekitE2EE.ts:553, 557-565). B never left `voiceUsers` and B's status is still \"unverified\", so the structural signature at line 943 is byte-identical to before, `subscribeSelector` fires no callback, `renderChannels()` never runs, and A's tooltip keeps showing FP1 while B's live session key hashes to FP2. It stays wrong until some unrelated structural change (someone toggles mute/camera, or a join/leave) happens to force a re-render. Reading FP1 out of band against B's screen (which shows FP2 via `localSessionFingerprint`, correctly refreshed because line 936 includes it) reports a false mismatch; the reverse ordering hides a real one.", + "evidence": "ChannelSidebar.ts:943 — `structSig += `:${uid}${u.muted ? \"m\" : \"\"}...${verif ? `@${verif.status}` : \"\"}`;` (only `verif.status`, no `verif.sessionFingerprint`)\nChannelSidebar.ts:81-83 — `(v.sessionFingerprint !== null ? ` Session fingerprint (changes every call — not an identity): ${v.sessionFingerprint}` : \"\")`\nChannelSidebar.ts:936 — the local half IS covered: `let structSig = `${state.currentChannelId ?? \"\"}#${state.localSessionFingerprint ?? \"\"}`;`", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-ws", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "05287f67725f44eb4933e29ff604481a1ad7baab", + "test": "Client/tauri-client/tests/unit/channel-sidebar.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Include the fingerprint (and safety number) in the structural signature at ChannelSidebar.ts:943 — replace `${verif ? `@${verif.status}` : \"\"}` with `${verif ? `@${verif.status}/${verif.safetyNumber ?? \"\"}/${verif.sessionFingerprint ?? \"\"}` : \"\"}`. One change in the shared selector covers every badge field the tooltip reads.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0209", + "title": "A replayed retired-key announce overwrites the peer's displayed verification and session fingerprint before the replay guard rejects it", + "file": "Client/tauri-client/src/lib/livekitE2EE.ts", + "line": 748, + "severity": "medium", + "why": "`handleAnnounceInner` runs `verifyPeerAnnounce` — which calls `setPeerVerificationIfCurrent` on every branch, including the success branches that publish `sessionFingerprint` computed from the announced key — before the `isRetiredPeerKey` replay guard at lines 779-783 / 790-793. An announce that is then rejected as a retired-key replay has already rewritten the voice store's verification entry for that peer, so the UI advertises the fingerprint of a key that is provably no longer the peer's live ECDH key (`_peerPublicKeys` is left untouched), and a red \"mismatch\" badge raised by a preceding forged announce is reset to green by the replay.", + "repro": "Peer P announces ephemeral key K1; A stores it and publishes verification {status, sessionFingerprint: FP(K1)}. P reconnects and announces K2; A retires K1 (`retirePeerKey`, line 785), stores K2, and publishes {status, sessionFingerprint: FP(K2)}. The relay now re-emits P's original, still-validly-signed K1 announce (the exact replay OC-0011's retired-key guard was added for — the announce message carries no channel/epoch/nonce). `handleAnnounceInner` calls `verifyPeerAnnounce(P, K1, sig1)`: the signature verifies against P's pinned identity key, so it returns true after writing `setPeerVerification({userId: P, status: \"verified\"|\"unverified\", sessionFingerprint: FP(K1)})`. Only then does line 780 reject the announce and return, leaving `_peerPublicKeys[P] === K2`. The badge now reports FP(K1) — a key A itself already retired — as P's current session fingerprint. If the replay is preceded by a forged/unsigned announce that set status \"mismatch\", the replay also clears that red badge back to verified.", + "evidence": "livekitE2EE.ts:747-751 — `if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) { return; }` runs first\nlivekitE2EE.ts:553 — `const sessionFingerprint = await computeRawKeyFingerprint(this.rawFromBase64(publicKeyBase64));` then `setPeerVerificationIfCurrent(..., sessionFingerprint)` at 557-565 / 613-622\nlivekitE2EE.ts:779-783 — `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error(\"E2EE: rejecting replayed peer key announce (previously retired)\", { userId }); return; }` (same guard again at 790-793) — reached only *after* the store write", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-ws", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec", + "test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Hoist the replay check ahead of verification: in handleAnnounceInner, immediately after the `_ecdhKeyPair` queue check / generation capture (~line 741), add `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error(...); return; }` and delete the two later duplicates at 779-783 and 790-793. Safe because a retired key is never the live key — retirePeerKey is only called for a key being replaced (785) or for a departing peer whose entry is deleted (1200/1214) — so the dedupe branch at 764-769 cannot be starved.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0210", + "title": "Ring-buffer-only mode has no cross-restart seq-epoch guard: a partial replay from a fresh seq epoch is presented as a clean resume", + "file": "Server/main.go", + "line": 431, + "severity": "medium", + "why": "`seedHubReplayState` is the only thing that re-seeds `h.seq` from persisted events AND calls `hub.MarkVisibilityChanged()` at boot, and it lives *behind* `runStartEventPersistence`'s early return. With `event_persistence.enabled: false` (an explicitly supported mode — config.go:81-83 \"falls back to ring-buffer-only behaviour (Phase A semantics)\") the hub restarts with `h.seq == 0`, `visibilityChangeSeq == 0`, and an empty ring, so a reconnecting client carrying a stale pre-restart `last_seq` is matched against seq numbers belonging to a completely different epoch. `EventRingBuffer.EventsSinceFiltered` (ringbuffer.go:101/107) only refuses when `afterSeq <= oldestSeq` or `afterSeq > newestSeq`; a stale watermark that happens to land inside the new epoch's live window passes both checks and yields a partial replay. `mustFullResync` (hub_events.go:84-87) is inert because the watermark is 0. The client-side mitigation (ws.ts:326-329, OC-0032) only resets `lastSeq` when `replay_source === \"none\"`, so this path — `replay_source: \"buffer\"` — bypasses it, and the client tracks only `max(seq)` so the skipped events can never be requested again.", + "repro": "Config `event_persistence.enabled: false`. Server runs briefly; hub seq reaches 40; client A's in-memory `lastSeq` = 40. Restart the server (admin Restart / update / supervisor). On boot `runStartEventPersistence` returns at main.go:431, so `h.seq = 0`, `visibilityChangeSeq = 0`, ring empty. Other clients reconnect first; each connect fans out a sequenced global presence/member frame, pushing the NEW epoch's seq to 60 (ring holds new-epoch seq 1..60). Client A now reconnects with `last_seq: 40`: `mustFullResync(40)` is false (w==0); `EventsSinceFiltered(40, allowed)` sees oldest=1, newest=60, so 40 > 1 and 40 <= 60 → it returns the 20 frames with seq 41..60. handleReconnect writes `auth_ok` with `replay_source: \"buffer\"` plus those 20 frames. Client A never receives new-epoch events 1..40 (the other users' presence/member/channel frames), its `lastSeq` is never reset because `replay_source != \"none\"`, and since it only reports `max(seq)` the hole is unrecoverable for the life of the connection — its member list and presence state stay silently wrong while the UI reports a successful resume.", + "evidence": "Server/main.go:431 `if !cfg.EventPersistence.Enabled || hub == nil { return nil, nil }` — line 435 `seedHubReplayState(bgCtx, hub, database, log)` and its `hub.MarkVisibilityChanged()` (main.go:846) are unreachable in ring-only mode.\nServer/ws/ringbuffer.go:101-108 `if afterSeq <= oldestSeq { return nil }` / `if afterSeq > rb.newestSeqLocked() { return nil }` — nothing rejects an in-window afterSeq from a previous epoch.\nServer/ws/hub_events.go:84-87 `func (h *Hub) mustFullResync(lastSeq uint64) bool { w := h.visibilityChangeSeq.Load(); return w > 0 && lastSeq <= w }` — `w == 0` on this boot, so always false.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-2", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "3bd29b5d9fea466025209d837c1036952c28f55b", + "test": "Server/main_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Add a per-process epoch nonce to the resume handshake and reject a mismatched one in the single shared guard. Concretely: generate a random `bootEpoch uint64` in ws.NewHub, emit it in buildAuthOK/buildReady, have serve_auth.go's authPayload accept an `epoch` field alongside `last_seq`, and in reconnectPrecheck (Server/ws/serve.go:298-310, next to the existing mustFullResync check) force the full-ready path whenever the echoed epoch is absent or != h.bootEpoch. That is one guard covering both tiers and both the disabled-persistence and empty-events-table cases. A server-only stopgap, if the protocol change is too big: set a `h.freshEpoch` flag whenever the boot did not seed from persistence (main.go:431 disabled branch and main.go:843 maxSeq<=0 branch) and make mustFullResync return true for any lastSeq > 0 while it is set — correct, at the cost of degrading every ring-only reconnect to a full ready.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0211", + "title": "Periodic session recheck disconnects the client on a transient DB error, unlike its sibling sweep which explicitly refuses to", + "file": "Server/ws/handlers.go", + "line": 122, + "severity": "low", + "why": "handleMessageSessionRecheck treats `dbErr != nil` identically to \"session row is gone\" and \"session expired\", kicking the connection and logging the misleading reason \"ws session expired\". The sibling backstop in the same package (sweepRevokedSessions) documents and implements the opposite rule for exactly this case, so the two authorization paths disagree about what a failed read means.", + "repro": "1. A client sends its 10th message since the last check (SessionCheckInterval, client.go:21), so `shouldCheck` is true.\n2. At that instant `h.db.GetSessionWithBanStatus` fails transiently — SQLITE_BUSY past busy_timeout, an I/O error, a maintenance window. It returns (nil, err).\n3. handlers.go:122 takes the branch and calls `h.kickClient(c)` at line 124: the client is deleted from h.clients, its send channels are closed and it is unsubscribed from every topic — with no error frame explaining why.\n4. Because the trigger is a server-wide DB condition, every connected client that crosses its 10-message boundary in that window is dropped simultaneously, and they all reconnect at once — adding load to the already-contended DB and feeding straight into the handshake path above (serve_auth.go:62), where the same failing query now produces a terminal auth_error and a logout.\nCompare Server/ws/hub_sweep.go:136-143, which on the identical failure logs and skips: \"A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep; the next tick retries.\" No test covers the dbErr path; Server/ws/handlers_test.go only exercises a genuinely deleted session.", + "evidence": "result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash)\nif dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {\n\tslog.Info(\"ws session expired, closing connection\", \"user_id\", c.userID)\n\th.kickClient(c)\n\treturn true\n}", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "ws-hub", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "59ac14a78b39b192950cc4fa770225c8cd7fb923", + "test": "Server/ws/oc_0211_session_recheck_dberr_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Treat a failed read as no evidence, matching the sibling sweep. In Server/ws/handlers.go:121, before the combined check: `if dbErr != nil { slog.Warn(\"ws session recheck: lookup failed, skipping\", \"user_id\", c.userID, \"err\", dbErr); return false }` — the next recheck and sweepRevokedSessions remain the enforcement backstops.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0212", + "title": "TOFU re-pin recovery is a no-op for the live call, and it deletes the only badge that showed the peer was blocked", + "file": "Client/tauri-client/src/lib/livekitE2EE.ts", + "line": 674, + "severity": "low", + "why": "`rePinPeerIdentity` writes the new pin and then calls `clearPeerVerification(userId)`, but nothing re-runs the announce that was rejected. `handleAnnounceInner` returns before `this._peerPublicKeys.set(...)` on a failed `verifyPeerAnnounce` (line 750), and a mid-call peer never re-announces (announce is only sent from `setupKeyExchange` and `reannounceForReconnect`), so the peer stays out of `_peerPublicKeys` — and therefore out of every offer and every rotation — for the rest of the call. Meanwhile `clearPeerVerification` removes the map entry entirely, so `ChannelSidebar.ts:480` (`if (verification !== null)`) renders no shield at all: the user sees the red shield-alert vanish and reads that as \"fixed\". The method's own doc comment (\"the next announce re-verifies against the new pin\") assumes an announce that a live call never produces.", + "repro": "Users A, B, C in one voice call; C is the key holder, A has B pinned to identity key K_old. B reinstalls (new identity key K_new) and rejoins the channel. B's `voice_e2ee_announce` reaches A: `verifyPeerAnnounce` sees `pin(K_old) !== publishedIdentity(K_new)` → status \"mismatch\", returns false, so A never stores B's ECDH key. C has no pin for B, accepts, and offers B the room key, so B is a normal participant for everyone but A. A clicks the red shield on B's row, confirms the fingerprint out of band, clicks \"Trust New Key\": `rePinPeerIdentity` succeeds, `clearPeerVerification(B)` runs, B's badge disappears. B is still absent from A's `_peerPublicKeys`. When C leaves and A is elected key holder, A's `distributeRoomKey` iterates `_peerPublicKeys` — B gets no offer, is stranded on the retired key, and goes permanently silent/undecryptable for the rest of the call, with no badge or error anywhere in A's UI.", + "evidence": "livekitE2EE.ts:663-676\n const result = await storeIdentityPin(host, String(userId), verifiedKey);\n if (result === \"failed\") { ...; return false; }\n clearPeerVerification(userId);\n log.info(\"E2EE: re-pinned peer identity key (TOFU recovery)\", { userId });\n return true;\n\nlivekitE2EE.ts:747-751 (the rejection that is never retried)\n if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) {\n return; // rejected/blocked — do not store or wrap\n }\n\nChannelSidebar.ts:480-503\n const verification = getPeerVerification(user.userId);\n if (verification !== null) { ...render badge... }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "voice-e2ee", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec", + "test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Buffer the rejected announce instead of discarding it: in verifyPeerAnnounce's mismatch branch (livekitE2EE.ts:536-547) record `{userId -> {publicKeyBase64, signatureBase64}}` in a new `_blockedAnnounces` map (cleared in clearState alongside _pendingAnnounces), and in rePinPeerIdentity, after the successful storeIdentityPin and before clearPeerVerification, replay it: `const pending = this._blockedAnnounces.get(userId); if (pending) { this._blockedAnnounces.delete(userId); await this.handleAnnounce(userId, pending.publicKeyBase64, pending.signatureBase64); }`. That re-runs the normal verifying path against the new pin, re-populates _peerPublicKeys, sends the offer if we are the holder, and lets setPeerVerification write the real \"verified\" badge rather than leaving the row blank.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0213", + "title": "A stale voice_leave retires a rejoined peer's LIVE ephemeral key, permanently locking them out of the room key", + "file": "Client/tauri-client/src/lib/livekitE2EE.ts", + "line": 1214, + "severity": "low", + "why": "`voice_leave` carries only `{channel_id, user_id}` (messages.go:704) — no join token — and it travels through the buffered `h.broadcast` queue plus the hub dispatch goroutine, while `voice_e2ee_announce` is published straight into the recipient's send queue from the sender's read-pump (`sendToVoiceChannelExcept` → `pubsub.Publish`). Hub_broadcast.go:64-72 explicitly names this asymmetry as a reordering hazard. So a peer's rejoin announce can be delivered ahead of the voice_leave for the join instance it superseded. `handleParticipantLeft` then reads the peer's CURRENT key as `departingKey`, deletes it from `_peerPublicKeys`, and feeds it to `retirePeerKey` — which `handleAnnounceInner` (lines 779-784 / 790-793) uses to reject any later announce carrying that key as a replay. The peer is both un-keyed and un-re-announceable.", + "repro": "A (key holder) and P are in voice channel X; A holds P's ephemeral key K2. P leaves X — `finishVoiceLeave` enqueues voice_leave(X,P) onto `h.broadcast` (hub.go:150, capacity 1024). Under a broadcast burst the hub dispatch goroutine lags. P rejoins X ~100 ms later; `setupKeyExchange` mints K4 and sends `voice_e2ee_announce`, which `sendToVoiceChannelExcept` publishes directly into A's send queue, overtaking the still-queued voice_leave. At A: the announce applies first — K2 retired, K4 stored, offer sent. Then the stale voice_leave(X,P) arrives: `handleParticipantLeft(P)` reads `departingKey = K4`, deletes P from `_peerPublicKeys`, retires K4, and (since `wasKeyHolder && hadPeerKey`) rotates the room key excluding P. P now decrypts nothing and is decrypted by nobody; P's reconnect-confirm re-announce of K4 (line 405) is rejected by `isRetiredPeerKey`, and P is never offered a key again for the remainder of the call unless P's SFU connection drops and mints a fresh keypair. A channel filter does not fix this — the stale leave names the same channel P rejoined.", + "evidence": "livekitE2EE.ts:1198-1215\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n this._peerOfferEpochs.delete(userId);\n clearPeerVerification(userId);\n if (departingKey) {\n this.retirePeerKey(userId, await exportPublicKey(departingKey));\n }\n\nlivekitE2EE.ts:790-793 (the resulting permanent rejection)\n if (this.isRetiredPeerKey(userId, publicKeyBase64)) {\n log.error(\"E2EE: rejecting replayed peer key announce (previously retired)\", { userId });\n return;\n }\n\nServer/ws/voice_e2ee.go:270-272 (direct publish, bypasses h.broadcast)\n func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) {\n h.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID)\n\nServer/ws/hub_broadcast.go:64-72 documents that publishing straight to pub/sub \"would reintroduce exactly that kind of reordering\".", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "voice-e2ee", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec", + "test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0214", + "title": "DeleteAccount's last-admin guard counts admins with `banned = 0`, so a lapsed temporary ban on any admin permanently blocks another admin's self-deletion", + "file": "Server/db/account.go", + "line": 202, + "severity": "low", + "why": "Same lapsed-ban split as above, in the opposite direction: the guard's \"is there another usable admin left\" count excludes an admin whose temporary ban has expired, even though that admin can log in and administer normally (auth.IsEffectivelyBanned returns false for them). The guard then reports ErrLastAdmin for a server that in fact still has a working administrator, and there is no way for the caller to clear it short of an explicit unban.", + "repro": "Server has exactly two admin-class accounts, alice and bob. Alice is temp-banned for 1h at some point; the hour lapses (users.banned stays 1, ban_expires in the past) and alice keeps logging in and administering fine. Bob now calls DELETE /api/v1/users/me. deleteAccountAdminGuard resolves the admin role ids, sees bob is admin-class, and runs `SELECT COUNT(*) FROM users WHERE role_id IN (...) AND id != bob AND banned = 0` -> alice is excluded -> adminCount == 0 -> ErrLastAdmin. Bob can never delete his account while alice's stale banned flag stands, even though alice is a fully functional administrator.", + "evidence": "Server/db/account.go:200-210\n\t\t\tvar adminCount int\n\t\t\tif err := tx.QueryRowContext(ctx,\n\t\t\t\tfmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`,\n\t\t\t\t\tstrings.Join(placeholders, \",\")),\n\t\t\t\targs...,\n\t\t\t).Scan(&adminCount); err != nil { ... }\n\t\t\tif adminCount == 0 {\n\t\t\t\treturn ErrLastAdmin\n\t\t\t}\n\n(the same file's anonymiseUser comment, account.go:110-113, explicitly documents that a stale lapsed ban_expires means banned=1 does NOT mean banned)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "db-storage", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "d793d3e1f3b932e48b0165756bc7db5ce99f9f01", + "test": "Server/db/account_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Reuse the package's canonical predicate instead of the raw column: in deleteAccountAdminGuard replace `AND banned = 0` with `AND ` + notBannedClause (db/mention_queries.go:40, same package). A permanently banned admin (ban_expires NULL) and a deleted/anonymised account still fail that clause, so the guard keeps excluding them; only the lapsed-temp-ban admin is counted again.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0215", + "title": "cert_store_key strips \":443\" off a bare IPv6 literal ending in hextet 443, pinning one server under two different keys", + "file": "Client/tauri-client/src-tauri/src/tofu.rs", + "line": 303, + "severity": "low", + "why": "`strip_suffix(\":443\")` runs before any bracket/IPv6 awareness, so a bare (unbracketed) IPv6 address whose final hextet is `443` has its last group eaten as if it were a port. `http_proxy` passes the bare host verbatim while `ws_proxy` passes the bracketed authority from the wss:// URL, so the same server resolves to two different cert-store keys. The sibling parser `http_proxy::split_host_port` (http_proxy.rs:285-298) explicitly guards this case (`Some((host, port)) if !host.contains(':')`); `cert_store_key` has no such guard.", + "repro": "Configure a server at a bare IPv6 address ending in :443, e.g. host = `fd00::443` (accepted by isValidHost's bare-IPv6 branch, and dialled correctly by http_proxy::resolve_remote_target as `[fd00::443]:443`).\n\n- REST path: `ensureHttpProxy(\"fd00::443\")` → http_proxy.rs:386 `tofu::cert_store_key(\"fd00::443\")` → strip_suffix(\":443\") matches → `\"fd00:\"`. The first-use prompt is emitted with `host: \"fd00:\"` and `accept_cert_fingerprint` pins under `\"fd00:\"`.\n- WS path: ws.ts:538 builds `wss://[fd00::443]/api/v1/ws` (bracketBareIPv6Host); Rust `extract_host` → `cert_store_key(\"[fd00::443]\")` → the string ends in `443]`, so strip_suffix(\":443\") misses → brackets stripped → `\"fd00::443\"`. `evaluate` finds no pin → a SECOND first-use prompt for the same certificate.\n- LiveKit path: `ensureLiveKitProxy` sends `[fd00::443]:443` → key `\"fd00::443\"`, agreeing with WS and disagreeing with HTTP.\n\nNet effect: the user is asked to confirm one server's fingerprint twice, one prompt shows the meaningless host string `fd00:`, and the REST tunnel's pin lives under a key no other surface ever reads or re-validates. On a later certificate rotation the same split produces two independent mismatch prompts. No existing test covers a bare IPv6 whose last hextet is 443 (tofu.rs:421-432 only covers `2001:db8::1`).", + "evidence": "tofu.rs:302-309\n pub(crate) fn cert_store_key(host: &str) -> String {\n let stripped = host.strip_suffix(\":443\").unwrap_or(host);\n let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped);\n unbracketed.to_ascii_lowercase()\n }\n\ncontrast http_proxy.rs:293-296 (the guard cert_store_key lacks):\n match remote_host.rsplit_once(':') {\n Some((host, port)) if !host.contains(':') => Ok((host, port)),\n _ => Ok((remote_host, \"443\")),\n }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "tauri-rust", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "0fe64f2f18cfad3ffc90ff4252923569a9c37904", + "test": "Client/tauri-client/src-tauri/src/tofu.rs", + "revertProof": "pass (hand-proved)" + }, + "suggestedFix": "Give cert_store_key the same bracket/IPv6 guard split_host_port already has, in the one shared function (tofu.rs:303): `let stripped = match host.strip_suffix(\":443\") { Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest, _ => host };` then keep the existing bracket-unwrap and lowercase. That leaves \"example.com:443\" -> \"example.com\" and \"[2001:db8::1]:443\" -> \"2001:db8::1\" unchanged, while \"fd00::443\" stays whole and matches the ws/livekit key.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0216", + "title": "Unmuting while server-deafened fires a voice_deafen the server always refuses — an error toast on every unmute", + "file": "Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts", + "line": 80, + "severity": "low", + "why": "`onMuteToggle`'s unmute branch auto-undeafens without checking `localServerDeafened`, while its sibling `onDeafenToggle` does carry the mirror-image `localServerMuted` guard (line 99). The server refuses a self-undeafen while `server_deafened` is set (Server/ws/voice_controls.go `refuseIfServerSilenced`, ErrCodeServerDeafened), and livekitSession.setDeafened(false) already refuses locally — so the frame is pure waste that lands in the dispatcher's generic error branch as a user-facing toast.", + "repro": "1) User A joins a voice channel, not muted, not deafened. 2) A moderator server-deafens A (`voice_mod_deafen`) → dispatcher's enforceModeratorAudioState sets localDeafened=true, localServerDeafened=true; VoiceWidget disables only the deafen button (VoiceWidget.ts:281-287), the mic button stays enabled. 3) A clicks the mic button (or presses Ctrl+M) to self-mute → localMuted=true. 4) A clicks the mic button again to unmute → `state.localMuted` is true, `state.localServerMuted` is false, so the branch runs: `voiceSessionSetMuted(false)` + `voice_mute{false}` (fine), then because `state.localDeafened` is true it calls `voiceSessionSetDeafened(false)` (silently refused by livekitSession.ts:1607) and sends `voice_deafen{deafened:false}`. The server answers SERVER_DEAFENED, and dispatcher.ts's catch-all error branch (line 1172) pops a red \"you were deafened by a moderator\" toast. Every subsequent mute/unmute cycle repeats it.", + "evidence": " if (state.localMuted) {\n voiceSessionSetMuted(false);\n ws.send({ type: \"voice_mute\", payload: { muted: false } });\n if (state.localDeafened) {\n voiceSessionSetDeafened(false);\n ws.send({ type: \"voice_deafen\", payload: { deafened: false } });\n }\n } else {\n\n// vs. the guarded sibling at line 99:\n// if (state.localServerMuted !== true) {\n// voiceSessionSetMuted(false);\n// ws.send({ type: \"voice_mute\", payload: { muted: false } });\n// }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "client-state", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "cfb8168eadbf82563ff9c7a90a521783f20cb7d1", + "test": "Client/tauri-client/tests/unit/voice-callbacks.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Mirror the sibling guard in Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:80 — change `if (state.localDeafened) {` to `if (state.localDeafened && state.localServerDeafened !== true) {` so no voice_deafen{deafened:false} frame is sent while the moderator deafen stands (the unmute half at 78-79 still goes through).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0217", + "title": "scrollToMessage registers a new permanent abort listener (pinning a message row) on the component-lifetime AbortSignal on every jump", + "file": "Client/tauri-client/src/components/MessageList.ts", + "line": 1021, + "severity": "low", + "why": "The highlight-flash cleanup is attached to `ac.signal` — the MessageList's whole-lifetime controller — once per `scrollToMessage` call, and `{ once: true }` only removes it when abort actually fires (i.e. at destroy). Each closure captures the target row's `HTMLElement`, so every jump adds one listener and pins one (usually already re-rendered away) DOM subtree until the channel is unmounted.", + "repro": "Open a channel and jump repeatedly within it — click a reply bar's jump arrow, a search hit, or a pinned entry — N times. Each successful `scrollToMessage` reaches line 1017-1021 and calls `ac.signal.addEventListener(\"abort\", …)`. After 200 jumps the single AbortSignal carries 200 listeners and 200 detached message-row elements are still strongly reachable through their closures; none are released until `destroy()` aborts the controller. `renderWindow()`/`renderAll()` rebuild `contentContainer`'s children on every store update, so the pinned nodes are dead DOM. Same defect shape as the already-fixed SearchOverlay.ts:96, context-menu.ts:88 and VoiceAudioTab.ts:490 findings.", + "evidence": " el.classList.add(\"highlight-flash\");\n const timer = window.setTimeout(() => {\n el.classList.remove(\"highlight-flash\");\n }, 1500);\n // Unmounting mid-flash must not leave a timer pointing at a dead node.\n ac.signal.addEventListener(\"abort\", () => clearTimeout(timer), { once: true });", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "client-state", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "8f6b22708d2a17f349b117f74817174c1a349faf", + "test": "Client/tauri-client/tests/unit/message-list.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Drop the per-call registration: hoist one factory-scoped `let flashTimer = 0; let flashEl: HTMLElement | null = null;`, and in scrollToMessage do `if (flashTimer !== 0) { clearTimeout(flashTimer); flashEl?.classList.remove(\"highlight-flash\"); } flashEl = el; flashTimer = window.setTimeout(() => { el.classList.remove(\"highlight-flash\"); flashTimer = 0; flashEl = null; }, 1500);`, then clear the same pair inside the existing destroy() (next to `ac.abort()`), so no abort listener is registered at all.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0218", + "title": "ready-time GET /blocks has no staleness guard, so it silently reverts a block/unblock the user performs while it is in flight", + "file": "Client/tauri-client/src/lib/dispatcher.ts", + "line": 467, + "severity": "low", + "why": "The `ready` handler fires `api.listBlocks()` and unconditionally applies the response with `setBlockedByMe(...)`, a whole-set replace. `onToggleBlock` writes the same store with a per-user delta (`setUserBlockedByMe`) only after its own `await api.blockUser/unblockUser`. Neither writer has a generation/epoch guard, so whichever network reply lands second wins — and the stale full-set reply can land after the fresh delta.", + "repro": "1. Client is in a session with user 42 in `blockedByMe`. 2. The socket reconnects onto the full-resync tier, so dispatcher's `ready` handler runs and issues GET /blocks at T0 (response body will contain 42). 3. At T0+50ms the user opens the member list and clicks \"Unblock\" on user 42; DELETE /blocks returns 204, `setUserBlockedByMe(42, false)` removes 42 from `blockedByMe`, and the toast says \"Unblocked \". 4. At T0+150ms the GET issued in step 2 resolves and `setBlockedByMe([...,42,...])` replaces the whole set, re-adding 42. Result: the server has the user unblocked, but `dmComposerBlockReason` still returns \"You've blocked this user. Unblock to send messages.\" and the DM composer stays disabled. Nothing else ever writes `blockedByMe`, so the contradiction persists until the next `ready`. The mirror case (user clicks Block inside the window) drops the block locally, un-gating a composer whose sends the server will refuse.", + "evidence": "dispatcher.ts:464-470\n clearBlockedByThem();\n if (api !== undefined) {\n api\n .listBlocks()\n .then((r) => setBlockedByMe(r.blocked_user_ids))\n .catch((err) => log.warn(\"Failed to load block list\", { error: String(err) }));\n }\n\nSidebarMemberSection.ts:176-185\n onToggleBlock: async (userId, username, block) => {\n try {\n if (block) {\n await api.blockUser(userId);\n } else {\n await api.unblockUser(userId);\n }\n setUserBlockedByMe(userId, block);\n getToast()?.show(block ? `Blocked ${username}` : `Unblocked ${username}`, \"success\");", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "concurrency", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932", + "test": "Client/tauri-client/tests/unit/dispatcher.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Put the guard in the store, not the caller, so both writers share it: give blocksStore a monotonically increasing `blockedByMeRev` that setUserBlockedByMe bumps on every accepted per-user delta, and change setBlockedByMe to take the revision observed before the fetch — `setBlockedByMe(userIds, rev)` returns without writing when `rev !== state.blockedByMeRev`. In dispatcher.ts:465-469 snapshot it before the call: `const rev = blocksStore.getState().blockedByMeRev; api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, rev))`. Existing callers of setBlockedByMe in tests pass the current revision (or make the parameter optional = force).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0219", + "title": "rollbackVoiceJoin clears the client's voice state but never drops its VoiceTopic subscription, so the socket keeps receiving another room's E2EE announces for the rest of the connection", + "file": "Server/ws/voice_join.go", + "line": 643, + "severity": "low", + "why": "Every other path that takes a client out of voice while its WS stays up goes through `clearVoiceAndUnsubscribe` (voice_leave.go:15) or an explicit `h.pubsub.Unsubscribe(c, VoiceTopic(...))` (voice_leave.go:47, hub_sweep.go:405, livekit_webhook.go:300). `rollbackVoiceJoin` does only `c.clearVoiceChID()` — and it is reached from `voiceJoinComplete` *after* `h.pubsub.Subscribe(c, VoiceTopic(channelID))` has already run (voice_join.go:471 → 494). The client's in-memory voice state and the pubsub subscription registry therefore disagree for the lifetime of the socket, which is exactly the hazard `clearVoiceAndUnsubscribe`'s own doc comment says every leave path must avoid.", + "repro": "1. Alice joins voice channel 5. `voiceJoinComplete` runs: line 471 subscribes her socket to VoiceTopic(5), line 474 elects a key holder, line 476 broadcasts her voice_state.\n2. The very next statement, `h.db.GetChannelVoiceStates(ctx, 5)` (line 491), fails (SQLITE_BUSY / I/O error / the ctx-free reader pool hiccup this branch was written for — see the OC-0172 test which fault-injects exactly this).\n3. Line 494 calls `rollbackVoiceJoin(ctx, c, 5, state.JoinedAt, true)`: `c.clearVoiceChID()` zeroes her voiceChID, the row is deleted, a compensating voice_leave is broadcast, and her client tears the session down. Line 495 sends her an INTERNAL error.\n4. But `ps.topics[\"voice:5\"]` still maps her userID → her *Client. `sendToVoiceChannelExcept` (voice_e2ee.go:271) publishes every `voice_e2ee_announce` for channel 5 onto that topic, and `buildVoiceE2EEAnnounce(userID, pubKey, sig)` carries no channel_id.\n5. Alice retries and joins voice channel 9. Bob (still in channel 5) reconnects to the SFU and re-announces. The relay reaches Alice's socket; `dispatcher.ts:965` calls `handleE2EEAnnounce(...)` with no channel filter, and `handleAnnounceInner` (livekitE2EE.ts:807) writes Bob into `_peerPublicKeys` for the channel-9 session. If Alice is channel 9's key holder, line 818 immediately wraps channel 9's room key for Bob and spends a `sendOfferPaced` slot plus one of the server's 64-offers/sec budget on an offer the server then drops. Every subsequent rotation re-includes Bob in `distributeRoomKey`'s peer snapshot, permanently taxing the rotation budget that OC-0167 was fixed to keep inside the server's cap.", + "evidence": "voice_join.go:471 h.pubsub.Subscribe(c, VoiceTopic(channelID))\nvoice_join.go:491-496\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\th.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true)\n\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to join voice channel\"))\n\t\treturn\n\t}\nvoice_join.go:642-643\n\tfunc (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) {\n\t\tc.clearVoiceChID() // <- no h.pubsub.Unsubscribe(c, VoiceTopic(channelID))\n\ncontrast, voice_leave.go:14-20\n\tfunc (h *Hub) clearVoiceAndUnsubscribe(c *Client) (int64, string) {\n\t\toldChID, oldJoinToken := c.clearVoiceState()\n\t\tif oldChID != 0 { h.pubsub.Unsubscribe(c, VoiceTopic(oldChID)) }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "state-desync", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "ee8c8214803b4d75833684c1a4701d0f31f8a4d9", + "test": "Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go", + "revertProof": "pass" + }, + "suggestedFix": "In Server/ws/voice_join.go:643 replace `c.clearVoiceChID()` with `h.clearVoiceAndUnsubscribe(c)`. It performs the same clear (clearVoiceChID already delegates to clearVoiceState, client.go:143-144) and additionally drops VoiceTopic(oldChID). This is safe for the other two call sites: at line 286 the client's voice state is still 0 (setVoiceState has not run), so no unsubscribe fires, and at line 396 oldChID == channelID, which is the subscription that should not exist yet and is a no-op.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0220", + "title": "A group DM that loses all its other members renders with a completely blank name everywhere", + "file": "Client/tauri-client/src/stores/dm.store.ts", + "line": 197, + "severity": "low", + "why": "`dmDisplayName` falls back to `dm.recipient.username` when `participants` is empty, but the server only populates `Recipient` when the \"others\" list is non-empty (`db.NewDMChannelInfo` / `GetUserDMChannels` both leave it as the zero-valued `DMUser`). A group DM legitimately reaches zero *other* participants — `LeaveGroupDM` only deletes the channel row when `remaining == 0`, so the last member keeps a live, is_group=1 channel — and `dm_participants` also CASCADEs when other members delete their accounts. The DmChannel doc comment (\"Never empty for a live DM\") and the DmSidebar avatar builder (which explicitly handles \"An empty group (every other member has left)\") both anticipate this state; `dmDisplayName` is the one place that does not, so it returns \"\".", + "repro": "1. A, B and C create an unnamed group DM (name is \"\", the default when the optional name is omitted).\n2. B closes the DM (DELETE /dms/{id} -> DMService.CloseDM -> LeaveGroupDM); then C does the same. `remaining` is 1, not 0, so the channel row survives for A.\n3. A reconnects (or just receives the dm_channel_open refresh). GetUserDMChannels returns the channel with Recipients=[] and Recipient={ID:0, Username:\"\"}.\n4. dmDisplayName returns \"\". A's DM sidebar row, the chat header (ChannelController.ts:593), the quick switcher, MainPage.ts:230 and DM desktop notifications (notifications.ts:35 -> NotificationsTab renders `@` + \"\") all render an empty label, and the avatar circle renders no letter. The conversation is unidentifiable and, in a list of several such groups, indistinguishable.\nNote: the existing test `dm-groups.test.ts:86` (\"falls back to the recipient when the participant list is empty\") only covers the legacy pre-group case where `recipient` IS populated, so it does not lock this behavior.", + "evidence": "dm.store.ts:194-201\n export function dmDisplayName(dm: DmChannel): string {\n if (dm.name !== \"\") return dm.name;\n const names = dm.participants.map((p) => (p.displayName ?? \"\") || p.username);\n if (names.length === 0) return dm.recipient.username; // <-- zero-value DMUser -> \"\"\n\nServer/db/dm_queries.go:80-82 (NewDMChannelInfo)\n if len(others) > 0 {\n info.Recipient = others[0]\n } // else Recipient stays the zero value (Username \"\")\n\nServer/db/dm_queries.go:246-249 (GetUserDMChannels) — same guard.\nServer/ws/serve_ready.go:88 already codes around it: `if dmChannels[i].Recipient.ID != 0 && ...`.\n\nDmSidebar.ts:120-122 acknowledges the same state for avatars:\n // An empty group (every other member has left) still needs a mark, so fall\n // back to the row's own label rather than rendering an empty circle.\n const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }];\n(convo.username is itself dmDisplayName's \"\", so the avatar letter is empty too.)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "ordering-boundary", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "b7229b94fec6cd686cbd423ce2f431a590e1152a", + "test": "Client/tauri-client/tests/unit/dm-groups.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Give the shared function a terminal fallback instead of returning a possibly-empty recipient username — one guard covers all seven call sites: replace dm.store.ts:197 with `if (names.length === 0) return dm.recipient.username !== \"\" ? dm.recipient.username : (dm.isGroup ? \"Empty group\" : \"Unknown user\");`", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0221", + "title": "Composer has no attachment-count cap while the server hard-rejects >10, so an 11-attachment message can never be sent", + "file": "Client/tauri-client/src/components/MessageInput.ts", + "line": 612, + "severity": "low", + "why": "`handlePasteFile` uploads and queues an attachment with no bound on `pendingAttachments.length`, and `handleSend` forwards every finished upload id. The server's chat_send constructor refuses the whole frame at exactly 11 (`if len(p.Attachments) > 10`), and that refusal is a *parse* error, so it comes back as the generic `BAD_REQUEST` / \"invalid payload\" with no mention of attachments. The composer has already cleared the preview bar by then, so the user is left with a permanently-failing message row and no way to learn what is wrong.", + "repro": "1. Open any channel with ATTACH_FILES. Paste (or pick, 11 times) 11 small images into the composer; all 11 upload successfully and show previews.\n2. Type any text and press Send.\n3. The server's chat_send parser returns BAD_REQUEST \"invalid payload\" carrying the send's correlation id; the optimistic row flips to \"failed\" with a generic error, the preview bar is already cleared, and the 11 uploads are orphaned server-side.\n4. Retry fails identically every time. Nothing in the UI indicates that the attachment count (11 > 10) is the cause, and the composer never prevented queueing the 11th.", + "evidence": "Client MessageInput.ts:536-612 (handlePasteFile) validates only `state.editing`, `file.size` and `file.type`, then:\n pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item });\n(no length check anywhere in the file — `pendingAttachments` is only ever read for `.length > 0` / `.length === 0`)\n\nClient MessageInput.ts:489-496 (handleSend)\n const attachmentIds = pendingAttachments\n .filter((a) => !a.id.startsWith(\"pending-\"))\n .map((a) => a.id);\n options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds);\n clearReply();\n clearPendingAttachments(); // previews discarded regardless of outcome\n\nServer/ws/command.go:416\n if len(p.Attachments) > 10 {\n return nil, fmt.Errorf(\"too many attachments (max 10)\")\n }\n\nServer/ws/handlers.go:51-54 — a constructor error yields:\n c.sendMsg(buildErrorMsgWithID(ErrCodeBadRequest, \"invalid payload\", env.ID))\n\nClient dispatcher.ts:1067-1086 routes that id to `markSendFailed(id, \"BAD_REQUEST\")`.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "ordering-boundary", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "dbf2867514ee50cf15b390246470ffaacb17c344", + "test": "Client/tauri-client/tests/unit/message-input.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Add the bound at the single entry point rather than at each call site — in handlePasteFile, alongside the existing size/type guards (MessageInput.ts, just before the `const tempId = ...` line): `if (pendingAttachments.length >= 10) { showUploadError(\"You can attach at most 10 files to a message\"); return; }`. Placing it before the upload also stops the 11th file from being uploaded and orphaned.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0222", + "title": "Resume path builds auth_ok before applyConnectStatus, so every reconnect ships the disconnect-time status and makes the client fire a redundant presence_update", + "file": "Server/ws/serve.go", + "line": 289, + "severity": "low", + "why": "handleFreshConnect settles the session status (applyConnectStatus, serve.go:836) BEFORE it writes auth_ok (serve.go:840), so a fresh connect's auth_ok carries db.ConnectStatus(saved). handleReconnect inverts that order: reconnectWriteReplay writes buildAuthOK(ctx, c.user, ...) at serve.go:574 and applyConnectStatus only runs at serve.go:289, after the replay burst. On a resume, c.user.Status is whatever the previous connection's teardown left in the row — and MarkUserDisconnected (Server/db/queries/sqlite/users.sql:31, 'SET status = CASE WHEN status = ''online'' THEN ''offline'' ELSE status END') rewrites a plain online user to 'offline'. So every resumed auth_ok tells the client its own status is 'offline' while the server is simultaneously about to set and broadcast 'online'. The client's MainPage.restoreSavedPresence() (Client/tauri-client/src/pages/MainPage.ts:196-201) compares loadUserStatus() against exactly that auth_ok value and, on mismatch, sends a presence_update — the call its own doc comment says 'is a no-op in the normal case', and which tests/unit/main-page.test.ts:624 explicitly forces to be a no-op by pinning authStore.user.status to 'online'. Ordering is deterministic: ws.ts's setState('connected') schedules the uiStore notification in a microtask (lib/store.ts:134) while setAuth runs synchronously in the same dispatch, so restoreSavedPresence always reads the freshly-received (stale) auth_ok status.", + "repro": "1. User U (no chosen status; local pref userStatus = \"online\") is connected; users.status = 'online'. 2. Kill the socket (proxy blip). readPump's defer runs MarkUserDisconnected -> users.status = 'offline'. 3. ws.ts backs off ~1s and reconnects with last_seq > 0; the ring buffer still covers it, so handleReconnect takes the buffer tier. 4. reconnectPrecheck's refreshUserSnapshot reads status 'offline'; reconnectWriteReplay writes auth_ok with payload.user.status = \"offline\". 5. Client: setAuth stores status 'offline'; uiStore.connectionStatus flips back to \"connected\"; MainPage's subscriber calls restoreSavedPresence(), sees \"online\" != \"offline\", and sends presence_update{status:\"online\"} — consuming the session's single 1-per-10s presence token and triggering a second server-wide sequenced presence fan-out on top of the one applyConnectStatus/announceConnectPresence already produced. 6. Consequence: any genuine status change (manual pick, or auto-idle's idle/return-to-online transition) in the next 10 s is deferred to presenceSender's retry instead of being sent immediately; and in a reconnect storm every reconnecting client adds an extra O(connected-clients) global broadcast, defeating the QueuePresence coalescer. Contrast a fresh connect (F5), where auth_ok carries 'online' and restoreSavedPresence is correctly a no-op.", + "evidence": "serve.go handleReconnect:\n\tif !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) { ... } // line 280 -> writes buildAuthOK(ctx, c.user, ...) at line 574\n\t// Update presence but skip member_join — user was already known.\n\tapplyConnectStatus(ctx, database, c) // line 289 <-- runs AFTER auth_ok\n\th.announceConnectPresence(c) // line 290\n\nvs. handleFreshConnect:\n\tapplyConnectStatus(ctx, database, c) // line 836 <-- runs BEFORE auth_ok\n\tif err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, \"none\")); ... // line 840\n\nClient/tauri-client/src/pages/MainPage.ts:196\n\tfunction restoreSavedPresence(): void {\n\t const status = loadUserStatus(); // \"online\" by default\n\t const serverStatus = authStore.getState().user?.status; // \"offline\" from the resumed auth_ok\n\t if (serverStatus === status) return;\n\t applyPresence(status); // spends the shared 1-per-10s token\n\t}", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-reconnect", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "7c05232817c4c87e31206a5c2b8a49a0380fa5d0", + "test": "Server/ws/oc_0222_reconnect_status_order_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Move the applyConnectStatus(ctx, database, c) call in handleReconnect from serve.go:289 to just before the reconnectWriteReplay call at serve.go:280 (i.e. immediately after reconnectRegister returns), leaving h.announceConnectPresence(c) where it is. That makes the resumed auth_ok carry db.ConnectStatus(saved), matching handleFreshConnect, with no change to the replay contents or the post-replay broadcast.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0223", + "title": "@here raises a mention badge for users who are offline but whose last chosen status was idle/dnd", + "file": "Server/service/mentions.go", + "line": 179, + "severity": "low", + "why": "The @here narrowing reads `users.status`, but that column keeps a *chosen* idle/dnd across a disconnect by design — MarkUserDisconnected rewrites only 'online' to 'offline'. Every other read path compensates with the \"no live connection is offline, whatever the row says\" rule (ws/serve_ready.go presentableMembers/presentableDMChannels); this one does not, so @here reaches signed-out users whose last status was idle or dnd while correctly skipping signed-out users whose last status was online.", + "repro": "User B sets status to Do Not Disturb (or Idle) and closes the client; readPump's teardown calls MarkUserDisconnected, which leaves users.status = 'dnd'. User C simply closes the client while online; their row becomes 'offline'. User A (holding MENTION_EVERYONE) posts \"@here standup\" in #general. applyMentionCounts -> mentionReaders returns both B and C; BroadcastStatus('dnd') != 'offline' so B is added to `recipients` and IncrementMentionCounts bumps B's read_states.mention_count, while C is correctly skipped. B — equally offline — comes back to a red @here mention badge, which is exactly what the @here/offline narrowing exists to prevent. No test in Server/service/mentions_test.go covers a disconnected idle/dnd reader (only the invisible-but-connected case, TestSendMessage_HereSkipsInvisibleUsers).", + "evidence": "// Server/service/mentions.go:179\nif set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline {\n continue\n}\n\n// Server/db/queries/sqlite/users.sql:25-34 (MarkUserDisconnected)\n// \"It clears only 'online' ... A stale choice never renders as 'present'\n// because the read path treats a member with no live connection as offline\n// regardless.\"\nUPDATE users\nSET status = CASE WHEN status = 'online' THEN 'offline' ELSE status END,\n last_seen = datetime('now')\nWHERE id = ?;\n\n// r.Status comes straight from the column: db/mention_queries.go:351\n// SELECT id, status, role_id FROM users WHERE AND role_id IN (...)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-message", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "0ad96147da2e50377b23e985d8f07dafc2953602", + "test": "Server/service/mentions_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Give applyMentionCounts the same live-connection rule the read path uses, in the one shared place rather than at each call site. Add an optional predicate to MessageService (e.g. `online func(int64) bool`, nil-safe) that the ws layer wires to the hub's connected-id lookup (Hub.GetClient / connectedUserIDs), then at Server/service/mentions.go:179 make the @here skip `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID)))`. Leaving s.online nil preserves today's behavior for tests and any caller that has no hub.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0224", + "title": "A DM message that lands between registerNow and buildReady is counted twice in the DM unread badge — updateDmLastMessage has no message-id monotonicity guard", + "file": "Client/tauri-client/src/stores/dm.store.ts", + "line": 142, + "severity": "low", + "why": "updateDmLastMessage increments unreadCount unconditionally, with no check that `messageId` is newer than the row's `lastMessageId`. On a fresh connect the server registers the client (ws/serve.go handleFreshConnect, registerNow) BEFORE it snapshots unread counts in buildReady, so a DM delivered in that window is both included in ready's authoritative `unread_count` and queued for delivery after ready — the client applies both.", + "repro": "1) User A opens the client (fresh connect, last_seq = 0). 2) handleFreshConnect calls registerNow, which subscribes A to UserTopic; a DM sent by B at this instant is fanned out via EmitEvents -> sendSequencedToUsers -> SendToUser and lands in A's send buffer. 3) buildReady then runs GetUserDMChannels, which already counts that message: dm_channels[i].unread_count = 1. 4) A's client applies `ready` (setDmChannels sets unreadCount = 1), then the transport delivers the queued chat_message; the dispatcher's CHAT_MESSAGE handler (dispatcher.ts:601) calls updateDmLastMessage, which bumps unreadCount to 2. The DM sidebar shows a badge of 2 for one unread message, and it persists until the DM is opened or the next full `ready`. buildReady runs ~8 DB queries after registration, so the window is milliseconds-to-tens-of-milliseconds wide on a loaded server.", + "evidence": "// Client/tauri-client/src/stores/dm.store.ts:136-143\nchannels: [\n {\n ...updated,\n lastMessageId: messageId,\n lastMessage: content,\n lastMessageAt: timestamp,\n unreadCount: updated.unreadCount + 1, // no `messageId > updated.lastMessageId` guard\n },\n ...rest,\n]\n\n// Server/ws/serve.go handleFreshConnect: registerNow(...) at ~line 805,\n// buildReady(...) at ~line 846 — registration precedes the snapshot, and\n// writePump (which drains the queued frame) only starts after the handshake\n// writes, so the queued chat_message is delivered strictly after `ready`.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-message", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "b7229b94fec6cd686cbd423ce2f431a590e1152a", + "test": "Client/tauri-client/tests/unit/dm-store.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Guard the increment on message-id monotonicity inside the shared store function, not at the dispatcher call sites (message ids are globally monotonic rowids, so id <= lastMessageId can only mean a duplicate or an out-of-order re-delivery). In Client/tauri-client/src/stores/dm.store.ts:142 replace `unreadCount: updated.unreadCount + 1` with `unreadCount: updated.lastMessageId !== null && messageId <= updated.lastMessageId ? updated.unreadCount : updated.unreadCount + 1`, keeping the preview/reorder update unconditional.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0225", + "title": "A transient DB read error in the admin perimeter is reported as 401, ejecting an admin from the panel mid-session", + "file": "Server/admin/middleware.go", + "line": 56, + "severity": "low", + "why": "adminAuthMiddleware's error switch has the same shape as api.AuthMiddleware's: ResolveTokenHash's wrapped DB errors are non-sentinel, so they fall into the `default` arm and are answered as 401 \"invalid or expired session\" — indistinguishable from an unknown token. Unlike the api middleware this one does not even log the distinction, so a DB outage on the admin perimeter is silently reported to the operator as a dead session. The desktop client routes adminRequest() through the same doFetch 401 sink as ordinary API calls, so an admin acting from the app (kick/ban/role change/channel edit all go to /admin/api) is signed out and has their credential deleted by the same path as finding 1.", + "repro": "1. An admin has the panel open (or is using the desktop client's admin actions) with a valid session.\n2. One SQLite read fails transiently — the scheduled backup's VACUUM INTO, a restore swapping the file, or plain lock contention — while a /admin/api/* request is in flight.\n3. ResolveTokenHash returns the wrapped DB error; the default arm writes 401 \"invalid or expired session\".\n4. The web panel shows the admin as logged out; from the desktop client, doFetch's 401 sink fires onUnauthorized -> clearAuth -> deleteCredential(host), ending the whole chat session and erasing the stored credential — all for a session that was never revoked.", + "evidence": "Server/admin/middleware.go:47-61\n\t\t\tuser, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash)\n\t\t\tif err != nil {\n\t\t\t\tswitch {\n\t\t\t\tcase errors.Is(err, auth.ErrTokenExpired): ...\n\t\t\t\tcase errors.Is(err, auth.ErrUserNotFound): ...\n\t\t\t\tcase errors.Is(err, auth.ErrRoleNotFound): ...\n\t\t\t\tdefault:\n\t\t\t\t\t// ErrTokenNotFound or a wrapped DB error.\n\t\t\t\t\twriteErr(w, http.StatusUnauthorized, \"UNAUTHORIZED\", \"invalid or expired session\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\nClient/tauri-client/src/lib/api.ts:190-197 — adminRequest() uses the same doFetch, so it hits the 401 sink at api.ts:150 with no skipUnauthorized opt-out.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "flow-session", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "b0ef1313f087bd19870ee237da5e7aa85bc3c59f", + "test": "Server/admin/middleware_db_error_test.go", + "revertProof": "pass" + }, + "suggestedFix": "Mirror the same split here: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other error log it and writeErr 503 SERVICE_UNAVAILABLE. Best done once — have both middlewares share a helper that maps a ResolveTokenHash error to (status, code, message).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0226", + "title": "uiStore is the only domain store clearAuth does not reset, so sidebarMode (and activeDmUserId) leak across a logout into the next server", + "file": "Client/tauri-client/src/stores/ui.store.ts", + "line": 36, + "severity": "low", + "why": "ui.store.ts exports no reset function and clearAuth (auth.store.ts:96-99) resets voice, messages, channels and blocks but never the UI store. sidebarMode is not restated by any `ready` payload, so it survives sign-out as module-global state and SidebarArea mounts the next server's sidebar in whatever mode the previous session left it in.", + "repro": "1. Sign into server X. 2. Click a DM in the sidebar -> SidebarDmHelpers.ts:51-52 calls setActiveDmUser() then setSidebarMode(\"dms\"). 3. Log out (lib/logout.ts:17 -> clearAuth). clearAuth resets voiceStore, messagesStore, channelsStore, blocksStore and authStore, but uiStore is untouched: sidebarMode is still \"dms\" and activeDmUserId still holds server X's user id. 4. Sign into a different server Y. MainPage mounts, createSidebarArea reads `const initialMode = uiStore.getState().sidebarMode;` (pages/main-page/SidebarArea.ts:705) and mounts the DM sidebar instead of Y's channel list, even though the chat pane shows a text channel the dispatcher auto-selected. The user must hit \"Back\" to reach the channel list. Same class as the already-fixed blocksStore-survives-clearAuth and settingsOpen-survives-ConnectPage.destroy leaks.", + "evidence": "ui.store.ts:36 export const uiStore = createStore(INITIAL_STATE); // no resetUiStore export anywhere in the file\nui.store.ts:180-186 export function setSidebarMode(mode) { uiStore.setState((prev) => ({ ...prev, sidebarMode: mode, activeDmUserId: mode === \"channels\" ? null : prev.activeDmUserId })); }\n\nauth.store.ts:96-99\n resetVoiceStore();\n resetMessagesStore();\n resetChannelsStore();\n resetBlocksStore();\n\nSidebarArea.ts:704-706\n // Initial mount based on current store state\n const initialMode = uiStore.getState().sidebarMode;\n mountSidebarContent(initialMode);", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "78c75ada02ea05601721336032a47b027924277a", + "test": "Client/tauri-client/tests/unit/auth.store.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "One line in clearAuth (auth.store.ts, alongside resetBlocksStore()): `setSidebarMode(\"channels\")` imported from @stores/ui.store — it also nulls activeDmUserId (ui.store.ts:180-186). ui.store imports nothing from auth.store, so there is no import cycle. Prefer this over a blanket resetUiStore(), which would also clobber `theme` (a user preference, not session state).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0227", + "title": "Video-call tiles label participants with the raw username and never refresh it, so a nickname is ignored and a mid-call rename leaves the tile stale", + "file": "Client/tauri-client/src/pages/MainPage.ts", + "line": 685, + "severity": "low", + "why": "The video grid's tile label is built from voiceStore's frozen `user.username` rather than the member's display name, and addStream is the only thing that writes the label (VideoGrid.ts:274-277). addStream is called exactly twice — from setOnRemoteVideo on LiveKit TrackSubscribed (MainPage.ts:690) and from VideoModeController's local-tile block, which is latched behind `if (!localTileAdded)` (VideoModeController.ts:174-185). Neither is re-driven by a profile change, so the label is fixed for the life of the tile. Every other identity surface was moved to memberDisplayName — ChannelSidebar's voice roster (ChannelSidebar.ts:421-422, \"Render the same identity a rename shows everywhere else\"), the member list, message rows, the DM sidebar and the typing indicator — with only the deliberately security-sensitive surfaces (E2EE mismatch modal, moderation menu) keeping the raw username. The video tile is neither, and it was missed.", + "repro": "Two users in a voice channel; user B has a nickname/display_name set (\"Bee\") that differs from their username (\"bob_1994\"). B turns on their camera. A's sidebar voice roster shows \"Bee\" (memberDisplayName), while the video tile that opens shows \"bob_1994\" — the same person under two names in one screen. Then, with the camera still on, B renames themselves via Settings → Account: the user_update fan-out patches membersStore and voiceStore, the sidebar row and every message row repaint with the new name, but the video tile keeps the old string because nothing calls addStream again for that tile.", + "evidence": "MainPage.ts:685-694\n\tconst username = isScreenshare\n\t ? user?.username ? `${user.username} (Screen)` : `User ${userId} (Screen)`\n\t : (user?.username ?? `User ${userId}`);\n\tvideoGrid.addStream(tileId, username, stream, {...});\n\nVideoModeController.ts:174-185\n\tif (!localTileAdded) { ... videoGrid.addStream(currentUserId, me?.username ? `${me.username} (You)` : \"You\", ...); localTileAdded = true; }\n\nVideoGrid.ts:274-277 (label is only rewritten by another addStream call)\n\nChannelSidebar.ts:421-422\n\tconst member = membersStore.getState().members.get(user.userId);\n\tconst label = (member !== undefined ? memberDisplayName(member) : user.username) || \"Unknown\";", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "hotspot-server-ws", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85", + "test": "Client/tauri-client/tests/unit/main-page.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Route both call sites through one shared label helper that prefers memberDisplayName: in MainPage.ts:685-689 and VideoModeController.ts:178/199, look up membersStore.getState().members.get(userId) and use memberDisplayName(member) with user.username as fallback (the exact idiom at ChannelSidebar.ts:421-422). For the staleness half, add a VideoGrid.setLabel(tileId, text) and call it from the existing USER_UPDATE handling — cheapest hook is right after updateVoiceUserProfile in dispatcher.ts:815, relabelling the plain and SCREENSHARE_TILE_ID_OFFSET tiles for that user id — rather than un-latching localTileAdded, which would re-run addStream and churn srcObject.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0228", + "title": "Mention pills bypass the server's authoritative `mentions` list, so a token the server refused still renders as a live (yellow \"you\") mention", + "file": "Client/tauri-client/src/lib/mentions.ts", + "line": 77, + "severity": "low", + "why": "`resolveMentionUserId` runs its member-list-by-username fallback unconditionally, not only when the server omitted `mentions`. The header comment promises \"a token the server would not resolve must not be highlighted here either\" and the row-level gate (`mentionsCurrentUser`, line 108) does honour the server, but the inline pill built by `buildMentionNode` (content-parser.ts:301) resolves locally and stamps `.mention-self` (the yellow \"you were mentioned\" styling, app.css:1298). Result: one and the same message can be un-highlighted at the row level and self-highlighted at the token level, and the sender sees a live pill for a ping that was never delivered.", + "repro": "Three independent ways to make the server's `mentions` disagree with the local parse, all of which produce a false pill:\n(1) Cap: post one message containing 21+ distinct @mentions of real members. Server resolves only the first 20 (maxMentionsPerMessage, mentions.go:17/142) and ships `mentions` with 20 ids; every client still renders all 21 as `.mention` pills, and the 21st user's own client renders theirs as `.mention-self` (yellow) while `highlightsCurrentUser` returns false — no row highlight, no unread mention badge, no notification.\n(2) Case folding: user `élodie` exists. Post \"hey @Élodie\". Server: LowerASCII(\"Élodie\") = \"Élodie\" != map key LowerASCII(\"élodie\") = \"élodie\" -> `mentions: []`, no badge. Client: \"Élodie\".toLowerCase() === \"élodie\".toLowerCase() -> resolves, renders `.mention .mention-self` for élodie.\n(3) The existing unit test tests/unit/mentions-render.test.ts:309 (\"trusts the server list over the local name parse\") sends content \"hey @me\" with mentions:[10] where the signed-in user is id 12, and asserts the row lacks `.mentioned`. Render the same message and query `.mention` — the span carries `mention-self` and data-user-id=\"12\", i.e. the exact case the test declares the server wins is still self-highlighted inline.", + "evidence": "mentions.ts:67-85\n for (const id of info?.mentions ?? []) {\n const member = members.get(id);\n if (member !== undefined && matches(member.username)) return id;\n }\n for (const member of members.values()) { // <- line 77, runs even when info.mentions was supplied\n if (matches(member.username)) return member.id;\n }\n\nvs the server, which is the sole authority for the wire field `mentions`:\nServer/service/mentions.go:142 if len(set.UserIDs) >= maxMentionsPerMessage { break } // cap = 20\nServer/service/mentions.go:71 raw := db.LowerASCII(m[2]) // ASCII-only fold, deliberately (OC-0131)\n\nvs the client, which folds with full Unicode:\nmentions.ts:51 const lower = token.toLowerCase();\nmentions.ts:71 const matches = (username) => spellings.includes(username.toLowerCase());", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "754ce6b5e70a3143bfd11729cee07168da7ca83e", + "test": "Client/tauri-client/tests/unit/mentions-render.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Gate the two local fallbacks in the shared function rather than at each call site. In resolveMentionUserId, after the info.mentions loop, return null when the server supplied a list: `if (info?.mentions !== undefined) return null;` placed between line 76 and line 77. Callers that legitimately have no server list — resolveMentionsFromContent (line 94), renderers.ts:149's renderMentions(msg.content), and optimistic echoes whose msg.mentions is undefined (messages.store.ts:299-311) — pass info?.mentions === undefined and keep the fallback. This makes the token-level gate agree with mentionsCurrentUser (line 108) and with the already-documented rule at lines 63-65 that a server-listed id the member map cannot name stays unhighlighted. Verified against the suite: no existing assertion in tests/unit/mentions-render.test.ts changes.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0229", + "title": "Channel rows register their context-menu listener on the sidebar-lifetime AbortSignal on every render, so every incoming message permanently retains a full set of detached rows", + "file": "Client/tauri-client/src/components/channel-sidebar/context-menu.ts", + "line": 53, + "severity": "low", + "why": "`attachChannelContextMenu` does `el.addEventListener(\"contextmenu\", …, { signal })` where `signal` is `ChannelSidebar`'s single factory-lifetime `ac.signal` (ChannelSidebar.ts:747, passed down at :806 → :610). Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal that holds a strong reference to the event target; it is only released when the signal aborts, which happens once, in `ChannelSidebar.destroy()`. `renderChannels()` (ChannelSidebar.ts:776) does `clearChildren(channelList)` and rebuilds every row from scratch, so each render leaks one retained detached row per channel. This is the same defect class the ledger already confirmed for SearchOverlay.ts:96 and MessageList.ts:1021 — but at far higher frequency, because `renderChannels()` is wired to `channelsStore.subscribeSelector((s) => s.channels, …)` (ChannelSidebar.ts:872) and `incrementUnread` builds a brand-new `channels` Map for every message delivered to a non-active channel (channels.store.ts:350). The same file also gets it right elsewhere: lines 185-188 explicitly tie the menu's bridge listener to `menuAc` \"so this bridge listener is torn down with the menu itself\", while the five menu-item listeners beside it (lines 82, 104, 135, 155, and the `signal` handed to `appendPurgeSection` at 172) stay on the long-lived `signal` and retain one detached `.channel-ctx-menu` subtree per right-click.", + "repro": "Sign in to a server with 20 channels and leave the client open on one channel while traffic flows in the others. Every message posted to a non-active channel calls `incrementUnread`, which returns `{...prev, channels: new Map(...)}`, which fires the `s.channels` selector, which runs `renderChannels()`: `clearChildren(channelList)` detaches all 20 rows and 20 fresh rows are built, each calling `attachChannelContextMenu(el, channel, ac.signal, …)` at ChannelSidebar.ts:610. After 1,000 messages, `ac.signal` holds 20,000 abort algorithms, each pinning a detached `.channel-item` subtree (plus, for a MANAGE_CHANNELS holder, three more per row from drag-reorder.ts:256/268/304). None are collectable until MainPage is destroyed. Take a heap snapshot after 10 minutes on a busy server and filter for detached `.channel-item` — the count grows monotonically with message volume and never drops. Secondary repro for the per-open variant: right-click the same channel row 200 times, dismissing each menu with a left-click; 200 detached `.channel-ctx-menu` subtrees (each with up to five item divs and their closures) stay reachable from `ac.signal`.", + "evidence": "context-menu.ts:53-56, 195\n el.addEventListener(\n \"contextmenu\",\n (e) => { … },\n { signal }, // ChannelSidebar's factory-lifetime ac.signal\n );\n\ncontext-menu.ts:82-89 (and 104, 135, 155) — per-open menu items on the same long-lived signal\n markItem.addEventListener(\"click\", () => { closeMenu(); markChannelRead(channel.id); }, { signal });\n\ncontext-menu.ts:185-188 — the file's own statement of the hazard, applied to one listener only\n // Tie this bridge listener's own lifetime to menuAc so it does not\n // outlive the menu it belongs to …\n signal.addEventListener(\"abort\", closeMenu, { signal: menuAc.signal });\n\nChannelSidebar.ts:747 const ac = new AbortController();\nChannelSidebar.ts:781 clearChildren(channelList);\nChannelSidebar.ts:806 ac.signal,\nChannelSidebar.ts:610 attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);\nChannelSidebar.ts:872-875\n const unsubChannelsMap = channelsStore.subscribeSelector((s) => s.channels, () => renderChannels());\n\nchannels.store.ts:346-352 (incrementUnread) — new Channel object AND new Map per message", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-3", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "aa963efa464fef4e2a5cece8706afd6d3072ee82", + "test": "Client/tauri-client/tests/unit/channel-sidebar.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Do not hand rows the factory-lifetime signal. In createChannelSidebar add `let renderAc: AbortController | null = null;` and at the top of renderChannels() (before clearChildren) do `renderAc?.abort(); renderAc = new AbortController();`, then pass `renderAc.signal` instead of `ac.signal` into renderCategoryGroup at ChannelSidebar.ts:806, and add `renderAc?.abort(); renderAc = null;` beside `ac.abort()` in destroy(). One change in the shared render function covers the context menu, drag handlers, and every other per-row listener; the sidebar-lifetime `ac.signal` stays for header/root listeners (mount at :826-855) that are created once.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0230", + "title": "\"Clear Logs\" empties the list but leaves the entry counter showing the pre-clear total", + "file": "Client/tauri-client/src/components/settings/LogsTab.ts", + "line": 241, + "severity": "low", + "why": "`countEl` is built once at LogsTab.ts:317 from `getLogBuffer().length` and is only ever rewritten inside the `addLogListener` callback at line 341. The Clear button's handler calls `clearLogBuffer()` + `renderLogEntries()` — neither of which touches `countEl` — so the counter and the list it labels disagree. The Refresh button (line 249) has the same gap. The counter only self-corrects when the next log entry at or above the current minimum level happens to be emitted *while the Logs tab is the active tab*; at the production default level of `info`, no UI interaction in the settings panel emits one, so the wrong number can sit there indefinitely.", + "repro": "Open Settings → Logs on a client that has been running a while (say 412 buffered entries). The panel shows the log rows and \"412 entries\". Click \"Clear Logs\". The list goes empty (renderLogEntries() re-runs against the now-empty buffer) but the line above it still reads \"412 entries\". Click \"Refresh\" — still \"412 entries\". It stays wrong until some component emits an info/warn/error log line while the Logs tab is still on screen.", + "evidence": "LogsTab.ts:237-245\n const clearBtn = createElement(\"button\", { class: \"ac-btn\" }, \"Clear Logs\");\n clearBtn.addEventListener(\"click\", () => {\n clearLogBuffer();\n renderLogEntries(); // no countEl update\n }, { signal });\n\nLogsTab.ts:317-323\n const countEl = createElement(\"div\", {...}, `${getLogBuffer().length} entries`);\n\nLogsTab.ts:338-343 — the only place countEl is ever updated\n unsubLogListener = addLogListener(() => {\n if (getActiveTab() === \"Logs\") {\n renderLogEntries();\n countEl.textContent = `${getLogBuffer().length} entries`;\n }\n });\n\nLogsTab.ts:88-100 — renderLogEntries touches only logListEl", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-3", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "bd007f675b3710b953cfbac14189abffee299c54", + "test": "Client/tauri-client/tests/unit/logs-tab.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Extract the count refresh into a local `function updateCount(): void { countEl.textContent = `${getLogBuffer().length} entries`; }` declared after countEl and call it from renderLogEntries (or from both the Clear and Refresh handlers plus the log listener). Cleanest single-point version: move countEl's creation above renderLogEntries' use and have renderLogEntries itself update the count, so every render path — clear, refresh, filter change, live entry — stays consistent.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0231", + "title": "stopVadPolling never detaches the VAD worklet's MessagePort handler, so a gate message posted before the worklet sees `stop` can re-gate the mic to zero after VAD has been turned off — with sensitivity 100 nothing ever un-gates it", + "file": "Client/tauri-client/src/lib/audioPipeline.ts", + "line": 418, + "severity": "low", + "why": "`stopVadPolling()` posts `{type:\"stop\"}`, calls `workletNode.disconnect()` and nulls `this.vadWorkletNode`, but never sets `workletNode.port.onmessage = null` nor `port.close()`. The `onmessage` closure installed at line 332 stays live (the closure itself keeps the node alive, and `disconnect()` only drops the node's outgoing edges — the incoming `analyser.connect(workletNode)` from line 324 is never removed either). The `stop` message has to cross to the audio thread, so the processor keeps running for several render quanta and can post `{type:\"gate\", gated:true}` in that window. That message is still dispatched on the main thread and sets `this.vadGated = true` + `updatePipelineGain()`, which drives the still-live GainNode to 0. On the `setVoiceSensitivity(>=100)` path there is no VAD left to ever post `gated:false`, and `updatePipelineGain()` keeps returning 0 for every later call because it reads `this.vadGated`.", + "repro": "In a voice call with VAD active (sensitivity < 100, the default 50), stop speaking so the worklet is about to gate, then drag the Input Sensitivity handle in Settings → Voice & Audio all the way to the far left (sensitivity 100, 'gate nothing'). VoiceAudioTab.ts:163-177 calls setVoiceSensitivity on every pointermove, so the last live worklet is stopped with no replacement. If the worklet emitted its silence→gated transition in the few quanta between the `stop` postMessage and the audio thread processing it, the late `{gate:true}` lands after stopVadPolling ungated, setting vadGated=true and driving the pipeline GainNode to 0. The mic is now permanently silent to every peer while the UI shows VAD disabled and unmuted; even moving the Input Volume slider does not help (setInputVolume → updatePipelineGain still multiplies by the stuck gate). Only a full pipeline rebuild — mute/unmute, a device change, or leaving and rejoining voice — clears it.", + "evidence": "audioPipeline.ts:418-439 `stopVadPolling()` — `this.vadWorkletNode.port.postMessage({ type: \"stop\" }); this.vadWorkletNode.disconnect(); this.vadWorkletNode = null;` (no `port.onmessage = null`, no `port.close()`), then `if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }`. audioPipeline.ts:332-342 `workletNode.port.onmessage = (event) => { if (event.data.type === \"gate\") { … this.vadGated = gated; this.updatePipelineGain(); } }`. audioPipeline.ts:253-258 — the `clamped >= 100` branch of `setVoiceSensitivity` does NOT restart VAD. audioPipeline.ts:223-231 `updatePipelineGain` → `const effectiveGain = this.vadGated ? 0 : this.currentInputGain;`. public/vad-worklet.js:42-44 `else if (event.data.type === \"stop\") { this._active = false; }` — only observed at the next `process()` call, and :77-83 posts `{gate:true}` from that same still-running `process()`.", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "9b0863967d3475e15d6600b2b9809b56921dbbef", + "test": "Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "In stopVadPolling(), detach the handler before stopping the node: `this.vadWorkletNode.port.onmessage = null;` immediately before the existing `postMessage({type:\"stop\"})` / `disconnect()` at audioPipeline.ts:429-434. One guard in the shared teardown covers every caller (setVoiceSensitivity, startVadPolling's self-stop, teardownAudioPipeline). Equivalent alternative: capture `const vadGen = this._vadGeneration` in startVadWorklet and early-return from the onmessage closure when `vadGen !== this._vadGeneration`.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0232", + "title": "\"Reduce Motion\" and \"Sync with OS\" are two writers of one CSS class with no arbitration, so a manual toggle silently overrides the OS accessibility setting", + "file": "Client/tauri-client/src/components/settings/AccessibilityTab.ts", + "line": 24, + "severity": "low", + "why": "The `reducedMotion` toggle's sideEffect writes `documentElement.classList.toggle(\"reduced-motion\", nowOn)` directly, and `syncOsMotionListener` (os-motion.ts:34/38) writes the same class from the media query. Neither consults the other. `.reduced-motion` (app.css:5228) is the app-wide animation kill switch — the three `@media (prefers-reduced-motion: reduce)` blocks in the stylesheets only cover `.highlight-flash`, `.jump-to-present-pill` and `.upp-popup`, so removing the class genuinely re-enables every other animation and transition. The toggle's rendered state is also always `loadPref(\"reducedMotion\", false)` (line 72), never the effective state, so with OS sync on the switch reads OFF while motion is in fact reduced.", + "repro": "OS has \"reduce motion\" enabled. Settings → Accessibility → turn ON \"Sync with OS\": `syncOsMotionListener(true)` adds `reduced-motion`; animations stop. The \"Reduce Motion\" switch still renders OFF (its pref is false). Now click \"Reduce Motion\" ON, then OFF: the second click runs `classList.toggle(\"reduced-motion\", false)`, removing the class. Animations are back app-wide even though \"Sync with OS\" is still ON and the OS still asks for reduced motion; nothing restores it until the OS setting itself changes or the app restarts. The mirror case also loses data: with OS sync ON and OS = no-reduce, a manual \"Reduce Motion\" ON survives to localStorage but is wiped on next launch, because applyStoredAppearance (appearance.ts:45-55) applies the manual pref first and then calls syncOsMotionListener, which re-derives the class from the OS.", + "evidence": "// AccessibilityTab.ts:19-26\n{ key: \"reducedMotion\", ..., sideEffect: (nowOn) => {\n document.documentElement.classList.toggle(\"reduced-motion\", nowOn);\n } },\n// os-motion.ts:32-41\nac = new AbortController();\nconst mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\ndocument.documentElement.classList.toggle(\"reduced-motion\", mq.matches);\nmq.addEventListener(\"change\", (e) => {\n document.documentElement.classList.toggle(\"reduced-motion\", e.matches);\n}, { signal: ac.signal });\n// app.css:5228\n.reduced-motion, .reduced-motion * { animation-duration: 0s !important; transition-duration: 0s !important; }", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-2", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "08e188135283b0883cd4aba261f3e30cb3769677", + "test": "Client/tauri-client/tests/unit/AccessibilityTab.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Make os-motion the single writer: change the reducedMotion sideEffect in AccessibilityTab.ts:23-25 to `sideEffect: () => syncOsMotionListener(loadPref(\"syncOsMotion\", false))`. savePref has already stored the new manual value, and syncOsMotionListener(false) re-reads it while syncOsMotionListener(true) re-derives the class from the media query, so whichever source owns the class wins consistently — matching applyStoredAppearance's startup ordering (appearance.ts:45-55).", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0233", + "title": "Desktop notification titles print the raw username, ignoring the nickname the message row beside them renders", + "file": "Client/tauri-client/src/lib/notifications.ts", + "line": 99, + "severity": "low", + "why": "`notifyIncomingMessage` builds the title from `payload.user.username`, but `MessageUser` carries `display_name` (types.ts:97) and the message list renders it via `resolveAuthor` (message-list/formatting.ts:145-152). The popup that tells you who wrote to you names them differently from the row you click through to.", + "repro": "User id 42 has username `a_martinez`, display_name `Alice`. She posts in #general while the window is unfocused. The desktop notification reads \"a_martinez in #general\"; opening the app shows the same message authored by \"Alice\". A user who only ever sees nicknames cannot tell who the notification is from.", + "evidence": "const title = sanitizeNotif(\n mentioned\n ? `${payload.user.username} mentioned you in ${channelLabel}`\n : `${payload.user.username} in ${channelLabel}`,\n 80,\n);", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-2", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "f3eeaad2edff76a208b4a3d0bee4fb00458f29cc", + "test": "Client/tauri-client/tests/unit/notifications.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Resolve the author name once at notifications.ts:97 the way every other surface does — `const authorName = memberDisplayName(membersStore.getState().members.get(payload.user.id) ?? { username: payload.user.username, displayName: payload.user.display_name ?? null });` (or reuse resolveAuthor) — and interpolate authorName into both title branches, leaving the 80-char sanitizeNotif cap unchanged.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0234", + "title": "resolveLanguage resolves fence tags against Object.prototype, so ```constructor / ```toString return a function instead of null", + "file": "Client/tauri-client/src/components/message-list/syntax-highlight.ts", + "line": 196, + "severity": "low", + "why": "ALIASES is a plain object literal, so the lookup walks the prototype chain. `ALIASES[\"constructor\"]` is the Object constructor, `ALIASES[\"toString\"]` / `[\"valueOf\"]` / `[\"hasOwnProperty\"]` / `[\"isPrototypeOf\"]` / `[\"propertyIsEnumerable\"]` / `[\"toLocaleString\"]` are Function objects — none are null or undefined, so `?? null` never fires and the function returns a non-string in violation of its declared `string | null` type and its documented \"null when unknown\" contract. LANG_TAG_REGEX (`/^[A-Za-z][\\w+#-]{0,19}$/`, content-parser.ts:435) accepts every one of those tags, so a message can reach it. The caller then writes the value straight into a DOM attribute.", + "repro": "Post a message whose body is a fence tagged `constructor`:\n```constructor\nx = 1\n```\ncontent-parser.ts:477 calls resolveLanguage(\"constructor\"), which returns the `Object` function rather than null; line 478's `canonical !== null` passes and block.setAttribute(\"data-lang\", canonical) stringifies it, producing data-lang=\"function Object() { [native code] }\" on the rendered
. The same input with the fix (an own-property guard, e.g. Object.hasOwn(ALIASES, tag) or a null-prototype map) yields no data-lang at all, which is what every other unknown tag does. Unit test content-markdown.test.ts:661 only pins `resolveLanguage(\"nope\")`, so the prototype keys are uncovered.", + "evidence": "syntax-highlight.ts:160-197:\n const ALIASES: Readonly> = { js: \"javascript\", ... };\n export function resolveLanguage(tag: string | null): string | null {\n if (tag === null) return null;\n return ALIASES[tag.toLowerCase()] ?? null; // prototype chain, no own-property guard\n }\n\ncontent-parser.ts:477-479:\n const canonical = resolveLanguage(lang);\n if (canonical !== null) block.setAttribute(\"data-lang\", canonical);\n for (const token of highlightCode(code, canonical)) {", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-3", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "74d1ea6d3fa2897a46443158faf7820d29bacb32", + "test": "Client/tauri-client/tests/unit/content-markdown.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Guard the lookup for own properties in the one shared function: `const hit = Object.hasOwn(ALIASES, key) ? ALIASES[key] : undefined; return hit ?? null;` (or declare ALIASES via Object.assign(Object.create(null), {...}) / a Map). No caller-side change needed.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0235", + "title": "In a group DM, the ringer leaving voice cancels every other callee's ring even though the call is still live", + "file": "Client/tauri-client/src/pages/MainPage.ts", + "line": 629, + "severity": "low", + "why": "The `voice_leave` handler treats \"the user who rang me left the DM's voice channel\" as \"the call is over\", but in a group DM the room can still hold other participants who already accepted. The condition tests only `payload.user_id === ringing.fromUserId`; it never asks whether the channel's voice roster is now empty, even though `voiceStore.voiceUsers` is a `Map>` that answers exactly that.", + "repro": "Group DM with A, B, C (channels.type='dm', is_group=1 — Server/db/dm_queries.go:292, and service/dm.go RingTargets fans out to every other participant). (1) A clicks call: MainPage.startCall() (MainPage.ts:292-304) joins the DM voice channel and sends call_ring. (2) B and C both get call_incoming and both ring. (3) B clicks Accept and joins the voice channel. C is still ringing. (4) A leaves voice (hangs up or switches channel). The server broadcasts voice_leave for A to the DM's READ audience, which includes C (hub_broadcast.go channelReadAudience for a DM = its participants). (5) C's handler matches A's user_id and calls ringCtrl.cancel(channelId) -> stopRinging(): C's banner disappears and the chime stops, even though B is sitting in the call waiting. C loses the one-click Accept and gets no indication the call is still open.", + "evidence": "MainPage.ts:626-631\n ws.on(\"voice_leave\", (payload) => {\n const ringing = ringCtrl?.current();\n if (ringing === null || ringing === undefined) return;\n if (payload.user_id === ringing.fromUserId) {\n ringCtrl?.cancel(payload.channel_id);\n }\n }),\n\ncall-ring.ts:114-117 — cancel() only re-checks the channel id, not occupancy:\n function cancel(channelId: number): void {\n if (state === null || state.channelId !== channelId) return;\n stopRinging();\n }\n\nThe roster that would answer the real question exists: voice.store.ts:73\n readonly voiceUsers: ReadonlyMap>; // channelId -> userId -> VoiceUser", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85", + "test": "Client/tauri-client/tests/unit/main-page.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Guard the cancel on the channel's voice roster being empty of anyone but the leaver, in the single MainPage voice_leave handler (voiceStore is already imported at MainPage.ts:27):\n\nws.on(\"voice_leave\", (payload) => {\n const ringing = ringCtrl?.current();\n if (ringing === null || ringing === undefined) return;\n if (payload.user_id !== ringing.fromUserId) return;\n const roster = voiceStore.getState().voiceUsers.get(payload.channel_id);\n const othersStillIn =\n roster !== undefined && [...roster.keys()].some((id) => id !== payload.user_id);\n if (!othersStillIn) ringCtrl?.cancel(payload.channel_id);\n});\n\n(The dispatcher's removeVoiceUser may or may not have run first; excluding payload.user_id makes the check order-independent, and keeps main-page.test.ts:503-528 green since that test's roster is empty.)", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0236", + "title": "Auto-idle's inactivity timer is never re-armed except by a DOM input event, so a tray status change leaves the watcher permanently disarmed", + "file": "Client/tauri-client/src/lib/autoIdle.ts", + "line": 107, + "severity": "low", + "why": "`arm()` is called only at construction and from `onActivity`. The timeout callback sets `timer = null` and calls `apply(true)` without re-arming. Any status change made through a surface that produces no window input event (the OS tray menu, which calls `saveUserStatus` directly) therefore cannot restart the ten-minute clock, and the user stays broadcast as Online while away — the exact outcome auto-idle exists to prevent.", + "repro": "(1) User is Online and walks away. At T+10 min the timer fires: apply(true) flips the pref to idle/auto, `timer` is left null. (2) User comes back and sets Online from the OS tray Status submenu. main.ts:270 calls saveUserStatus(\"online\") — a native tray menu delivers no mousemove/keydown/mousedown into the webview, so onActivity never runs and arm() is never called. Status is now online/manual with no armed timer. (3) User walks away again without clicking inside the app window. The ten-minute watcher is gone: they show Online to every other member indefinitely. The same dead-timer state is also reached whenever the timer fires while the status is ineligible (dnd/invisible/manual-idle), where apply(true) returns null and nothing re-arms. tests/unit/auto-idle.test.ts:144-155 exercises the dnd case but never asserts re-arming, so nothing locks the current behaviour in.", + "evidence": "autoIdle.ts:105-130\n function arm(): void {\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null; // <- fired; never re-armed here\n if (destroyed) return;\n apply(true);\n }, delayMs);\n }\n\n function onActivity(): void { // the ONLY other arm() caller\n ...\n if (now - lastActivityRun < ACTIVITY_THROTTLE_MS) return;\n lastActivityRun = now;\n arm();\n }\n\nmain.ts:264-272 — the tray writes the status with no notifyActivity()/arm():\n void listen(\"status-change\", (e) => {\n ...\n saveUserStatus(mapped);\n getActivePresenceSender()?.send(mapped);\n });\n\n(The controller exposes notifyActivity() at autoIdle.ts:138 but nothing in src/ calls it.)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-1", + "finder": "opus", + "confidence": "medium", + "fix": { + "commit": "e9686461682f8719591981d4fa9dcc61b1f02564", + "test": "Client/tauri-client/tests/unit/auto-idle.test.ts", + "revertProof": "pass" + }, + "suggestedFix": "Re-arm inside the timeout callback so the watcher survives a firing that changed nothing:\n\n timer = setTimeout(() => {\n timer = null;\n if (destroyed) return;\n apply(true);\n arm(); // keep watching: the status may become eligible again\n }, delayMs);\n\nOne change in the shared arm(), rather than a notifyActivity() call bolted onto every external status-writing surface. Re-arming is harmless when already idle — nextAutoStatus(idle, *, true) returns null.", + "fixedDate": "2026-08-20" + }, + { + "id": "OC-0237", + "title": "WebSocket internal errors ship the raw wrapped error to the client and are never logged, unlike every sibling path", + "file": "Server/ws/handlers_chat.go", + "line": 185, + "severity": "low", + "why": "serviceErrorToResult's default branch puts err.Error() into the ClientError message for ErrCodeInternal. Service-layer ErrInternal wrappers embed the underlying driver error via %v, so the raw DB error text is sent to the requesting client. Its REST twin (writeServiceError, Server/api/channel_handler.go:418-422) deliberately does the opposite — it logs the error and replies with the fixed string \"an internal error occurred\" — and every other ErrCodeInternal site inside package ws uses a fixed message (deps.go:132/139, registry.go:59, voice_controls.go:58/161/249/262, serve.go:856). Worse, ws/handlers.go:85-92 only logs when result.Error is NOT a ClientError, so this path also produces zero server-side log output: the operator sees nothing while the client sees everything.", + "repro": "An authenticated user sends a `call_ring` frame for a DM they participate in while the SQLite file is under write contention or otherwise erroring. handlers_call.go:45 calls DMSvc.RingTargets, which fails at Server/service/dm.go:404-406 and returns fmt.Errorf(\"%w: failed to read DM participants: %v\", ErrInternal, err). handlers_call.go:47 hands that to serviceErrorToResult, which falls to the default branch at handlers_chat.go:184-185 and builds ClientError{Code:\"INTERNAL\", Message: err.Error()}. handlers.go:86-87 writes that message verbatim onto the socket, so the client receives e.g. `{\"type\":\"error\",\"code\":\"INTERNAL\",\"message\":\"internal error: failed to read DM participants: GetDMParticipantIDs: database is locked\"}` — internal query names and driver state disclosed to an ordinary member — while nothing is written to the server log, so the operator has no record the failure happened. The identical REST call would have logged it and returned only \"an internal error occurred\".", + "evidence": "Server/ws/handlers_chat.go:184-186\n\tdefault:\n\t\treturn Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}}\n\t}\n\ncontrast Server/api/channel_handler.go:418-422\n\tcase errors.Is(err, service.ErrInternal):\n\t\tslog.ErrorContext(ctx, \"service error\", \"error\", err)\n\t\twriteJSON(w, http.StatusInternalServerError, errorResponse{Error: \"INTERNAL_ERROR\", Message: \"an internal error occurred\"})\n\ncontrast Server/ws/handlers.go:85-92 (ClientError branch does not log)\n\tif ce, ok := result.Error.(ClientError); ok {\n\t\tc.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID))\n\t} else {\n\t\tslog.Error(\"ws handler internal error\", ...)", + "status": "fixed", + "found": "2026-08-20", + "hunt": "2026-08-20-general", + "lens": "explore-3", + "finder": "opus", + "confidence": "high", + "fix": { + "commit": "5dcf18f3466e76cc613c3a8a7a2d14c9c7fe1bb9", + "test": "Server/ws/oc_0237_service_error_internal_test.go", + "revertProof": "pass" + }, + "suggestedFix": "In the default branch of serviceErrorToResult (Server/ws/handlers_chat.go:184-186) log and return a fixed string, matching writeServiceError: `default: slog.Error(\"ws service internal error\", \"err\", err); return Result{Error: ClientError{Code: ErrCodeInternal, Message: \"internal error\"}}`. One change in the shared helper covers all seven call sites; no caller change needed.", + "fixedDate": "2026-08-20" } ] } diff --git a/Client/tauri-client/public/vad-worklet.js b/Client/tauri-client/public/vad-worklet.js index 566d4da0..315230a1 100644 --- a/Client/tauri-client/public/vad-worklet.js +++ b/Client/tauri-client/public/vad-worklet.js @@ -16,14 +16,19 @@ class VadProcessor extends AudioWorkletProcessor { constructor() { super(); this._threshold = 0.05; - this._gateOnFrames = 12; // ~200ms of silence before gating - this._gateOffFrames = 2; // ~33ms of speech before ungating + // process() runs once per 128-sample render quantum (2.667ms @ 48kHz — + // see audioPipeline.ts's `new AudioContext({ sampleRate: 48000 })`), NOT + // once per ~16ms poll like the setTimeout fallback. These frame counts + // are therefore ~6x the fallback's, so both paths gate on the same + // wall-clock timing. + this._gateOnFrames = 75; // ~200ms of silence before gating + this._gateOffFrames = 12; // ~32ms of speech before ungating this._silentFrames = 0; this._speechFrames = 0; this._gated = false; this._active = true; this._startupFrames = 0; - this._startupGrace = 30; // ~500ms grace period + this._startupGrace = 188; // ~500ms grace period this._frameCounter = 0; // for throttled RMS updates this.port.onmessage = (event) => { @@ -65,10 +70,10 @@ class VadProcessor extends AudioWorkletProcessor { return true; } - // Send RMS value to main thread every ~6 frames (~50ms at 128 samples/frame @ 48kHz) + // Send RMS value to main thread every ~19 frames (~50ms at 128 samples/frame @ 48kHz) // This is used for the VAD indicator bar in the UI this._frameCounter++; - if (this._frameCounter >= 6) { + if (this._frameCounter >= 19) { this._frameCounter = 0; this.port.postMessage({ type: "rms", value: rms }); } diff --git a/Client/tauri-client/src-tauri/src/tofu.rs b/Client/tauri-client/src-tauri/src/tofu.rs index 0762d33b..9631e26f 100644 --- a/Client/tauri-client/src-tauri/src/tofu.rs +++ b/Client/tauri-client/src-tauri/src/tofu.rs @@ -300,7 +300,17 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier { /// *non-default* port keeps its brackets: "[::1]:8443" stays its own distinct /// key, matching how a plain "host:8443" is never collapsed into "host". pub(crate) fn cert_store_key(host: &str) -> String { - let stripped = host.strip_suffix(":443").unwrap_or(host); + // Only strip a trailing ":443" when what's left is unambiguously a host + // (no remaining colon) or a bracketed IPv6 literal (ends in `]`, as in + // "[::1]:443"). Without this guard, a BARE IPv6 literal whose final + // hextet is "443" — e.g. "fd00::443" — would have that hextet eaten as + // if it were a port, truncating the address to "fd00:" and pinning the + // same server under a different key than the ws/livekit proxies use for + // the bracketed form of the same address (OC-0215). + let stripped = match host.strip_suffix(":443") { + Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest, + _ => host, + }; let unbracketed = stripped .strip_prefix('[') .and_then(|rest| rest.strip_suffix(']')) @@ -431,6 +441,26 @@ mod tests { assert_eq!(cert_store_key("[2001:db8::1]:8443"), "[2001:db8::1]:8443"); } + // OC-0215: a BARE (unbracketed) IPv6 literal whose final hextet happens to + // be "443" must NOT have that hextet eaten by the ":443" default-port + // strip — "fd00::443" is a whole address, not "fd00::" on port 443. The + // http proxy passes bare hosts verbatim (http_proxy::split_host_port has + // an explicit `!host.contains(':')` guard for exactly this reason), while + // the ws/livekit proxies see the bracketed form of the same address. All + // three MUST resolve to the same key or the same server's certificate is + // pinned (and re-confirmed by the user) under two different entries. + #[test] + fn cert_store_key_does_not_truncate_bare_ipv6_ending_in_443() { + assert_eq!(cert_store_key("fd00::443"), "fd00::443"); + // Must agree with the bracketed forms the ws/livekit proxies derive + // for the very same server. + assert_eq!(cert_store_key("fd00::443"), cert_store_key("[fd00::443]")); + assert_eq!( + cert_store_key("fd00::443"), + cert_store_key("[fd00::443]:443") + ); + } + // DNS names are case-insensitive, but a raw host string (a profile-entered // host, or one taken verbatim from a wss:// URL) is not normalized before // reaching here. Two call sites can derive the SAME host in different diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 7eeba884..ecb5cfd3 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -745,6 +745,17 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC onPurgeChannel, } = options; const ac = new AbortController(); + // renderChannels() rebuilds every row from scratch on every channels-store + // notification (unread count, active channel, role change, mute toggle, + // ...). Per-row listeners (context menu, drag handlers) must NOT be + // registered on the sidebar-lifetime `ac.signal`, which only aborts once, + // at destroy() -- addEventListener({ signal }) keeps a detached row alive + // via that signal's own retained "abort" listener list until it fires, so + // every re-render would otherwise leak one full set of detached rows + // (OC-0229). renderAc is aborted and replaced at the top of every + // renderChannels() call, so only the CURRENT render's rows stay reachable; + // header/root listeners registered once in mount() keep using `ac.signal`. + let renderAc: AbortController | null = null; let root: HTMLDivElement | null = null; let channelList: HTMLDivElement | null = null; let serverNameEl: HTMLSpanElement | null = null; @@ -778,6 +789,12 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC if (channelList === null) { return; } + // Abort the previous render's row-scoped listeners before the rows they + // belong to are detached below, so a stale row can never outlive the + // render that replaced it (OC-0229). + renderAc?.abort(); + const currentRenderAc = new AbortController(); + renderAc = currentRenderAc; clearChildren(channelList); voiceRowByUserId.clear(); @@ -803,7 +820,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC category, channels, state.activeChannelId, - ac.signal, + currentRenderAc.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, @@ -937,10 +954,13 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC for (const [chId, users] of state.voiceUsers) { structSig += `|${chId}`; for (const [uid, u] of users) { - // Include the E2EE verification status so a verified↔unverified↔mismatch - // flip re-renders the badge (it lives outside voiceUsers, in peerVerifications). + // Include the E2EE verification status, safety number, and session + // fingerprint so a verified↔unverified↔mismatch flip *and* a + // same-status fingerprint/safety-number change (e.g. a reconnect that + // re-announces a fresh ephemeral key, OC-0208) both re-render the + // badge (it lives outside voiceUsers, in peerVerifications). const verif = state.peerVerifications?.get(uid); - structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}` : ""}`; + structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}/${verif.safetyNumber ?? ""}/${verif.sessionFingerprint ?? ""}` : ""}`; } } return structSig; @@ -968,6 +988,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC // ac.abort() also releases this sidebar's hold on the shared document-level // drag listeners (drag-reorder.ts tracks owners by signal). ac.abort(); + renderAc?.abort(); + renderAc = null; for (const unsub of unsubscribers) { unsub(); } diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index 8536eaea..8b42104b 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -126,6 +126,11 @@ const TYPING_THROTTLE_MS = 3_000; const MAX_TEXTAREA_HEIGHT = 200; const SEND_DEBOUNCE_MS = 200; const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit +// Server/ws/command.go rejects the whole chat_send frame (as a generic parse +// error, not an attachment-specific one) once len(Attachments) > 10 -- cap +// the queue client-side so we never upload an attachment doomed to be +// orphaned by a send that can never succeed. +const MAX_ATTACHMENTS = 10; const ALLOWED_TYPES = [ "image/", "video/", @@ -556,6 +561,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo return; } + // Cap the queue at the server's hard limit. Refusing here -- before the + // upload starts -- keeps the composer's state and the eventual send in + // sync with what the server will actually accept. + if (pendingAttachments.length >= MAX_ATTACHMENTS) { + showUploadError(`You can attach at most ${MAX_ATTACHMENTS} files to a message`); + return; + } + const tempId = `pending-${++previewCounter}`; const isImage = file.type.startsWith("image/"); diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 7e0e571d..65e67f57 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -264,6 +264,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo let renderedStart = 0; let renderedEnd = 0; + // scrollToMessage's highlight-flash: at most one outstanding flash at a + // time, so its cleanup timer never needs a per-call abort listener (which + // would accumulate one listener — and pin one row element — per jump). + let flashTimer = 0; + let flashEl: HTMLElement | null = null; + /** * Unread count this channel carried when the visit that created this list * began. Read once here, not per render: the badge is cleared by the visit @@ -965,6 +971,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo clearTimeout(renderWindowResetTimer); renderWindowResetTimer = 0; } + if (flashTimer !== 0) { + clearTimeout(flashTimer); + flashTimer = 0; + flashEl = null; + } unsubLoadingReset(); for (const unsub of unsubscribers) { unsub(); @@ -1013,12 +1024,19 @@ export function createMessageList(options: MessageListOptions): MessageListCompo const localIdx = idx - renderedStart; const el = contentContainer.children[localIdx] as HTMLElement | undefined; if (el !== undefined) { + // A prior flash still pending (rapid repeat jumps) must not linger on + // its now-stale row, and must not leave its timer live once replaced. + if (flashTimer !== 0) { + clearTimeout(flashTimer); + flashEl?.classList.remove("highlight-flash"); + } el.classList.add("highlight-flash"); - const timer = window.setTimeout(() => { + flashEl = el; + flashTimer = window.setTimeout(() => { el.classList.remove("highlight-flash"); + flashTimer = 0; + flashEl = null; }, 1500); - // Unmounting mid-flash must not leave a timer pointing at a dead node. - ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); } } diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 5cea6bec..e7eba4e9 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -26,6 +26,10 @@ export interface TileConfig { export interface VideoGridComponent extends MountableComponent { addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void; + /** Update an already-open tile's label in place (e.g. a mid-call rename). + * No-op if no tile is open for this id — callers don't need to know + * whether the tile exists. */ + setLabel(userId: number, username: string): void; removeStream(userId: number): void; /** Remove every tile — used on a real voice leave so stale remote tiles * from the previous session don't survive into the next join. */ @@ -401,6 +405,19 @@ export function createVideoGrid(): VideoGridComponent { } } + /** Update an already-open tile's label in place. No-op if the tile isn't + * open — used to keep a remote tile's name in sync with a mid-call + * rename without re-creating the tile (addStream is only called once per + * tile, from the LiveKit TrackSubscribed callback). */ + function setLabel(userId: number, username: string): void { + const entry = cells.get(userId); + if (entry === undefined) return; + const label = entry.el.querySelector(".video-username"); + if (label !== null) { + label.textContent = username; + } + } + function removeStream(userId: number): void { const entry = cells.get(userId); if (entry === undefined) return; @@ -487,6 +504,7 @@ export function createVideoGrid(): VideoGridComponent { mount, destroy, addStream, + setLabel, removeStream, clearStreams, hasStreams, diff --git a/Client/tauri-client/src/components/message-list/content-parser.ts b/Client/tauri-client/src/components/message-list/content-parser.ts index 40760031..c42dbbad 100644 --- a/Client/tauri-client/src/components/message-list/content-parser.ts +++ b/Client/tauri-client/src/components/message-list/content-parser.ts @@ -44,6 +44,29 @@ export const MESSAGE_LINK_REGEX = /owncord:\/\/message\/\d+\/\d+/g; export type { MentionInfo }; +/** + * Strip trailing punctuation that is likely sentence-level, not part of the + * URL — e.g. the period after "https://example.com." in "Check this out.". + * + * Gives back one trailing ")" if it balances an unmatched "(" earlier in the + * URL, since `https://en.wikipedia.org/wiki/Rust_(programming_language)` is a + * real address, not prose wrapped in parens. + * + * This is the single source of truth for "what counts as part of the URL vs. + * surrounding prose" — every consumer of a raw URL_REGEX match (linkifying + * anchors, extracting URLs for the embed pipeline) must strip through this + * function so they agree on the same URL. + */ +export function stripUrlTrailingPunctuation(rawUrl: string): string { + let stripped = rawUrl.replace(/[.,;:!?)]+$/, ""); + if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") { + const opens = (stripped.match(/\(/g) ?? []).length; + const closes = (stripped.match(/\)/g) ?? []).length; + if (opens > closes) stripped = stripped + ")"; + } + return stripped || rawUrl; // fallback if stripping emptied it +} + /** Quotes may contain blocks, but a quote inside a quote inside a quote is a * fight the renderer does not need to have. */ const MAX_BLOCK_DEPTH = 2; @@ -168,17 +191,9 @@ export function renderMentions(text: string, info?: MentionInfo): DocumentFragme } // Strip trailing punctuation that is likely sentence-level, not part of the URL const rawUrl = match[0]; - let stripped = rawUrl.replace(/[.,;:!?)]+$/, ""); - // Give back one trailing ")" if it balances an unmatched "(" earlier in - // the URL — e.g. https://en.wikipedia.org/wiki/Rust_(programming_language) - // is a real address, not prose wrapped in parens. - if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") { - const opens = (stripped.match(/\(/g) ?? []).length; - const closes = (stripped.match(/\)/g) ?? []).length; - if (opens > closes) stripped = stripped + ")"; - } + const stripped = stripUrlTrailingPunctuation(rawUrl); const trailing = rawUrl.slice(stripped.length); - const url = stripped || rawUrl; // fallback if stripping emptied it + const url = stripped; if (isSafeUrl(url)) { const link = createElement("a", { class: "msg-link", diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts index ec4f1849..16acb5a4 100644 --- a/Client/tauri-client/src/components/message-list/media.ts +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -15,6 +15,7 @@ import { CODE_BLOCK_REGEX, INLINE_CODE_REGEX, MASKED_LINK_REGEX, + stripUrlTrailingPunctuation, URL_REGEX, } from "./content-parser"; import { renderGenericLinkPreview } from "./embeds"; @@ -513,7 +514,10 @@ export function extractUrls(content: string): string[] { .replace(INLINE_CODE_REGEX, "") .replace(MASKED_LINK_REGEX, ""); const matches = withoutCodeBlocks.match(URL_REGEX); - return matches ?? []; + // Strip the same trailing sentence punctuation the linkifier strips (see + // stripUrlTrailingPunctuation), so the embed pipeline and the rendered + // anchor agree on exactly the same URL. + return (matches ?? []).map(stripUrlTrailingPunctuation); } /** Render URL embeds (YouTube players, generic link previews). */ diff --git a/Client/tauri-client/src/components/message-list/syntax-highlight.ts b/Client/tauri-client/src/components/message-list/syntax-highlight.ts index 9ab3d7e9..eb2a841b 100644 --- a/Client/tauri-client/src/components/message-list/syntax-highlight.ts +++ b/Client/tauri-client/src/components/message-list/syntax-highlight.ts @@ -193,7 +193,14 @@ const DEFAULT_IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y; /** Canonical language id for a fence tag, or null when unknown. */ export function resolveLanguage(tag: string | null): string | null { if (tag === null) return null; - return ALIASES[tag.toLowerCase()] ?? null; + const key = tag.toLowerCase(); + // Object.hasOwn guards against inherited keys ("constructor", "toString"), + // which a bare index read would resolve to a prototype value. Once that + // guard holds the own value is a real string, but noUncheckedIndexedAccess + // still types the read as string | undefined, so narrow it explicitly. + if (!Object.hasOwn(ALIASES, key)) return null; + const canonical = ALIASES[key]; + return canonical === undefined ? null : canonical; } /** diff --git a/Client/tauri-client/src/components/settings/AccessibilityTab.ts b/Client/tauri-client/src/components/settings/AccessibilityTab.ts index 5959c510..c55d5013 100644 --- a/Client/tauri-client/src/components/settings/AccessibilityTab.ts +++ b/Client/tauri-client/src/components/settings/AccessibilityTab.ts @@ -20,8 +20,16 @@ const TOGGLES: ReadonlyArray = [ label: "Reduce Motion", desc: "Disable animations and transitions", fallback: false, - sideEffect: (nowOn) => { - document.documentElement.classList.toggle("reduced-motion", nowOn); + // Do not write the `reduced-motion` class directly here: when "Sync with + // OS" is on, os-motion.ts owns that class via a live media-query + // listener, and writing it directly would silently fight that listener + // (OC-0232). savePref has already stored the new manual value by the + // time this runs, so re-invoking syncOsMotionListener lets whichever + // source is supposed to own the class re-derive it consistently: ON + // re-reads the OS media query (OS wins), OFF re-reads the just-saved + // manual pref — matching applyStoredAppearance's startup ordering. + sideEffect: () => { + syncOsMotionListener(loadPref("syncOsMotion", false)); }, }, { diff --git a/Client/tauri-client/src/components/settings/LogsTab.ts b/Client/tauri-client/src/components/settings/LogsTab.ts index 0ecb171e..52977bce 100644 --- a/Client/tauri-client/src/components/settings/LogsTab.ts +++ b/Client/tauri-client/src/components/settings/LogsTab.ts @@ -78,6 +78,7 @@ export interface LogsTabHandle { export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): LogsTabHandle { let logListEl: HTMLDivElement | null = null; + let countEl: HTMLDivElement | null = null; let logFilterLevel: LogLevel | "all" = readMigratedStringPref( "logs_filter_level", "all", @@ -85,11 +86,18 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): ); let unsubLogListener: (() => void) | null = null; + // Single point of truth for both the list and the "N entries" counter above + // it, so every render path (filter change, Clear, Refresh, live entry) + // keeps them in sync — see OC-0230. function renderLogEntries(): void { + const entries = getLogBuffer(); + if (countEl !== null) { + countEl.textContent = `${entries.length} entries`; + } + if (logListEl === null) return; clearChildren(logListEl); - const entries = getLogBuffer(); for (const entry of entries) { if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue; logListEl.appendChild(formatLogEntry(entry)); @@ -314,7 +322,7 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): section.appendChild(diagBtns); // Log count - const countEl = createElement( + countEl = createElement( "div", { style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;", @@ -338,7 +346,6 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): unsubLogListener = addLogListener(() => { if (getActiveTab() === "Logs") { renderLogEntries(); - countEl.textContent = `${getLogBuffer().length} entries`; } }); @@ -349,6 +356,7 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): unsubLogListener?.(); unsubLogListener = null; logListEl = null; + countEl = null; } return { build, cleanup }; diff --git a/Client/tauri-client/src/lib/audioPipeline.ts b/Client/tauri-client/src/lib/audioPipeline.ts index c0262efe..99956526 100644 --- a/Client/tauri-client/src/lib/audioPipeline.ts +++ b/Client/tauri-client/src/lib/audioPipeline.ts @@ -424,6 +424,13 @@ export class AudioPipeline { } // Stop AudioWorklet if (this.vadWorkletNode !== null) { + // Detach the handler first — the worklet's `process()` loop only + // observes `stop` on its next audio-thread callback, so it can still + // post one more {type:"gate"} message after this postMessage but + // before it does. Leaving onmessage live would let that late message + // re-gate the mic with no VAD left running to ever un-gate it again + // (OC-0231). + this.vadWorkletNode.port.onmessage = null; // oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage this.vadWorkletNode.port.postMessage({ type: "stop" }); this.vadWorkletNode.disconnect(); diff --git a/Client/tauri-client/src/lib/autoIdle.ts b/Client/tauri-client/src/lib/autoIdle.ts index 98b36f3d..4eb75c1d 100644 --- a/Client/tauri-client/src/lib/autoIdle.ts +++ b/Client/tauri-client/src/lib/autoIdle.ts @@ -108,6 +108,19 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController { timer = null; if (destroyed) return; apply(true); + // Re-check: apply() invokes options.onStatusChange synchronously, and a + // caller reacting to that (e.g. tearing down the page) may call + // destroy() from inside it. `timer` is already null at this point, so + // destroy()'s clearTimeout would be a no-op — the re-check below is + // what actually stops a synchronous destroy from being undone. + if (destroyed) return; + // Keep watching even when this firing changed nothing (already idle, + // or dnd/invisible/manual-idle made it a no-op): a status change made + // through a surface that produces no DOM activity event — the OS tray's + // Status submenu calls saveUserStatus() directly — can make the status + // eligible again without ever calling arm() itself. Re-arming here is + // the one place that covers every such surface at once. + arm(); }, delayMs); } diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index b39d9bd0..c4558451 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -31,6 +31,7 @@ import { invalidateLoadedMessageWindows, setChannelLoading, setChannelLoadError, + isWindowDetached, } from "@stores/messages.store"; import { setMembers, @@ -65,7 +66,12 @@ import { updateDmParticipant, } from "@stores/dm.store"; import type { DmChannel } from "@stores/dm.store"; -import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store"; +import { + blocksStore, + setBlockedByMe, + setUserBlockedByThem, + clearBlockedByThem, +} from "@stores/blocks.store"; import { setCustomEmoji } from "@stores/emoji.store"; import type { DmChannelPayload } from "./types"; import { isTextLikeChannel } from "./types"; @@ -267,6 +273,16 @@ export function wireDispatcher( unsubs.push( ws.on(S.READY, (payload) => { + // OC-0201: snapshot the current voice channel's peer roster BEFORE the + // wholesale replace below, so the reconciliation branch further down + // can tell who left while the socket was down. Must run before + // setVoiceStates() overwrites voiceUsers with the fresh payload. + const prevVoiceChannelId = voiceStore.getState().currentChannelId; + const prevVoicePeerIds = + prevVoiceChannelId !== null + ? new Set(voiceStore.getState().voiceUsers.get(prevVoiceChannelId)?.keys() ?? []) + : new Set(); + setChannels(payload.channels); setRoles(payload.roles ?? []); setMembers(payload.members); @@ -303,6 +319,31 @@ export function wireDispatcher( selfVoiceState.server_muted === true, selfVoiceState.server_deafened === true, ); + + // OC-0201: same gap, for E2EE. A full resync never replays the + // voice_leave for anyone who departed our voice channel during the + // outage — handleParticipantLeft (the only path that prunes a + // departed peer's key, rotates for membership forward secrecy, and + // re-runs the lowest-uid key-holder election) is otherwise only ever + // driven by a live voice_leave frame. Without this, a departed peer + // keeps a working room key indefinitely, and a client the server + // just elected key holder on reconnect (Server/ws hub.go + // registerNow -> updateKeyHolder) never self-elects. Only reconcile + // when the resync's self voice state is for the SAME channel the + // snapshot above was taken from — a channel change is out of scope + // here and comparing rosters across two different channels would + // misfire. + if (prevVoiceChannelId === selfVoiceState.channel_id) { + const currentVoicePeerIds = new Set( + payload.voice_states + .filter((vs) => vs.channel_id === selfVoiceState.channel_id) + .map((vs) => vs.user_id), + ); + for (const uid of prevVoicePeerIds) { + if (uid === currentUserId || currentVoicePeerIds.has(uid)) continue; + void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid)); + } + } } // F3: publish our long-term identity public key so peers can pin+verify @@ -463,9 +504,17 @@ export function wireDispatcher( // clear it and re-fetch our own outgoing blocks authoritatively. clearBlockedByThem(); if (api !== undefined) { + // OC-0218: snapshot the revision blocksStore was at right before + // issuing this fetch. If the user blocks/unblocks someone (via + // SidebarMemberSection's onToggleBlock -> setUserBlockedByMe) while + // this GET is in flight, that per-user delta bumps the revision; + // setBlockedByMe then sees the mismatch and skips applying this + // reply instead of clobbering the fresher local truth with a stale + // full-set snapshot. + const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0; api .listBlocks() - .then((r) => setBlockedByMe(r.blocked_user_ids)) + .then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch)) .catch((err) => log.warn("Failed to load block list", { error: String(err) })); } @@ -566,30 +615,46 @@ export function wireDispatcher( const currentUserId = authStore.getState().user?.id ?? null; const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId; - // Increment channel-level unread for non-active, non-own-message channels. - // Replayed frames increment unread counts like live ones — the burst - // is exactly the messages missed while away (a full-ready resume sends - // no burst at all; ready's unread_count values are authoritative - // there). DM channel IDs are not in channelsStore (they use dmStore), - // so incrementUnread is a no-op for DMs, but the own-message guard is - // applied here for defence-in-depth. + // Increment channel-level unread for non-active, non-own-message + // channels — OR for the active channel when its loaded window is + // detached from the live tail (OC-0204). "Active" normally means "the + // user is watching the live tail", which is why it is otherwise + // excluded here, but a jump to an old permalink/reply/search hit can + // leave the active channel showing a detached around-window + // (messages.store's detachedChannels) — addMessage already refuses to + // append a live broadcast onto that window, so without this a message + // (an @mention included) that arrives while the user reads + // back-history leaves no row AND no badge, with nothing to tell them + // it ever arrived. Replayed frames increment unread counts like live + // ones — the burst is exactly the messages missed while away (a + // full-ready resume sends no burst at all; ready's unread_count values + // are authoritative there). DM channel IDs are not in channelsStore + // (they use dmStore), so incrementUnread is a no-op for DMs, but the + // own-message guard is applied here for defence-in-depth. const isMention = highlightsCurrentUser(payload.content, { mentions: payload.mentions, mentionsEveryone: payload.mentions_everyone, }); + const isDetached = isWindowDetached(payload.channel_id); - if (payload.channel_id !== activeId && !isOwnMessage) { - incrementUnread(payload.channel_id); + if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) { + // incrementUnread/incrementMention skip the active channel by + // default — evenIfActive (isDetached here) is a no-op for a + // genuinely non-active channel, since their internal guard only + // fires when channelId IS the active one. + incrementUnread(payload.channel_id, isDetached); // A mention is an unread too — the mention badge just outranks it. if (isMention) { - incrementMention(payload.channel_id); + incrementMention(payload.channel_id, isDetached); } } // Update DM store last message if this message belongs to a DM channel. - // Skip unread increment for own messages and the currently focused DM. + // Skip unread increment for own messages and the currently focused DM + // — unless that DM's window is detached from the live tail (OC-0204), + // the same exception the channel-level increment above makes. if (isDm) { - const isDmActive = payload.channel_id === activeId; + const isDmActive = payload.channel_id === activeId && !isDetached; if (isOwnMessage || isDmActive) { // Update last message preview but don't increment unread count. updateDmLastMessagePreview( @@ -1111,10 +1176,22 @@ export function wireDispatcher( // that voice_leave's channel no longer matches the already-updated // currentChannelId, so it must not tear down the NEW channel's // optimistic state either), so voiceStatus is still "joining" when - // this error lands and the guard clears it here instead. An - // already-established session is never in "joining", so this never - // touches a live voice call. + // this error lands and the guard clears it here instead. A plain + // store rollback is safe for an already-established session (never + // "joining") and for a first-time join refusal (no prior session to + // tear down) — but a channel *switch* refused at precheck (RATE_LIMITED, + // FORBIDDEN, NOT_FOUND, archived-channel BAD_REQUEST) never reaches + // voiceJoinLeaveCurrent server-side, so no voice_leave is broadcast and + // the OLD channel's LiveKit room is still connected (mic still + // published) while the store already points at the NEW channel + // (OC-0193). isVoiceConnected() distinguishes that live-session case + // from the first-time-join refusal; tearing it down here also sends + // voice_leave so the server/SFU state for the OLD channel matches the + // now-cleared store. if (voiceStore.getState().voiceStatus === "joining") { + void livekitSession().then(({ isVoiceConnected, leaveVoice }) => { + if (isVoiceConnected()) leaveVoice(true); + }); leaveVoiceChannel(); } // Voice capacity refusals. The server owns the limits (voice_max_users / diff --git a/Client/tauri-client/src/lib/livekitE2EE.ts b/Client/tauri-client/src/lib/livekitE2EE.ts index be66947d..52b778de 100644 --- a/Client/tauri-client/src/lib/livekitE2EE.ts +++ b/Client/tauri-client/src/lib/livekitE2EE.ts @@ -94,6 +94,15 @@ export class E2EEManager { publicKeyBase64: string; signatureBase64?: string; }> = []; + /** Announce that verifyPeerAnnounce rejected as a TOFU pin mismatch, keyed + * by userId — buffered so a subsequent successful rePinPeerIdentity can + * replay it instead of leaving the recovery a no-op for the live call + * (OC-0212): a mid-call peer never re-announces on its own, so nothing + * else would re-run verification against the freshly-stored pin. At most + * one entry per peer; a later mismatch (or a later legitimate announce) + * simply overwrites the previous one. Cleared in clearState(). */ + private _blockedAnnounces: Map = + new Map(); /** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */ private _keyRotationTimer: ReturnType | null = null; /** Interval between periodic key rotations (5 minutes). */ @@ -535,6 +544,11 @@ export class E2EEManager { // Pinned peer whose delivered key is absent or differs from the pin — // possible server MITM. Block until the user re-pins. if (pin !== null && publishedIdentity !== pin) { + // Buffer this announce (OC-0212) so a successful rePinPeerIdentity can + // replay it: a mid-call peer never re-announces on its own, so without + // this, re-pinning writes a new pin that nothing ever verifies the + // peer's key against, leaving them un-keyed for the rest of the call. + this._blockedAnnounces.set(userId, { publicKeyBase64, signatureBase64 }); this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "mismatch", @@ -671,7 +685,23 @@ export class E2EEManager { }); return false; } - clearPeerVerification(userId); + // Replay the announce verifyPeerAnnounce buffered when it blocked this + // peer as a mismatch (OC-0212). Without this, the pin write above is a + // no-op for the live call: nothing else re-runs the peer's announce, so + // they never (re-)enter _peerPublicKeys — staying out of every offer and + // rotation for the rest of the call — and clearPeerVerification below + // would erase the badge entirely rather than showing the real (now + // hopefully "verified") outcome. handleAnnounce re-verifies against the + // pin just stored above and writes the real status itself, so it stands + // in for clearPeerVerification when a replay is available. + const pending = this._blockedAnnounces.get(userId); + if (pending) { + this._blockedAnnounces.delete(userId); + log.info("E2EE: replaying blocked announce after re-pin (TOFU recovery)", { userId }); + await this.handleAnnounce(userId, pending.publicKeyBase64, pending.signatureBase64); + } else { + clearPeerVerification(userId); + } log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); return true; } @@ -739,6 +769,20 @@ export class E2EEManager { // can be detected before this continuation writes into a session a newer // (or no) attempt now owns (finding B3-7). const myGeneration = this._sessionGeneration; + // Reject a replay of a key we've already retired for this peer BEFORE + // verifyPeerAnnounce runs (OC-0209). verifyPeerAnnounce writes the peer's + // displayed verification (status + sessionFingerprint, computed from + // THIS announce's key) on every branch it can take, including its + // success branches — so if the replay guard ran only after verification + // (as it used to, further below), a replayed announce would overwrite + // the peer's badge with the retired key's fingerprint/status before + // being rejected, even though _peerPublicKeys itself was never touched. + // This check is synchronous (no await), so it introduces no new window + // for a session to be superseded before it runs. + if (this.isRetiredPeerKey(userId, publicKeyBase64)) { + log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId }); + return; + } try { // ── F3 TOFU verification gate ────────────────────────────────────── // Resolve the peer's identity key and verify the announce signature @@ -768,29 +812,14 @@ export class E2EEManager { isDuplicate = true; log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId }); } else { - // Reject a replay of a key we've already retired for this peer. The - // signed announce message carries no channel/epoch/nonce (F3), so an - // old, validly-signed announce replays cleanly — without this check a - // malicious relay could re-emit a recorded announce and swap the live - // key back to one nobody holds anymore, silently blackholing the peer - // (OC-0011). A genuine peer never reuses an ephemeral key across - // sessions (freshly generated every join), so this never rejects a - // legitimate re-announce. - if (this.isRetiredPeerKey(userId, publicKeyBase64)) { - log.error("E2EE: rejecting replayed peer key announce (previously retired)", { - userId, - }); - return; - } + // The replay-of-a-retired-key check now runs up front (OC-0209), + // before verifyPeerAnnounce — see the comment there (was + // previously duplicated in both branches here). this.retirePeerKey(userId, existingB64); peerKey = await importPublicKey(publicKeyBase64); log.warn("E2EE: peer public key changed (reconnect?)", { userId }); } } else { - if (this.isRetiredPeerKey(userId, publicKeyBase64)) { - log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId }); - return; - } peerKey = await importPublicKey(publicKeyBase64); } // Re-check after the export/import awaits above: a clearState()+rejoin @@ -1200,6 +1229,11 @@ export class E2EEManager { this._peerPublicKeys.delete(userId); this._peerOfferEpochs.delete(userId); clearPeerVerification(userId); + + const channelId = this._channelId ?? this.deps.getCurrentChannelId(); + const state = voiceStore.getState(); + const channelUsers = channelId ? state.voiceUsers.get(channelId) : undefined; + // Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only // defense against replay of a validly-signed announce (the signed // message carries no channel/epoch/nonce, F3) and handleAnnounceInner @@ -1210,15 +1244,26 @@ export class E2EEManager { // whose private half no longer exists (blackholing them). A genuine // rejoin always mints a fresh ECDH pair (setupKeyExchange, // reannounceForReconnect), so this never rejects a legitimate re-announce. - if (departingKey) { + // + // BUT: voice_leave travels through the buffered hub broadcast queue while + // voice_e2ee_announce is published straight into the recipient's send + // queue from the sender's read-pump (Server/ws/hub_broadcast.go documents + // this as a reordering hazard) — a peer's rejoin announce can overtake + // the stale voice_leave for the join instance it superseded (OC-0213). + // If the local roster (voice_state, kept current by the server) still + // lists this peer as present in the channel, this IS that stale case: + // retiring their (in that case, still-live) key would have every later, + // genuine re-announce of it rejected as a replay, permanently stranding + // a peer who never actually left. Skip retirement in that case — the key + // is still removed from _peerPublicKeys above (and, below, this event + // still correctly excludes them from any resulting rotation) so nothing + // regresses for a genuine departure. + if (departingKey && !channelUsers?.has(userId)) { this.retirePeerKey(userId, await exportPublicKey(departingKey)); } - const channelId = this._channelId ?? this.deps.getCurrentChannelId(); if (!channelId) return; - const state = voiceStore.getState(); - const channelUsers = state.voiceUsers.get(channelId); const myUserId = authStore.getState().user?.id ?? 0; // Elect key holder: lowest user_id among remaining participants. The @@ -1423,6 +1468,7 @@ export class E2EEManager { this._rotationPending = false; this._e2eeEpoch = 0; this._pendingAnnounces.length = 0; + this._blockedAnnounces.clear(); // The server's offer rate limit is scoped per (sender, channel) — a // fresh channel gets a fresh bucket server-side, so stale timestamps // from the old channel must not throttle the new one. diff --git a/Client/tauri-client/src/lib/mentions.ts b/Client/tauri-client/src/lib/mentions.ts index 10647471..5d71939d 100644 --- a/Client/tauri-client/src/lib/mentions.ts +++ b/Client/tauri-client/src/lib/mentions.ts @@ -74,6 +74,12 @@ export function resolveMentionUserId(token: string, info?: MentionInfo): number const member = members.get(id); if (member !== undefined && matches(member.username)) return id; } + // The server is authoritative once it has spoken: a token it did not list + // must not be resolved locally either, or the row-level gate (which trusts + // info.mentions outright) and this token-level pill disagree on the same + // message. Only fall back to the member-list/self scan when the server + // sent no list at all (predates mentions, or a purely local render). + if (info?.mentions !== undefined) return null; for (const member of members.values()) { if (matches(member.username)) return member.id; } diff --git a/Client/tauri-client/src/lib/notifications.ts b/Client/tauri-client/src/lib/notifications.ts index 6b9debc5..7fdd001d 100644 --- a/Client/tauri-client/src/lib/notifications.ts +++ b/Client/tauri-client/src/lib/notifications.ts @@ -9,9 +9,12 @@ import { loadUserStatus } from "./userStatus"; import { authStore } from "@stores/auth.store"; import { channelsStore } from "@stores/channels.store"; import { dmStore, dmDisplayName } from "@stores/dm.store"; +import { isWindowDetached } from "@stores/messages.store"; import type { ChatMessagePayload } from "./types"; import { mentionsCurrentUser } from "./mentions"; import { createLogger } from "./logger"; +import { resolveAuthor } from "@components/message-list/formatting"; +import { resolveDisplayName } from "@lib/avatar"; const log = createLogger("notifications"); @@ -51,9 +54,22 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { // Don't notify for own messages if (currentUser !== null && payload.user.id === currentUser.id) return; - // Don't notify if the window is focused AND the message is in the active channel + // Don't notify if the window is focused AND the message is in the active + // channel — UNLESS that channel is showing a detached around-window + // (OC-0204). "Active" only means this is the channel on screen; a jump to + // an old permalink/reply/search hit can leave it detached from the live + // tail (messages.store's detachedChannels), in which case the user is + // reading back-history and cannot see the new message at all — addMessage + // silently refuses to append it. Without this check that combination + // suppresses the one thing that would have told the user anything arrived. const activeChannelId = channelsStore.getState().activeChannelId; - if (isWindowFocused() && payload.channel_id === activeChannelId) return; + if ( + isWindowFocused() && + payload.channel_id === activeChannelId && + !isWindowDetached(payload.channel_id) + ) { + return; + } const mentionInfo = { mentions: payload.mentions, @@ -87,6 +103,13 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { const { name: channelName, isDm } = resolveNotificationChannel(payload.channel_id); const channelLabel = isDm ? channelName : `#${channelName}`; + // The name to show for the author, resolved the same way the message list + // resolves it (resolveAuthor prefers the live membersStore nickname over + // whatever was frozen into the payload; resolveDisplayName falls back to + // the username when no nickname is set). Without this the notification + // names the sender differently from the message row it points at. + const authorName = resolveDisplayName(resolveAuthor(payload.user)); + // oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability function sanitizeNotif(s: string, maxLen: number): string { // eslint-disable-next-line no-control-regex -- intentional: strip control chars from user-provided strings @@ -96,8 +119,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { const title = sanitizeNotif( mentioned - ? `${payload.user.username} mentioned you in ${channelLabel}` - : `${payload.user.username} in ${channelLabel}`, + ? `${authorName} mentioned you in ${channelLabel}` + : `${authorName} in ${channelLabel}`, 80, ); const body = sanitizeNotif(payload.content, 100); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index b1d2c5ec..3d9f1348 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -25,6 +25,7 @@ import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle"; import { channelsStore, getActiveChannel } from "@stores/channels.store"; import { dmStore, dmDisplayName } from "@stores/dm.store"; import { voiceStore } from "@stores/voice.store"; +import { membersStore, memberDisplayName } from "@stores/members.store"; import { clearCustomEmoji } from "@stores/emoji.store"; import { cleanupAll as voiceCleanupAll, @@ -621,12 +622,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // The ringer hanging up before anyone answered: their voice_leave is the // only signal there is that the call is over, because there is no call // record to close. Ringing for a room with nobody in it is worse than a - // missed call, so a leave stops the ring for that channel. + // missed call, so a leave stops the ring for that channel — but only + // when the ringer leaving actually emptied it. A group DM can still hold + // other callees who already accepted (voiceStore.voiceUsers answers + // that), and the ringer hanging up must not silence a call that is + // still live for them (OC-0235). unsubscribers.push( ws.on("voice_leave", (payload) => { const ringing = ringCtrl?.current(); if (ringing === null || ringing === undefined) return; - if (payload.user_id === ringing.fromUserId) { + if (payload.user_id !== ringing.fromUserId) return; + const roster = voiceStore.getState().voiceUsers.get(payload.channel_id); + const othersStillIn = + roster !== undefined && [...roster.keys()].some((id) => id !== payload.user_id); + if (!othersStillIn) { ringCtrl?.cancel(payload.channel_id); } }), @@ -673,20 +682,34 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Wire voice error callback to toast setVoiceOnError((msg) => showToast(msg, "error")); + // The label shown on a remote video tile: memberDisplayName when known — + // the same identity a rename shows everywhere else (ChannelSidebar's + // voice roster, message rows, the member list) — falling back to the + // voice roster's (possibly frozen) username, then a placeholder. Single + // writer so tile creation (setOnRemoteVideo below) and tile relabeling + // on a mid-call rename (the voiceStore subscriber below) cannot disagree + // (OC-0227). + function remoteTileLabel(userId: number, isScreenshare: boolean): string { + const voice = voiceStore.getState(); + const channelId = voice.currentChannelId; + const channelUsers = channelId !== null ? voice.voiceUsers.get(channelId) : undefined; + const voiceUser = channelUsers?.get(userId); + const member = membersStore.getState().members.get(userId); + const name = (member !== undefined ? memberDisplayName(member) : "") || voiceUser?.username; + if (name === undefined || name === "") { + return isScreenshare ? `User ${userId} (Screen)` : `User ${userId}`; + } + return isScreenshare ? `${name} (Screen)` : name; + } + // Wire remote video callbacks to video grid setOnRemoteVideo((userId, stream, isScreenshare) => { if (videoGrid === null) return; const voice = voiceStore.getState(); const channelId = voice.currentChannelId; if (channelId === null) return; - const channelUsers = voice.voiceUsers.get(channelId); - const user = channelUsers?.get(userId); const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId; - const username = isScreenshare - ? user?.username - ? `${user.username} (Screen)` - : `User ${userId} (Screen)` - : (user?.username ?? `User ${userId}`); + const username = remoteTileLabel(userId, isScreenshare); videoGrid.addStream(tileId, username, stream, { isSelf: false, audioUserId: userId, @@ -701,20 +724,51 @@ export function createMainPage(options: MainPageOptions): MountableComponent { }); unsubscribers.push(() => clearOnRemoteVideo()); - // Subscribe to voice store for camera/screenshare state changes only (not speaking ticks) + // Subscribe to voice store for camera/screenshare state changes, voice + // channel switches, and remote-tile identity changes (not speaking ticks) let prevVideoSignature = ""; + const prevTileLabels = new Map(); unsubscribers.push( voiceStore.subscribe((state) => { try { - // Build a lightweight signature of video-relevant state (camera + screenshare) - let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : ""); const channelId = state.currentChannelId; + // Seed the signature with the channel id so ANY voice-channel + // switch changes it, even one where the camera/screenshare flags + // happen to be identical on both sides (e.g. both channels empty). + // Without this, VideoModeController.checkVideoMode() — the only + // writer of its own lastChannelId — never runs for that switch, + // so lastChannelId is still the old channel the next time it runs + // (e.g. right after setOnRemoteVideo adds a fresh remote tile), + // and it clears the grid it was just given (OC-0207). + let sig = + `${String(channelId)}|` + + (state.localCamera ? "c" : "") + + (state.localScreenshare ? "s" : ""); if (channelId !== null) { const users = state.voiceUsers.get(channelId); if (users) { for (const [uid, u] of users) { if (u.camera) sig += `:c${uid}`; if (u.screenshare) sig += `:s${uid}`; + // Relabel an already-open remote tile whose display name + // changed (mid-call rename) — addStream only runs once per + // tile, so nothing else keeps its label in sync (OC-0227). + // setLabel() no-ops for a tile that isn't open yet. + if (u.camera) { + const label = remoteTileLabel(uid, false); + if (prevTileLabels.get(uid) !== label) { + prevTileLabels.set(uid, label); + videoGrid?.setLabel(uid, label); + } + } + if (u.screenshare) { + const tileId = uid + SCREENSHARE_TILE_ID_OFFSET; + const label = remoteTileLabel(uid, true); + if (prevTileLabels.get(tileId) !== label) { + prevTileLabels.set(tileId, label); + videoGrid?.setLabel(tileId, label); + } + } } } } diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 28284b6d..4413d999 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -223,6 +223,13 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // onJumpToPresent — reattach clears "loaded" so the tail is refetched. if (isWindowDetached(channelId)) { reattachToPresent(channelId); + // OC-0204: while detached, this (already-active) channel could have + // picked up an unread/mention badge for messages that arrived below + // the gap (dispatcher.ts's evenIfActive path) — nothing else clears + // it, since incrementUnread's usual "active channel" skip is exactly + // what a detached window opts out of. Jumping to present is reading + // it, so mark it read the same way leaving a channel does. + markChannelRead(channelId); if (channelAbort !== null) { void msgCtrl.loadMessages(channelId, channelAbort.signal); } @@ -288,6 +295,11 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // Dropping the detached flag also clears "loaded", so loadMessages // refetches the live tail instead of short-circuiting. reattachToPresent(channelId); + // OC-0204: see performSend's identical call above — a detached + // active channel's badge (from dispatcher.ts's evenIfActive path) + // must be cleared here too, or it lingers after the user has jumped + // back to present and is looking straight at the live tail. + markChannelRead(channelId); if (channelAbort !== null) { void msgCtrl.loadMessages(channelId, channelAbort.signal); } diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index 6ee2e8cb..96ae8c8e 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -4,6 +4,7 @@ */ import { voiceStore } from "@stores/voice.store"; +import { membersStore, memberDisplayName } from "@stores/members.store"; import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession"; import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants"; import type { VideoGridComponent } from "@components/VideoGrid"; @@ -170,17 +171,21 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid // Manage local self-view tile — only add once, skip if already showing const currentUserId = getCurrentUserId(); + // Prefer the member's display name (same identity every other surface + // shows for a rename — see ChannelSidebar.ts's voice roster) over the + // frozen voice-roster username (OC-0227). + const member = membersStore.getState().members.get(currentUserId); + const me = channelUsers.get(currentUserId); + const myName = (member !== undefined ? memberDisplayName(member) : "") || me?.username; if (voice.localCamera) { if (!localTileAdded) { const localStream = getLocalCameraStream(); if (localStream !== null) { - const me = channelUsers.get(currentUserId); - videoGrid.addStream( - currentUserId, - me?.username ? `${me.username} (You)` : "You", - localStream, - { isSelf: true, audioUserId: currentUserId, isScreenshare: false }, - ); + videoGrid.addStream(currentUserId, myName ? `${myName} (You)` : "You", localStream, { + isSelf: true, + audioUserId: currentUserId, + isScreenshare: false, + }); localTileAdded = true; } } @@ -195,10 +200,9 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid if (!localScreenshareTileAdded) { const localStream = getLocalScreenshareStream(); if (localStream !== null) { - const me = channelUsers.get(currentUserId); videoGrid.addStream( screenshareUserId, - me?.username ? `${me.username} (Screen)` : "Your Screen", + myName ? `${myName} (Screen)` : "Your Screen", localStream, { isSelf: true, audioUserId: currentUserId, isScreenshare: true }, ); diff --git a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts index b0813807..86e1c667 100644 --- a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts +++ b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts @@ -77,7 +77,10 @@ export function createVoiceWidgetCallbacks( if (state.localMuted) { voiceSessionSetMuted(false); ws.send({ type: "voice_mute", payload: { muted: false } }); - if (state.localDeafened) { + // A moderator-imposed deafen is not ours to lift; the server refuses + // the undeafen, so don't spend the round-trip (same guard as + // onDeafenToggle's localServerMuted check below). + if (state.localDeafened && state.localServerDeafened !== true) { voiceSessionSetDeafened(false); ws.send({ type: "voice_deafen", payload: { deafened: false } }); } diff --git a/Client/tauri-client/src/stores/auth.store.ts b/Client/tauri-client/src/stores/auth.store.ts index ac865f3b..6911094c 100644 --- a/Client/tauri-client/src/stores/auth.store.ts +++ b/Client/tauri-client/src/stores/auth.store.ts @@ -9,6 +9,7 @@ import { resetVoiceStore, voiceStore } from "@stores/voice.store"; import { resetMessagesStore } from "@stores/messages.store"; import { resetChannelsStore } from "@stores/channels.store"; import { resetBlocksStore } from "@stores/blocks.store"; +import { setSidebarMode } from "@stores/ui.store"; import { cleanupNotificationAudio } from "@lib/notifications"; import { clearNsfwAcknowledgements } from "@lib/nsfw-gate"; import { createLogger } from "@lib/logger"; @@ -76,7 +77,12 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m * Also clears blocksStore: block state is keyed by user id, which (like * channel/message ids) is only unique per-server — otherwise a previous * server's blocked-user ids would gate DM composers on the next server - * until the next successful GET /blocks refetch. */ + * until the next successful GET /blocks refetch. Also resets uiStore's + * sidebarMode (and, via setSidebarMode, activeDmUserId): unlike every other + * domain store, nothing in the `ready` payload restates sidebarMode, so a + * "dms" mode left over from the previous session would otherwise survive + * logout as module-global state and mount the DM sidebar (with the old + * server's DM peer id) on whatever server is signed into next. */ export function clearAuth(reason: LogoutReason = "user"): void { // livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded // lazily so it stays out of the startup path. Only import it when there is @@ -97,6 +103,7 @@ export function clearAuth(reason: LogoutReason = "user"): void { resetMessagesStore(); resetChannelsStore(); resetBlocksStore(); + setSidebarMode("channels"); // NSFW acknowledgements are per-viewer consent, not per-device: without this // the next account signed into the same server inherits the previous user's // acks and the age gate silently never appears for them. Host-scoping the diff --git a/Client/tauri-client/src/stores/blocks.store.ts b/Client/tauri-client/src/stores/blocks.store.ts index fec91125..ac12a302 100644 --- a/Client/tauri-client/src/stores/blocks.store.ts +++ b/Client/tauri-client/src/stores/blocks.store.ts @@ -21,18 +21,43 @@ export const BLOCKED_BY_THEM_REASON = "You can't message this user right now."; export interface BlocksState { readonly blockedByMe: ReadonlySet; readonly blockedByThem: ReadonlySet; + /** + * Bumped by every accepted setUserBlockedByMe delta (OC-0218). Optional — + * absent/undefined reads as revision 0 — so state literals that predate + * this field (tests, a full setState replace) do not need updating. + * + * Lets a ready-time GET /blocks snapshot the revision it observed just + * before issuing the request and pass it back to setBlockedByMe: if a + * setUserBlockedByMe delta landed (bumping the revision) while that fetch + * was in flight, the fetch's reply is answering a question that is no + * longer current and must not clobber the fresher local truth. + */ + readonly blockedByMeRev?: number; } const INITIAL: BlocksState = { blockedByMe: new Set(), blockedByThem: new Set(), + blockedByMeRev: 0, }; export const blocksStore = createStore(INITIAL); -/** Replace the blocked-by-me set (from GET /blocks). */ -export function setBlockedByMe(userIds: readonly number[]): void { - blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) })); +/** + * Replace the blocked-by-me set (from GET /blocks). + * + * `rev`, when given, must match the store's current blockedByMeRev — the + * revision the caller observed right before starting the fetch this reply + * answers (OC-0218). A mismatch means a fresher setUserBlockedByMe delta + * landed after the fetch was issued, so this reply is stale and is skipped + * rather than reverting that delta. Omit `rev` to always apply (existing + * direct callers, tests). + */ +export function setBlockedByMe(userIds: readonly number[], rev?: number): void { + blocksStore.setState((prev) => { + if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev; + return { ...prev, blockedByMe: new Set(userIds) }; + }); } /** Mark (or unmark) a user as blocked by the local user (after PUT/DELETE /blocks). */ @@ -42,7 +67,7 @@ export function setUserBlockedByMe(userId: number, blocked: boolean): void { const next = new Set(prev.blockedByMe); if (blocked) next.add(userId); else next.delete(userId); - return { ...prev, blockedByMe: next }; + return { ...prev, blockedByMe: next, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 }; }); } diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts index f8b9b536..d9001da7 100644 --- a/Client/tauri-client/src/stores/channels.store.ts +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -333,10 +333,19 @@ export function getChannelsByCategory(): Map { }); } -/** Increment unread count for a channel, unless it is the active channel. */ -export function incrementUnread(channelId: number): void { +/** + * Increment unread count for a channel, unless it is the active channel. + * + * `evenIfActive` (OC-0204) opts out of that skip: "active" normally means + * "the user is watching the live tail" — the reason a badge would be + * redundant there — but the active channel's loaded window can be detached + * from the live tail (a jump to an old permalink/reply/search hit), in which + * case the message is genuinely unseen and must still count. Callers own + * deciding when that applies; this still always skips an unknown channel id. + */ +export function incrementUnread(channelId: number, evenIfActive = false): void { channelsStore.setState((prev) => { - if (prev.activeChannelId === channelId) { + if (prev.activeChannelId === channelId && !evenIfActive) { return prev; } const existing = prev.channels.get(channelId); @@ -357,10 +366,12 @@ export function incrementUnread(channelId: number): void { * Increment the mention count for a channel, unless it is the active channel. * Callers also call incrementUnread — a mention is always an unread too, and * the two counters are kept independent so the badge can outrank. + * + * `evenIfActive` mirrors incrementUnread's escape hatch — see its doc for why. */ -export function incrementMention(channelId: number): void { +export function incrementMention(channelId: number, evenIfActive = false): void { channelsStore.setState((prev) => { - if (prev.activeChannelId === channelId) { + if (prev.activeChannelId === channelId && !evenIfActive) { return prev; } const existing = prev.channels.get(channelId); diff --git a/Client/tauri-client/src/stores/dm.store.ts b/Client/tauri-client/src/stores/dm.store.ts index aa7e2633..0a4ec58c 100644 --- a/Client/tauri-client/src/stores/dm.store.ts +++ b/Client/tauri-client/src/stores/dm.store.ts @@ -139,7 +139,10 @@ export function updateDmLastMessage( lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp, - unreadCount: updated.unreadCount + 1, + unreadCount: + updated.lastMessageId !== null && messageId <= updated.lastMessageId + ? updated.unreadCount + : updated.unreadCount + 1, }, ...rest, ], @@ -194,7 +197,13 @@ export function clearDmUnread(channelId: number): void { export function dmDisplayName(dm: DmChannel): string { if (dm.name !== "") return dm.name; const names = dm.participants.map((p) => (p.displayName ?? "") || p.username); - if (names.length === 0) return dm.recipient.username; + if (names.length === 0) { + return dm.recipient.username !== "" + ? dm.recipient.username + : dm.isGroup + ? "Empty group" + : "Unknown user"; + } if (!dm.isGroup) return names[0]!; if (names.length <= 3) return names.join(", "); return `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`; diff --git a/Client/tauri-client/tests/e2e/helpers.ts b/Client/tauri-client/tests/e2e/helpers.ts index 0e8122f6..42fe1a7b 100644 --- a/Client/tauri-client/tests/e2e/helpers.ts +++ b/Client/tauri-client/tests/e2e/helpers.ts @@ -688,10 +688,21 @@ export function buildTauriMockScript(opts: { ${ opts.identityPinError === true ? `throw new Error("keyring unavailable (mock)");` - : `return ${JSON.stringify(opts.identityPins ?? {})}[String(args?.userId)] ?? null;` + : `window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})}; + return window.__mockIdentityPins[String(args?.userId)] ?? null;` } } - if (cmd === "store_identity_pin") return null; + // A pin write must be visible to the next read, exactly as the real + // keyring is: re-pinning a mismatched peer replays the announce that + // was blocked and re-verifies it against the pin just stored + // (OC-0212). A no-op store would serve the stale pin straight back, + // so the replay would re-fail and the mock would report a permanent + // mismatch the real keyring never produces. + if (cmd === "store_identity_pin") { + window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})}; + window.__mockIdentityPins[String(args?.userId)] = args?.pin ?? null; + return null; + } // ---- Window/webview plugin stubs ---- if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null; diff --git a/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts index 7b050a0f..6c851183 100644 --- a/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts @@ -321,9 +321,15 @@ test.describe("Voice E2EE identity verification (§7)", () => { await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden(); // The EXACT displayed key was pinned (TOCTOU-safe re-pin), and the - // mismatch block cleared — the badge disappears until the next announce - // re-verifies against the new pin. - await expect(badge).toHaveCount(0); + // mismatch block cleared. Re-pinning replays the announce that was + // blocked as a mismatch (OC-0212), which re-verifies against the pin just + // stored — so the peer lands in the verified state rather than losing its + // badge entirely. The badge must not simply disappear: a mid-call peer + // never re-announces on its own, so an empty badge would mean the peer + // stayed un-keyed for the rest of the call while the UI showed nothing. + await expect(badge).toBeVisible(); + await expect(badge).toHaveClass(/verified/); + await expect(badge).toHaveAttribute("title", /^Identity verified · Safety number: /); const pins = await invokesOf(page, "store_identity_pin"); expect(pins).toHaveLength(1); expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 }); diff --git a/Client/tauri-client/tests/unit/AccessibilityTab.test.ts b/Client/tauri-client/tests/unit/AccessibilityTab.test.ts new file mode 100644 index 00000000..da1e5189 --- /dev/null +++ b/Client/tauri-client/tests/unit/AccessibilityTab.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { buildAccessibilityTab } from "../../src/components/settings/AccessibilityTab"; + +/** + * OC-0232: "Reduce Motion" and "Sync with OS" are two independent writers of + * the `.reduced-motion` class with no arbitration. A manual toggle click + * writes the class directly, ignoring whether OS sync currently owns it — + * so turning "Reduce Motion" OFF while "Sync with OS" is ON and the OS still + * asks for reduced motion silently disables reduced motion app-wide. + */ +describe("AccessibilityTab — reduced-motion arbitration (OC-0232)", () => { + let container: HTMLDivElement; + let controller: AbortController; + let matchMediaListeners: Map; + const matchMediaMatches = true; // OS prefers reduced motion, for the whole test + + function findToggle(label: string): HTMLElement { + const rows = container.querySelectorAll(".setting-row"); + for (const row of Array.from(rows)) { + const labelEl = row.querySelector(".setting-label"); + if (labelEl?.textContent === label) { + const toggle = row.querySelector('[role="switch"]'); + if (toggle === null) { + throw new Error(`toggle not found for label "${label}"`); + } + return toggle as HTMLElement; + } + } + throw new Error(`row not found for label "${label}"`); + } + + beforeEach(() => { + matchMediaListeners = new Map(); + + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => { + const mql = { + matches: matchMediaMatches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((type: string, handler: Function) => { + matchMediaListeners.set(type, handler); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + } as unknown as MediaQueryList; + return mql; + }); + + localStorage.clear(); + document.documentElement.classList.remove("reduced-motion"); + + controller = new AbortController(); + container = buildAccessibilityTab(controller.signal); + document.body.appendChild(container); + }); + + afterEach(() => { + controller.abort(); + container.remove(); + document.documentElement.classList.remove("reduced-motion"); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("keeps reduced-motion applied when OS sync is on and the OS still prefers it, even after a manual Reduce Motion toggle is switched off", () => { + const syncToggle = findToggle("Sync with OS"); + const motionToggle = findToggle("Reduce Motion"); + + // Turn on "Sync with OS": the OS prefers reduced motion, so the class + // should be applied by the media-query-driven listener. + syncToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(document.documentElement.classList.contains("reduced-motion")).toBe(true); + + // Manually flip "Reduce Motion" on, then off. "Sync with OS" is still + // on and the (mocked) OS still prefers reduced motion throughout, so + // the effective state must never change out from under it. + motionToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); // -> on + expect(document.documentElement.classList.contains("reduced-motion")).toBe(true); + + motionToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); // -> off + expect(document.documentElement.classList.contains("reduced-motion")).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/accessibility-tab.test.ts b/Client/tauri-client/tests/unit/accessibility-tab.test.ts index 49802ec2..6d909615 100644 --- a/Client/tauri-client/tests/unit/accessibility-tab.test.ts +++ b/Client/tauri-client/tests/unit/accessibility-tab.test.ts @@ -262,15 +262,20 @@ describe("AccessibilityTab", () => { // ----------------------------------------------------------------------- describe("side effects", () => { - it("toggles reduced-motion class on documentElement for reducedMotion", () => { + it("routes reducedMotion through syncOsMotionListener rather than writing the class directly (OC-0232)", () => { + // os-motion.ts is the single writer of `.reduced-motion`; the Reduce + // Motion toggle must delegate to it (passing the current syncOsMotion + // pref) instead of touching documentElement itself, so a manual toggle + // can no longer fight the OS-sync listener. With os-motion mocked here, + // the real class-application behaviour is covered in AccessibilityTab.test.ts. const section = buildAccessibilityTab(ac.signal); container.appendChild(section); clickToggle(container, 0); - expect(document.documentElement.classList.contains("reduced-motion")).toBe(true); + expect(mockSyncOsMotionListener).toHaveBeenLastCalledWith(false); clickToggle(container, 0); - expect(document.documentElement.classList.contains("reduced-motion")).toBe(false); + expect(mockSyncOsMotionListener).toHaveBeenLastCalledWith(false); }); it("toggles high-contrast class on documentElement for highContrast", () => { diff --git a/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts new file mode 100644 index 00000000..66932e3a --- /dev/null +++ b/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts @@ -0,0 +1,153 @@ +// OC-0231: stopVadPolling() must detach the worklet's MessagePort handler so +// a "gate" message the worklet posts *after* stop() has already been sent +// (but before the audio thread has processed it) cannot re-gate the mic with +// no VAD left running to ever un-gate it again. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), + mockSavePref: vi.fn(), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: (key: string, val: unknown) => mockSavePref(key, val), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/noise-suppression", () => ({ + createRNNoiseProcessor: vi.fn(), +})); + +vi.mock("livekit-client", () => ({ + Track: { + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, + }, +})); + +import { AudioPipeline } from "../../src/lib/audioPipeline"; + +describe("AudioPipeline VAD worklet teardown (OC-0231)", () => { + let pipeline: AudioPipeline; + let mockGainNode: any; + let mockAnalyserNode: any; + let mockRoom: any; + + beforeEach(() => { + vi.clearAllMocks(); + pipeline = new AudioPipeline(); + + mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + mockAnalyserNode = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + const mockDestNode = { + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) }, + disconnect: vi.fn(), + }; + const mockSourceNode = { connect: vi.fn() }; + const mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode), + createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockResolvedValue(undefined) }, + }; + + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => ({ + port: { + postMessage: vi.fn(), + onmessage: null as ((event: MessageEvent) => void) | null, + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + ); + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), + }, + }), + }, + }; + + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "voiceSensitivity") return 50; + if (key === "inputVolume") return 100; + return defaultVal; + }); + }); + + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("ignores a late 'gate:true' message delivered after stopVadPolling", async () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; + const workletInstance = WorkletNodeConstructor.mock.results[0].value; + + // sensitivity dragged to 100: setVoiceSensitivity(100) stops VAD without + // restarting it (mirrors audioPipeline.ts setVoiceSensitivity clamped>=100 branch) + pipeline.stopVadPolling(); + expect(pipeline.isVadGated).toBe(false); + + // The worklet's audio-thread process() loop was mid-flight when `stop` + // was posted and still emits one more "gate" message before it honors + // `_active = false`. That message arrives on the *same* port object the + // pipeline handed out, after stopVadPolling() already ran. + expect(workletInstance.port.onmessage).toBeNull(); + workletInstance.port.onmessage?.({ data: { type: "gate", gated: true } } as MessageEvent); + + // Must stay ungated — there is no VAD left running to ever undo this. + expect(pipeline.isVadGated).toBe(false); + // And the pipeline gain must not have been driven to 0 by the stale message. + mockGainNode.gain.setTargetAtTime.mockClear(); + }); +}); diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts index 7516a5fc..0bf64ada 100644 --- a/Client/tauri-client/tests/unit/auth.store.test.ts +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -20,6 +20,7 @@ import type { ReadyChannel } from "../../src/lib/types"; import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate"; import { addLogListener, type LogEntry } from "@lib/logger"; import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types"; +import { uiStore, setSidebarMode, setActiveDmUser } from "../../src/stores/ui.store"; // Mock the lazily-imported voice SDK module so we can assert clearAuth() only // pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists. @@ -516,4 +517,24 @@ describe("auth store", () => { expect(channelsStore.getState().channels.has(999)).toBe(false); }); }); + + // Regression: clearAuth() must also reset uiStore's sidebarMode and + // activeDmUserId, or a "dms" sidebar mode (and the previous server's DM + // peer id) survive a logout as module-global state and leak into the next + // signed-into server — SidebarArea.ts reads uiStore.getState().sidebarMode + // on initial mount, so the next server mounts the DM sidebar instead of its + // channel list even though nothing restated sidebarMode for the new session. + describe("clearAuth ui cleanup", () => { + it("resets sidebarMode to 'channels' and clears activeDmUserId on logout", () => { + setSidebarMode("dms"); + setActiveDmUser(7); + expect(uiStore.getState().sidebarMode).toBe("dms"); + expect(uiStore.getState().activeDmUserId).toBe(7); + + clearAuth(); + + expect(uiStore.getState().sidebarMode).toBe("channels"); + expect(uiStore.getState().activeDmUserId).toBeNull(); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/auto-idle.test.ts b/Client/tauri-client/tests/unit/auto-idle.test.ts index 8aff0541..d98ed5e4 100644 --- a/Client/tauri-client/tests/unit/auto-idle.test.ts +++ b/Client/tauri-client/tests/unit/auto-idle.test.ts @@ -198,6 +198,64 @@ describe("startAutoIdle", () => { expect(loadUserStatusOrigin()).toBe("manual"); }); + it("stays armed after a firing that changed nothing, so a later external status change is still watched", () => { + // Regression for OC-0236: the timer callback used to leave `timer` at + // null forever after it fired once. That's invisible while the status + // stays untouched between firings, but a surface that writes + // saveUserStatus() directly instead of going through onActivity — the OS + // tray's Status submenu, which delivers no DOM event into the webview — + // can make the status eligible again (dnd -> online) without ever + // re-arming the watcher. Without re-arming, the user then stays broadcast + // as Online indefinitely. + saveUserStatus("dnd"); + const onStatusChange = vi.fn(); + const target = createTarget(); + controller = startAutoIdle({ onStatusChange, target }); + + // First firing: ineligible (dnd), apply(true) is a no-op. + vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS); + expect(onStatusChange).not.toHaveBeenCalled(); + + // The tray writes the status directly — no DOM event, so onActivity/arm() + // never runs on this path. + saveUserStatus("online", "manual"); + + // A further full delay of continued inactivity should now flip to idle, + // exactly as it would have if "online" had been the status from the + // start. That requires the timer to still be armed. + vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS); + expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("idle"); + expect(loadUserStatus()).toBe("idle"); + expect(loadUserStatusOrigin()).toBe("auto"); + }); + + it("leaves no pending timer when destroy() is called synchronously from onStatusChange", () => { + // The re-arm added for OC-0236 runs after apply(true), which invokes + // onStatusChange synchronously. If that callback tears the page down and + // calls destroy() from inside it, `timer` is already null at that point + // (cleared before apply() ran), so destroy()'s own clearTimeout is a + // no-op. Without re-checking `destroyed` before the re-arm, destroy() + // would appear to work (no wrong status change ever fires, since the + // handler's own top-of-body check still catches it) while actually + // leaking a dangling timer that outlives the controller. + saveUserStatus("online"); + const target = createTarget(); + const onStatusChange = vi.fn(() => { + controller?.destroy(); + controller = null; + }); + // Baseline first: the environment (jsdom/vitest) may hold timers of its + // own that have nothing to do with this controller, so assert against a + // delta rather than an absolute count of 0. + const before = vi.getTimerCount(); + controller = startAutoIdle({ onStatusChange, target }); + expect(vi.getTimerCount()).toBe(before + 1); + + vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS); + expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("idle"); + expect(vi.getTimerCount()).toBe(before); + }); + it("stops firing after destroy", () => { saveUserStatus("online"); const onStatusChange = vi.fn(); diff --git a/Client/tauri-client/tests/unit/blocks-store.test.ts b/Client/tauri-client/tests/unit/blocks-store.test.ts index 4fd32e66..f1ee9977 100644 --- a/Client/tauri-client/tests/unit/blocks-store.test.ts +++ b/Client/tauri-client/tests/unit/blocks-store.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { blocksStore, setBlockedByMe, + setUserBlockedByMe, setUserBlockedByThem, clearBlockedByThem, dmComposerBlockReason, @@ -80,4 +81,46 @@ describe("blocksStore", () => { expect(blocksStore.getState()).toBe(before); }); }); + + // OC-0218: a ready-time GET /blocks and a user-initiated block/unblock can + // race. The GET is issued before the user's own action but its reply can + // land after — a stale full-set reply must not clobber a fresher per-user + // delta. + describe("setBlockedByMe staleness guard (OC-0218)", () => { + it("applies when no revision is given (direct/legacy caller)", () => { + setBlockedByMe([5]); + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON); + }); + + it("a reply carrying the revision observed before a fresher local delta must not re-add it", () => { + // Local user 42 starts blocked (seeded, as if from a previous ready). + setBlockedByMe([42]); + // A reconnect fires a fresh GET /blocks — the caller snapshots the + // revision it observed right before issuing the request. Real callers + // (dispatcher.ts) default the optional field to 0, exactly like + // setBlockedByMe's own internal comparison does. + const revBeforeFetch = blocksStore.getState().blockedByMeRev ?? 0; + + // While that GET is in flight, the user clicks "Unblock" — this is the + // fresher, authoritative local truth. + setUserBlockedByMe(42, false); + expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull(); + + // The GET's reply lands late, still carrying the stale pre-unblock + // snapshot and the revision observed before the unblock. It must be + // ignored, not re-add 42. + setBlockedByMe([42], revBeforeFetch); + + expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull(); + }); + + it("a reply carrying the current revision still applies", () => { + setBlockedByMe([1]); + const rev = blocksStore.getState().blockedByMeRev; + // No local delta happened since — the snapshot is still current. + setBlockedByMe([1, 2], rev); + expect(dmComposerBlockReason(blocksStore.getState(), 1)).toBe(BLOCKED_BY_ME_REASON); + expect(dmComposerBlockReason(blocksStore.getState(), 2)).toBe(BLOCKED_BY_ME_REASON); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 257634f7..d6734b6b 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -518,6 +518,7 @@ describe("createChannelController", () => { // After a jump into history the composer stays enabled; sending must // land the optimistic row in the live tail, not mid-history. mockIsWindowDetached.mockReturnValueOnce(true); + mockMarkChannelRead.mockClear(); const opts = makeOpts(); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -530,6 +531,10 @@ describe("createChannelController", () => { expect(opts.msgCtrl.loadMessages).toHaveBeenCalledWith(42, expect.any(AbortSignal)); // The send itself still goes out. expect(opts.ws.send).toHaveBeenCalledWith(expect.objectContaining({ type: "chat_send" })); + // OC-0204: a detached-but-active channel can carry an unread/mention + // badge dispatcher.ts left behind for messages missed below the gap — + // jumping to present (which sending here implies) must clear it. + expect(mockMarkChannelRead).toHaveBeenCalledWith(42); }); it("onSend while disconnected records a failed optimistic row (no silent drop)", () => { @@ -582,6 +587,7 @@ describe("createChannelController", () => { }); it("onJumpToPresent reattaches the channel and refetches the live tail", () => { + mockMarkChannelRead.mockClear(); const opts = makeOpts(); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -596,6 +602,10 @@ describe("createChannelController", () => { expect(mockReattachToPresent.mock.invocationCallOrder[0]).toBeLessThan( (opts.msgCtrl.loadMessages as ReturnType).mock.invocationCallOrder[0]!, ); + // OC-0204: clicking "Jump to Present" is reading whatever arrived + // below the gap — clear the badge it may have left, the same way + // leaving a channel does. + expect(mockMarkChannelRead).toHaveBeenCalledWith(42); }); it("onRetry re-sends the failed draft with a fresh correlation id", () => { diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index c5b91e32..d0487894 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -2001,6 +2001,23 @@ describe("ChannelSidebar voice identity badge", () => { expect(title).toContain("not an identity"); }); + it("refreshes the badge tooltip when a peer's session fingerprint changes at an unchanged status (OC-0208)", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "unverified", null, "5E55 1234 5678 9ABC"); + sidebar.mount(container); + + expect(badgeFor(10)!.getAttribute("title") ?? "").toContain("5E55 1234 5678 9ABC"); + + // Peer reconnects: LiveKit E2EE re-announces a fresh ephemeral keypair, + // producing a new session fingerprint while `status` stays "unverified". + setPeerVerif(10, "unverified", null, "9C71 8888 4444 2222"); + voiceStore.flush(); + + const title = badgeFor(10)!.getAttribute("title") ?? ""; + expect(title).toContain("9C71 8888 4444 2222"); + expect(title).not.toContain("5E55 1234 5678 9ABC"); + }); + it("shows the local user's own session fingerprint on their voice row", () => { authStore.setState((prev) => ({ ...prev, @@ -2426,3 +2443,75 @@ describe("ChannelSidebar channel context menu permissions", () => { expect(menu?.querySelector('[data-testid="ctx-edit-channel"]')).not.toBeNull(); }); }); + +// ── Per-row listeners must not outlive the render that created them (OC-0229) ── +// +// renderChannels() does clearChildren(channelList) and rebuilds every row from +// scratch on every channels-store notification (a new unread count, a new +// active channel, a role change, ...). Each row's listeners (context menu, +// drag handlers, ...) used to be registered on the sidebar's single +// factory-lifetime AbortSignal, which only aborts once, in destroy(). That +// signal's "abort" algorithm list is what actually keeps a DOM node alive in +// a browser once addEventListener({ signal }) has been called on it, so a +// detached row whose listener is still registered on that signal is retained +// for the sidebar's entire lifetime instead of being collectable after the +// re-render that replaced it. +// +// This cannot observe GC directly in jsdom, but the retained listener is +// itself observable: a detached row whose "contextmenu" listener is still +// live will still open a context menu when the event fires on it, even +// though the row has not been part of the document since the render that +// superseded it. +describe("ChannelSidebar row listeners across re-renders (OC-0229)", () => { + let container: HTMLDivElement; + let sidebar: ReturnType; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() }); + }); + + afterEach(() => { + sidebar.destroy?.(); + container.remove(); + document.querySelectorAll(".channel-ctx-menu").forEach((el) => el.remove()); + }); + + it("does not leave a stale row's context-menu listener live after a re-render replaces it", () => { + setChannels(testChannels); + sidebar.mount(container); + + const staleRow = container.querySelector('[data-channel-id="1"]') as HTMLElement; + expect(staleRow).not.toBeNull(); + + // Provoke renderChannels() the same way incrementUnread does for every + // message delivered to a non-active channel: a fresh channels Map with + // fresh Channel object references flows through the `s.channels` + // selector, which is not shallow-equal to the previous one. + setChannels(testChannels); + channelsStore.flush(); + + // clearChildren(channelList) detached the old row and a new one replaced it. + const freshRow = container.querySelector('[data-channel-id="1"]') as HTMLElement; + expect(freshRow).not.toBeNull(); + expect(freshRow).not.toBe(staleRow); + expect(staleRow.isConnected).toBe(false); + + // The stale, detached row must not still be able to open a menu -- if it + // does, its listener is still registered (on a signal that only aborts at + // sidebar destroy()), which is the retention this finding is about. + staleRow.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 4 }), + ); + expect(document.querySelector(".channel-ctx-menu")).toBeNull(); + + // The replacement row must still work normally -- the fix must scope the + // listener to the render, not break the context menu outright. + freshRow.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 4 }), + ); + expect(document.querySelector(".channel-ctx-menu")).not.toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/content-markdown.test.ts b/Client/tauri-client/tests/unit/content-markdown.test.ts index e56c86b2..0e8b0834 100644 --- a/Client/tauri-client/tests/unit/content-markdown.test.ts +++ b/Client/tauri-client/tests/unit/content-markdown.test.ts @@ -662,6 +662,17 @@ describe("syntax highlighting", () => { expect(resolveLanguage(null)).toBeNull(); }); + it("rejects Object.prototype property names as fence tags", () => { + expect(resolveLanguage("constructor")).toBeNull(); + expect(resolveLanguage("toString")).toBeNull(); + expect(resolveLanguage("valueOf")).toBeNull(); + expect(resolveLanguage("hasOwnProperty")).toBeNull(); + expect(resolveLanguage("isPrototypeOf")).toBeNull(); + expect(resolveLanguage("propertyIsEnumerable")).toBeNull(); + expect(resolveLanguage("toLocaleString")).toBeNull(); + expect(resolveLanguage("__proto__")).toBeNull(); + }); + it("returns one plain token for an unknown language", () => { expect(highlightCode("anything", null)).toEqual([{ text: "anything", cls: null }]); }); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index cbfab919..d4261076 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -20,7 +20,7 @@ import { import { membersStore } from "../../src/stores/members.store"; import { voiceStore } from "../../src/stores/voice.store"; import { dmStore } from "../../src/stores/dm.store"; -import { blocksStore } from "../../src/stores/blocks.store"; +import { blocksStore, setUserBlockedByMe } from "../../src/stores/blocks.store"; import { emojiStore, setCustomEmoji, @@ -85,6 +85,8 @@ import { leaveVoice as mockLeaveVoice, disableCamera as mockDisableCamera, disableScreenshare as mockDisableScreenshare, + isVoiceConnected as mockIsVoiceConnected, + handleParticipantLeft as mockHandleParticipantLeft, } from "@lib/livekitSession"; import { rollbackPendingVideo as mockRollbackPendingVideo } from "@lib/screenShare"; @@ -430,6 +432,53 @@ describe("WS Dispatcher", () => { expect(ch?.unreadCount).toBe(1); }); + // OC-0204: "active channel" normally means "the user is watching the live + // tail", so skipping the unread bump there is correct — until a jump to an + // old permalink/reply/search hit leaves the SAME active channel showing a + // detached around-window (messages.store's detachedChannels). addMessage + // already refuses to append a live broadcast onto a detached window, so + // without also bumping the badge here, a message arriving while the user + // reads back-history leaves no row AND no badge — nothing records it ever + // arrived. + it("wires chat_message to increment unread for the active channel when its window is detached", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(5, { + id: 5, + name: "general", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: ch, activeChannelId: 5 }; // channel 5 IS active... + }); + // ...but its loaded window is detached from the live tail (viewing + // back-history via a jump). + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([5]) })); + + mock.dispatch("chat_message", { + id: 200, + channel_id: 5, + user: { id: 2, username: "bob", avatar: null }, + content: "ping", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const ch = channelsStore.getState().channels.get(5); + expect(ch?.unreadCount).toBe(1); + }); + describe("chat_message notifications during a reconnect replay burst", () => { // The server writes auth_ok before the replay burst, so by the time // replayed chat_message frames arrive the client is already "connected" @@ -3282,6 +3331,44 @@ describe("WS Dispatcher", () => { expect([...blocksStore.getState().blockedByMe]).toEqual([11, 22]); }); + // OC-0218: the ready-time GET /blocks and a user-initiated block/unblock + // (SidebarMemberSection's onToggleBlock -> setUserBlockedByMe, after its + // own await api.blockUser/unblockUser) can race. The GET is issued first + // but its reply can land after the user's own fresher action — applying it + // unconditionally reverts what the user just did. + it("does not let a slow-to-resolve ready-time listBlocks revert a fresher local unblock", async () => { + cleanup(); // tear down the no-api dispatcher wired in beforeEach + let resolveListBlocks!: (v: { blocked_user_ids: number[] }) => void; + const listBlocks = vi.fn( + () => + new Promise<{ blocked_user_ids: number[] }>((resolve) => { + resolveListBlocks = resolve; + }), + ); + cleanup = wireDispatcher(mock.ws, { listBlocks }); + + // Local user 42 is blocked from a previous session. + blocksStore.setState(() => ({ blockedByMe: new Set([42]), blockedByThem: new Set() })); + + // Reconnect: ready fires the GET, which does not resolve yet. + mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] }); + expect(listBlocks).toHaveBeenCalled(); + + // While it's in flight, the user clicks "Unblock" on 42 — the same + // sequence SidebarMemberSection's onToggleBlock performs once its own + // await api.unblockUser resolves. + setUserBlockedByMe(42, false); + expect([...blocksStore.getState().blockedByMe]).toEqual([]); + + // The GET finally resolves with the stale pre-unblock snapshot. + resolveListBlocks({ blocked_user_ids: [42] }); + await Promise.resolve(); + await Promise.resolve(); + + // The user's unblock must win — 42 must not be silently re-added. + expect([...blocksStore.getState().blockedByMe]).toEqual([]); + }); + it("wires a local transport send failure to mark the pending row failed", () => { uiStore.setState((prev) => ({ ...prev, transientError: null })); @@ -3440,6 +3527,34 @@ describe("WS Dispatcher", () => { expect(dm?.unreadCount).toBe(0); }); + // OC-0204's DM-path sibling: the same "active means watching the live + // tail" assumption governs isDmActive here, and breaks the same way when + // the active DM's loaded window is detached (a jump to an old permalink/ + // search hit inside the conversation). + it("updates DM last message WITH unread when the active DM's window is detached", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([50]) })); + + mock.dispatch("chat_message", { + id: 503, + channel_id: 50, + user: { id: 10, username: "bob", avatar: "" }, + content: "arrived while reading back-history", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const dms = dmStore.getState().channels; + const dm = dms.find((c) => c.channelId === 50); + expect(dm?.lastMessage).toBe("arrived while reading back-history"); + expect(dm?.unreadCount).toBe(1); + }); + it("increments the DM mention badge for an incoming @mention", () => { channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); authStore.setState((prev) => ({ @@ -3982,6 +4097,83 @@ describe("WS Dispatcher", () => { expect(voiceLeaveSent).toBe(false); }); + // OC-0201: a full-ready resync that preserves a live voice session (the + // LiveKit room outlived a WS drop) never replays voice_leave for anyone + // who departed the channel while the socket was down — `ready` rebuilds + // voiceUsers wholesale and stops. Without reconciliation, a departed peer + // keeps a working room key forever (no rotation ever runs for them) and a + // client newly elected key holder by the server-side re-registration never + // self-elects, since only handleParticipantLeft runs the election. + it("reconciles E2EE state for peers who left during a full-ready resync with a live voice session", async () => { + vi.mocked(mockHandleParticipantLeft).mockClear(); + + authStore.setState(() => ({ + token: "test-token", + user: { id: 42, username: "me", avatar: null, role: "member" }, + serverName: "Test", + motd: "", + isAuthenticated: true, + })); + + // Before the resync: self (42) and peer (7) are both in channel 10 — the + // live LiveKit session survived the WS drop. + voiceStore.setState((prev) => ({ + ...prev, + voiceStatus: "connected", + currentChannelId: 10, + voiceUsers: new Map([ + [ + 10, + new Map([ + [ + 42, + { + userId: 42, + username: "me", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + serverMuted: false, + serverDeafened: false, + }, + ], + [ + 7, + { + userId: 7, + username: "departed", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + serverMuted: false, + serverDeafened: false, + }, + ], + ]), + ], + ]), + })); + + // The full resync's voice_states shows peer 7 has left channel 10 while + // we were disconnected — only self remains. + mock.dispatch("ready", { + channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }], + members: [], + voice_states: [{ user_id: 42, channel_id: 10, muted: false, deafened: false }], + roles: [], + dm_channels: [], + }); + await vi.runAllTimersAsync(); + + expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7); + // Must never be called for ourselves. + expect(mockHandleParticipantLeft).not.toHaveBeenCalledWith(42); + }); + it("unknown event type does not throw", () => { expect(() => { mock.dispatch("totally_unknown_server_event", { some: "data" }); @@ -4109,6 +4301,31 @@ describe("WS Dispatcher", () => { expect(voiceStore.getState().currentChannelId).toBeNull(); expect(voiceStore.getState().voiceStatus).toBe("idle"); }); + + // OC-0193: a channel *switch* refusal (precheck FORBIDDEN/BAD_REQUEST/ + // RATE_LIMITED — anything that lands before the server's self voice_leave + // for the OLD channel) optimistically moved currentChannelId to the NEW + // channel and voiceStatus to "joining", but the LiveKit room from the OLD + // channel is still live — connected, mic published. The store-only + // leaveVoiceChannel() rollback used to leave that session dangling: the + // widget disappears (currentChannelId null hides it entirely) while audio + // keeps flowing and the server still lists us in the old channel. The + // rollback must also tear down the live LiveKit session so the media + // state and the store agree. + it("tears down a still-live LiveKit session when a channel-switch join is refused", async () => { + vi.mocked(mockLeaveVoice).mockClear(); + vi.mocked(mockIsVoiceConnected).mockReturnValue(true); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 7, voiceStatus: "joining" })); + + mock.dispatch("error", { code: "FORBIDDEN", message: "missing CONNECT_VOICE permission" }); + await vi.runAllTimersAsync(); + + expect(mockLeaveVoice).toHaveBeenCalledWith(true); + expect(voiceStore.getState().currentChannelId).toBeNull(); + expect(voiceStore.getState().voiceStatus).toBe("idle"); + + vi.mocked(mockIsVoiceConnected).mockReturnValue(false); + }); }); // A server refusal of voice_camera/voice_screenshare (FORBIDDEN, diff --git a/Client/tauri-client/tests/unit/dm-groups.test.ts b/Client/tauri-client/tests/unit/dm-groups.test.ts index 36d1e5bc..e4fa6130 100644 --- a/Client/tauri-client/tests/unit/dm-groups.test.ts +++ b/Client/tauri-client/tests/unit/dm-groups.test.ts @@ -86,6 +86,23 @@ describe("dmDisplayName", () => { it("falls back to the recipient when the participant list is empty", () => { expect(dmDisplayName(makeDm({ participants: [] }))).toBe("bob"); }); + + // Regression (OC-0220): a group DM that has lost every other member still + // has a live, is_group=1 channel row (LeaveGroupDM only deletes the row + // when the LAST member leaves), but the server never populates `recipient` + // for a channel with zero "other" participants — it stays the zero-valued + // DMUser (username ""). Falling back to that empty username renders a + // blank label everywhere dmDisplayName is used. + it("never renders blank for a group that has lost every other member", () => { + const name = dmDisplayName( + makeDm({ + isGroup: true, + participants: [], + recipient: { id: 0, username: "", avatar: "", status: "" }, + }), + ); + expect(name).not.toBe(""); + }); }); // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/dm-store.test.ts b/Client/tauri-client/tests/unit/dm-store.test.ts index b629fa30..a82962f7 100644 --- a/Client/tauri-client/tests/unit/dm-store.test.ts +++ b/Client/tauri-client/tests/unit/dm-store.test.ts @@ -335,6 +335,28 @@ describe("dmStore", () => { expect(dmStore.getState().channels[1]!.unreadCount).toBe(2); expect(dmStore.getState().channels[1]!.lastMessageId).toBeNull(); }); + + // Regression (OC-0224): on a fresh connect, registerNow (subscribing the + // client) runs before buildReady snapshots unread_count, so a DM that + // lands in that window is counted once by `ready` and then delivered + // again as a queued chat_message. Applying `ready` already sets + // unreadCount/lastMessageId to that message; a second call for the SAME + // message id must not double-count it. + it("does not double-count a message id already reflected by the last ready snapshot", () => { + setDmChannels([makeDm({ channelId: 5, unreadCount: 1, lastMessageId: 42 })]); + updateDmLastMessage(5, 42, "hello", "2026-03-28T12:00:00Z"); + const ch = dmStore.getState().channels[0]!; + expect(ch.unreadCount).toBe(1); + }); + + // A stale/out-of-order redelivery of an older message must not bump the + // badge either. + it("does not count a message id older than the channel's last message", () => { + setDmChannels([makeDm({ channelId: 5, unreadCount: 2, lastMessageId: 50 })]); + updateDmLastMessage(5, 42, "stale", "2026-03-28T12:00:00Z"); + const ch = dmStore.getState().channels[0]!; + expect(ch.unreadCount).toBe(2); + }); }); // ── updateDmLastMessagePreview ────────────────────────── diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts index 2a8957b9..145c2b2a 100644 --- a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -1629,4 +1629,135 @@ describe("E2EEManager", () => { vi.useRealTimers(); } }); + + // ── Ledger findings OC-0209 / OC-0212 / OC-0213 ─────────────────────────── + + it("[OC-0209] rejects a replayed retired-key announce before it overwrites the peer's verification badge", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // establishes our keypair, holder + + // Make import/export round-trip faithfully on the announced base64 + // string (the shared mock default returns a fixed constant regardless + // of input, which would mask this bug). + vi.mocked(importPublicKey).mockImplementation( + async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) => + (key as unknown as { type: string }).type.replace("peer-key-", ""), + ); + + const KEY_A = "b2xk"; + const KEY_B = "bmV3"; + + try { + // Peer announces key A, then a genuine key change to B — A is now retired. + await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA"); + await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB"); + expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` }); + + vi.mocked(setPeerVerification).mockClear(); + + // A malicious relay replays the old, still validly-signed announce for + // the retired key A. The replay guard must reject it BEFORE any + // verification write — a replay that reaches verifyPeerAnnounce first + // would overwrite the peer's badge (status + sessionFingerprint) with + // the retired key's, even though the guard then rejects the announce + // and _peerPublicKeys is left untouched. + await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA"); + + expect(setPeerVerification).not.toHaveBeenCalled(); + expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` }); + } finally { + vi.mocked(importPublicKey).mockImplementation( + async () => ({ type: "public" }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA=="); + } + }); + + it("[OC-0212] replays the blocked announce after a successful re-pin, restoring the peer instead of leaving them un-keyed with the badge cleared", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // holder in channel 1 + + const NEW_IDENTITY = "new-identity-key-b64"; + mockMembers.set(PEER_ID, { identityPublicKey: NEW_IDENTITY }); + + // The peer reinstalled (new identity key). A still has them pinned to + // their OLD identity key, so the announce under the new identity is + // blocked as a TOFU mismatch. + vi.mocked(getIdentityPin).mockResolvedValueOnce({ + status: "pinned", + pin: "old-identity-key-b64", + }); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + vi.mocked(setPeerVerification).mockClear(); + + // The user confirms the fingerprint out of band and re-pins to the + // peer's new identity key. + vi.mocked(getIdentityPin).mockResolvedValueOnce({ status: "pinned", pin: NEW_IDENTITY }); + const result = await mgr.rePinPeerIdentity(PEER_ID, NEW_IDENTITY); + + expect(result).toBe(true); + // The blocked announce must be replayed against the new pin — not just + // discarded with the badge cleared — so the peer actually re-enters + // _peerPublicKeys and is offered the room key for the rest of the call. + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "verified" }), + ); + }); + + it("[OC-0213] does not permanently retire a peer's key on a stale voice_leave when the peer is still listed as present in the channel roster", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // holder in channel 1 + + vi.mocked(importPublicKey).mockImplementation( + async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) => + (key as unknown as { type: string }).type.replace("peer-key-", ""), + ); + + const KEY = "b2xk"; + + try { + // The peer's rejoin announce (carrying a fresh key) arrives first — + // the OC-0213 repro's reordering, where the directly-published + // voice_e2ee_announce overtakes the still-queued voice_leave for the + // join instance it superseded. + await mgr.handleAnnounce(PEER_ID, KEY, "sig"); + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + + // voice_state (the local roster) still lists the peer as present in + // the channel — this is what tells apart a stale, lagging leave for a + // superseded join instance from a genuine departure. + mockVoiceState.voiceUsers.set(1, new Map([[PEER_ID, {}]])); + + // The stale voice_leave for the superseded join instance now arrives. + await mgr.handleParticipantLeft(PEER_ID); + + // Removed from the live peer map (unchanged behavior)... + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false); + + // ...but the key must not be permanently retired: the peer's own, + // still-valid re-announce of the SAME key must be accepted again, not + // rejected as a replay of a "retired" key — otherwise the peer is + // stranded, un-re-announceable, for the rest of the call. + await mgr.handleAnnounce(PEER_ID, KEY, "sig"); + expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY}` }); + } finally { + vi.mocked(importPublicKey).mockImplementation( + async () => ({ type: "public" }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA=="); + } + }); }); diff --git a/Client/tauri-client/tests/unit/logs-tab.test.ts b/Client/tauri-client/tests/unit/logs-tab.test.ts index fc842611..7465415b 100644 --- a/Client/tauri-client/tests/unit/logs-tab.test.ts +++ b/Client/tauri-client/tests/unit/logs-tab.test.ts @@ -405,6 +405,47 @@ describe("LogsTab", () => { expect(copiedText).toContain('"key"'); }); + it("clear button updates the entry count, not just the list", () => { + mockGetLogBuffer.mockReturnValue([makeMockEntry("info", "one"), makeMockEntry("info", "two")]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + expect(el.textContent).toContain("2 entries"); + + // Simulate clearLogBuffer() actually emptying the buffer. + mockClearLogBuffer.mockImplementation(() => { + mockGetLogBuffer.mockReturnValue([]); + }); + + const clearBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "Clear Logs", + )!; + clearBtn.click(); + + expect(el.querySelectorAll(".log-entry").length).toBe(0); + expect(el.textContent).toContain("0 entries"); + expect(el.textContent).not.toContain("2 entries"); + }); + + it("Refresh button updates the entry count to match the refreshed list", () => { + mockGetLogBuffer.mockReturnValue([makeMockEntry("info", "initial")]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + expect(el.textContent).toContain("1 entries"); + + mockGetLogBuffer.mockReturnValue([ + makeMockEntry("info", "initial"), + makeMockEntry("warn", "new entry"), + ]); + + const refreshBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "Refresh", + )!; + refreshBtn.click(); + + expect(el.textContent).toContain("2 entries"); + expect(el.textContent).not.toContain("1 entries"); + }); + it("Refresh Diagnostics button re-renders diagnostics panel", () => { mockGetLogBuffer.mockReturnValue([]); const handle = createLogsTab(() => "Logs" as TabName, controller.signal); diff --git a/Client/tauri-client/tests/unit/main-page.test.ts b/Client/tauri-client/tests/unit/main-page.test.ts index 5716833d..313bce86 100644 --- a/Client/tauri-client/tests/unit/main-page.test.ts +++ b/Client/tauri-client/tests/unit/main-page.test.ts @@ -20,9 +20,19 @@ vi.mock("@lib/logger", () => ({ }), })); +const { capturedOnRemoteVideo } = vi.hoisted(() => ({ + capturedOnRemoteVideo: { + current: null as null | ((userId: number, stream: MediaStream, isScreenshare: boolean) => void), + }, +})); + vi.mock("@lib/livekitSession", () => ({ cleanupAll: vi.fn(), - setOnRemoteVideo: vi.fn(), + setOnRemoteVideo: vi.fn( + (cb: (userId: number, stream: MediaStream, isScreenshare: boolean) => void) => { + capturedOnRemoteVideo.current = cb; + }, + ), setOnRemoteVideoRemoved: vi.fn(), clearOnRemoteVideo: vi.fn(), setWsClient: vi.fn(), @@ -93,6 +103,15 @@ const { videoGridSlot: HTMLDivElement; }; dmProfileSlot: HTMLDivElement; + videoGrid: { + addStream: ReturnType; + removeStream: ReturnType; + clearStreams: ReturnType; + hasStreams: ReturnType; + setFocusedTile: ReturnType; + getFocusedTileId: ReturnType; + setLabel: ReturnType; + }; }, }, })); @@ -132,20 +151,22 @@ vi.mock("../../src/pages/main-page/ChatArea", () => ({ videoGridSlot: document.createElement("div"), }; const dmProfileSlot = document.createElement("div"); - capturedChatAreaRef.current = { slots, dmProfileSlot }; + const videoGrid = { + addStream: vi.fn(), + removeStream: vi.fn(), + clearStreams: vi.fn(), + hasStreams: vi.fn(() => false), + setFocusedTile: vi.fn(), + getFocusedTileId: vi.fn(() => null), + setLabel: vi.fn(), + mount: vi.fn(), + destroy: vi.fn(), + }; + capturedChatAreaRef.current = { slots, dmProfileSlot, videoGrid }; return { chatArea: document.createElement("div"), slots, - videoGrid: { - addStream: vi.fn(), - removeStream: vi.fn(), - clearStreams: vi.fn(), - hasStreams: vi.fn(() => false), - setFocusedTile: vi.fn(), - getFocusedTileId: vi.fn(() => null), - mount: vi.fn(), - destroy: vi.fn(), - }, + videoGrid, chatHeaderName: document.createElement("span"), chatHeaderRefs: { hashEl: document.createElement("span"), @@ -165,8 +186,9 @@ import { createMainPage } from "../../src/pages/MainPage"; import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store"; import { authStore } from "../../src/stores/auth.store"; import { uiStore } from "../../src/stores/ui.store"; -import { voiceStore } from "../../src/stores/voice.store"; +import { voiceStore, updateVoiceUserProfile } from "../../src/stores/voice.store"; import { dmStore } from "../../src/stores/dm.store"; +import { membersStore, updateMemberProfile } from "../../src/stores/members.store"; import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws"; import type { ApiClient } from "../../src/lib/api"; import type { ServerMessage } from "../../src/lib/types"; @@ -196,6 +218,7 @@ function resetStores(): void { voiceStatus: "idle", })); dmStore.setState(() => ({ channels: [] })); + membersStore.setState(() => ({ members: new Map(), typingUsers: new Map(), roleRevision: 0 })); } type FakeWsClient = WsClient & { @@ -383,6 +406,175 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => { expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe(""); }); + it("does not clear a just-added remote video tile when a voice-channel switch left the camera/screenshare signature unchanged (OC-0207)", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, textChannel(1, "general")); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + + page = createMainPage({ ws: fakeWs(), api: fakeApi() }); + page.mount(container); + + // Alice joins voice channel A (9). Someone's camera briefly toggles the + // signature so the real VideoModeController actually runs checkVideoMode + // against channel 9 and latches its lastChannelId there — mirroring + // "Alice is already in voice channel A" from the finding's repro. + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 9, + voiceUsers: new Map([ + [ + 9, + new Map([ + [ + 100, + { + userId: 100, + username: "carl", + muted: false, + deafened: false, + speaking: false, + camera: true, + screenshare: false, + }, + ], + ]), + ], + ]), + })); + voiceStore.flush(); + voiceStore.setState((prev) => ({ + ...prev, + voiceUsers: new Map([ + [ + 9, + new Map([ + [ + 100, + { + userId: 100, + username: "carl", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ], + ]), + ], + ]), + })); + voiceStore.flush(); + + // Alice switches to voice channel B (10). Nobody in B has camera or + // screenshare on either, so MainPage's camera/screenshare signature does + // not change across the switch — the blind spot the finding describes. + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 10, + voiceUsers: new Map([[10, new Map()]]), + })); + voiceStore.flush(); + + // The switch itself may legitimately clear stale tiles from channel A + // (that is the correct, eager fix) — what matters for this finding is + // that nothing clears the grid again *after* the new tile is added. + const videoGrid = capturedChatAreaRef.current!.videoGrid; + videoGrid.clearStreams.mockClear(); + + // Bob's screenshare track arrives via LiveKit in channel B ahead of the + // server's voice_state broadcast (the documented TrackSubscribed race). + const fakeStream = {} as MediaStream; + capturedOnRemoteVideo.current!(200, fakeStream, true); + + expect(videoGrid.addStream).toHaveBeenCalled(); + // The bug: VideoModeController's lastChannelId is still stuck on channel + // A (9) because the switch to B (10) never changed the signature, so the + // checkVideoMode() call right after addStream sees a "channel change" + // that isn't one and wipes the tile it was just given. + expect(videoGrid.clearStreams).not.toHaveBeenCalled(); + }); + + it("relabels a remote video tile with the member's display name, not the raw username, and keeps it in sync with a mid-call rename (OC-0227)", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, textChannel(1, "general")); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + membersStore.setState(() => ({ + members: new Map([ + [ + 200, + { + id: 200, + username: "bob_1994", + avatar: null, + role: "member", + status: "online" as const, + displayName: "Bee", + }, + ], + ]), + typingUsers: new Map(), + roleRevision: 0, + })); + + page = createMainPage({ ws: fakeWs(), api: fakeApi() }); + page.mount(container); + + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 9, + voiceUsers: new Map([ + [ + 9, + new Map([ + [ + 200, + { + userId: 200, + username: "bob_1994", + muted: false, + deafened: false, + speaking: false, + camera: true, + screenshare: false, + }, + ], + ]), + ], + ]), + })); + voiceStore.flush(); + + const fakeStream = {} as MediaStream; + capturedOnRemoteVideo.current!(200, fakeStream, false); + + const videoGrid = capturedChatAreaRef.current!.videoGrid; + // The tile must show the same identity the voice roster and every other + // surface show for user 200 — the nickname "Bee" — not the raw username + // "bob_1994" (ChannelSidebar.ts's memberDisplayName idiom). + expect(videoGrid.addStream).toHaveBeenCalledWith( + 200, + "Bee", + fakeStream, + expect.objectContaining({ isSelf: false }), + ); + + // Bob renames himself mid-call (Settings -> Account). The USER_UPDATE + // fan-out updates both membersStore and voiceStore's frozen username + // copy, exactly like dispatcher.ts's USER_UPDATE handler does. + updateMemberProfile(200, { username: "bob_1994", avatar: null, displayName: "Robert" }); + updateVoiceUserProfile(200, { username: "bob_1994" }); + voiceStore.flush(); + + // The already-open tile must pick up the new name without the tile + // being torn down and re-created (no new addStream call for tile 200). + expect(videoGrid.setLabel).toHaveBeenCalledWith(200, "Robert"); + }); + it("does not open the 1:1 profile panel for a group DM header click", () => { channelsStore.setState((prev) => { const ch = new Map(prev.channels); @@ -527,6 +719,55 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => { expect(banner.style.display).toBe("none"); }); + it("does not cancel a group-DM ring when the ringer leaves voice but another callee is still in the call (OC-0235)", () => { + const ws = fakeWs(); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" })); + + page = createMainPage({ ws, api: fakeApi() }); + page.mount(container); + + // Alice (10) rings a group DM (channel 50); this client is a third + // participant (C). + ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" }); + + const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement; + expect(banner.style.display).not.toBe("none"); + + // Bob (11) already accepted and is sitting in the DM's voice channel. + // The dispatcher's own voice_leave handler may already have removed + // Alice from the roster by the time this fires (order-independent), so + // her entry is absent here too — only Bob remains. + voiceStore.setState((prev) => ({ + ...prev, + voiceUsers: new Map([ + [ + 50, + new Map([ + [ + 11, + { + userId: 11, + username: "bob", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ], + ]), + ], + ]), + })); + voiceStore.flush(); + + // Alice, the ringer, hangs up. The call is still live — Bob is in it — + // so this client's own one-click Accept must not disappear. + ws.emit("voice_leave", { channel_id: 50, user_id: 10 }); + + expect(banner.style.display).not.toBe("none"); + }); + it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => { page = createMainPage({ ws: fakeWs(), api: fakeApi() }); page.mount(container); diff --git a/Client/tauri-client/tests/unit/media.test.ts b/Client/tauri-client/tests/unit/media.test.ts index 42f87f42..8a2c7353 100644 --- a/Client/tauri-client/tests/unit/media.test.ts +++ b/Client/tauri-client/tests/unit/media.test.ts @@ -1252,6 +1252,26 @@ describe("media.ts", () => { const urls = extractUrls("http://insecure.com https://secure.com"); expect(urls).toEqual(["http://insecure.com", "https://secure.com"]); }); + + it("strips sentence-ending trailing punctuation, matching the linkifier", () => { + const urls = extractUrls("Nice pic https://cdn.example.com/a.png."); + expect(urls).toEqual(["https://cdn.example.com/a.png"]); + }); + + it("strips a wrapping close-paren but keeps a balanced one from the URL itself", () => { + const wrapped = extractUrls("(https://cdn.example.com/a.png)"); + expect(wrapped).toEqual(["https://cdn.example.com/a.png"]); + + const balanced = extractUrls( + "See https://en.wikipedia.org/wiki/Rust_(programming_language) for details", + ); + expect(balanced).toEqual(["https://en.wikipedia.org/wiki/Rust_(programming_language)"]); + }); + + it("strips trailing punctuation from a YouTube link so it resolves to a valid video id", () => { + const urls = extractUrls("Check https://youtu.be/dQw4w9WgXcQ."); + expect(urls).toEqual(["https://youtu.be/dQw4w9WgXcQ"]); + }); }); // ========================================================================= diff --git a/Client/tauri-client/tests/unit/mentions-render.test.ts b/Client/tauri-client/tests/unit/mentions-render.test.ts index 350e87b3..006f4112 100644 --- a/Client/tauri-client/tests/unit/mentions-render.test.ts +++ b/Client/tauri-client/tests/unit/mentions-render.test.ts @@ -145,6 +145,17 @@ describe("@mention rendering", () => { const el = render("hi @alice", { mentions: [20] }); expect(el.querySelector(".mention")?.getAttribute("data-user-id")).toBe("20"); }); + + it("does not fall back to the member list when the server sent a mentions list that omits the token (OC-0228)", () => { + // The server resolved this message's mentions to [10] (alice) only, not + // the signed-in user (id 12, username "me"). The inline pill must agree + // with the row-level gate and stay unresolved for "@me" here — a token + // the server did not list must not render as a live mention, let alone a + // self one. + const el = render("hey @me", { mentions: [10] }); + expect(el.querySelector(".mention")).toBeNull(); + expect(el.textContent).toBe("hey @me"); + }); }); describe("@everyone / @here", () => { diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 9362f650..7cd0116b 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -1162,6 +1162,36 @@ describe("MessageInput", () => { comp.destroy?.(); }); + // ── Attachment count cap (server hard-rejects >10 attachments) ── + + it("refuses to queue an 11th attachment instead of uploading it", async () => { + const onUploadFile = vi.fn(async () => ({ id: "x", url: "http://x", filename: "x" })); + const opts = makeOptions({ onUploadFile }); + const comp = createMessageInput(opts); + comp.mount(container); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + for (let i = 0; i < 11; i++) { + const file = new File(["data"], `file${i}.txt`, { type: "text/plain" }); + Object.defineProperty(fileInput, "files", { value: [file], writable: true }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((r) => setTimeout(r, 10)); + } + + // The server hard-rejects a chat_send with more than 10 attachments (as + // a parse error with no attachment-specific messaging), so the composer + // must never upload -- let alone queue -- an 11th one. + expect(onUploadFile).toHaveBeenCalledTimes(10); + expect(container.querySelectorAll(".attachment-preview-item").length).toBe(10); + + const error = container.querySelector(".attachment-upload-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("10"); + + comp.destroy?.(); + }); + // ── Toggling emoji picker closed ── it("clicking emoji button again closes the picker", () => { diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index a72781cc..643fde2a 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -252,6 +252,31 @@ describe("MessageList", () => { expect(container.querySelector('[data-testid="message-150"]')).not.toBeNull(); }); + it("OC-0217: repeated jumps do not each register a permanent abort listener on the component-lifetime signal", () => { + // As a user clicking a reply bar's jump arrow, a search hit, or a pinned + // entry repeatedly does across a live session. + const messages = Array.from({ length: 10 }, (_, i) => makeMessage({ id: i + 1 })); + setMessages(1, messages); + msgList.mount(container); + + // Installed after mount() so it only observes what scrollToMessage does, + // not mount's own (single, expected) abort registration. + const addEventListenerSpy = vi.spyOn(AbortSignal.prototype, "addEventListener"); + + for (let i = 1; i <= 5; i++) { + expect(msgList.scrollToMessage(i)).toBe(true); + } + + // Each jump's highlight-flash cleanup must not add a new listener to the + // whole-lifetime AbortSignal — that accumulates one listener (and pins + // one detached row element through its closure) per jump, released only + // when the channel unmounts, not when that jump's flash finishes. + const abortRegistrations = addEventListenerSpy.mock.calls.filter(([type]) => type === "abort"); + expect(abortRegistrations.length).toBe(0); + + addEventListenerSpy.mockRestore(); + }); + it("rebuilds the virtual window when scrolling outside the rendered range", async () => { setHasMore(1, false); const many = Array.from({ length: 300 }, (_, i) => makeMessage({ id: i + 1 })); diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts index 3a01debb..89c8e990 100644 --- a/Client/tauri-client/tests/unit/notifications.test.ts +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -4,6 +4,8 @@ import { authStore } from "../../src/stores/auth.store"; import { channelsStore } from "../../src/stores/channels.store"; import { dmStore } from "../../src/stores/dm.store"; import type { DmChannel } from "../../src/stores/dm.store"; +import { membersStore } from "../../src/stores/members.store"; +import { messagesStore } from "../../src/stores/messages.store"; import type { ChatMessagePayload } from "../../src/lib/types"; // vi.hoisted ensures testPrefs is available when vi.mock factory runs @@ -143,6 +145,19 @@ describe("notifyIncomingMessage", () => { // into another that expects the plain channelsStore fallback. dmStore.setState(() => ({ channels: [] })); + // Reset the member store so a nickname seeded by one test cannot leak + // into another that expects the plain-username title. + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + roleRevision: 0, + })); + + // A channel marked detached by one test (viewing a back-history + // around-window) must not leak into another that expects the plain + // active-channel suppression. + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set() })); + // Ensure document.hasFocus returns false (simulating unfocused window) vi.spyOn(document, "hasFocus").mockReturnValue(false); }); @@ -719,6 +734,76 @@ describe("notifyIncomingMessage", () => { }); }); + // OC-0233: the popup that tells you who wrote to you has to name them the + // same way the message row you click through to does. resolveAuthor + // (message-list/formatting.ts) prefers the live membersStore copy of the + // author's nickname over whatever was frozen into the payload. + it("titles the notification with the member store's nickname, not the raw username", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + membersStore.setState(() => ({ + members: new Map([ + [ + 2, + { + id: 2, + username: "a_martinez", + avatar: null, + role: "member", + status: "online" as const, + displayName: "Alice", + }, + ], + ]), + typingUsers: new Map(), + roleRevision: 1, + })); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + const payload = makePayload({ + user: { id: 2, username: "a_martinez", avatar: null }, + channel_id: 1, + content: "hi", + }); + notifyIncomingMessage(payload); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith({ + title: "Alice in #general", + body: "hi", + }); + }); + }); + + // Same fix, payload-only path: the author has a display_name on the + // message but is not (yet) in the member store. + it("titles the notification with the payload's display_name when the author is not in the member store", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + const payload = makePayload({ + user: { id: 2, username: "a_martinez", avatar: null, display_name: "Alice" }, + channel_id: 1, + content: "hi", + }); + notifyIncomingMessage(payload); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith({ + title: "Alice in #general", + body: "hi", + }); + }); + }); + it("uses fallback channel name with correct channel ID", async () => { const { sendNotification } = await import("@tauri-apps/plugin-notification"); (sendNotification as ReturnType).mockClear(); @@ -1100,6 +1185,34 @@ describe("notifyIncomingMessage", () => { expect(sendNotification).toHaveBeenCalled(); }); }); + + // OC-0204: "active channel" is not the same thing as "the user is + // watching the live tail". A jump to an old permalink/reply/search hit + // in the active channel opens a detached around-window (messages.store's + // detachedChannels) — addMessage refuses to append a live broadcast onto + // it, and dispatcher.ts skips the unread bump because the channel is + // "active". If this guard also suppresses the notification, an @mention + // that arrives while the user reads back-history reaches them through + // literally nothing — not even a popup — even though the window is + // focused and they are looking at #general. + it("proceeds when window focused AND channel matches BUT the window is detached (reading back-history)", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + vi.spyOn(document, "hasFocus").mockReturnValue(true); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([1]) })); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + notifyIncomingMessage(makePayload({ channel_id: 1 })); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); + }); }); describe("notification toggles independently control each action", () => { diff --git a/Client/tauri-client/tests/unit/vad-worklet-timing.test.ts b/Client/tauri-client/tests/unit/vad-worklet-timing.test.ts new file mode 100644 index 00000000..ec05f978 --- /dev/null +++ b/Client/tauri-client/tests/unit/vad-worklet-timing.test.ts @@ -0,0 +1,152 @@ +// Pins OC-0206: AudioWorkletProcessor.process() runs once per 128-sample +// render quantum (2.667ms at the 48kHz AudioContext AudioPipeline creates), +// not once per ~16ms poll like the setTimeout fallback. vad-worklet.js's gate +// timing constants were copy-pasted from the fallback's 16ms-poll frame +// counts, so on the worklet path the mic gate closes ~6x faster than +// intended (~32ms of silence instead of ~200ms), and the other timing +// constants are off by the same factor. +// +// This loads the actual public/vad-worklet.js source (not a reimplementation) +// into a small VM sandbox that stands in for the AudioWorkletGlobalScope, so +// it exercises the real VadProcessor class. + +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WORKLET_PATH = resolve(__dirname, "../../public/vad-worklet.js"); + +// One render quantum at the 48kHz AudioContext AudioPipeline creates +// (audioPipeline.ts: `new AudioContext({ sampleRate: 48000 })`). +const FRAME_MS = (128 / 48000) * 1000; // ≈ 2.667ms + +function loadVadProcessor(): new () => any { + const code = readFileSync(WORKLET_PATH, "utf-8"); + const registered: Record any> = {}; + + class AudioWorkletProcessor { + port: { onmessage: ((event: unknown) => void) | null; postMessage: (msg: unknown) => void }; + constructor() { + this.port = { onmessage: null, postMessage: () => {} }; + } + } + + const sandbox: Record = { + AudioWorkletProcessor, + registerProcessor: (name: string, cls: new () => unknown) => { + registered[name] = cls as new () => any; + }, + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: "vad-worklet.js" }); + const ctor = registered["vad-processor"]; + if (ctor === undefined) { + throw new Error('vad-worklet.js did not registerProcessor("vad-processor")'); + } + return ctor; +} + +function frame(value: number, length = 128): Float32Array { + return new Float32Array(length).fill(value); +} + +function postMessageMock(proc: any): ReturnType { + return proc.port.postMessage as ReturnType; +} + +/** Repeatedly calls process() with a constant-level input frame until + * port.postMessage receives a message of `type`, returning the number of + * process() calls that took (the call that produced the message counts). */ +function callsUntilMessageOfType( + proc: any, + sampleValue: number, + type: string, + maxCalls: number, +): number { + for (let i = 1; i <= maxCalls; i++) { + const before = postMessageMock(proc).mock.calls.length; + proc.process([[frame(sampleValue)]]); + const calls = postMessageMock(proc).mock.calls; + for (let j = before; j < calls.length; j++) { + const call = calls[j]; + if (call === undefined) continue; + if ((call[0] as { type: string }).type === type) return i; + } + } + throw new Error(`no "${type}" message within ${maxCalls} process() calls`); +} + +describe("vad-worklet.js VadProcessor timing (128-sample render quanta @48kHz)", () => { + const SILENT = 0; // rms 0, below default threshold 0.05 + const LOUD = 0.5; // rms 0.5, above default threshold 0.05 + + function freshUngatedProcessor(): any { + const VadProcessor = loadVadProcessor(); + const proc = new VadProcessor(); + proc.port.postMessage = vi.fn(); + // Fast-forward well past the startup grace period with loud (non-gating) + // audio. Starting ungated, loud audio never posts a "gate" message, so + // this is safe regardless of how long the grace period actually is. + for (let i = 0; i < 300; i++) proc.process([[frame(LOUD)]]); + return proc; + } + + it("does not close the gate until ~200ms of silence (≈75 render quanta), not ~32ms (12 quanta)", () => { + const proc = freshUngatedProcessor(); + + const calls = callsUntilMessageOfType(proc, SILENT, "gate", 200); + const elapsedMs = calls * FRAME_MS; + + // 12 quanta (the current, wrong constant) is ~32ms — well under 150ms. + // 75 quanta (~200ms) is the intended timing. + expect(elapsedMs).toBeGreaterThan(150); + expect(elapsedMs).toBeLessThan(260); + }); + + it("does not reopen the gate until ~32ms of speech (≈12 render quanta), not ~5ms (2 quanta)", () => { + const proc = freshUngatedProcessor(); + + // Drive it into the gated state first. + callsUntilMessageOfType(proc, SILENT, "gate", 200); + postMessageMock(proc).mockClear(); + + const calls = callsUntilMessageOfType(proc, LOUD, "gate", 200); + const elapsedMs = calls * FRAME_MS; + + // 2 quanta (current) is ~5.3ms. 12 quanta (~32ms, matching the + // setTimeout fallback's GATE_OFF_FRAMES=2 @ 16ms poll) is intended. + expect(elapsedMs).toBeGreaterThan(20); + expect(elapsedMs).toBeLessThan(45); + }); + + it("suppresses all messages for close to 500ms of startup grace (≈188 quanta), not ~80ms (30 quanta)", () => { + const VadProcessor = loadVadProcessor(); + const proc = new VadProcessor(); + proc.port.postMessage = vi.fn(); + + // 160 quanta ≈ 427ms: comfortably past the current, wrong 30-quantum + // (~80ms) grace plus the current 12-quantum gate-on delay, but still + // short of the intended ~500ms grace. + for (let i = 0; i < 160; i++) proc.process([[frame(SILENT)]]); + + expect(postMessageMock(proc)).not.toHaveBeenCalled(); + }); + + it("posts the RMS indicator roughly every ~50ms (≈19 quanta) once past startup, not every ~16ms (6 quanta)", () => { + const proc = freshUngatedProcessor(); + + // Discard the first (possibly phase-shifted) interval, then measure a + // full period: the counter resets to 0 immediately after each post. + callsUntilMessageOfType(proc, LOUD, "rms", 300); + postMessageMock(proc).mockClear(); + const period = callsUntilMessageOfType(proc, LOUD, "rms", 100); + const periodMs = period * FRAME_MS; + + // 6 quanta (current) is ~16ms. 19 quanta (~50ms) is intended. + expect(periodMs).toBeGreaterThan(35); + expect(periodMs).toBeLessThan(65); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-callbacks.test.ts b/Client/tauri-client/tests/unit/voice-callbacks.test.ts index cc974583..e2aabb24 100644 --- a/Client/tauri-client/tests/unit/voice-callbacks.test.ts +++ b/Client/tauri-client/tests/unit/voice-callbacks.test.ts @@ -186,6 +186,27 @@ describe("createVoiceWidgetCallbacks", () => { expect(mockSetMuted).not.toHaveBeenCalled(); }); + + it("does not send voice_deafen{deafened:false} on unmute while server-deafened (OC-0216)", () => { + // Mirrors onDeafenToggle's localServerMuted guard (OC-0179): a + // moderator-imposed deafen is not ours to lift, so unmuting must not + // spend a doomed voice_deafen round-trip that the server will refuse + // with SERVER_DEAFENED. + mockVoiceStoreGetState.mockReturnValue( + makeVoiceState({ localMuted: true, localDeafened: true, localServerDeafened: true }), + ); + const ws = makeWs(); + const cbs = createVoiceWidgetCallbacks(ws, makeLimiters()); + + cbs.onMuteToggle(); + + // The unmute itself still goes through... + expect(mockSetMuted).toHaveBeenCalledWith(false); + expect(ws.send).toHaveBeenCalledWith({ type: "voice_mute", payload: { muted: false } }); + // ...but the undeafen must be suppressed while the server deafen stands. + expect(mockSetDeafened).not.toHaveBeenCalled(); + expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "voice_deafen" })); + }); }); describe("onDeafenToggle", () => { diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go index bdcee704..7b7f720d 100644 --- a/Server/admin/handlers_channel_perms.go +++ b/Server/admin/handlers_channel_perms.go @@ -138,7 +138,17 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid } // Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR // cannot grant bits their own role lacks via a channel override. - if err := requireGrantableOverride(actorRole, allow, deny); err != nil { + // Checked against the union of the bits being written and the bits + // already present on the row: clearing an existing deny is also a + // grant (EffectivePerms = (rolePerm &^ deny) | allow), so writing an + // all-zero mask over a deny the actor's own role lacks must not slip + // past this guard just because the NEW mask alone is empty. + curAllow, curDeny, err := database.GetChannelPermissions(r.Context(), ch.ID, roleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel permission") + return + } + if err := requireGrantableOverride(actorRole, curAllow|allow, curDeny|deny); err != nil { writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) return } @@ -215,6 +225,21 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") return } + // Escalation guard: deleting an override is a permission mutation with + // the same authority as writing one — removing a deny row restores + // exactly the access the PUT path refuses to grant (EffectivePerms = + // (rolePerm &^ deny) | allow) — so gate it identically to + // handlePutChannelPermission, checked against the bits the deleted row + // actually carries. + curAllow, curDeny, err := database.GetChannelPermissions(r.Context(), ch.ID, roleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel permission") + return + } + if err := requireGrantableOverride(actorRole, curAllow, curDeny); err != nil { + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } // Hierarchy guard: deleting an override is a permission mutation with the // same authority as writing one (removing a deny row restores exactly the // access the PUT path refuses to grant), so gate it identically to @@ -339,7 +364,16 @@ func handlePutChannelUserPermission(database *db.DB, hub HubBroadcaster, permInv } // Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR // cannot grant bits their own role lacks via a per-user override. - if err := requireGrantableOverride(actorRole, allow, deny); err != nil { + // Checked against the union of the bits being written and the bits + // already present on the row, same rationale as + // handlePutChannelPermission: clearing an existing deny is a grant, so + // an all-zero write must not bypass this guard. + curAllow, curDeny, err := database.GetUserChannelPermissions(r.Context(), ch.ID, user.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel user permission") + return + } + if err := requireGrantableOverride(actorRole, curAllow|allow, curDeny|deny); err != nil { writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) return } @@ -392,6 +426,20 @@ func handleDeleteChannelUserPermission(database *db.DB, hub HubBroadcaster, perm writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") return } + // Escalation guard: clearing a per-user override restores exactly the + // access the PUT path refuses to grant (EffectivePerms = (rolePerm &^ + // deny) | allow), so gate it identically to + // handlePutChannelUserPermission, checked against the bits the + // deleted row actually carries. + curAllow, curDeny, err := database.GetUserChannelPermissions(r.Context(), ch.ID, user.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel user permission") + return + } + if err := requireGrantableOverride(actorRole, curAllow, curDeny); err != nil { + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } // Hierarchy guard: clearing a higher-ranked member's override is the // same authority as writing one, so gate it identically. if !requireManageableUser(database, w, r, user, actorRole) { diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index 2a257427..0a9d36ab 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -431,3 +431,91 @@ func TestDeleteChannelPermission_UnknownRole(t *testing.T) { t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String()) } } + +// Clearing an override is a permission grant when it removes a deny bit the +// actor's own role does not hold: EffectivePerms = (rolePerm &^ deny) | allow, +// so wiping a deny row hands back exactly the access the PUT path refuses to +// grant (TestPutChannelPermission_ModeratorCannotEscalate). The DELETE +// handler must apply requireGrantableOverride to the override being REMOVED, +// not skip it just because the hierarchy guard alone passes. +func TestDeleteChannelPermission_EscalationGuard(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + + // Helper role: low position, base permissions include MANAGE_MESSAGES. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (20, 'Helper', NULL, ?, 5, 0)`, + permissions.ManageMessages, + ); err != nil { + t.Fatalf("seed Helper role: %v", err) + } + + // Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR, + // ranked above Helper so only the escalation guard is exercised. + _, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser") + + chID, err := database.CreateChannel(context.Background(), "escalate-del", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelOverride(context.Background(), chID, 20, 0, permissions.ManageMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/20", modToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 20) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != permissions.ManageMessages { + t.Errorf("override mutated by forbidden delete: (%#x, %#x)", allow, deny) + } +} + +// A PUT with an all-zero mask that clears an existing deny bit the actor's +// own role does not hold is exactly as much an escalation as writing that +// bit directly (TestPutChannelPermission_ModeratorCannotEscalate): clearing a +// deny is a grant. requireGrantableOverride must see the bits being REMOVED +// by this write, not just the (trivially empty) bits being written. +func TestPutChannelPermission_ClearByZeroMaskEscalationGuard(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (20, 'Helper', NULL, ?, 5, 0)`, + permissions.ManageMessages, + ); err != nil { + t.Fatalf("seed Helper role: %v", err) + } + _, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser") + + chID, err := database.CreateChannel(context.Background(), "escalate-zero", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelOverride(context.Background(), chID, 20, 0, permissions.ManageMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/20", modToken, + map[string]any{"allow": 0, "deny": 0}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 20) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != permissions.ManageMessages { + t.Errorf("override mutated by forbidden zero-mask PUT: (%#x, %#x)", allow, deny) + } +} diff --git a/Server/admin/handlers_channel_user_perms_test.go b/Server/admin/handlers_channel_user_perms_test.go index 94c4e2cc..34e9d9e7 100644 --- a/Server/admin/handlers_channel_user_perms_test.go +++ b/Server/admin/handlers_channel_user_perms_test.go @@ -382,3 +382,72 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) { t.Errorf("second delete status = %d, want 204", w.Code) } } + +// Clearing a per-user override is a permission grant when it removes a deny +// bit the actor's own role does not hold, exactly like the role-layer case +// (TestDeleteChannelPermission_EscalationGuard in handlers_channel_perms_test.go). +// The DELETE handler must apply requireGrantableOverride to the override +// being REMOVED, not skip the escalation guard because hierarchy alone +// passes. +func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR. + _, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser") + target := seedOverrideTarget(t, database, "escalate-del-target") + + chID, err := database.CreateChannel(context.Background(), "escalate-del-user", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelUserOverride(context.Background(), chID, target, 0, permissions.ManageMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), modToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != permissions.ManageMessages { + t.Errorf("override mutated by forbidden delete: (%#x, %#x)", allow, deny) + } +} + +// Same escalation, reached through a PUT that writes an all-zero mask: it +// still clears the existing deny bit, which is a grant +// (TestPutChannelPermission_ClearByZeroMaskEscalationGuard's per-user twin). +func TestPutChannelUserPermission_ClearByZeroMaskEscalationGuard(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser") + target := seedOverrideTarget(t, database, "escalate-zero-target") + + chID, err := database.CreateChannel(context.Background(), "escalate-zero-user", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelUserOverride(context.Background(), chID, target, 0, permissions.ManageMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), modToken, + map[string]any{"allow": 0, "deny": 0}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != permissions.ManageMessages { + t.Errorf("override mutated by forbidden zero-mask PUT: (%#x, %#x)", allow, deny) + } +} diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index d62250f9..c06681cf 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -3,6 +3,7 @@ package admin import ( "context" "errors" + "log/slog" "net/http" "github.com/owncord/server/auth" @@ -53,9 +54,18 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found") case errors.Is(err, auth.ErrRoleNotFound): writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found") - default: - // ErrTokenNotFound or a wrapped DB error. + case errors.Is(err, auth.ErrTokenNotFound): writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") + default: + // A wrapped DB error, not one of the sentinels above (mirrors + // api.AuthMiddleware). A DB outage is not a bad token: + // answering 401 here would make the client treat a live, + // valid session as expired — the desktop client's doFetch + // 401 sink clears auth and deletes the stored credential for + // a session that was never revoked. Log it and report the + // failure as a server-side fault instead. + slog.ErrorContext(r.Context(), "admin: token resolution failed", "error", err) + writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authentication service temporarily unavailable") } return } diff --git a/Server/admin/middleware_db_error_test.go b/Server/admin/middleware_db_error_test.go new file mode 100644 index 00000000..52c4973f --- /dev/null +++ b/Server/admin/middleware_db_error_test.go @@ -0,0 +1,66 @@ +// Package admin whitebox test for OC-0225: adminAuthMiddleware must not +// report a transient DB error from auth.ResolveTokenHash as 401. A wrapped +// DB error is not "invalid or expired session" — treating it as one ejects +// an admin whose session was never revoked (see the finding for the desktop +// client's onUnauthorized -> clearAuth -> deleteCredential chain triggered by +// a stray 401). +package admin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/owncord/server/auth" +) + +// TestAdminAuthMiddleware_DBErrorIsNotUnauthorized verifies that when +// ResolveTokenHash fails with a wrapped (non-sentinel) DB error — as happens +// when the underlying SQLite connection is unavailable — adminAuthMiddleware +// reports 503 SERVICE_UNAVAILABLE, not 401 UNAUTHORIZED. A 401 here is +// indistinguishable from a genuinely dead/unknown session and drives the +// desktop client to clear auth and delete the stored credential for a +// session that was never actually revoked. +func TestAdminAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) { + database := openWhiteboxTestDB(t) + + uid, err := database.CreateUser(context.Background(), "dberroruser", "$2a$12$x", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token := "db-error-token" + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Close the DB so the very next query — GetSessionByTokenHash, called + // from inside ResolveTokenHash — fails with a wrapped, non-sentinel + // error (not sql.ErrNoRows, so not ErrTokenNotFound either). + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code == http.StatusUnauthorized { + t.Fatalf("status = %d (UNAUTHORIZED), want 503 (SERVICE_UNAVAILABLE) for a transient DB error; body: %s", w.Code, w.Body.String()) + } + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503 (SERVICE_UNAVAILABLE); body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["error"] == "UNAUTHORIZED" { + t.Errorf("error = %q, must not be UNAUTHORIZED for a DB outage", resp["error"]) + } +} diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index b7dee469..ff1df48d 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -72,7 +72,7 @@ var _ dmVoiceEvictor = (*ws.Hub)(nil) func MountDMRoutes(r chi.Router, database *db.DB, svc *service.Services, broadcaster DMBroadcaster) { r.Route("/api/v1/dms", func(r chi.Router) { r.Use(AuthMiddleware(database)) - r.Post("/", handleCreateDM(svc)) + r.Post("/", handleCreateDM(svc, broadcaster)) r.Post("/group", handleCreateGroupDM(svc, broadcaster)) r.Get("/", handleListDMs(svc)) r.Patch("/{channelId}", handleRenameGroupDM(svc, broadcaster)) @@ -117,7 +117,7 @@ type listDMsResponse struct { } // handleCreateDM creates or retrieves a DM channel with a recipient. -func handleCreateDM(svc *service.Services) http.HandlerFunc { +func handleCreateDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserKey).(*db.User) if !ok || user == nil { @@ -141,6 +141,19 @@ func handleCreateDM(svc *service.Services) http.HandlerFunc { return } + // A brand-new 1:1 DM has dm_open_state pre-seeded for BOTH users by + // GetOrCreateDMChannel (db/dm_queries.go), so the recipient's first + // OpenDM call — fired later from the sender's first message — finds + // the row already present and reports opened=false. Without this, + // nothing ever tells the recipient the DM exists: no live event, and + // no visibility-watermark bump for a warm reconnect either. Only the + // creation path needs this — CreateDM re-opening an existing DM for + // the caller only touches the caller's own dm_open_state row, which + // the caller obviously already knows about. + if result.Created { + broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID}) + } + avatarStr := "" if result.Recipient.Avatar != nil { avatarStr = *result.Recipient.Avatar @@ -399,6 +412,14 @@ func handleBlockUser(svc *service.Services, broadcaster DMBroadcaster) http.Hand return } + // The block has already committed at this point, so the rest of this + // handler must survive the caller's request context being cancelled + // right after that commit (client disconnect mid-handler) — same + // reasoning as handleRenameGroupDM's own bgCtx. Without this, a + // canceled request context makes the shared-DM lookup below fail and + // get skipped, silently defeating the eviction it gates. + bgCtx := context.WithoutCancel(r.Context()) + // Revocation must evict a live session, not merely block the next // join (the same invariant the voice sweep states): without this, a // blocked user already in the pair's 1:1 DM voice call stays in it @@ -407,11 +428,11 @@ func handleBlockUser(svc *service.Services, broadcaster DMBroadcaster) http.Hand // controls. Group DM calls are deliberately untouched, matching // requireDMNotBlocked's group exemption. if ve, evictable := broadcaster.(dmVoiceEvictor); evictable { - if chID, exists, err := svc.DMs.SharedOneToOneDM(r.Context(), user.ID, targetID); err != nil { + if chID, exists, err := svc.DMs.SharedOneToOneDM(bgCtx, user.ID, targetID); err != nil { slog.Warn("block: shared-DM lookup for voice eviction failed", "blocker_id", user.ID, "target_id", targetID, "err", err) } else if exists { - ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), targetID, chID) + ve.DisconnectFromVoiceInChannel(bgCtx, targetID, chID) } } diff --git a/Server/api/dm_handler_block_context_test.go b/Server/api/dm_handler_block_context_test.go new file mode 100644 index 00000000..a449115d --- /dev/null +++ b/Server/api/dm_handler_block_context_test.go @@ -0,0 +1,88 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/service" +) + +// cancelAfterBlockStore wraps a real in-memory *db.DB and cancels an +// externally supplied context the instant BlockUser's write commits, +// simulating a client disconnect landing between the block commit and the +// post-commit voice-eviction gate (OC-0198). FindDMChannelIDBetween is +// overridden to fail fast on an already-canceled context, mirroring the +// context.Canceled a real sql query would surface in that window. +type cancelAfterBlockStore struct { + *db.DB + cancel context.CancelFunc +} + +func (s *cancelAfterBlockStore) BlockUser(ctx context.Context, blockerID, blockedID int64) error { + err := s.DB.BlockUser(ctx, blockerID, blockedID) + if err == nil { + s.cancel() + } + return err +} + +func (s *cancelAfterBlockStore) FindDMChannelIDBetween(ctx context.Context, user1ID, user2ID int64) (int64, bool, error) { + if err := ctx.Err(); err != nil { + return 0, false, err + } + return s.DB.FindDMChannelIDBetween(ctx, user1ID, user2ID) +} + +// TestBlockUser_EvictsVoiceEvenIfRequestContextCanceledAfterCommit pins +// OC-0198: BlockUser has already committed once the store call returns, so a +// client disconnect that cancels the request context right after must not +// suppress the post-commit voice eviction. The shared-DM lookup gating that +// eviction has to run on a context detached from the request — the same way +// the eviction call itself already does — or the blocked user stays in the +// blocker's live 1:1 DM call forever. +func TestBlockUser_EvictsVoiceEvenIfRequestContextCanceledAfterCommit(t *testing.T) { + database := newDMTestDB(t) + bc := &watermarkVoiceBroadcaster{mockBroadcaster: &mockBroadcaster{}} + + alice := dmCreateToken(t, database, "alice", 4) + dmCreateToken(t, database, "bob", 4) + + setupRouter := buildDMRouter(database, bc) + rr := dmPost(t, setupRouter, "/api/v1/dms", alice, map[string]any{"recipient_id": 2}) + var created struct { + ChannelID int64 `json:"channel_id"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create-dm response %q: %v", rr.Body.String(), err) + } + bc.evictCalls = nil + + ctx, cancel := context.WithCancel(context.Background()) + store := &cancelAfterBlockStore{DB: database, cancel: cancel} + svc := service.New(store, auth.NewRateLimiter()) + + r := chi.NewRouter() + api.MountDMRoutes(r, database, svc, bc) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/blocks/2", nil) + req.Header.Set("Authorization", "Bearer "+alice) + req.RemoteAddr = "127.0.0.1:9999" + req = req.WithContext(ctx) + blockRR := httptest.NewRecorder() + r.ServeHTTP(blockRR, req) + + if blockRR.Code != http.StatusOK { + t.Fatalf("block: %d %s", blockRR.Code, blockRR.Body.String()) + } + if len(bc.evictCalls) != 1 || bc.evictCalls[0].userID != 2 || bc.evictCalls[0].channelID != created.ChannelID { + t.Fatalf("DisconnectFromVoiceInChannel calls = %+v, want exactly one for user=2 channel=%d even though "+ + "the request context was canceled right after the block commit", bc.evictCalls, created.ChannelID) + } +} diff --git a/Server/api/dm_handler_create_notify_test.go b/Server/api/dm_handler_create_notify_test.go new file mode 100644 index 00000000..1cebd187 --- /dev/null +++ b/Server/api/dm_handler_create_notify_test.go @@ -0,0 +1,72 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" +) + +// TestCreateDM_Success_NotifiesRecipient pins OC-0199: a REST-created 1:1 DM +// must tell the recipient about it immediately (a dm_channel_open event), +// mirroring what handleCreateGroupDM already does via broadcastDMOpen. +// +// Without this, GetOrCreateDMChannel pre-opens dm_open_state for BOTH users +// at creation time, so the recipient's OpenDM call on the first message +// later finds the row already present (INSERT OR IGNORE affects 0 rows) and +// never reports "opened" either — leaving the recipient with no live event +// and no visibility-watermark bump to pick the DM up on a warm reconnect. +func TestCreateDM_Success_NotifiesRecipient(t *testing.T) { + database := newDMTestDB(t) + broadcaster := &mockBroadcaster{} + router := buildDMRouter(database, broadcaster) + + tokenAlice := dmCreateToken(t, database, "notify_alice", 4) + _ = dmCreateToken(t, database, "notify_bob", 4) + bob, err := database.GetUserByUsername(context.Background(), "notify_bob") + if err != nil || bob == nil { + t.Fatalf("lookup bob: %v", err) + } + + rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ + "recipient_id": bob.ID, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("CreateDM: status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var gotOpenForBob bool + for _, m := range broadcaster.sent { + if m.UserID != bob.ID { + continue + } + var payload struct { + Type string `json:"type"` + } + if jsonErr := json.Unmarshal(m.Msg, &payload); jsonErr != nil { + continue + } + if payload.Type == "dm_channel_open" { + gotOpenForBob = true + } + } + if !gotOpenForBob { + t.Errorf("CreateDM: recipient %d never got a dm_channel_open broadcast; sent = %v", + bob.ID, dumpSent(broadcaster.sent)) + } +} + +func dumpSent(sent []mockBroadcastMsg) string { + var b bytes.Buffer + for _, m := range sent { + b.WriteString(m.String()) + b.WriteByte('\n') + } + return b.String() +} + +// String renders a mockBroadcastMsg for test failure output. +func (m mockBroadcastMsg) String() string { + return string(m.Msg) +} diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index 0532dcd0..b83e6e74 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -67,6 +67,22 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +-- AuthMiddleware falls through to an API-token lookup whenever a bearer +-- token matches no session (auth.ResolveTokenHash), so this table must exist +-- even in DM-only fixtures — otherwise an ordinary "no such session" lookup +-- for a garbage/unknown token hits GetActiveAPIToken and fails with a real +-- "no such table" SQL error instead of the intended not-found sentinel. +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 6bce11e7..47a27bb4 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -114,17 +114,24 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { Message: "role not found", }) return - case err != nil: - // ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad - // token — log it so it's distinguishable from ordinary 401s. - if !errors.Is(err, auth.ErrTokenNotFound) { - slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err) - } + case errors.Is(err, auth.ErrTokenNotFound): writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "invalid or expired session", }) return + case err != nil: + // A wrapped DB error, not one of the sentinels above. A DB outage + // is not a bad token: answering 401 here would make the client + // treat a live, valid session as expired — it clears auth, + // disconnects the WS, and deletes the stored credential. Log it + // and report the failure as a server-side fault instead. + slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err) + writeJSON(w, http.StatusServiceUnavailable, errorResponse{ + Error: "SERVICE_UNAVAILABLE", + Message: "authentication service temporarily unavailable", + }) + return } // Reject effectively-banned users before any further processing. diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 11849737..02d7df80 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -299,6 +299,43 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { } } +// TestAuthMiddleware_DBErrorIsNotUnauthorized pins OC-0202: a transient DB +// read error while resolving the bearer token (auth.ResolveTokenHash returns +// it WRAPPED, never as a sentinel) must not be reported as 401 UNAUTHORIZED. +// The desktop client treats every 401 as "session expired": it clears auth, +// disconnects the WS, and deletes the stored OS-keyring credential. A DB +// outage is not a bad token, so it must surface as a server-side failure +// (503) instead of tearing down a perfectly valid session. +func TestAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser(context.Background(), "erin", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + + // Close the underlying DB so the next GetSessionByTokenHash call fails + // with a wrapped "database is closed" error rather than sql.ErrNoRows — + // standing in for a transient outage (locked DB, disk I/O error, a + // restore swapping the file underneath the running server). + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code == http.StatusUnauthorized { + t.Errorf("AuthMiddleware DB error status = %d, want non-401 (503)", rr.Code) + } + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("AuthMiddleware DB error status = %d, want 503", rr.Code) + } +} + // ─── RequirePermission tests ────────────────────────────────────────────────── func TestRequirePermission_Allowed(t *testing.T) { diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index ebdf44d2..2e64fea0 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -31,8 +31,10 @@ type updateProfileRequest struct { Avatar *string `json:"avatar"` IdentityPublicKey *string `json:"identity_public_key"` // DisplayName and About are omitted = unchanged, "" = cleared. Both are - // sanitized and length-checked in UserService, which is also the path a - // non-REST caller would take. + // length-checked in UserService, which is also the path a non-REST + // caller would take; DisplayName is additionally sanitized in this + // handler (before validateDisplayName runs — see the OC-0197 comment at + // the call site) and UserService's own sanitize of it is then a no-op. DisplayName *string `json:"display_name"` About *string `json:"about"` } @@ -158,6 +160,131 @@ var allowedAvatarMIME = map[string]bool{ // ─── Handlers ──────────────────────────────────────────────────────────────── // handleUpdateProfile processes PATCH /api/v1/users/me. +// parseUpdateProfileRequest decodes the PATCH /users/me body and applies the +// bound-then-sanitize-then-validate pass to every field, in the same order the +// register path canonicalizes them. On any failure it writes the error response +// and returns ok=false, and the caller must return without writing anything +// further. Split out of handleUpdateProfile only to keep that handler under the +// funlen limit; the field logic is unchanged. +func parseUpdateProfileRequest(w http.ResponseWriter, r *http.Request) (updateProfileRequest, bool) { + var req updateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: "malformed request body", + }) + return req, false + } + + // OC-0151: bound the raw field before it ever reaches the fixpoint + // sanitizer below, for the same reason as the register path + // (auth_handler.go's registerReadRequest) — sanitizeToFixpoint's + // cost is quadratic in input length, and nothing bounds this field + // before it runs. This is a cheap byte-length pre-check — *4 still + // admits any legitimate 32-rune UTF-8 username. + if len(req.Username) > maxLoginUsernameLen*4 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: "username is too long", + }) + return req, false + } + + // Use the fixpoint sanitizer (service.SanitizeText), not a bare + // bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always + // HTML-escaped, so a plain apostrophe would be persisted as ' + // and login (which never re-escapes) would look the account up + // under a name that no longer matches. See service.SanitizeText's + // doc comment and the register path (auth_handler.go), which + // already canonicalizes the same way. + req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) + if req.Username == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: "username is required", + }) + return req, false + } + if err := auth.ValidateUsername(req.Username); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return req, false + } + + // OC-0192: bound the raw field before it reaches the fixpoint + // sanitizer below, same reasoning as the username bound above — + // sanitizeToFixpoint's cost is quadratic in input length. Unlike + // username, an oversized avatar was previously only caught *after* + // sanitizing, by validateAvatarURL's maxAvatarURLLen check. + if req.Avatar != nil && len(*req.Avatar) > maxAvatarURLLen*4 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: "avatar URL is too long", + }) + return req, false + } + + // Sanitize and validate avatar if provided. Use the fixpoint + // sanitizer (service.SanitizeText), not a bare + // bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped, so a URL with more + // than one query parameter would have its "&" separators rewritten + // to "&" and be persisted (and served) broken. Same reasoning as + // the username path above. + if req.Avatar != nil { + trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar)) + if err := validateAvatarURL(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return req, false + } + req.Avatar = &trimmed + } + + // display_name gets the same username-shaped scrutiny beyond length: + // it is rendered wherever a username is, so control characters and + // bidi overrides are exactly as unwelcome here. Length and the + // empty-clears-it rule still live in UserService, but sanitizing has + // to happen *before* validateDisplayName, not after: OC-0197 found + // that validating the raw JSON string let an HTML-entity-encoded + // control or bidi character (e.g. "‮") pass this check as + // harmless ASCII, only to be turned into the real character + // afterwards by UserService.UpdateProfile's cleanText call — the + // same sanitize-then-validate order the username path above already + // uses. OC-0192's raw-byte bound applies here too, now that + // sanitizing happens in this handler (UserService.UpdateProfile + // still bounds DisplayName/About the same way before its own + // cleanText calls, for any non-REST caller; cleanText's fixpoint + // output is stable, so that re-sanitize is a no-op here). + if req.DisplayName != nil { + if len(*req.DisplayName) > service.MaxDisplayNameLen*4 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: "display_name is too long", + }) + return req, false + } + trimmed := strings.TrimSpace(service.SanitizeText(*req.DisplayName)) + if err := validateDisplayName(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return req, false + } + req.DisplayName = &trimmed + } + + // Validate the identity key before any write so the request is + // all-or-nothing. + if req.IdentityPublicKey != nil { + trimmed := strings.TrimSpace(*req.IdentityPublicKey) + if err := validateIdentityKey(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return req, false + } + req.IdentityPublicKey = &trimmed + } + return req, true +} + func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserKey).(*db.User) @@ -168,91 +295,11 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) return } - var req updateProfileRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: "malformed request body", - }) + req, ok := parseUpdateProfileRequest(w, r) + if !ok { return } - // OC-0151: bound the raw field before it ever reaches the fixpoint - // sanitizer below, for the same reason as the register path - // (auth_handler.go's registerReadRequest) — sanitizeToFixpoint's - // cost is quadratic in input length, and nothing bounds this field - // before it runs. This is a cheap byte-length pre-check — *4 still - // admits any legitimate 32-rune UTF-8 username. - if len(req.Username) > maxLoginUsernameLen*4 { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: "username is too long", - }) - return - } - - // Use the fixpoint sanitizer (service.SanitizeText), not a bare - // bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always - // HTML-escaped, so a plain apostrophe would be persisted as ' - // and login (which never re-escapes) would look the account up - // under a name that no longer matches. See service.SanitizeText's - // doc comment and the register path (auth_handler.go), which - // already canonicalizes the same way. - req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) - if req.Username == "" { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: "username is required", - }) - return - } - if err := auth.ValidateUsername(req.Username); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: err.Error(), - }) - return - } - - // Sanitize and validate avatar if provided. Use the fixpoint - // sanitizer (service.SanitizeText), not a bare - // bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped, so a URL with more - // than one query parameter would have its "&" separators rewritten - // to "&" and be persisted (and served) broken. Same reasoning as - // the username path above. - if req.Avatar != nil { - trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar)) - if err := validateAvatarURL(trimmed); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: err.Error(), - }) - return - } - req.Avatar = &trimmed - } - - // display_name gets the same username-shaped scrutiny beyond length: - // it is rendered wherever a username is, so control characters and - // bidi overrides are exactly as unwelcome here. Length, sanitization - // and the empty-clears-it rule live in UserService. - if req.DisplayName != nil { - if err := validateDisplayName(*req.DisplayName); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: err.Error(), - }) - return - } - } - - // Validate the identity key before any write so the request is - // all-or-nothing. - if req.IdentityPublicKey != nil { - trimmed := strings.TrimSpace(*req.IdentityPublicKey) - if err := validateIdentityKey(trimmed); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{ - Error: "INVALID_INPUT", Message: err.Error(), - }) - return - } - req.IdentityPublicKey = &trimmed - } - updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{ Username: req.Username, Avatar: req.Avatar, diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index 94023d23..d0e7ef8e 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -201,6 +201,81 @@ func TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) { } } +// OC-0192: same story as OC-0151 above, but for the avatar field — the +// service.SanitizeText call in the avatar branch has no byte-length guard at +// all, unlike the username field just above it. validateAvatarURL's +// maxAvatarURLLen check never gets a chance to reject a huge payload cheaply, +// because the fixpoint sanitizer already spent its (quadratic) cost on it +// first. The fix must reject an oversized avatar on a cheap byte-length +// check before sanitizing, so the rejection is near-instant regardless of +// payload size. +func TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "avatarvictim", 4) + + // Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's + // doc comment for why this shape is quadratic to sanitize. + hugeAvatar := "&" + strings.Repeat("amp;", 4000) + "lt;" + + start := time.Now() + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": "avatarvictim", + "avatar": hugeAvatar, + }) + elapsed := time.Since(start) + + if rr.Code != http.StatusBadRequest { + t.Errorf("UpdateProfile oversized avatar status = %d, want 400; body = %s", rr.Code, rr.Body.String()) + } + + // See TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing for the + // rationale behind this bound: a guard that runs before sanitizing + // rejects in well under a millisecond, while the pre-fix code spends over + // 150ms in sanitizeToFixpoint on this payload before validateAvatarURL's + // length check ever runs. + if elapsed > 150*time.Millisecond { + t.Errorf("UpdateProfile oversized avatar took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed) + } +} + +// OC-0197: display_name is validated (validateDisplayName) against the raw +// JSON string, before the fixpoint sanitizer's outer html.UnescapeString +// ever runs (that happens later, inside UserService.UpdateProfile's +// cleanText call). So an entity-encoded control or bidi character like +// "‮" sails through validateDisplayName as harmless ASCII, and is only +// turned into the real U+202E RIGHT-TO-LEFT OVERRIDE character afterwards, +// on its way into storage. TestUpdateProfile_RejectsBadDisplayName +// (avatar_handler_test.go) shows the literal character is correctly +// rejected; this is the entity-encoded bypass of that same guard — the fix +// is to sanitize display_name before validating it, the same order the +// username field above already uses. +func TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "dnentity", 4) + + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": "dnentity", + "display_name": "ada‮gnp.exe", + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("entity-encoded bidi override display_name status = %d, want 400; body = %s", rr.Code, rr.Body.String()) + } + + // Regardless of what the handler answered, the stored row must never end + // up holding a real bidi override character smuggled in via the entity + // encoding — that is the actual harm (it renders wherever the username + // does, in every connected client, once broadcast). + u, err := database.GetUserByUsername(context.Background(), "dnentity") + if err != nil || u == nil { + t.Fatalf("GetUserByUsername: %v, %v", u, err) + } + if u.DisplayName != nil && strings.ContainsRune(*u.DisplayName, '\u202e') { + t.Errorf("stored display_name = %q, contains a real U+202E bidi override smuggled past validateDisplayName via HTML entity", *u.DisplayName) + } +} + // OC-0180: the avatar branch must canonicalize with the same fixpoint // sanitizer (service.SanitizeText) as the username path above it, not the // bare bluemonday sanitizer.Sanitize — Sanitize's output is always diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index baae9d08..3e7c91fe 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -81,6 +81,22 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +-- AuthMiddleware falls through to an API-token lookup whenever a bearer +-- token matches no session (auth.ResolveTokenHash), so this table must exist +-- even in upload-only fixtures — otherwise an ordinary "no such session" +-- lookup for a garbage/unknown token hits GetActiveAPIToken and fails with a +-- real "no such table" SQL error instead of the intended not-found sentinel. +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, diff --git a/Server/db/account.go b/Server/db/account.go index e311c5fe..ffedc052 100644 --- a/Server/db/account.go +++ b/Server/db/account.go @@ -198,9 +198,13 @@ func deleteAccountAdminGuard(ctx context.Context, tx *sql.Tx, userID int64) erro args = append(args, userID) var adminCount int - if err := tx.QueryRowContext(ctx, - fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`, - strings.Join(placeholders, ",")), + // notBannedClause is appended outside the Sprintf format string + // (rather than joined into it) because it contains strftime + // verbs like %Y and %H that fmt.Sprintf would otherwise try to + // parse as its own format directives. + query := fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND `, + strings.Join(placeholders, ",")) + notBannedClause + if err := tx.QueryRowContext(ctx, query, args..., ).Scan(&adminCount); err != nil { return fmt.Errorf("DeleteAccount count admins: %w", err) diff --git a/Server/db/account_test.go b/Server/db/account_test.go index 33affe21..ad5c0c23 100644 --- a/Server/db/account_test.go +++ b/Server/db/account_test.go @@ -48,6 +48,33 @@ func TestDeleteAccount_AllowedWhenOtherAdminExists(t *testing.T) { } } +// TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan locks the guard's +// "is there another usable admin left" count against the same lapsed-ban +// split anonymiseUser and notBannedClause document elsewhere: an admin whose +// temporary ban has expired (banned=1, ban_expires in the past) is fully +// functional per auth.IsEffectivelyBanned, so the raw `banned = 0` filter +// must not make the guard blind to them. +func TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan(t *testing.T) { + database := openMigratedMemory(t) + admin1 := seedUser(t, database, "admin1") + admin2 := seedUser(t, database, "admin2") + setRole(t, database, admin1, 2) // Admin + setRole(t, database, admin2, 2) // Admin + + // admin2's temp ban has lapsed: banned stays 1 but ban_expires is in the + // past, so admin2 logs in and administers normally. + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET banned = 1, ban_expires = '2020-01-01 00:00:00' WHERE id = ?`, admin2, + ); err != nil { + t.Fatalf("set lapsed temp ban: %v", err) + } + + err := database.DeleteAccount(context.Background(), admin1) + if err != nil { + t.Fatalf("DeleteAccount with a lapsed-temp-ban admin present: %v", err) + } +} + func TestDeleteAccount_AdminAllowedWhenOwnerExists(t *testing.T) { database := openMigratedMemory(t) ownerID := seedUser(t, database, "owner") diff --git a/Server/main.go b/Server/main.go index e1d1f303..1de1b448 100644 --- a/Server/main.go +++ b/Server/main.go @@ -427,13 +427,22 @@ func runClosePlugins(registry *plugin.Registry) { // runStartEventPersistence starts the event persister and pruner, returning // both as (nil, nil) when event persistence is disabled. Extracted from run. +// +// seedHubReplayState runs unconditionally (whenever hub is non-nil), NOT +// gated on cfg.EventPersistence.Enabled: it seeds the hub's seq counter from +// a persisted floor even in ring-buffer-only mode, which is what closes +// OC-0210 — see its doc comment. func runStartEventPersistence(bgCtx context.Context, log *slog.Logger, cfg *config.Config, hub *ws.Hub, database *db.DB) (*ws.EventPersister, <-chan struct{}) { - if !cfg.EventPersistence.Enabled || hub == nil { + if hub == nil { return nil, nil } seedHubReplayState(bgCtx, hub, database, log) + if !cfg.EventPersistence.Enabled { + return nil, nil + } + persister := ws.NewEventPersister( database, 4096, @@ -813,29 +822,65 @@ func loadPinnedCert(path string) []byte { return block.Bytes } -// seedHubReplayState restores the hub's monotonic seq counter from the -// persisted MAX(events.seq) so wrapped-payload seqs stay monotonic across -// restarts. Without this, the events table accumulates rows whose payload -// seqs reset to 1 after every restart, breaking the reconnect "events since -// last_seq" contract. +// wsSeqFloorSettingKey is the generic settings-table key (see db.GetSetting / +// db.SetSetting) seedHubSeqFloor persists its reserved floor under. +const wsSeqFloorSettingKey = "ws_seq_floor" + +// wsSeqFloorReserve is the block seedHubSeqFloor reserves above the persisted +// floor on every single boot (OC-0210). It only has to exceed the number of +// hub-sequenced broadcasts any one boot could plausibly emit before its own +// next restart — comfortably true at 1e9 for a self-hosted chat server — so +// this leaves an enormous safety margin while uint64's range still allows +// billions of restarts before the floor could ever wrap. +const wsSeqFloorReserve = 1_000_000_000 + +// seedHubReplayState seeds the hub's monotonic seq counter at startup from +// two independent, composable sources — both go through hub.SeedSeq, which +// only ever moves h.seq forward (CAS-max), so it doesn't matter which of the +// two runs first or whether either is available: // -// It also forces every client resuming from at or before that restored seq -// onto the full-ready path for this boot. h.seq is persisted and restored -// here, but the paired watermark that tells a resuming client whether a -// channel-visibility change happened since its last_seq -// (visibilityChangeSeq) is in-memory only and always starts at 0 on a fresh -// process — see ws/hub_events.go's mustFullResync. Channel-visibility -// changes made to an offline client (RefreshChannelVisibility, -// revokeUnreadableChannels) are sent as targeted, unsequenced messages that -// are never written to the events table, so replay can never recover them. -// Without the MarkVisibilityChanged call below, a client resuming with -// last_seq at or before the pre-restart max sails straight through -// mustFullResync's zeroed watermark and can silently miss a visibility -// change it should have converged on. +// 1. seedHubSeqFloor (below) reserves and persists a fresh block of seq +// space on every boot, regardless of whether event persistence is +// enabled. This is what closes OC-0210: previously this function did +// nothing at all when event_persistence.enabled is false (the +// documented "ring-buffer-only behaviour", config.go's +// EventPersistenceConfig.Enabled), so every boot's h.seq — and +// therefore its ring buffer's first entries — started back at 0/1. A +// client reconnecting with a last_seq remembered from a PRIOR boot +// could then coincidentally land inside the new boot's own live ring +// window: EventRingBuffer.EventsSinceFiltered has no way to tell that +// watermark apart from a legitimate one from this boot, and would +// silently serve a partial cross-epoch replay as if it were an +// ordinary resume. Seeding a floor far above anything a single boot +// could reach guarantees every previous boot's real seq values now sit +// below the new ring buffer's oldest entry, so a stale last_seq is +// correctly rejected by the pre-existing "afterSeq <= oldestSeq" guard +// in ringbuffer.go and falls through to a full ready instead +// (serve.go's handleReconnect, the `events == nil` branch) — the same +// path any other unrecoverable resume already takes, with no protocol +// change required. +// 2. When event persistence is enabled and the events table has history, +// MAX(events.seq) is exact (not a heuristic reserve) and naturally +// wins if it is the higher of the two. This branch is also what forces +// the paired visibilityChangeSeq watermark forward via +// MarkVisibilityChanged: h.seq is restored here, but the watermark +// that tells a resuming client whether a channel-visibility change +// happened since its last_seq (visibilityChangeSeq) is in-memory only +// and always starts at 0 on a fresh process — see +// ws/hub_events.go's mustFullResync. Channel-visibility changes made to +// an offline client (RefreshChannelVisibility, revokeUnreadableChannels) +// are sent as targeted, unsequenced messages that are never written to +// the events table, so replay can never recover them. Without the +// MarkVisibilityChanged call below, a client resuming with last_seq at +// or before the pre-restart max would sail straight through +// mustFullResync's zeroed watermark and could silently miss a +// visibility change it should have converged on. func seedHubReplayState(ctx context.Context, hub *ws.Hub, database *db.DB, log *slog.Logger) { + seedHubSeqFloor(ctx, hub, database, log) + maxSeq, seedErr := database.GetMaxEventSeq(ctx) if seedErr != nil { - log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr) + log.Warn("event persistence: failed to read MAX(events.seq); hub seq still advanced from the persisted floor for this boot", "error", seedErr) return } if maxSeq <= 0 { @@ -846,6 +891,39 @@ func seedHubReplayState(ctx context.Context, hub *ws.Hub, database *db.DB, log * hub.MarkVisibilityChanged() } +// seedHubSeqFloor reserves and persists a fresh block of the hub's sequence +// space on every boot, independent of event persistence (OC-0210) — see +// seedHubReplayState's doc for why this is what actually closes the bug. A +// read or write failure against the settings table is logged and skipped +// rather than fatal: it leaves this one boot with the pre-fix exposure +// (plain Phase A ring-buffer behaviour) instead of blocking startup over a +// heuristic safety net. +func seedHubSeqFloor(ctx context.Context, hub *ws.Hub, database *db.DB, log *slog.Logger) { + var floor uint64 + raw, err := database.GetSetting(ctx, wsSeqFloorSettingKey) + switch { + case err == nil: + parsed, perr := strconv.ParseUint(raw, 10, 64) + if perr != nil { + log.Warn("event persistence: stored ws seq floor is not a valid uint64, resetting to 0", "value", raw, "error", perr) + break + } + floor = parsed + case errors.Is(err, db.ErrNotFound): + // No prior boot has ever reserved a floor — start from 0. + default: + log.Warn("event persistence: failed to read persisted ws seq floor; hub seq not advanced this boot", "error", err) + return + } + + newFloor := floor + wsSeqFloorReserve + if err := database.SetSetting(ctx, wsSeqFloorSettingKey, strconv.FormatUint(newFloor, 10)); err != nil { + log.Warn("event persistence: failed to persist advanced ws seq floor; hub seq not advanced this boot", "error", err) + return + } + hub.SeedSeq(newFloor) +} + // printBanner writes the startup banner to stderr (so it doesn't mix with // the structured log output on stdout). func printBanner(cfg *config.Config, ver string, tls bool) { diff --git a/Server/main_test.go b/Server/main_test.go index 76f936bb..b004a52f 100644 --- a/Server/main_test.go +++ b/Server/main_test.go @@ -16,6 +16,7 @@ import ( "github.com/owncord/server/admin" "github.com/owncord/server/auth" + "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/ws" ) @@ -166,3 +167,173 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) { bufTier, dbTier, fullTier) } } + +// waitForFirstRingBufferEntry polls hub's ring buffer until it holds at +// least one entry and returns that entry's seq, or fails the test after a +// timeout. BroadcastToAll enqueues onto the hub's dispatch channel and +// returns before a seq is actually assigned, so tests that need to know a +// real assigned seq must synchronize on this instead of assuming one. +func waitForFirstRingBufferEntry(t *testing.T, hub *ws.Hub) uint64 { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + if oldest := hub.ReplayBuffer().OldestSeq(); oldest != 0 { + return oldest + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for the first ring buffer entry to land") + } + time.Sleep(time.Millisecond) + } +} + +// waitForRingBufferNewestAtLeast polls hub's ring buffer until its newest +// entry's seq is >= target, or fails the test after a timeout. See +// waitForFirstRingBufferEntry on why this can't be a fixed sleep. +func waitForRingBufferNewestAtLeast(t *testing.T, hub *ws.Hub, target uint64) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + if newest := hub.ReplayBuffer().NewestSeq(); newest >= target { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for hub ring buffer newest seq to reach >= %d (currently %d)", + target, hub.ReplayBuffer().NewestSeq()) + } + time.Sleep(time.Millisecond) + } +} + +// TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync pins +// OC-0210: with event_persistence.enabled=false ("ring-buffer-only +// behaviour", config.go's EventPersistenceConfig.Enabled doc), every boot's +// h.seq previously started at 0 with an empty ring buffer, with nothing to +// distinguish this boot's own watermarks from a PRIOR boot's. A reconnecting +// client carrying a last_seq from a prior process's epoch was checked only +// against whatever the new epoch's ring buffer happened to hold; if the new +// epoch's traffic (e.g. other clients reconnecting first) had pushed seq past +// that stale value, EventsSinceFiltered reported it as an ordinary in-window +// replay instead of refusing it, silently handing back a different epoch's +// events as if they were a contiguous resume. +// +// This simulates exactly the repro: hub "A" (a prior boot) runs with +// persistence disabled and a client observes 40 broadcasts go by (its +// last_seq is whatever the 40th one's seq turns out to be — captured +// dynamically here rather than hardcoded, since the fix changes what that +// number actually is). Hub A is then stopped (restart) and a fresh hub "B" is +// booted with the same disabled config; other clients' traffic pushes hub B's +// own (unrelated) epoch's seq past that same watermark, then the client +// reconnects against hub B with its old last_seq — a watermark that has never +// existed in hub B's epoch. That resume must be forced onto the full-ready +// path; before the fix it silently resolves via the ordinary buffer tier +// instead. +func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer database.Close() //nolint:errcheck + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + ctx := context.Background() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := &config.Config{EventPersistence: config.EventPersistenceConfig{Enabled: false}} + + userID, err := database.CreateUser(ctx, "oc-0210-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + limiter := auth.NewRateLimiter() + + // --- Prior boot: hub A runs with persistence disabled. A client's + // last_seq ends up as whatever the 40th broadcast's real seq turns out to + // be — captured dynamically so this test holds regardless of what value + // scheme is in effect (raw 1..N pre-fix, or a seeded floor post-fix). --- + hubOld := ws.NewHub(database, limiter, nil) + go hubOld.Run() + if persister, prunerDone := runStartEventPersistence(ctx, log, cfg, hubOld, database); persister != nil || prunerDone != nil { + t.Fatalf("runStartEventPersistence with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone) + } + for range 40 { + hubOld.BroadcastToAll([]byte(`{"type":"broadcast"}`)) + } + oldFirstSeq := waitForFirstRingBufferEntry(t, hubOld) + staleLastSeq := oldFirstSeq + 39 // the 40th broadcast's seq: what a real client's lastSeq tracker would hold + waitForRingBufferNewestAtLeast(t, hubOld, staleLastSeq) + hubOld.Stop() + + // --- Restart: hub B is a brand-new process-equivalent hub, same disabled + // config, same (in-memory but never touched by persistence) database. --- + hubNew := ws.NewHub(database, limiter, nil) + go hubNew.Run() + defer hubNew.Stop() + if persister, prunerDone := runStartEventPersistence(ctx, log, cfg, hubNew, database); persister != nil || prunerDone != nil { + t.Fatalf("runStartEventPersistence with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone) + } + + // Other clients reconnect first and push hub B's own new epoch forward by + // 60 broadcasts — enough to overtake staleLastSeq pre-fix (repro's 1..60 + // window covering 40) and trivially so post-fix (the seeded floor alone + // already exceeds it). + for range 60 { + hubNew.BroadcastToAll([]byte(`{"type":"broadcast"}`)) + } + newFirstSeq := waitForFirstRingBufferEntry(t, hubNew) + newTargetSeq := newFirstSeq + 59 + waitForRingBufferNewestAtLeast(t, hubNew, newTargetSeq) + if newTargetSeq <= staleLastSeq { + t.Fatalf("test setup invariant broken: hub B's epoch (reached %d) never overtook the stale watermark (%d)", newTargetSeq, staleLastSeq) + } + + handler := ws.ServeWS(hubNew, database, []string{"*"}, 0) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // staleLastSeq is a watermark from hub A's epoch. It sits inside hub B's + // own live ring window, but nothing about it describes hub B's history — + // it must not be served by ordinary replay. + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": staleLastSeq, + }, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + if _, _, err := conn.Read(dialCtx); err != nil { + t.Fatalf("read handshake response: %v", err) + } + + bufTier, dbTier, fullTier := hubNew.ReconnectTierStats() + if fullTier != 1 { + t.Fatalf("reconnect tiers (buffer=%d db=%d full=%d): want full=1 — a last_seq from a prior epoch must never be served by ring-buffer replay in ring-buffer-only mode, since the server has no way to tell it apart from an in-epoch watermark", + bufTier, dbTier, fullTier) + } +} diff --git a/Server/service/channel.go b/Server/service/channel.go index 91bf0e1a..f8db8af8 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "time" - "unicode/utf8" "github.com/owncord/server/auth" "github.com/owncord/server/db" @@ -179,9 +178,14 @@ func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, var cleaned *string if customStatus != nil { - text := cleanText(*customStatus) - if utf8.RuneCountInString(text) > MaxCustomStatusLen { - return nil, fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen) + // OC-0195: bound the raw bytes before cleanText (sanitizeToFixpoint) + // runs — see cleanTextBounded's doc comment (user.go). This path is + // reachable over the WS presence_update frame, whose read limit is + // config.MaxMessageBytes (1 MiB), far larger than any REST body that + // reaches the equivalent guard on SetCustomStatus/UpdateProfile. + text, err := cleanTextBounded(*customStatus, MaxCustomStatusLen, "custom_status") + if err != nil { + return nil, err } cleaned = nullable(text) } diff --git a/Server/service/dm.go b/Server/service/dm.go index 4f47b0c2..6585e9f0 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "time" - "unicode/utf8" "github.com/owncord/server/auth" "github.com/owncord/server/db" @@ -231,9 +230,11 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID return nil, fmt.Errorf("%w: a group DM holds at most %d users", ErrBadRequest, db.MaxGroupDMParticipants) } - cleanName := cleanText(name) - if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen { - return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen) + // OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint) + // runs — see cleanTextBounded's doc comment (user.go). + cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name") + if err != nil { + return nil, err } for _, rid := range unique { @@ -320,9 +321,11 @@ func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest) } - cleanName := cleanText(name) - if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen { - return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen) + // OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint) + // runs — see cleanTextBounded's doc comment (user.go). + cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name") + if err != nil { + return nil, err } if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil { diff --git a/Server/service/dm_test.go b/Server/service/dm_test.go index 2925ca71..fa23c0cc 100644 --- a/Server/service/dm_test.go +++ b/Server/service/dm_test.go @@ -3,7 +3,9 @@ package service import ( "context" "errors" + "strings" "testing" + "time" "github.com/owncord/server/db" ) @@ -62,6 +64,69 @@ func TestDMService_CreateGroupDM_RefusesBannedRecipient(t *testing.T) { } } +// OC-0194: same defect as OC-0192/OC-0195 (see +// TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing and +// TestHandlePresenceUpdate_OversizedCustomStatusRejectedBeforeSanitizing) but +// reached via CreateGroupDM. /api/v1/dms carries no rate limiter, and +// CreateGroupDM runs cleanText(name) *before* the recipient-existence/ban/ +// block checks, so an adversarial nested-entity name pays the full quadratic +// sanitizeToFixpoint cost even for a request that is going to 404 on its +// recipients. The raw-byte guard must reject on cheap byte length alone. +func TestDMService_CreateGroupDM_OversizedNameRejectedBeforeSanitizing(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + svc := NewDMService(database) + + // Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's + // doc comment (message.go) for why this shape is quadratic to sanitize. + huge := "&" + strings.Repeat("amp;", 4000) + "lt;" + + start := time.Now() + // Recipients 999998/999999 do not exist. The guard must fire before the + // per-recipient GetUserByID/ban checks reach the database, matching the + // order CreateGroupDM actually runs them in. + _, err := svc.CreateGroupDM(context.Background(), 1, []int64{999998, 999999}, huge) + elapsed := time.Since(start) + if !errors.Is(err, ErrBadRequest) { + t.Errorf("CreateGroupDM with oversized name err = %v, want ErrBadRequest", err) + } + // A guard that runs before sanitizing rejects in well under a + // millisecond; the pre-fix code spends well over 150ms in + // sanitizeToFixpoint on this payload before the rune-count check ever + // runs. 150ms gives generous margin over noise while staying far below + // the unguarded cost. + if elapsed > 150*time.Millisecond { + t.Errorf("CreateGroupDM with oversized name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed) + } +} + +// OC-0194 sibling: RenameGroupDM runs the identical cleanText(name) call and +// must be bounded the same way as CreateGroupDM. +func TestDMService_RenameGroupDM_OversizedNameRejectedBeforeSanitizing(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUser(t, database, &db.User{ID: 3, Username: "carol"}) + svc := NewDMService(database) + + created, err := svc.CreateGroupDM(context.Background(), 1, []int64{2, 3}, "") + if err != nil { + t.Fatalf("setup CreateGroupDM: %v", err) + } + + huge := "&" + strings.Repeat("amp;", 4000) + "lt;" + + start := time.Now() + _, err = svc.RenameGroupDM(context.Background(), 1, created.Channel.ID, huge) + elapsed := time.Since(start) + if !errors.Is(err, ErrBadRequest) { + t.Errorf("RenameGroupDM with oversized name err = %v, want ErrBadRequest", err) + } + if elapsed > 150*time.Millisecond { + t.Errorf("RenameGroupDM with oversized name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed) + } +} + // cancelAfterCreateGroupDMStore wraps a real *db.DB and cancels a context the // instant CreateGroupDMChannel returns successfully — simulating a client // disconnect that lands exactly in the gap between the channel's commit and diff --git a/Server/service/mentions.go b/Server/service/mentions.go index 65e72f78..96bad826 100644 --- a/Server/service/mentions.go +++ b/Server/service/mentions.go @@ -176,7 +176,16 @@ func (s *MessageService) applyMentionCounts(ctx context.Context, channelID, msgI // here and a literal comparison would ping them with @here — the one // thing "appear offline" is meant to stop. Collapsing first makes // @here agree with what everyone else can see of that reader. - if set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline { + // + // That column check alone is not enough: users.status keeps a + // *chosen* idle/dnd across a disconnect by design + // (MarkUserDisconnected only ever rewrites "online" -> "offline"), + // so a signed-out reader whose last status was idle/dnd would still + // read as non-offline here. s.online (nil-safe) applies the read + // path's "no live connection is offline, whatever the row says" + // rule (ws/serve_ready.go presentableMembers) to close that gap. + if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || + (s.online != nil && !s.online(r.UserID))) { continue } recipients[r.UserID] = struct{}{} diff --git a/Server/service/mentions_test.go b/Server/service/mentions_test.go index 1a98d7d7..4bd3072b 100644 --- a/Server/service/mentions_test.go +++ b/Server/service/mentions_test.go @@ -301,6 +301,37 @@ func TestSendMessage_HereSkipsInvisibleUsers(t *testing.T) { } } +// TestSendMessage_HereSkipsDisconnectedIdleDndUsers locks OC-0223: @here must +// treat a reader with no live connection as offline even when their stored +// status is idle/dnd, matching the read path's "no live connection is +// offline, whatever the row says" rule (ws/serve_ready.go presentableMembers). +// MarkUserDisconnected only ever rewrites "online" -> "offline" — an idle/dnd +// choice survives the disconnect by design, so a bare +// db.BroadcastStatus(r.Status) == db.StatusOffline test can never catch a +// disconnected idle/dnd reader without also consulting live connection state. +func TestSendMessage_HereSkipsDisconnectedIdleDndUsers(t *testing.T) { + svc, _, database := newMentionFixture(t) + + // bob's last chosen status was "dnd" before disconnecting (mirrors what + // MarkUserDisconnected leaves behind for a non-"online" status). + if err := database.UpdateUserStatus(context.Background(), 2, db.StatusDND); err != nil { + t.Fatalf("UpdateUserStatus(dnd): %v", err) + } + // bob has no live connection. + svc.SetOnlineChecker(func(userID int64) bool { return userID != 2 }) + + sendAs(t, svc, 4, "@here quick question") + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("disconnected dnd bob mention_count = %d, want 0", got) + } + + // A plain @everyone still reaches them: only @here narrows on presence. + sendAs(t, svc, 4, "@everyone meeting now") + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("disconnected dnd bob @everyone mention_count = %d, want 1", got) + } +} + // TestSendMessage_EveryoneSkipsUsersWithoutRead locks that the @everyone // fan-out honors per-channel denies, not just the base role mask. func TestSendMessage_EveryoneSkipsUsersWithoutRead(t *testing.T) { diff --git a/Server/service/message.go b/Server/service/message.go index 8c8b3e5e..702a7f4d 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -133,6 +133,26 @@ type MessageService struct { // tests swap it for an inline runner via RunBackgroundInlineForTest so they // can read the counts deterministically right after a send. bg func(fn func()) + // online reports whether userID currently holds a live connection. It is + // wired by the ws layer (Hub.IsUserConnected) after both are constructed, + // so @here can apply the same "no live connection is offline, whatever the + // row stores" rule the read path uses (ws/serve_ready.go + // presentableMembers) instead of trusting users.status alone — that column + // keeps a *chosen* idle/dnd/invisible across a disconnect by design + // (MarkUserDisconnected only ever rewrites "online" -> "offline"), so a + // disconnected idle/dnd reader would otherwise still collect an @here + // badge. nil (the zero value, e.g. in tests and any caller with no hub) + // means "no live-connection information available" and applies no extra + // narrowing, preserving prior behavior. + online func(userID int64) bool +} + +// SetOnlineChecker wires the live-connection predicate @here's offline +// narrowing consults in addition to users.status. Passing nil clears it. Safe +// to call once at startup (the ws layer, after constructing both the Hub and +// the Services) or from a test. +func (s *MessageService) SetOnlineChecker(online func(userID int64) bool) { + s.online = online } // NewMessageService creates a MessageService. diff --git a/Server/service/profile_fields_test.go b/Server/service/profile_fields_test.go index 01c1d195..91f599a1 100644 --- a/Server/service/profile_fields_test.go +++ b/Server/service/profile_fields_test.go @@ -209,6 +209,49 @@ func TestUpdateProfile_RejectsOverlongFields(t *testing.T) { } } +// OC-0192: UpdateProfile is the one function every transport (the REST +// handler, and any future non-REST caller — see ProfilePatch's doc comment) +// goes through, so the raw-length bound belongs here, not only in the +// handler. cleanText (sanitizeToFixpoint) is quadratic in input length, and +// nothing bounds DisplayName/About before line 140/143 run it — the rune- +// count checks there run cleanText's full (expensive) output before ever +// looking at how long it is. A caller that hands UpdateProfile an +// adversarial nested-entity payload must be rejected on a cheap byte-length +// check, not after the fixpoint sanitizer has already paid its cost on it. +func TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing(t *testing.T) { + svc, _ := newUserSvc(t) + ctx := context.Background() + + // Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's + // doc comment (message.go) for why this shape is quadratic to sanitize. + huge := "&" + strings.Repeat("amp;", 4000) + "lt;" + + start := time.Now() + _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &huge}) + elapsed := time.Since(start) + if !errors.Is(err, ErrBadRequest) { + t.Errorf("oversized display_name err = %v, want ErrBadRequest", err) + } + // A guard that runs before sanitizing rejects in well under a + // millisecond; the pre-fix code spends well over 150ms in + // sanitizeToFixpoint on this payload before the rune-count check ever + // runs. 150ms gives generous margin over noise while staying far below + // the unguarded cost. + if elapsed > 150*time.Millisecond { + t.Errorf("oversized display_name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed) + } + + start = time.Now() + _, err = svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", About: &huge}) + elapsed = time.Since(start) + if !errors.Is(err, ErrBadRequest) { + t.Errorf("oversized about err = %v, want ErrBadRequest", err) + } + if elapsed > 150*time.Millisecond { + t.Errorf("oversized about took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed) + } +} + func TestSetCustomStatus_RoundTripClearAndBound(t *testing.T) { svc, database := newUserSvc(t) ctx := context.Background() @@ -293,6 +336,44 @@ func TestHandlePresenceUpdate_AcceptsInvisibleAndCarriesCustomStatus(t *testing. } } +// OC-0195: same defect as OC-0192 (TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing) +// but reached over presence_update instead of PATCH /users/me. HandlePresenceUpdate +// applied MaxCustomStatusLen to cleanText's *output*, so an adversarial +// nested-entity payload paid the full quadratic sanitizeToFixpoint cost before +// ever being measured. The WS read limit (config.MaxMessageBytes, 1 MiB) admits +// a payload here far larger than PATCH /users/me's body ever could, and this +// runs on the connection's own readPump goroutine. +func TestHandlePresenceUpdate_OversizedCustomStatusRejectedBeforeSanitizing(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database))) + ctx := context.Background() + + // Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's + // doc comment (message.go) for why this shape is quadratic to sanitize. + huge := "&" + strings.Repeat("amp;", 4000) + "lt;" + + start := time.Now() + _, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusOnline, &huge, nil) + elapsed := time.Since(start) + if !errors.Is(err, ErrBadRequest) { + t.Errorf("oversized custom_status err = %v, want ErrBadRequest", err) + } + // A guard that runs before sanitizing rejects in well under a + // millisecond; the pre-fix code spends well over 150ms in + // sanitizeToFixpoint on this payload before the rune-count check ever + // runs. 150ms gives generous margin over noise while staying far below + // the unguarded cost. + if elapsed > 150*time.Millisecond { + t.Errorf("oversized custom_status took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed) + } + // The rejected call must not have committed the status either. + u, _ := database.GetUserByID(ctx, 1) + if u.Status == db.StatusOnline { + t.Error("a rejected presence_update must not commit the status") + } +} + func TestHandlePresenceUpdate_RejectsUnknownStatusAndOverlongText(t *testing.T) { database := newTestDB(t) seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) diff --git a/Server/service/user.go b/Server/service/user.go index 95f64fcd..9358d4d8 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -114,6 +114,33 @@ func cleanText(v string) string { return strings.TrimSpace(sanitizeToFixpoint(v)) } +// cleanTextBounded is cleanText plus the raw-byte guard OC-0192 established +// for UpdateProfile's DisplayName/About fields, generalized for every other +// free-text field that runs through cleanText: SetCustomStatus, +// HandlePresenceUpdate's custom_status, and group DM names (OC-0195). +// +// cleanText's sanitizeToFixpoint pass is quadratic in input length, so a +// bound applied only to its *output* (a plain rune-count check on the +// cleaned string) still lets an adversarial nested-entity payload pay the +// full sanitize cost first — it can even sanitize down to something well +// under maxRunes and be silently accepted, having spent seconds of CPU to +// get there. The byte-length pre-check runs before cleanText ever does, on +// the untouched input, so the cost of rejecting an oversized value is +// O(len(v)) instead of the sanitizer's cost. *4 is deliberately looser than +// maxRunes — it exists only to keep the sanitizer from ever seeing a +// pathological payload, not to duplicate the real (rune-count) bound, which +// still runs afterward on the cleaned, trimmed value. +func cleanTextBounded(v string, maxRunes int, fieldName string) (string, error) { + if len(v) > maxRunes*4 { + return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes) + } + cleaned := cleanText(v) + if utf8.RuneCountInString(cleaned) > maxRunes { + return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes) + } + return cleaned, nil +} + // resolveOptional picks the column value for one nullable text field: the // sanitized patch when it was supplied, the existing row otherwise. func resolveOptional(patch *string, existing *string) *string { @@ -137,6 +164,23 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro span.End() }() + // OC-0192: bound the raw bytes before either reaches cleanText + // (sanitizeToFixpoint) below — its cost is quadratic in input length, + // and an adversarial nested-entity payload can sanitize down to + // something well under the rune-count bound while still costing seconds + // of CPU to get there, so the rune-count check alone never rejects it + // early. This is the same cheap byte-length pre-check the handler uses + // for username/avatar (profile_handler.go); *4 still admits any + // legitimate UTF-8 value at the rune bound. UpdateProfile is the one + // function every transport reaches (see ProfilePatch's doc comment), so + // the guard belongs here rather than only in the REST handler. + if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 { + return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen) + } + if patch.About != nil && len(*patch.About) > MaxAboutLen*4 { + return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen) + } + if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen { return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen) } @@ -199,9 +243,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro // value persists across reconnects and is cleared explicitly on logout, which // is why it is stored rather than held on the connection. func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text string) error { - cleaned := cleanText(text) - if utf8.RuneCountInString(cleaned) > MaxCustomStatusLen { - return fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen) + cleaned, err := cleanTextBounded(text, MaxCustomStatusLen, "custom_status") + if err != nil { + return err } if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil { return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err) diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 2fd1e67b..6baf42ea 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -119,7 +119,18 @@ func (h *Hub) handleMessageSessionRecheck(c *Client) bool { if shouldCheck && c.tokenHash != "" { result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash) - if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { + if dbErr != nil { + // A failed read says nothing about this session's validity — + // kicking the client on a transient DB error (SQLITE_BUSY, an + // I/O error, a maintenance window) would be a false positive. + // Skip this recheck; the next one retries, and + // sweepRevokedSessions remains the time-based backstop for + // idle connections. Matches sweepRevokedSessions's identical + // rule for a failed batch lookup (hub_sweep.go). + slog.Warn("ws session recheck: lookup failed, skipping", "user_id", c.userID, "err", dbErr) + return false + } + if result == nil || auth.IsSessionExpired(result.ExpiresAt) { slog.Info("ws session expired, closing connection", "user_id", c.userID) h.kickClient(c) return true diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 90cdfbb3..d34d85d6 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -3,6 +3,7 @@ package ws import ( "context" "errors" + "log/slog" "github.com/owncord/server/db" "github.com/owncord/server/service" @@ -182,6 +183,13 @@ func serviceErrorToResult(err error) Result { case errors.Is(err, service.ErrConflict): return Result{Error: ClientError{Code: ErrCodeConflict, Message: err.Error()}} default: - return Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}} + // Internal errors (service.ErrInternal wrappers embed the underlying + // driver error via %v) must not reach the client verbatim, matching + // the REST twin writeServiceError (Server/api/channel_handler.go) and + // every other ErrCodeInternal site in this package. Log server-side + // since this is the only ErrCodeInternal path whose caller + // (handlers.go) skips its own logging for ClientError results. + slog.Error("ws service internal error", "err", err) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}} } } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index fd79179f..38afcfba 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -179,6 +179,11 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * callDeps.DMSvc = svc.DMs h.messageSvc = svc.Messages h.perms = svc.Permissions + // So @here's offline narrowing can tell a disconnected idle/dnd reader + // (users.status keeps their last *chosen* value across a disconnect) + // from one who is actually still connected — the same live-connection + // rule presentableMembers applies to the members array. + svc.Messages.SetOnlineChecker(h.IsUserConnected) } registerChatHandlers(reg, chatDeps) diff --git a/Server/ws/oc_0211_session_recheck_dberr_test.go b/Server/ws/oc_0211_session_recheck_dberr_test.go new file mode 100644 index 00000000..a98349ac --- /dev/null +++ b/Server/ws/oc_0211_session_recheck_dberr_test.go @@ -0,0 +1,61 @@ +package ws + +// Internal test for OC-0211: handleMessageSessionRecheck must not treat a +// transient DB error the same as a genuinely revoked/expired session. The +// sibling sweep in hub_sweep.go (sweepRevokedSessions) already documents and +// implements the correct rule for the identical failure: "a failed batch +// lookup says nothing about any individual session — kicking everyone on a +// transient DB error would be a mass disconnect. Skip this sweep; the next +// tick retries." handleMessageSessionRecheck disagreed, kicking the client on +// dbErr != nil exactly like a deleted/expired session. + +import ( + "context" + "testing" + + "github.com/owncord/server/auth" +) + +func TestHandleMessageSessionRecheck_TransientDBErrorDoesNotKick(t *testing.T) { + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "recheck-dberr") + + tokenHash := "tok-recheck-dberr" + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test-device", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + c := NewTestClient(h, uid, make(chan []byte, 8)) + c.tokenHash = tokenHash + // Put the client one message away from the periodic recheck boundary, so + // the very next call to handleMessageSessionRecheck triggers the DB read. + c.msgCount = SessionCheckInterval - 1 + h.clients[uid] = c + + // Force GetSessionWithBanStatus to fail with a genuine DB error (not + // sql.ErrNoRows, which the production code already treats as "session + // gone" — that path is not in question here). Closing the underlying + // connection pool reproduces the same dbErr != nil branch a transient + // SQLITE_BUSY, I/O error, or maintenance window would. + if err := database.Close(); err != nil { + t.Fatalf("database.Close: %v", err) + } + + closed := h.handleMessageSessionRecheck(c) + + if closed { + t.Fatalf("handleMessageSessionRecheck reported the connection closed on a transient DB lookup error; " + + "a failed read is not evidence the session is invalid (compare sweepRevokedSessions, which skips on the same failure)") + } + + h.mu.RLock() + _, stillConnected := h.clients[uid] + h.mu.RUnlock() + if !stillConnected { + t.Fatalf("client was removed from h.clients on a transient DB lookup error during session recheck") + } + if c.isSendClosed() { + t.Fatalf("client's send channels were closed on a transient DB lookup error during session recheck") + } +} diff --git a/Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go b/Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go new file mode 100644 index 00000000..5a471fd2 --- /dev/null +++ b/Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go @@ -0,0 +1,100 @@ +package ws + +// oc_0219_voice_join_rollback_unsubscribe_test.go — regression test for +// finding OC-0219. +// +// rollbackVoiceJoin clears the client's voice channel ID but never drops its +// VoiceTopic subscription. voiceJoinComplete subscribes the joiner to +// VoiceTopic(channelID) (voice_join.go) BEFORE it reads back the channel's +// existing participants via GetChannelVoiceStates; when that read fails, the +// handler calls rollbackVoiceJoin to undo the join. Every other path that +// takes a client out of voice while its WS stays up (clearVoiceAndUnsubscribe +// in voice_leave.go, and its callers) also drops the VoiceTopic subscription +// — rollbackVoiceJoin is the only one that does not. A socket left subscribed +// after a failed join keeps receiving that room's voice_e2ee_announce relays +// (which carry no channel_id to filter on) for the rest of the connection, +// polluting whatever voice session the client joins next. +// +// This reuses voiceJoinPostTokenRaceHook (test-only plumbing shared with +// OC-0008 and OC-0172) to fault-inject a GetChannelVoiceStates failure inside +// voiceJoinComplete, landing strictly after h.pubsub.Subscribe has already +// run for this join. + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic pins +// OC-0219: rollbackVoiceJoin must drop the client's VoiceTopic subscription, +// not just its in-memory voiceChID, so a socket that failed mid-join stops +// receiving that room's E2EE relays. +func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.T) { + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "join-0219-victim") + chID := mustCreateVoiceChannel(t, database, "voice-join-0219") + + lk, err := NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-0219", + LiveKitAPISecret: "test-api-secret-0219-xyz", + LiveKitURL: "ws://127.0.0.1:1", // never dialed: GenerateToken is local + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + h.SetLiveKit(lk) + + send := make(chan []byte, 8) + c := NewTestClient(h, uid, send) + c.user = &db.User{ID: uid, Username: "join-0219-victim"} + h.mu.Lock() + h.clients[uid] = c + h.mu.Unlock() + + // Fault-inject the GetChannelVoiceStates call inside voiceJoinComplete — + // same technique as the OC-0172 regression test. This hook fires after + // GenerateToken succeeds and strictly before voiceJoinComplete's + // h.pubsub.Subscribe call runs, so by the time GetChannelVoiceStates + // executes the client is already subscribed to VoiceTopic(chID). + var hookRan bool + voiceJoinPostTokenRaceHook = func(client *Client) { + hookRan = true + if _, err := database.ExecContext(context.Background(), `ALTER TABLE users RENAME TO users_bak_0219`); err != nil { + t.Fatalf("hook: rename users: %v", err) + } + } + defer func() { voiceJoinPostTokenRaceHook = nil }() + + payload, _ := json.Marshal(map[string]any{"channel_id": chID}) + h.handleVoiceJoin(context.Background(), c, json.RawMessage(payload)) + + if !hookRan { + t.Fatal("voiceJoinPostTokenRaceHook never fired — test setup is broken, not exercising the join path") + } + + drainChan(send, 200*time.Millisecond) + + // Sanity: the in-memory voiceChID was rolled back (OC-0172 already pins + // this half of the cleanup). + if gotCh := c.getVoiceChID(); gotCh != 0 { + t.Fatalf("client voiceChID = %d after GetChannelVoiceStates failed mid-join, want 0 (rolled back)", gotCh) + } + + // The bug: rollbackVoiceJoin must also drop the VoiceTopic subscription + // that voiceJoinComplete already established. Left in place, this socket + // keeps receiving voice_e2ee_announce relays for chID indefinitely. + topic := VoiceTopic(chID) + for _, tp := range h.pubsub.TopicsForClient(uid) { + if tp == topic { + t.Fatalf("client is still subscribed to %q after rollbackVoiceJoin — E2EE relays for this channel will keep reaching a socket that never finished joining it", topic) + } + } +} diff --git a/Server/ws/oc_0222_reconnect_status_order_test.go b/Server/ws/oc_0222_reconnect_status_order_test.go new file mode 100644 index 00000000..d09405ac --- /dev/null +++ b/Server/ws/oc_0222_reconnect_status_order_test.go @@ -0,0 +1,145 @@ +package ws + +// oc_0222_reconnect_status_order_test.go — regression test for OC-0222. +// +// handleReconnect wrote the resume handshake's auth_ok (reconnectWriteReplay, +// which reads c.user.Status) BEFORE calling applyConnectStatus, which is what +// settles c.user.Status via db.ConnectStatus(saved) and persists it. So a +// resumed auth_ok always carried the disconnect-time status rather than the +// status the session is about to come online as. +// +// Concretely: MarkUserDisconnected rewrites a plain "online" user to +// "offline" on socket loss. On a fast reconnect (still covered by the ring +// buffer, so the buffer-tier replay path is taken) the resumed auth_ok's +// payload.user.status must reflect db.ConnectStatus("offline") == "online" — +// matching what applyConnectStatus is about to write and broadcast — not the +// raw "offline" row value read moments earlier by refreshUserSnapshot. +// +// handleFreshConnect already gets this right: it calls applyConnectStatus +// before building auth_ok (serve.go, handleFreshConnect). This test locks the +// same ordering for the resume path. + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +func TestReconnect_AuthOKReflectsSettledStatus_NotDisconnectTimeStatus(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "resume-status-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // Establish the user as a plain "online" session, then simulate the + // socket loss that precedes every reconnect: MarkUserDisconnected only + // rewrites a plain "online" row to "offline" (idle/dnd/invisible survive + // untouched), so this is the ordinary case, not a contrived one. + if err := database.UpdateUserStatus(ctx, userID, db.StatusOnline); err != nil { + t.Fatalf("UpdateUserStatus(online): %v", err) + } + if err := database.MarkUserDisconnected(ctx, userID); err != nil { + t.Fatalf("MarkUserDisconnected: %v", err) + } + pre, err := database.GetUserByID(ctx, userID) + if err != nil || pre == nil { + t.Fatalf("GetUserByID (precondition): %v", err) + } + if pre.Status != db.StatusOffline { + t.Fatalf("precondition: expected status=offline after MarkUserDisconnected, got %q", pre.Status) + } + + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + hub := NewHub(database, auth.NewRateLimiter(), nil) + go hub.Run() + defer hub.Stop() + + // Global (channel_id 0) frames bracketing last_seq=99, so the resume takes + // the buffer tier rather than falling through to a full ready (which also + // sends auth_ok, but via handleFreshConnect's already-correct ordering — + // asserting on that path would not exercise the bug). + rb := hub.ReplayBuffer() + rb.Push(98, 0, []byte(`{"seq":98,"type":"presence","payload":{}}`)) + rb.Push(99, 0, []byte(`{"seq":99,"type":"presence","payload":{}}`)) + rb.Push(100, 0, []byte(`{"seq":100,"type":"presence","payload":{}}`)) + + srv := httptest.NewServer(ServeWS(hub, database, []string{"*"}, 0)) + defer srv.Close() + + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + raw, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": uint64(99), + }, + }) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second) + defer readCancel() + _, msg, err := conn.Read(readCtx) + if err != nil { + t.Fatalf("read handshake response: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(msg, &parsed); err != nil { + t.Fatalf("unmarshal handshake response: %v; raw=%s", err, msg) + } + if parsed["type"] != MsgTypeAuthOK { + t.Fatalf("expected auth_ok (buffer-tier resume), got %v; raw=%s", parsed["type"], msg) + } + payload, _ := parsed["payload"].(map[string]any) + if payload["replay_source"] != "buffer" { + t.Fatalf("expected replay_source=buffer (so this exercises handleReconnect, not the fresh-connect fallback), got %v", payload["replay_source"]) + } + userField, _ := payload["user"].(map[string]any) + gotStatus, _ := userField["status"].(string) + if gotStatus != db.StatusOnline { + t.Fatalf("resumed auth_ok payload.user.status = %q, want %q (db.ConnectStatus of the pre-reconnect \"offline\" row) — "+ + "the resumed auth_ok must carry the status the session is settling on, not the stale disconnect-time row value", + gotStatus, db.StatusOnline) + } + + // The persisted row must agree with what auth_ok claimed — applyConnectStatus + // must have actually run and been visible before/at the point auth_ok was + // built, not merely be about to run after the client already parsed the + // (wrong) value. + post, err := database.GetUserByID(ctx, userID) + if err != nil || post == nil { + t.Fatalf("GetUserByID (postcondition): %v", err) + } + if post.Status != db.StatusOnline { + t.Fatalf("persisted status after reconnect = %q, want %q", post.Status, db.StatusOnline) + } +} diff --git a/Server/ws/oc_0237_service_error_internal_test.go b/Server/ws/oc_0237_service_error_internal_test.go new file mode 100644 index 00000000..5b16ece5 --- /dev/null +++ b/Server/ws/oc_0237_service_error_internal_test.go @@ -0,0 +1,64 @@ +package ws + +// Internal test for OC-0237: serviceErrorToResult's default branch (the one +// hit for service.ErrInternal, since ErrInternal has no dedicated case above +// it) put err.Error() straight into the ClientError sent to the requesting +// client, and never logged anything server-side. Service-layer ErrInternal +// wrappers embed the underlying driver error via %v (see Server/service/dm.go), +// so this leaked internal query names and driver state to an ordinary member, +// while producing zero server-side log output — handlers.go only logs when +// result.Error is NOT a ClientError. The REST twin, writeServiceError in +// Server/api/channel_handler.go, does the opposite: it logs the error and +// replies with the fixed string "an internal error occurred". + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/owncord/server/service" +) + +func TestServiceErrorToResult_InternalErrorDoesNotLeakAndIsLogged(t *testing.T) { + prev := slog.Default() + var buf bytes.Buffer + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + // Mirrors Server/service/dm.go:149 — a real ErrInternal wrapper embedding + // driver error text via %v, exactly what handlers_call.go's RingTargets + // call produces when GetDMParticipantIDs fails. + driverErr := errors.New("GetDMParticipantIDs: database is locked") + svcErr := fmt.Errorf("%w: failed to read DM participants: %v", service.ErrInternal, driverErr) + + result := serviceErrorToResult(svcErr) + + ce, ok := result.Error.(ClientError) + if !ok { + t.Fatalf("serviceErrorToResult(ErrInternal wrapper) did not return a ClientError, got %T", result.Error) + } + if ce.Code != ErrCodeInternal { + t.Fatalf("ClientError.Code = %q, want %q", ce.Code, ErrCodeInternal) + } + + // The client-facing message must not leak driver/query internals — it + // must match every other ErrCodeInternal site in this package, which all + // use a fixed string (deps.go, registry.go, voice_controls.go, serve.go). + if strings.Contains(ce.Message, "database is locked") || strings.Contains(ce.Message, "GetDMParticipantIDs") { + t.Fatalf("ClientError.Message leaked internal error detail to the client: %q", ce.Message) + } + if ce.Message == svcErr.Error() { + t.Fatalf("ClientError.Message is the raw wrapped service error verbatim: %q", ce.Message) + } + + // Unlike the REST path (writeServiceError), and unlike this same handler + // path for every other error class, nothing was ever written to the + // server log for an internal error — the operator had no record the + // failure happened at all. + if !strings.Contains(buf.String(), "database is locked") { + t.Fatalf("serviceErrorToResult did not log the internal error server-side; log output: %q", buf.String()) + } +} diff --git a/Server/ws/ringbuffer.go b/Server/ws/ringbuffer.go index 353a576d..95926215 100644 --- a/Server/ws/ringbuffer.go +++ b/Server/ws/ringbuffer.go @@ -133,3 +133,13 @@ func (rb *EventRingBuffer) OldestSeq() uint64 { oldestIdx := (rb.pos - rb.count + rb.size) % rb.size return rb.entries[oldestIdx].seq } + +// NewestSeq returns the highest sequence number in the buffer, or 0 if empty. +func (rb *EventRingBuffer) NewestSeq() uint64 { + rb.mu.RLock() + defer rb.mu.RUnlock() + if rb.count == 0 { + return 0 + } + return rb.newestSeqLocked() +} diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 6a97d447..f4e557da 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -277,6 +277,15 @@ func (h *Hub) handleReconnect( events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...) } + // Settle the session's status BEFORE the auth_ok write below, mirroring + // handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status + // to build auth_ok, so if this ran after that write the resumed client + // would be told its disconnect-time status (routinely "offline", since + // MarkUserDisconnected just rewrote it) instead of the status it is about + // to come online as and broadcast (OC-0222). Skips member_join — the user + // was already known. + applyConnectStatus(ctx, database, c) + if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) { // startPumps=false: the teardown inside reconnectWriteReplay already ran // in full. Starting readPump on this closed conn would hit an immediate @@ -285,8 +294,6 @@ func (h *Hub) handleReconnect( return true, false } - // Update presence but skip member_join — user was already known. - applyConnectStatus(ctx, database, c) h.announceConnectPresence(c) return true, true diff --git a/Server/ws/serve_auth.go b/Server/ws/serve_auth.go index 28345347..00c53189 100644 --- a/Server/ws/serve_auth.go +++ b/Server/ws/serve_auth.go @@ -58,13 +58,17 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db hash := auth.HashToken(p.Token) sess, err := database.GetSessionByTokenHash(ctx, hash) - if err != nil || sess == nil { + if err != nil { + // DB outage, not a bad token — send a non-terminal error frame so the + // client's normal backoff/reconnect logic retries instead of treating + // this like a genuinely invalid session (buildAuthError is defined as + // non-recoverable on the wire: the client stops reconnecting and + // clears its stored credentials on that frame). + _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry")) + return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err) + } + if sess == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) - if err != nil { - // DB outage, not a bad token — carry the cause so the caller's log - // distinguishes it from an ordinary invalid-token rejection. - return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err) - } return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session") } @@ -74,11 +78,13 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db } user, err := database.GetUserByID(ctx, sess.UserID) - if err != nil || user == nil { + if err != nil { + // Same DB-outage-vs-bad-credential distinction as above. + _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry")) + return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err) + } + if user == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found")) - if err != nil { - return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err) - } return nil, "", resumeHint{}, fmt.Errorf("auth: user not found") } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index defff796..ff1a20e8 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -640,7 +640,16 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo // the row back far enough to learn it), the row is re-read here and the // delete is skipped unless it still names channelID. func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) { - c.clearVoiceChID() + // OC-0219: use clearVoiceAndUnsubscribe (not the bare clearVoiceChID) so a + // join that already reached voiceJoinComplete's h.pubsub.Subscribe call + // drops its VoiceTopic subscription along with its in-memory voiceChID — + // exactly like every other path that takes a client out of voice while its + // WS stays up (see clearVoiceAndUnsubscribe's doc comment in + // voice_leave.go). Safe for the two earlier call sites too: + // Unsubscribe is a documented no-op when the client was never subscribed + // to that topic (pubsub.go), which is the case whenever this fires before + // voiceJoinComplete's Subscribe has run. + h.clearVoiceAndUnsubscribe(c) // The client's voice state is now set before token generation (BUG-088), // so a concurrent join/leave in the same channel can have elected this // half-joined client key holder. Re-run the election after taking it back diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index b7c4b13e..e2ca6cb5 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -281,6 +281,89 @@ func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) { } } +// TestAuthenticateConn_SessionLookupDBError_NotTerminal verifies OC-0196: a +// transient DB error while looking up the session (GetSessionByTokenHash +// returning a genuine error rather than sql.ErrNoRows) must NOT be reported +// as the terminal auth_error frame. The client treats auth_error as +// non-recoverable — it stops reconnecting and clears the user's stored +// credentials (see Client/tauri-client/src/lib/ws.ts and dispatcher.ts) — so +// collapsing "DB unreachable" into "bad token" force-logs-out every client +// that reconnects during a sub-second SQLite hiccup even though its session +// row is perfectly valid. A DB error must surface as a non-terminal error +// frame instead, so the client's normal backoff/reconnect logic retries. +func TestAuthenticateConn_SessionLookupDBError_NotTerminal(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter, nil) + go hub.Run() + defer hub.Stop() + + userID, err := database.CreateUser(context.Background(), "db-hiccup-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}, 0) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, resp, err := websocket.Dial(ctx, wsURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Simulate a transient DB outage AFTER the session/token above were + // written successfully: close the database so the next query + // (GetSessionByTokenHash, made when the auth frame below is processed) + // returns a genuine driver error instead of (nil, nil). The session row + // itself remains logically valid — this models momentary SQLite reader + // contention (WAL checkpoint, backup, busy_timeout), not a bad token. + if err := database.Close(); err != nil { + t.Fatalf("database.Close: %v", err) + } + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write: %v", err) + } + + _, respRaw, readErr := conn.Read(ctx) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + var msg map[string]any + if err := json.Unmarshal(respRaw, &msg); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if msg["type"] == ws.MsgTypeAuthError { + t.Errorf("got terminal %q frame for a transient DB error — the client "+ + "treats this as non-recoverable and clears stored credentials; a DB "+ + "hiccup must surface as a retryable error instead", ws.MsgTypeAuthError) + } + if msg["type"] != ws.MsgTypeError { + t.Errorf("response type = %q, want %q (non-terminal error frame)", msg["type"], ws.MsgTypeError) + } +} + // TestServeWS_ValidAuth_FullHandshake verifies the complete happy path: // valid token → auth_ok + ready received, client counted in hub. func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { diff --git a/docs/api.md b/docs/api.md index 810c31b7..e14d8079 100644 --- a/docs/api.md +++ b/docs/api.md @@ -995,6 +995,11 @@ Create or retrieve a 1-on-1 DM channel with another user. If a DM channel alread } ``` +On a newly created channel (`201`, `"created": true`) the recipient also +receives a `dm_channel_open`. Re-opening an existing DM (`200`) emits nothing — +it only touches the caller's own open state. The creator is not sent the event +on either path; it learns the channel from the response body above. + --- ### GET /api/v1/dms diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md index b51bf0d0..65c944c1 100644 --- a/docs/architecture/ux/channels-members-dms.md +++ b/docs/architecture/ux/channels-members-dms.md @@ -144,7 +144,7 @@ sequenceDiagram P->>API: POST /dms {recipient_id} API-->>P: DM channel P->>DM: open DM mode + focus channel - Note over U,DM: server also broadcasts dm_channel_open to both parties + Note over U,DM: on a newly created DM the server sends dm_channel_open to the recipient ``` ### 3.1a Group DMs