From 8cf019c03fe20fae88e450ebb21f667d28bbd6d2 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:32:07 +0200 Subject: [PATCH] fix: 40 correctness fixes from the 2026-08-19 bug hunt (#1392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(identity): 1 defect(s) (OC-0151) * fix(ws): 1 defect(s) (OC-0152) * fix(admin): 1 defect(s) (OC-0153) * fix(admin): 1 defect(s) (OC-0154) * fix(voice): 2 defect(s) (OC-0155, OC-0167) Replace distributeRoomKey's per-call offer counter with an instance-level sliding-window budget shared by every voice_e2ee_offer send path. - OC-0155: back-to-back rotations (the second run immediately by drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their combined sends could exceed the server's single per-second cap. - OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing entirely, letting a key holder joining a large ongoing call burst every queued announce's offer unpaced. The shared budget is reset in clearState() since the server's limit is scoped per (sender, channel). * fix(client): 1 defect(s) (OC-0156) createPresenceSender dropped a queued custom_status when a later plain status change superseded the pending retry. The retry now carries the last committed custom_status forward. * fix(client): 2 defect(s) (OC-0160, OC-0163) OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message size limit and run the guard after parsing. A 'ready' frame grows unbounded with member/channel/DM counts and carries no seq, so dropping it left the client on empty stores with no error and no recovery path. OC-0163: bracket a bare IPv6 host when building the wss:// URL so the authority parses, and collapse bracketed/bare IPv6 literals to the same cert_store_key so one server is not pinned (and user-confirmed) twice. * fix(voice): 1 defect(s) (OC-0162) updatePttKey armed the Rust poller when a PTT key was bound mid-call but never applied the gate. The poller only emits 'ptt-state' on a press/release transition, so an idle key produced no event and the already-published mic stayed hot until the user's first physical press+release. Mirror the join-time gate computation in updatePttKey, guarded on being in a call, polling actually being live, and the mic not already being gated. * fix(client): 1 defect(s) (OC-0164) * fix(plugin): 1 defect(s) (OC-0165) scanPluginDirectory now skips a malformed plugin subdirectory and joins its error instead of aborting the whole scan, and LoadAll logs-and-continues so one bad plugin directory cannot disable every other plugin. * fix(ws): 1 defect(s) (OC-0166) Route PresenceSelfEvent onto the owner's normal-priority queue instead of letting it fall through to the UserTargetedEvent high-priority case, so a user's own presence frames all share one FIFO and cannot be delivered out of order relative to the visible presence_update path. * fix(db): 1 defect(s) (OC-0168) * fix(client): 1 defect(s) (OC-0169) * fix(client): 1 defect(s) (OC-0171) addMessage appended a broadcast at the tail even when trailing optimistic rows were still unreconciled, so a message that committed while our own send was in flight ended up ordered behind the row confirmSend later stamped with a higher server id/timestamp. Insert before the trailing unreconciled run instead. * fix(voice): 1 defect(s) (OC-0172) * fix(client): 1 defect(s) (OC-0174) * fix(ws): 1 defect(s) (OC-0175) * fix(client): 1 defect(s) (OC-0177) * fix(client): 1 defect(s) (OC-0178) * fix(voice): 1 defect(s) (OC-0179) Undeafening no longer sends a voice_mute{muted:false} the server will refuse while a moderator-imposed mute stands, matching the localServerMuted guard already present in onMuteToggle. * fix(client): 1 defect(s) (OC-0182) * fix(plugin): 1 defect(s) (OC-0183) * fix(client): 1 defect(s) (OC-0184) Treat a trailing underscore as an emphasis delimiter, not part of the URL, when scanning for the end of an autolinked URL. * fix(client): 1 defect(s) (OC-0185) Reveal .msg-actions-bar on .message:focus-within, not only on hover, so keyboard users can see the per-message action buttons they Tab into instead of activating them at opacity: 0. Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * fix(client): 1 defect(s) (OC-0186) * fix(client): 1 defect(s) (OC-0187) The Add Server modal validated addresses with its own narrower regex that never gained IPv6 support when api.ts's validator did, so an IPv6 server could be logged into but never saved as a profile. Extract the validator into src/lib/hostValidation.ts and use it from both call sites. * fix(client): 1 defect(s) (OC-0189) DM sidebar rows dropped mention counts entirely and the header total excluded muted conversations outright, so a direct mention in a muted DM was invisible. Render a mention badge that outranks the plain unread badge, and count a muted channel's mentionCount toward the header total. * fix(client): 1 defect(s) (OC-0190) * fix(client): 1 defect(s) (OC-0191) * fix(client): 2 defect(s) (OC-0157, OC-0176) * fix(client): 1 defect(s) (OC-0161) confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch. * fix(admin): 1 defect(s) (OC-0173) * fix(identity): 1 defect(s) (OC-0180) * fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158) handlePatchChannel commits the AdminUpdateChannel write, then re-reads the channel to drive voice eviction and the visibility fan-out. When that post-commit re-read failed, the handler returned early: the archive was durable but connected clients were never told and voice members were never evicted, leaving users talking in a channel that no longer exists for them. Drive the post-commit work off the values already in hand rather than abandoning it when the re-read fails. Adds SetPatchChannelPostCommitHook so the test can land a cancellation in that exact window deterministically instead of racing wall-clock timing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * fix(admin): role changes commit with no client ever notified (OC-0170) broadcastRoles derived its context from the inbound *http.Request, so the roles_update fan-out was tied to the request lifetime. A role create, update, or delete could commit to the database and then broadcast nothing once that request context was done, leaving every connected client on a stale role list until the next full resync. Decouple the fan-out from the request context so the broadcast follows the commit rather than the caller. Adds BroadcastRolesForTest to reach broadcastRoles from the external test package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * fix(client): username rename stomps the profile card header (OC-0188) The account profile card's header is a resolveDisplayName() slot, but the username-rename save path wrote the raw username straight into it. A user with a display name set would see the header switch from their display name to their new username after a rename, disagreeing with every other surface that renders the same identity. Resolve the header through the same display-name path the initial render uses, so a rename updates the username field without touching the header. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * fix(client): settings overlay never focuses when mounted already-open (OC-0181) mount() synced initial state — including the show() that calls focusDialog() — before appending root to the container. .focus() on a still-detached subtree is a silent no-op, so a caller that mounts while uiStore.settingsOpen is already true (ConnectPage's lazy first-open path) got a visible overlay whose focus trap never captured focus: keyboard users landed outside the dialog with Tab escaping to the page behind it. Attach root before syncing initial state so focusDialog() runs against a connected subtree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * chore: satisfy the CI gates for this fix batch The fix batch's own commits left three CI gates red. Nothing here changes behaviour; every edit is a lint, type, or formatting correction to code this batch introduced. golangci-lint: - OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer, and OC-0151 the last use of api's sanitizer, leaving both package-level bluemonday vars unused. Remove them along with the now-unused imports, and reword the comments that named them so they still explain why the fixpoint sanitizer is the right one without pointing at deleted symbols. - Modernize the new handshake-deadline test's loop to range-over-int. tsc --noEmit: - jsdom ships no types and @types/jsdom is not a dependency, so declare the surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts. - Narrow the last-call lookup instead of indexing under noUncheckedIndexedAccess, with an explicit failure message. - membersStore.setState replaces whole state, so the presence-sender mocks must supply typingUsers. prettier: reformat the five files this batch touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * chore(ledger): record the 2026-08-19 hunt and its fixes Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40 fixed on this branch, each with its commit, the test that pins it, and revertProof "pass". "pass" means an independent check, not the fixing agent's self-report: every commit had its source diff reverted against the working tree, its own test re-run and required to FAIL, then the source restored and the test required to PASS. Commits whose tests live inline in Rust #[cfg(test)] blocks were proven the same way at hunk level, splicing the pre-fix source onto the post-fix test module. OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and flow-message lenses independently found the same unbounded handshake write and proposed the same helper over the same call sites. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK * test(e2e): make the voice-roster join fixture self-consistent The voice-widget join test emitted a voice_state for user_id 4 claiming username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE. A real server never sends a voice_state whose username disagrees with the member record for that id, and the same file's VOICE_STATE_EVENT already pairs id 1 with "testuser" correctly — this one event was the outlier. The contradiction was invisible while the roster rendered the payload's raw username. OC-0177 makes it resolve identity through membersStore so a nickname shows the same in voice as everywhere else, at which point the fixture's own inconsistency surfaced as a failure. Send id 4's real username and assert on it. The test still covers what it did before — a genuine join by a user not previously in voice, asserted by name and by roster count. Verified against the app unchanged: with the old fixture the spec fails 1/5 (matching CI), with this one it passes 5/5. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK --------- Co-authored-by: Claude --- .superpowers/FINDINGS.md | 1090 ++++++++++++++++- .superpowers/findings-ledger.json | 943 +++++++++++++- .../src-tauri/src/secret_store.rs | 61 +- Client/tauri-client/src-tauri/src/tofu.rs | 38 +- .../src/components/ChannelSidebar.ts | 11 +- .../tauri-client/src/components/MemberList.ts | 19 +- .../src/components/SettingsOverlay.ts | 9 +- .../src/components/TypingIndicator.ts | 8 +- .../src/components/message-list/markdown.ts | 2 +- .../src/components/message-list/reactions.ts | 29 +- .../src/components/settings/AccountTab.ts | 13 +- Client/tauri-client/src/lib/a11y.ts | 7 +- Client/tauri-client/src/lib/admin-panel.ts | 10 +- Client/tauri-client/src/lib/api.ts | 43 +- Client/tauri-client/src/lib/dispatcher.ts | 7 +- Client/tauri-client/src/lib/hostValidation.ts | 34 + Client/tauri-client/src/lib/livekitE2EE.ts | 139 ++- Client/tauri-client/src/lib/presence.ts | 46 +- Client/tauri-client/src/lib/ptt.ts | 19 +- Client/tauri-client/src/lib/types.ts | 14 + Client/tauri-client/src/lib/ws.ts | 43 +- Client/tauri-client/src/main.ts | 29 +- Client/tauri-client/src/pages/MainPage.ts | 21 +- .../src/pages/connect-page/ServerPanel.ts | 8 +- .../src/pages/main-page/SidebarArea.ts | 5 +- .../src/pages/main-page/SidebarDmSection.ts | 21 +- .../src/pages/main-page/VoiceCallbacks.ts | 9 +- .../tauri-client/src/stores/messages.store.ts | 14 +- Client/tauri-client/src/styles/app.css | 3 +- .../tests/e2e/voice-widget.spec.ts | 10 +- Client/tauri-client/tests/types/jsdom.d.ts | 16 + Client/tauri-client/tests/unit/a11y.test.ts | 41 + .../tests/unit/admin-panel.test.ts | 16 + .../unit/admin-static-channel-perms.test.ts | 142 +++ Client/tauri-client/tests/unit/api.test.ts | 14 + .../tests/unit/channel-sidebar.test.ts | 36 + .../unit/components/TypingIndicator.test.ts | 76 ++ .../tests/unit/content-markdown.test.ts | 7 + .../tests/unit/dispatcher.test.ts | 104 ++ .../tests/unit/livekit-e2ee.test.ts | 82 ++ .../tauri-client/tests/unit/main-page.test.ts | 49 + Client/tauri-client/tests/unit/main.test.ts | 93 ++ .../tests/unit/member-list.test.ts | 27 + .../tests/unit/messages.store.test.ts | 46 + .../unit/msg-actions-bar-focus-css.test.ts | 38 + .../tests/unit/presence-sender.test.ts | 118 ++ Client/tauri-client/tests/unit/ptt.test.ts | 113 +- .../tests/unit/reactions-keyboard.test.ts | 85 ++ .../tests/unit/server-panel.test.ts | 38 + .../tests/unit/settings-overlay.test.ts | 138 ++- .../tests/unit/sidebar-dm-section.test.ts | 65 + .../tests/unit/typing-indicator.test.ts | 41 +- .../tests/unit/voice-callbacks.test.ts | 24 + .../tests/unit/ws-lifecycle.test.ts | 30 + .../tests/unit/ws-messaging.test.ts | 75 ++ Server/admin/channels_archive_voice_test.go | 60 + Server/admin/export_test.go | 22 + Server/admin/handlers_channels.go | 27 +- Server/admin/handlers_roles.go | 9 +- Server/admin/handlers_roles_test.go | 27 + Server/admin/setup_handler.go | 15 +- Server/admin/setup_handler_test.go | 38 + Server/admin/setup_wizard.go | 13 +- Server/admin/setup_wizard_test.go | 35 + Server/admin/static/index.html | 33 +- Server/api/auth_handler.go | 25 +- Server/api/auth_handler_test.go | 41 + Server/api/profile_handler.go | 26 +- Server/api/profile_handler_test.go | 73 ++ Server/db/plugin_queries.go | 12 +- Server/db/plugin_queries_test.go | 48 + Server/plugin/loader.go | 33 +- Server/plugin/loader_test.go | 41 + Server/plugin/registry.go | 7 +- Server/plugin/registry_test.go | 39 + Server/plugin/sandbox_wazero.go | 13 +- Server/plugin/sandbox_wazero_test.go | 39 + Server/ws/emit.go | 17 + Server/ws/emit_presence_self_priority_test.go | 70 ++ Server/ws/handler_focus_revoke_race_test.go | 40 + Server/ws/handlers.go | 2 +- ...e_join_getchannelvoicestates_error_test.go | 143 +++ Server/ws/serve.go | 29 +- .../ws/serve_handshake_write_deadline_test.go | 200 +++ Server/ws/voice_join.go | 13 + 85 files changed, 5224 insertions(+), 185 deletions(-) create mode 100644 Client/tauri-client/src/lib/hostValidation.ts create mode 100644 Client/tauri-client/tests/types/jsdom.d.ts create mode 100644 Client/tauri-client/tests/unit/admin-static-channel-perms.test.ts create mode 100644 Client/tauri-client/tests/unit/components/TypingIndicator.test.ts create mode 100644 Client/tauri-client/tests/unit/msg-actions-bar-focus-css.test.ts create mode 100644 Client/tauri-client/tests/unit/presence-sender.test.ts create mode 100644 Client/tauri-client/tests/unit/reactions-keyboard.test.ts create mode 100644 Server/ws/emit_presence_self_priority_test.go create mode 100644 Server/ws/oc_0172_voice_join_getchannelvoicestates_error_test.go create mode 100644 Server/ws/serve_handshake_write_deadline_test.go diff --git a/.superpowers/FINDINGS.md b/.superpowers/FINDINGS.md index 96a946ee..d717e7bd 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** · 6 blocked · 140 fixed · 4 declined · 0 refuted · 0 duplicate +**0 open** · 6 blocked · 180 fixed · 4 declined · 0 refuted · 1 duplicate ## Blocked — fix attempted, revert-proof failed @@ -3497,6 +3497,1069 @@ MainPage.ts:505-506 attaches the quick-switcher manager with no isSuspended; Mai **Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/overlay-managers.test.ts` · revert-proof pass +### OC-0151 — high — Registration sanitizes the username before bounding it, so a 1 MiB body pins a CPU core for minutes (unauthenticated) + +`Server/api/auth_handler.go:285` · found 2026-08-19 · hunt `2026-08-19-general` · lens `api-authz` + +registerReadRequest runs the fixpoint sanitizer over the raw JSON `username` field *before* auth.ValidateUsername applies the 32-rune cap. service.SanitizeText/sanitizeToFixpoint loops sanitizePass until the string stops changing, and nested HTML entities force one iteration per two nesting levels — so the cost is O(n^2) in the attacker-supplied field length. The sibling login path (loginReadRequest, auth_handler.go:430) bounds the username *first* precisely because it is unvalidated at that point, and service.sanitizeContent (service/message.go:220) bounds `raw` before calling sanitizeToFixpoint for the same reason; register and the profile PATCH are the two call sites that skipped the bound. + +**Repro:** POST /api/v1/auth/register with body {"username":"&"+"amp;"*262000+"lt;", "password":"x", "invite_code":"x"} (≈1 MiB, under the 1 MiB MaxBodySizeUnless cap; /api/v1/auth/register is not in bodyCapExemptPrefixes). registerPolicyGate passes on a default server (registration_open defaults true), the body is decoded, and line 285 enters sanitizeToFixpoint. Measured on this tree by calling service.SanitizeText directly: 4 KB → 17 ms, 8 KB → 59 ms, 16 KB → 236 ms, 32 KB → 986 ms, 64 KB → 3.05 s — clean quadratic. Extrapolated to 1 MiB that is ~13 minutes of single-core CPU (plus ~100k allocations of ~1 MiB each) for one request, and the endpoint allows 3 requests/min/IP, so a handful of requests saturates every core indefinitely. No authentication, no invite, and no valid username is required — the work happens before any validation. + +**Evidence:** Server/api/auth_handler.go:285 req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) +Server/api/auth_handler.go:296 if err := auth.ValidateUsername(req.Username); err != nil { // 32-rune cap, runs AFTER +Server/service/message.go:195-205 func sanitizeToFixpoint(raw string) string { s := raw; for i := 0; i <= len(raw); i++ { next := sanitizePass(s); if next == s { return next }; s = next } ... } +Server/service/message.go:166 func sanitizePass(s string) string { return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) } +contrast — Server/api/auth_handler.go:430 if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen { ... } // login bounds first +contrast — Server/service/message.go:220 if len(raw) > maxMessageLen*4 { return "", ... } // message content bounds first + +**Suggested fix:** Bound the raw field before sanitizing, mirroring sanitizeContent's `len(raw) > maxMessageLen*4` pattern: in registerReadRequest, immediately after the json.Decode and before line 285, reject with 400 when `len(req.Username) > maxLoginUsernameLen*4` (bytes, so it is a cheap pre-check that still admits any legitimate 32-rune UTF-8 name). Apply the identical guard at Server/api/profile_handler.go:186, which is the same call with the same ordering (authenticated, so lower severity but the same amplification). + +**Fixed:** `9de9a6b5` · test `Server/api/auth_handler_test.go` · revert-proof pass + +### OC-0152 — high — WebSocket handshake writes (auth_ok / ready / replay burst) have no write deadline, so a peer that stops reading pins the handler goroutine and its socket forever + +`Server/ws/serve.go:748` · found 2026-08-19 · hunt `2026-08-19-general` · lens `flow-message` + +Every handshake write uses the bare request context (`ctx := r.Context()` from ServeWS). `websocket.Accept` hijacks the connection, which stops net/http's background read, so that context is only cancelled when the handler returns — i.e. after the write it is supposed to bound. coder/websocket's `Conn.Write` blocks until `ctx.Done()` fires (`setupWriteTimeout` in conn.go), so with a never-firing ctx the write blocks indefinitely once the socket send buffer and the peer's receive window fill. The sibling write path bounds itself: `writePumpWrite` wraps every frame in `context.WithTimeout(ctx, writeTimeout)` (serve_pumps.go:16), and `authenticateConn` bounds its own reads/writes with `authDeadline` (serve_auth.go:27). The `writeTimeout` constant is declared in serve.go:22 and never used in that file. + +**Repro:** An authenticated client opens /api/v1/ws, sends a valid auth frame with `last_seq` set to a value the ring buffer still covers, and then stops reading from the TCP socket (or advertises a tiny receive window). `reconnectWriteReplay` writes auth_ok plus up to `maxColdReplay` = 5000 replay frames (serve.go:32, 505-527); once the kernel send buffer plus the peer window fill, `conn.Write` blocks with a context that can never be cancelled. The same happens on a fresh connect with a large `ready` payload (serve.go:756) on a server with many members/channels. Consequences: the ServeWS goroutine and the FD are pinned permanently; the client stays in `h.clients` receiving broadcasts until its 256-slot `send` buffer overflows and `closeAllSendLocked` fires, and until `sweepStaleClients` evicts it 90 s later (hub_sweep.go:86) — neither of which unblocks the write or closes the connection. Repeating the connect leaks one goroutine + one FD per attempt without bound, and each attempt is not counted by `maxConns` after the sweep removes it from the hub. + +**Evidence:** serve.go:21-22 authDeadline = 10 * time.Second / writeTimeout = 10 * time.Second +serve.go:70 ctx := r.Context() +serve.go:748 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil { +serve.go:756 if err := conn.Write(ctx, websocket.MessageText, ready); err != nil { +serve.go:513 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil { +serve.go:520 for _, evt := range events { if err := conn.Write(ctx, websocket.MessageText, evt); err != nil { +-- vs -- +serve_pumps.go:16 wCtx, cancel := context.WithTimeout(ctx, writeTimeout) + +**Suggested fix:** Add one helper in Server/ws/serve.go and use it for all five handshake writes (serve.go:513, 520, 748, 756, 764) instead of conn.Write(ctx, ...): + +func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error { + wCtx, cancel := context.WithTimeout(ctx, writeTimeout) + defer cancel() + return conn.Write(wCtx, websocket.MessageText, msg) +} + +The existing error branches then fire normally: coder/websocket's AfterFunc closes the underlying conn on timeout, the Write returns an error, and unregisterFailedHandshake + conn.Close already run on each of those paths. + +**Fixed:** `e2fe7cdc` · test `Server/ws/serve_handshake_write_deadline_test.go` · revert-proof pass + +### OC-0153 — high — First-run setup stores the Owner's username HTML-escaped while login looks it up raw — the Owner is permanently locked out of their own account + +`Server/admin/setup_handler.go:175` · found 2026-08-19 · hunt `2026-08-19-general` · lens `flow-session` + +setupPrecheck canonicalizes the owner username with a bare `bluemonday.StrictPolicy().Sanitize`, whose text tokens are written through html.EscapeString (' -> ', " -> ", & -> &, < -> <, > -> >). handleLogin looks the user up with only `strings.TrimSpace(req.Username)` (loginReadRequest -> loginAuthenticate -> GetUserByUsername), so the stored name never matches what the user types. This is the exact defect already fixed on the registration path (auth_handler.go:285) and the profile-rename path (profile_handler.go:186), both of which now use the fixpoint `service.SanitizeText`; the setup path was not converted. + +**Repro:** Fresh install, no users yet. POST /admin/api/setup with {"username":"O'Brien","password":""}. auth.ValidateUsername passes ("O'Brien" is 11 runes, no control/Cf chars), so users.username is stored as "O'Brien" and a 30-day session token is returned, hiding the problem. Now POST /api/v1/auth/login {"username":"O'Brien","password":""} -> GetUserByUsername("O'Brien") returns nil -> 401 "invalid credentials", forever, on every device and after the initial session expires. Same for any owner name containing ' " & < >. Logging in as "O'Brien" is the only way in, and nothing tells the operator that. + +**Evidence:** Server/admin/setup_handler.go:21 var setupSanitizer = bluemonday.StrictPolicy() +Server/admin/setup_handler.go:175 req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username)) + -> CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID) (setup_handler.go:220) + +vs. the already-fixed sibling: +Server/api/auth_handler.go:285 req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) + // "a plain call here would store a different string than what handleLogin looks up + // (which only trims), permanently locking out any username containing one of those characters" + +and the lookup side: +Server/api/auth_handler.go:407 req.Username = strings.TrimSpace(req.Username) // no sanitizer +Server/api/auth_handler.go:470 user, err := database.GetUserByUsername(r.Context(), req.Username) + +bluemonday v1.0.27 sanitize.go:417-443 — case html.TextToken: default: buff.WriteString(token.String()) // x/net/html TextToken.String() == EscapeString(Data) + +**Suggested fix:** In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/owncord/server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username. + +**Fixed:** `29619536` · test `Server/admin/setup_handler_test.go` · revert-proof pass + +### OC-0154 — high — Admin "Can access" toggle is silently reverted by the override matrix in the same Save — channel stays public + +`Server/admin/static/index.html:1175` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-server-admin` + +saveChannelPerms writes the quick "Can access" toggles first (PUT allow=0/deny=0x202) and then writes the override matrix. The matrix masks are collected from radios rendered from the *pre-save* snapshot (state.permChannel.roles), so when the matrix target is the very role just hidden, collectOverrideMasks() returns (0,0) and the handler DELETEs the override row it wrote one line earlier. The operator is told "Channel permissions updated" and the channel is still visible to that role. + +**Repro:** Admin panel → Channels → lock icon on #general. Role "Member" has no override (allow=0, deny=0). In the "Override matrix" dropdown pick "Member" (every radio renders as Inherit). Then untick "Can access" next to Member. Click Save. Request 1: PUT /admin/api/channels/{id}/permissions/{memberRoleId} {allow:0,deny:514}. Request 2: masks.allow===0 && masks.deny===0 → DELETE /admin/api/channels/{id}/permissions/{memberRoleId}. Net override state: none. Toast says "Channel permissions updated"; #general remains readable by every Member. + +**Evidence:** 1167: if(!box.checked)await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE}); +1172: const path=permTargetPath(); +1173: if(path){ +1174: const masks=collectOverrideMasks(); +1175: if(masks.allow===0&&masks.deny===0)await api('DELETE',path); + +**Suggested fix:** In saveChannelPerms, record the role IDs the quick-toggle loop actually wrote and skip the matrix step when the selected target is one of them — one guard in the one function: collect `const touched=new Set()` in the loop (add role.role_id on each PUT/DELETE), then wrap the matrix block in `if(path && !(permTargetPath().indexOf('/permissions/')>-1 && touched.has(tid)))`. Cleanest variant: give the quick checkbox an onchange that patches the in-memory role.allow/role.deny in state.permChannel and calls renderPermMatrix(), so the matrix always reflects the pending toggle instead of the stale snapshot. + +**Fixed:** `69258a51` · test `Client/tauri-client/tests/unit/admin-static-channel-perms.test.ts` · revert-proof pass + +### OC-0155 — medium — Room-key offer pacing budget is per-call, so two back-to-back rotations blow the server's per-second offer cap and strand peers on a dead key + +`Client/tauri-client/src/lib/livekitE2EE.ts:976` · found 2026-08-19 · hunt `2026-08-19-general` · lens `voice-e2ee` + +`distributeRoomKey` declares `let sentInWindow = 0` as a local, so the OC-0005 pacing budget is reset on every invocation. The server's outer limiter is a *sliding* 1 s window of 64 offers per (sender, channel) (Server/ws/voice_e2ee.go:22 `voiceE2EEOfferRateLimit = 64`, Server/auth/ratelimit.go sliding window), and `drainPendingRotationOrArmTimer` (livekitE2EE.ts:1256-1261) deliberately runs a second rotation *immediately* after the first finishes. Two rotations of N peers therefore issue 2N offers inside one server window; everything past 64 is rejected with RATE_LIMITED, and the client has no handler for that error at all (no `NOT_KEY_HOLDER`/`RATE_LIMITED` branch exists anywhere under Client/tauri-client/src for voice_e2ee_offer), so the drops are silent. + +**Repro:** Voice channel with 40 participants; I am the key holder and hold 39 peer ECDH keys. (1) Peer X leaves -> handleParticipantLeft takes the `wasKeyHolder && hadPeerKey` branch (livekitE2EE.ts:1177-1191) -> rotateKeyPeriodically -> distributeRoomKey sends 38 offers with no pause (38 < OFFER_RATE_LIMIT_PER_SEC=60). (2) While that loop is awaiting `wrapRoomKey`, peer Y leaves -> `this._rotatingKey` is true, so `_rotationPending = true` (line 1188). (3) Rotation 1 completes -> drainPendingRotationOrArmTimer immediately runs rotateKeyPeriodically again -> distributeRoomKey with `sentInWindow` reset to 0 -> 37 more offers, all within ~1 s of the first batch. (4) Server's sliding window for (me, channel) now sees 75 offers in <1 s; offers 65..75 return ErrCodeRateLimited and are discarded. (5) ~11 peers never receive the epoch-2 room key. I encrypt with epoch-2, they encrypt/decrypt with epoch-1 (livekit key slot 0 is overwritten in place, no key-index versioning), so audio is dead in both directions with those peers until the next periodic rotation fires 5 minutes later. The existing regression test (tests/unit/livekit-e2ee.test.ts:953 "[OC-0005] paces room-key offers...") only exercises a single rotation and does not catch this. + +**Evidence:** livekitE2EE.ts:976-1009 + let sentInWindow = 0; + for (const [peerId, peerKey] of peers) { + ... + if (sentInWindow >= E2EEManager.OFFER_RATE_LIMIT_PER_SEC) { // 60 + await new Promise((resolve) => setTimeout(resolve, E2EEManager.OFFER_RATE_WINDOW_MS)); + sentInWindow = 0; + ... + this.deps.getWs()?.send({ type: "voice_e2ee_offer", payload: {...} }); + sentInWindow++; + +livekitE2EE.ts:1256-1263 + private async drainPendingRotationOrArmTimer(): Promise { + if (this._rotationPending) { + this._rotationPending = false; + await this.rotateKeyPeriodically(); // second full N-offer burst, sentInWindow back to 0 + return; + } + +Server/ws/voice_e2ee.go:203-206 + ratKey := auth.Key(auth.Key("voice_e2ee_offer", info.UserID), voiceChID) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EEOfferRateLimit, voiceE2EEWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} + } + +**Suggested fix:** Make the budget an instance-level sliding window instead of a per-call counter, and funnel every offer send through it. Add a private field `_offerSendTimes: number[] = []` and a single `private async sendOfferPaced(targetUserId, encryptedKey, iv)` that prunes entries older than OFFER_RATE_WINDOW_MS, sleeps until the oldest falls out when `_offerSendTimes.length >= OFFER_RATE_LIMIT_PER_SEC`, then pushes `Date.now()` and does the `getWs()?.send(...)`. Replace distributeRoomKey's local `sentInWindow` bookkeeping (lines 976, 984-996, 1006) with a call to it; reset `_offerSendTimes.length = 0` in clearState(). One shared gate covers the rotation, become-holder, and H3 paths at once. + +**Fixed:** `5db10850` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass + +### OC-0156 — medium — A queued custom-status presence_update loses its custom_status when a later plain status change supersedes it + +`Client/tauri-client/src/lib/presence.ts:71` · found 2026-08-19 · hunt `2026-08-19-general` · lens `client-state` + +createPresenceSender's send() clears any pending retry and re-arms it carrying only its own `customStatus` argument. Every producer except the custom-status commit calls send(status) with customStatus undefined, so a status change landing inside the shared 1-per-10s limiter window replaces a queued frame that carried custom_status with one that omits it. The text was already applied locally (updatePresence at line 52 plus saveCustomStatus in the UserBar), and nothing ever re-sends it — restoreSavedPresence (MainPage.ts:190) only re-sends `status` — so the client and the server disagree about the user's custom status for the rest of the install's life. + +**Repro:** Preconditions: signed in, socket connected, presence limiter is createPresenceLimiter() = 1 token / 10 000 ms. + +t=0s Click the UserBar avatar dot -> click "Idle". + UserBar.ts:174 -> presenceSender.send("idle"). limiter.tryConsume() succeeds, {status:"idle"} goes out. The window is now closed until t=10s. + +t=2s In the same open dropdown, type "Working on OwnCord" into the custom-status input and press Enter. + UserBar.ts:185 -> saveCustomStatus(text); presenceSender.send("idle", "Working on OwnCord"). + updatePresence writes the text into membersStore. tryConsume() fails, so + retry = setTimeout(() => send(loadUserStatus(), "Working on OwnCord"), ~8000). + +t=4s Click "Do Not Disturb" in the same dropdown. + UserBar.ts:174 -> presenceSender.send("dnd"), customStatus === undefined. + presence.ts:54-57 clears the t=2s timer; presence.ts:71 re-arms it as + send(loadUserStatus(), undefined). + +t=10s The retry fires and emits ws.send({type:"presence_update", payload:{status:"dnd"}}) — no custom_status key. + +Observed: the server's users.custom_status still holds whatever it held before t=2s; every other client's member list and profile popup show the old (or empty) text. Locally the picker input still reads "Working on OwnCord" (loadCustomStatus() from localStorage) and membersStore's self row still carries it, so the user has no signal anything was lost. A later reconnect's `ready` restates the server value into membersStore but the picker input keeps showing the localStorage copy, and restoreSavedPresence (MainPage.ts:190-195) only ever re-sends `status`, so the text is never retransmitted. + +Expected: the retry should carry the most recent custom_status the user committed (or the send should not silently drop a field the superseding call simply did not mention). + +Not test-locked: tests/unit/status-picker-userbar.test.ts:129 exercises only the queue-and-retry of `status`; no test asserts anything about custom_status surviving supersession. + +**Evidence:** presence.ts:49-73 + function send(status: UserStatus, customStatus?: string): void { + const userId = authStore.getState().user?.id ?? 0; + if (userId !== 0) { + updatePresence(userId, status, customStatus); // local optimistic write happens regardless + } + if (retry !== null) { + clearTimeout(retry); // <-- discards the queued frame's custom_status + retry = null; + } + if (limiter.tryConsume()) { + if (customStatus === undefined) { + ws.send({ type: "presence_update", payload: { status } }); // no custom_status field + } else { + ws.send({ type: "presence_update", payload: { status, custom_status: customStatus } }); + } + } else { + retry = setTimeout(() => { + retry = null; + send(loadUserStatus(), customStatus); // status is re-read live; customStatus is the *stale* argument + }, limiter.getRemainingMs()); + } + } + +Both producers share one PresenceSender (MainPage.ts:133 `const presenceSender = createPresenceSender(ws, limiters.presence);`) and live in the same dropdown: + components/UserBar.ts:174 onStatusChange: sender.send(status); // customStatus === undefined + components/UserBar.ts:185 onCustomStatusChange: sender.send(loadUserStatus(), text); + +**Suggested fix:** Carry the pending custom status across supersession inside createPresenceSender (one guard in the shared function, no caller changes). Track it in the closure: add `let pendingCustom: string | undefined;` next to `retry`; at the top of send(), do `const effective = customStatus !== undefined ? customStatus : (retry !== null ? pendingCustom : undefined);` then use `effective` for both the updatePresence call and the ws.send branch; when queuing set `pendingCustom = effective;` and when actually sending (or in destroy()) set `pendingCustom = undefined;`. A plain status change then no longer drops a custom_status the user already committed but that has not yet reached the server. + +**Fixed:** `7243d1cb` · test `Client/tauri-client/tests/unit/presence-sender.test.ts` · revert-proof pass + +### OC-0157 — medium — A session that ends between auth_ok and ready never tears down the ConnectedOverlay, leaving an opaque full-screen cover over the connect page forever + +`Client/tauri-client/src/main.ts:763` · found 2026-08-19 · hunt `2026-08-19-general` · lens `lifecycle` + +The ConnectedOverlay is created and appended to #app inside the `auth_ok` handler (main.ts:417-428) while the router is still on "connect", and its only teardown paths are the overlay's own `onReady` timer, the next `wirePostAuth`, `onAutoLoginCancel`, and the invite deep-link handler. The logout/disconnect subscriber — the one path a mid-handshake ban, auth_error or server shutdown actually takes — is gated on `router.getCurrentPage() === "main"` and does not call `connectedOverlay.destroy()` at all, so the overlay is orphaned in the DOM with no remaining owner. + +**Repro:** 1) Log in (or auto-login) to a server. `auth_ok` arrives, main.ts:417-428 mounts the ConnectedOverlay over #app and shows it; the router is still on "connect" (it only moves to "main" from the overlay's own onReady, 800 ms after `ready`). +2) Before `ready` arrives, an admin bans the account. The server sends `{"type":"error","payload":{"code":"BANNED"}}`; dispatcher.ts:1057-1058 runs `ws.disconnect(); clearAuth();`. (Same outcome for `auth_error` on an intervening reconnect — dispatcher.ts:261 — and for `server_restart` reason "shutdown" — dispatcher.ts:995.) +3) `clearAuth()` flips isAuthenticated; the subscriber at main.ts:763 sees `getCurrentPage() === "connect"` and does nothing at all, so `connectedOverlay` is never destroyed and `router.navigate("connect")` (main.ts:795) is never reached either. +4) `ws.disconnect()` set `intentionalClose` and tore down the Tauri listeners, so `ready` can never arrive: `unsubReady` never fires, `markReady()` never runs, the 800 ms `onReady` timer is never armed, and `destroy()` is never called. +5) The `.connected-overlay.visible` element (position:fixed, inset:0, opaque `--bg-primary`, z-index 200) stays over the connect page permanently — the login form and the "You have been banned" transient error are both unreachable. Only restarting the app clears it. +Variant with a wider window: if the BANNED/shutdown frame lands *after* `ready` but inside the 800 ms `READY_DELAY_MS` window, the already-armed timer still fires `onReady()` → `router.navigate("main")`, mounting MainPage on a cleared authStore and a disconnected socket (the isAuthenticated subscriber has already run and will not fire again). + +**Evidence:** main.ts:417 connectedOverlay = createConnectedOverlay({ ... onReady: () => { connectedOverlay?.destroy(); connectedOverlay = null; router.navigate("main"); } }); +main.ts:427 appEl!.appendChild(connectedOverlay.element); +main.ts:428 connectedOverlay.show(); +main.ts:430 const unsubReady = ws.on("ready", () => { unsubReady(); connectedOverlay?.markReady(); }); + +main.ts:763 if (!isAuthenticated && router.getCurrentPage() === "main") { +main.ts:776 dispatcherCleanup?.(); +main.ts:778 sessionCleanup?.(); +main.ts:780 ws.disconnect(); + // ...no connectedOverlay?.destroy() anywhere in this block +main.ts:795 router.navigate("connect"); +main.ts:796 } + +styles/login.css:1427 .connected-overlay { position: fixed; inset: 0; background: var(--bg-primary); display: none; ... z-index: 200; } +styles/login.css:1438 .connected-overlay.visible { display: flex; } + +lib/dispatcher.ts:1046 if (payload.code === "BANNED") { +lib/dispatcher.ts:1056 setTransientError(payload.message || "You have been banned"); +lib/dispatcher.ts:1057 ws.disconnect(); +lib/dispatcher.ts:1058 clearAuth(); + +**Suggested fix:** Widen the gate at main.ts:763 so the in-flight-session case is covered, e.g. `if (!isAuthenticated && (router.getCurrentPage() === "main" || connectedOverlay !== null))`, and add `connectedOverlay?.destroy(); connectedOverlay = null;` next to the existing `sessionCleanup?.()` teardown (main.ts:776-779) before the `router.navigate("connect")` at 795. That is exactly the teardown handleInviteDeepLink already open-codes at main.ts:836-852, so one guard in the shared subscriber replaces the per-caller copies. + +**Fixed:** `b9b86067` · test `Client/tauri-client/tests/unit/main.test.ts` · revert-proof pass + +### OC-0158 — medium — Archived channel PATCH commits, then skips voice eviction and visibility fan-out when the post-commit re-read fails + +`Server/admin/handlers_channels.go:249` · found 2026-08-19 · hunt `2026-08-19-general` · lens `error-paths` + +handlePatchChannel commits AdminUpdateChannel (which can set archived=1) and only afterwards re-reads the row with `database.GetChannel(r.Context(), id)`. That read is still bound to the admin's request context, so a client disconnect / deadline after the commit makes it fail, and the handler returns early — never calling hub.CleanupVoiceForChannel(id) nor hub.RefreshChannelVisibility(updated). The sibling handleDeleteChannel in the same file was explicitly hardened against exactly this (OC-0010, `delCtx := context.WithoutCancel(r.Context())`); the archive path was not. + +**Repro:** Admin PATCHes /admin/api/channels/{id} with archived=true on a voice channel that has live participants. AdminUpdateChannel commits. The admin's browser tab closes (or the network blips) before the handler's next statement, cancelling r.Context(). GetChannel returns context.Canceled -> handler writes 500 and returns. Result: the channel row is archived=1, but the live voice participants keep their voice_states row, their VoiceTopic subscription and their LiveKit session in a room no client can see, and every connected client still shows the channel in its sidebar until it reconnects. No sweep recovers this: CleanupVoiceForChannel is the only path that evicts them and it was skipped. + +**Evidence:** updated, err := database.GetChannel(r.Context(), id) +if err != nil || updated == nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel") + return +} +if hub != nil { + hub.BroadcastChannelUpdate(updated) + if existing.Archived != updated.Archived { + if !existing.Archived && updated.Archived { + hub.CleanupVoiceForChannel(id) + } + hub.RefreshChannelVisibility(updated) + } +} + +**Suggested fix:** Mirror handleDeleteChannel: after AdminUpdateChannel returns, take tail := context.WithoutCancel(r.Context()) once and use it for the GetChannel re-read (and the existing WriteAudit), so a caller cancellation arriving after the commit cannot skip CleanupVoiceForChannel/RefreshChannelVisibility. + +**Fixed:** `c9b72055` · test `Server/admin/channels_archive_voice_test.go` · revert-proof pass + +### OC-0160 — medium — Client silently drops any WS frame over 1 MiB, and `ready` is the one unbounded frame — a large server's full-resync is discarded with no recovery + +`Client/tauri-client/src/lib/ws.ts:241` · found 2026-08-19 · hunt `2026-08-19-general` · lens `flow-reconnect` + +handleMessage drops any frame longer than maxMessageSizeBytes (DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; neither main.ts:357, main.ts:186 nor cert-reconnect.ts:46 ever passes an override) with only a log.warn, before parsing. `ready` is the only server frame with no size bound: buildReady embeds `members` from database.ListMembers — every registered user, uncapped and unpaginated — plus roles, all visible channels and every open DM with its full last_message text. Unlike a sequenced frame, `ready` carries no seq so nothing re-requests it, and the drop happens after auth_ok already flipped the client to "connected" and reset lastSeq to 0 (ws.ts:323-330). The UI sits on the main page with empty/stale channelsStore, membersStore, dmStore and voiceStates, and every subsequent reconnect takes the same replay_source="none" tier and drops the same oversized payload again. + +**Repro:** Server with ~3.5k registered users (each MemberSummary serializes to ~280-300 bytes: id, username, avatar path, status, role, ~124-char base64 identity_public_key, display_name, custom_status), so buildReady's members array alone exceeds 1_048_576 chars. Any fresh login or any full-resync reconnect (replay_source="none": buffer window closed, cold-tier gap, or mustFullResync after a channel-visibility/DM-visibility change) delivers `ready`; ws.ts:241 returns before dispatch, the READY listener at dispatcher.ts:262 never runs, and the client renders a connected session with no channels and no member list. No error is surfaced and there is no retry path. + +**Evidence:** ws.ts:113 const DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; // 1MB +ws.ts:239 const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE; +ws.ts:241 if (raw.length > maxSize) { +ws.ts:242 log.warn("Message exceeds size limit, dropping", { size: raw.length }); +ws.ts:243 return; +-- Server/ws/serve_ready.go:326 -- +members, err := database.ListMembers(ctx) // no LIMIT, no pagination +serve_ready.go:363-367 "channels": channelPayloads, "members": members, "roles": roles, "dm_channels": dmChannels +-- main.ts:357 -- ws.connect({ host, token }); // no maxMessageSizeBytes + +**Suggested fix:** One guard in ws.ts handleMessage: move the size check after JSON.parse and exempt the handshake frame, e.g. parse first, then `if (raw.length > maxSize && parsed.type !== "ready" && parsed.type !== "auth_ok") { log.warn(...); return; }`. The frame is already fully materialized in `raw` by the time handleMessage runs, so the pre-parse check saves nothing but a parse and is what makes the drop unrecoverable. (A server-side complement — paginating members out of buildReady — is a larger change and not required to stop the data loss.) + +**Fixed:** `f5aee939` · test `Client/tauri-client/tests/unit/ws-messaging.test.ts, Client/tauri-client/tests/unit/ws-lifecycle.test.ts` · revert-proof pass + +### OC-0161 — medium — A mistyped 2FA enrollment code signs the user out and permanently deletes their saved credential + +`Client/tauri-client/src/lib/api.ts:155` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-client-tauri-client-src-lib` + +`doFetch` treats every HTTP 401 as "the session expired" and fires the global `onUnauthorized` sink, which calls `clearAuth()` (and, via main.ts's `isAuthenticated` subscriber, `deleteCredential(host)` + `skip-auto-login`). But `POST /users/me/totp/confirm` answers **401 UNAUTHORIZED / "invalid two-factor code"** for a wrong enrollment code — the one non-session 401 on any authenticated endpoint. Every sibling confirmation failure on the same handler family deliberately avoids 401 (wrong password -> 400 INVALID_INPUT via `requirePasswordConfirmation`; delete-account wrong password -> 400; change-password wrong password -> 403; disable-2FA policy refusal -> 403), so this is a drift, not a convention. + +**Repro:** 1. Sign in to a server with "remember me" so a credential is stored (host H). +2. Settings -> Account -> Enable two-factor authentication; enter the correct password, scan the QR. +3. Type any wrong 6-digit code and submit. +4. Server: handleConfirmTOTP -> VerifyTOTPCodeOnce fails -> 401 UNAUTHORIZED. +5. Client: doFetch sees 401 -> onUnauthorized() -> setTransientError("Your session expired — sign in again.") + clearAuth() -> isAuthenticated flips false while router page === "main" -> voice torn down, dispatcher/session cleanup, ws.disconnect(), deleteCredential(H), skip-auto-login set, navigate to the connect page. +Expected: an inline "invalid code, try again" and the enrollment dialog stays open. Actual: full logout with a false "session expired" banner, the stored username/token/password for H erased, auto-login suppressed, and the pending 2FA enrollment (QR + backup codes) unrecoverable from the UI. + +**Evidence:** Client/tauri-client/src/lib/api.ts:154-158 + if (res.status === 401) { + onUnauthorized?.(); + const err = await parseError(res); + throw new ApiClientError(401, err.error, err.message); + } + +Server/api/totp_handler.go:315-321 (handleConfirmTOTP) + if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid two-factor code", + }) + return + } + +Client/tauri-client/src/main.ts:120-129 + const api = createApiClient({ host: "" }, () => { + if (authStore.getState().isAuthenticated) { + setTransientError("Your session expired — sign in again."); + } + clearAuth(); + }); + +Client/tauri-client/src/main.ts:786-794 (authStore isAuthenticated subscriber) + const host = api.getConfig().host; + if (host && authStore.getState().logoutReason !== "server_shutdown") { + void deleteCredential(host); + sessionStorage.setItem("owncord:skip-auto-login", "1"); + } + router.navigate("connect"); + +Client/tauri-client/src/pages/MainPage.ts:486-495 (onConfirmTotp catches and toasts — after clearAuth already ran) + +No test locks this: the client's own test mocks the wrong contract — tests/unit/api.test.ts:497-500 asserts confirmTotp with a **400 INVALID_CODE** response, a status the server never sends for this case. The server-side test (Server/api/totp_handler_test.go:299) only asserts the status code, not the client reaction. + +**Suggested fix:** Give doFetch/request a per-call opt-out from the session-expiry sink (e.g. an options arg `{ sessionSink: false }` consulted at api.ts:155 before calling onUnauthorized) and set it on the single caller whose 401 is not a session verdict, confirmTotp (api.ts:395-396). Server-side alternative (larger blast radius): change totp_handler.go:316 to 400 INVALID_INPUT to match the sibling confirmation failures — but two tests assert 401 there (Server/api/auth_handler_test.go:1436, Server/api/totp_handler_test.go:299), so that is a deliberate contract change. + +**Fixed:** `01dca528` · test `Client/tauri-client/tests/unit/api.test.ts` · revert-proof pass + +### OC-0162 — medium — Binding a push-to-talk key while already in a voice call leaves the microphone hot — PTT does not gate until the first press+release + +`Client/tauri-client/src/lib/ptt.ts:269` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +`updatePttKey` starts the poller via `initPtt()` but never applies the PTT gate to the already-published microphone. The Rust poller emits `ptt-state` only on a transition (`ptt_transition` returns `Some` only when `pressed != was_pressed`, and `was_pressed` starts at `false`), so an idle key produces no event at all. The user has a PTT key bound and believes transmission is gated, while the mic keeps streaming to every peer. + +**Repro:** 1) Start the app with no PTT key bound (`pttVk` = 0), so `initPtt()` at main.ts:107 returns immediately and `listening` stays false. 2) Join a voice channel. `LiveKitSession.restoreLocalVoiceState("join")` computes `pttArmed = isPttPollingLive() && loadPref("pttVk",0) !== 0` = false, publishes the mic, `pttGated` stays false. 3) While still in the call, open Settings -> Keybinds and bind a key. `updatePttKey(vk)` -> `ptt_set_key`, then `!listening && vk !== 0` -> `initPtt()`, which calls `ptt_start`, sets `setPttPollingLive(true)`, registers the `ptt-state` listener and sets `listening = true` — but never calls `setPttGated(true)` and never calls `setMuted(true)`. 4) The Rust thread starts with `was_pressed = false` and the key idle, so `ptt_transition(vk, false, false)` returns `None` on every tick and no `ptt-state` event is ever emitted. Result: mic stays published and unmuted, `voiceStore.pttGated` stays false (so `isMicPolicyGated()` in deviceManager.ts:32 also reports ungated), and the user keeps transmitting until they physically press and release the key once. Note the teardown direction IS handled — `stopPtt()` calls `ungateMic(mutedByPtt)` precisely so a PTT-applied mute is never stranded — which makes the missing setup-side gate an asymmetry, not a deliberate omission. + +**Evidence:** export async function updatePttKey(vk: number): Promise { + savePref("pttVk", vk); + ... + await invoke("ptt_set_key", { vkCode: vk }); + if (!listening && vk !== 0) { + await initPtt(); // starts the poller; never setPttGated(true) / setMuted(true) + } + if (vk === 0) { + await stopPtt(); // the reverse direction DOES rearm, via ungateMic() + } + +// Rust side (src-tauri/src/ptt.rs:311): +fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option { + let pressed = vk != 0 && key_down; + (pressed != was_pressed).then_some(pressed) // idle key after start => None, forever +} + +**Suggested fix:** In updatePttKey, after the `await initPtt()` succeeds, arm the gate if a call is already up — mirroring the join-time computation: if `voiceStore.getState().currentChannelId !== null && isPttPollingLive() && voiceStore.getState().pttGated !== true`, call `setPttGated(true)`, and if the user is not already self-muted/deafened, `setMuted(true)` and set `pttOwnsMute = true` so the next press may lift it. Putting it in updatePttKey (not initPtt) keeps the startup path, which has no call in progress, unchanged. + +**Fixed:** `e391caeb` · test `Client/tauri-client/tests/unit/ptt.test.ts` · revert-proof pass + +### OC-0163 — medium — A bare (unbracketed) IPv6 host is accepted everywhere except the WebSocket URL, which is built by raw interpolation — login succeeds, the socket never connects + +`Client/tauri-client/src/lib/ws.ts:540` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-client-tauri-client-src-lib` + +`connect()` builds `wss://${cfg.host}/api/v1/ws` with no bracketing, so a bare IPv6 host (`2001:db8::1`) yields the unparseable URL `wss://2001:db8::1/api/v1/ws`. Every sibling path deliberately supports bare IPv6: `isValidHost` (api.ts:91) explicitly allows it, `http_proxy.rs::split_host_port`/`resolve_remote_target` brackets it for the dial target, and `livekitSession.ensureLiveKitProxy` wraps it in brackets before invoking the LiveKit proxy. Only ws.ts does not. + +**Repro:** On the Connect page enter the server address as a bare IPv6 literal, e.g. `2001:db8::1` or `::1` (accepted by `api.setConfig` — locked in by tests/unit/api.test.ts:366 "accepts a bare (unbracketed) IPv6 literal"). The health check and login both succeed because REST goes through the Rust http proxy, which brackets the dial target. `wirePostAuth` then calls `ws.connect({host: "2001:db8::1", token})`, which invokes `ws_connect` with `wss://2001:db8::1/api/v1/ws`; tokio-tungstenite's URL parse rejects that authority (host `2001`, port `db8::1`), `ws_connect` returns `ws connect failed: …`, and `scheduleReconnect()` retries the same malformed URL forever. The user is logged in but never receives `ready` — no channels, no messages. Using `[2001:db8::1]` instead works everywhere. + +**Evidence:** ws.ts:540 const wsUrl = `wss://${cfg.host}/api/v1/ws`; + +api.ts:87-91 // Bare (unbracketed) IPv6 literal, e.g. "2001:db8::1" or "::1". + if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true; + +livekitSession.ts:759-761 } else if ((this.serverHost.match(/:/g) ?? []).length > 1) { + // Bare IPv6 (multiple colons) — wrap in brackets and add default port + hostWithPort = `[${this.serverHost}]:443`; + +http_proxy.rs:311-315 let dial_target = if hostname.contains(':') { format!("[{hostname}]:{port}") } else { ... }; + +**Suggested fix:** Canonicalize the host to bracketed form once, at the single place it enters the app, rather than in each consumer: in api.ts's isValidHost/setConfig path (or a small exported `canonicalizeHost` used by main.ts's onLogin/onRegister and the profile store), rewrite a bare multi-colon IPv6 literal to `[...]` before it is stored — every other consumer (http_proxy.rs, livekit_proxy.rs, livekitSession.ensureLiveKitProxy) already handles the bracketed form. If a local fix is preferred, bracket at ws.ts:540: `const hostForUrl = !cfg.host.startsWith("[") && (cfg.host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(cfg.host) ? `[${cfg.host}]` : cfg.host;` — but then also strip brackets in tofu::cert_store_key (src-tauri/src/tofu.rs:292), or the ws pin key `[2001:db8::1]` will not match the http proxy's pin key `2001:db8::1` and the user gets a second first-use cert prompt. + +**Fixed:** `f5aee939` · test `Client/tauri-client/tests/unit/ws-messaging.test.ts, Client/tauri-client/tests/unit/ws-lifecycle.test.ts` · revert-proof pass + +### OC-0164 — medium — The server's "update_aborted" restart-cancel broadcast puts the client into a permanent "Reconnecting..." banner — the exact failure it was added to prevent + +`Client/tauri-client/src/pages/MainPage.ts:363` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +When a staged update fails to swap, the server sends `server_restart{reason:"update_aborted", delay_seconds:0}` purely to *cancel* the countdown it already announced. The client has no cancel path: MainPage treats every non-"shutdown" reason as a new restart announcement and calls `banner.showRestart(0)`, whose interval immediately falls through `remaining <= 0` into `showReconnecting()`. Because the WebSocket never actually drops, `uiStore.connectionStatus` never changes, the change-only subscription at MainPage.ts:334 never fires, and nothing ever calls `banner.hide()`. + +**Repro:** Admin panel -> apply update. Server broadcasts `server_restart{reason:"update",delay_seconds:5}` (Server/admin/update_handlers.go:149); client shows the countdown. The staged-binary re-verification or rename then fails, so `applyStagedUpdate`'s deferred guard broadcasts `server_restart{reason:"update_aborted",delay_seconds:0}` (Server/admin/update_handlers.go:181) and the process does NOT restart. Client: MainPage.ts:363 calls `showRestart(0)` -> ServerBanner.ts:33 paints "Server restarting in 0 seconds...", then at t=1000ms ServerBanner.ts:36-40 computes remaining=-1, clears the interval and calls `showReconnecting()`. The socket stays connected forever, so the banner reads "Reconnecting..." over a perfectly healthy session until the app is restarted. dispatcher.ts:998 additionally sets the transient error string "Server is restarting: update_aborted". + +**Evidence:** MainPage.ts:359-364: + if (banner !== null && payload.reason !== "shutdown") { + banner.showRestart(payload.delay_seconds); + } + +ServerBanner.ts:35-43: + intervalId = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { clearCountdown(); showReconnecting(); return; } + setText(root, `Server restarting in ${remaining} seconds...`); + }, 1000); + +Server/admin/update_handlers.go:163-181 (the intent this violates): + // The caller has already broadcast "restarting in 5s" ... every failure path + // must correct that promise -- otherwise the client's restart banner counts + // down to a permanent "Reconnecting..." over a connection that never + // actually dropped (OC-0226). + defer func() { if !committed && hub != nil { hub.BroadcastServerRestart("update_aborted", 0) } }() + +**Suggested fix:** In the single shared handler at MainPage.ts:361-364, treat a zero/negative delay as a cancel rather than a countdown: `if (banner !== null && payload.reason !== "shutdown") { if (payload.delay_seconds <= 0) applyConnectionStatus(banner, uiStore.getState().connectionStatus); else banner.showRestart(payload.delay_seconds); }`. That re-syncs the banner to the real connection status (hidden while connected) and covers any future cancel broadcast without a per-reason list. + +**Fixed:** `c7d8e13c` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass + +### OC-0165 — medium — scanPluginDirectory aborts the entire scan on the first bad plugin directory, so one malformed plugin disables every other plugin + +`Server/plugin/loader.go:87` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-3` + +Every per-plugin failure (malformed plugin.json, malformed plugin.toml, missing .wasm entrypoint, a stray symlink anywhere in the tree) returns from `scanPluginDirectory` instead of skipping that one directory. `Registry.LoadAll` (registry.go:171) propagates that error and returns *before* the `installFromDisk` loop and before `activateAll`, and `main.go:406` only logs a warning. The registry therefore starts completely empty. This contradicts the per-plugin `continue` policy LoadAll already applies to `installFromDisk` failures (registry.go:176) and the documented contract of `tryLoadPluginTOML` ("the caller will skip the plugin and log the error" — manifest_toml.go:19), which the caller does not honour. + +**Repro:** With plugins enabled and two plugins on disk (`plugins/hello/` working, `plugins/broken/plugin.json` containing a trailing comma or with its .wasm deleted), restart the server. scanPluginDirectory returns `plugin "broken": ...` at loader.go:72 or :87; LoadAll returns that error; main.go logs `plugin loader: failed to scan directory` and continues. `hello` is never installed into r.byName and never activated, so every `/hello` slash command dispatch now misses — while the `plugins` DB row still says enabled=1, so the admin panel lists it as enabled. Removing the broken directory and restarting restores it, proving the good plugin was collateral damage. + +**Evidence:** loader.go:72 return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr) +loader.go:87 return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr) +registry.go:171-173 manifests, err := scanPluginDirectory(r.cfg.Directory); if err != nil { return fmt.Errorf(...) } +registry.go:176 if err := r.installFromDisk(ctx, found); err != nil { slog.Warn(...); continue } // per-plugin policy, never reached +registry.go:180 return r.activateAll(ctx) // never reached + +**Suggested fix:** Make the scan per-plugin fault-tolerant in one place: in scanPluginDirectory, replace each per-entry `return nil, ...` with recording the error (errors.Join into a scanErr) and `continue`, then `return found, scanErr`; in registry.go LoadAll, log the returned error via slog.Warn and proceed with `manifests` instead of returning early. That keeps TestScanPluginDirectoryRejectsSymlinkEntrypoint green (err is still non-nil) while good plugins still install and activate. + +**Fixed:** `4bdb24e3` · test `Server/plugin/loader_test.go` · revert-proof pass + +### OC-0166 — low — A user's own presence is split across two per-client queues with different drain order: the invisible self-frame goes high-priority, every other frame goes normal + +`Server/ws/emit.go:77` · found 2026-08-19 · hunt `2026-08-19-general` · lens `ws-hub` + +`PresenceSelfEvent` satisfies `UserTargetedEvent`, so EmitEvents routes it through `h.SendToUserHigh` (c.sendHigh). Every other frame carrying the same user's own status uses the normal queue — `PresenceEvent`→`BroadcastToAll` (emit.go:105) for a visible status, and `BroadcastPresence`'s private half `h.SendToUser(userID, ...)` (hub_broadcast.go:737) for the connect/disconnect coalescer flush. writePump drains sendHigh strictly before send (serve_pumps.go priority 1 and 2), so a newer invisible self-frame can be written ahead of an older visible-status frame still queued in `send`, and the owner's client settles on the stale one. This is exactly the split-FIFO hazard the surrounding comments say was fixed for the "others" half in OC-0003/OC-0214; the self half still has it. + +**Repro:** User U is connected and U's normal send queue has a backlog (busy channel, or a stalled/slow socket — the same precondition OC-0003 assumed). (1) U sends presence_update{status:"online"|"dnd"} → handlePresenceV2 → presenceEvents → single PresenceEvent → BroadcastToAll → deliverBroadcast → frame appended to U's `c.send` behind the backlog. (2) >=3s later U sends presence_update{status:"invisible"} → presenceEvents returns PresenceOthersEvent (broadcast to everyone EXCEPT U) + PresenceSelfEvent → EmitEvents hits the UserTargetedEvent branch → SendToUserHigh → frame lands on U's empty `c.sendHigh`. (3) writePump's priority-1 select drains sendHigh first, so U's socket receives presence{U, invisible} and only afterwards presence{U, dnd}. U's own status picker ends up showing "Do Not Disturb" while users.status is 'invisible' and every other client sees U as offline. The self-frame is unsequenced (SendToUserHigh does not wrapWithSeq), so reconnect replay never repairs it; only a fresh auth_ok (which carries user.Status) does. + +**Evidence:** emit.go:62-77 `case UserTargetedEvent:` … `h.SendToUserHigh(e.TargetUserID(), e.Payload())` +event.go:270-277 `type PresenceSelfEvent struct{ targetUserID int64; payload []byte }` with `TargetUserID()`+`Payload()` (satisfies UserTargetedEvent) +event.go:286-311 `presenceEvents` returns `[]Event{PresenceOthersEvent{...}, PresenceSelfEvent{...}}` only when `db.BroadcastStatus(status) != status`, i.e. status == invisible +hub_broadcast.go:736-737 `h.BroadcastToAllExcept(userID, ...)` / `h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))` ← same logical frame, NORMAL queue +serve_pumps.go:70-101 writePump: "Priority 1: drain all pending high-priority messages first" + +**Suggested fix:** Keep every source of one user's presence on the same per-client FIFO: in EmitEvents (Server/ws/emit.go), add `case PresenceSelfEvent: h.SendToUser(e.targetUserID, e.payload)` immediately BEFORE `case UserTargetedEvent` (Go type switches take the first matching case), mirroring how PresenceOthersEvent is special-cased out of the low-priority default. One guard in the shared router, no caller changes. + +**Fixed:** `21a1a73d` · test `Server/ws/emit_presence_self_priority_test.go` · revert-proof pass + +### OC-0167 — low — The queued-announce drain and the per-announce offer send bypass the rate pacing entirely, so a key holder joining a large ongoing call fires one unpaced offer per existing participant + +`Client/tauri-client/src/lib/livekitE2EE.ts:245` · found 2026-08-19 · hunt `2026-08-19-general` · lens `voice-e2ee` + +`setupKeyExchange`'s drain loop calls `handleAnnounce` once per queued announce, and `handleAnnounceInner` sends a `voice_e2ee_offer` per call (line 807) with no reference to `distributeRoomKey`'s `sentInWindow` budget and no pacing of its own. For a key holder joining an ongoing call, every existing participant's relayed announce is queued (they arrive while `_ecdhKeyPair` is still null), so the drain emits N offers in one uninterrupted burst — the exact shape OC-0005's pacing was added to prevent, on a sibling path the fix never covered. + +**Repro:** A voice channel has 70 participants. A user whose user_id is lower than all of them joins, so the server sets `is_key_holder = true` in voice_token (Server/ws/voice_join.go:438). voiceJoinComplete then pushes 70 `voice_e2ee_announce` frames to the joiner; all 70 land while connectAndSetup is still awaiting createRoom/resolveLiveKitUrl/generateECDHKeyPair/buildAnnouncePayload, so handleAnnounceInner queues every one of them into `_pendingAnnounces` (line 701-704). setupKeyExchange then drains all 70 in a tight loop, each producing one `voice_e2ee_offer`. The server's (sender, channel) sliding window caps at 64/s, so the last ~6 offers are rejected with ErrCodeRateLimited and dropped with no client-side retry — those participants never receive the new holder's room key and cannot hear or be heard by anyone until the joiner's 5-minute periodic rotation. + +**Evidence:** livekitE2EE.ts:244-249 + const queued = this._pendingAnnounces.splice(0); + for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) { + await this.handleAnnounce(qId, qKey, qSig); + log.info("E2EE: drained queued announce", { userId: qId }); + } + +livekitE2EE.ts:786-811 (handleAnnounceInner) + if (this._isKeyHolder && currentRoomKey && keypair) { + ... + this.deps.getWs()?.send({ + type: "voice_e2ee_offer", + payload: { target_user_id: userId, encrypted_key: encryptedKey, iv }, + }); + +Server/ws/voice_join.go:490-495 — voiceJoinComplete replays every existing participant's announce to the joiner: + if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != "" { + c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig)) + } + +**Suggested fix:** Same single fix as candidate 1 — route livekitE2EE.ts:806-810's `getWs()?.send({type: "voice_e2ee_offer", ...})` through the shared instance-level `sendOfferPaced(...)` helper rather than calling `getWs()?.send` directly, so announce-driven offers draw from the same sliding-window budget as rotation offers. No per-caller pacing and no change to the drain loop at 244-249 is needed. + +**Fixed:** `5db10850` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass + +### OC-0168 — low — PluginKVScan implements a prefix scan with LIKE, so '_'/'%' in the prefix are wildcards and matching is ASCII-case-insensitive — disagreeing with the exact-match get/set/delete on the same table + +`Server/db/plugin_queries.go:116` · found 2026-08-19 · hunt `2026-08-19-general` · lens `db-storage` + +The contract is a prefix scan (parameter is named `prefix`; Registry.StorageScan documents "all keys with the given prefix"), but the SQL uses LIKE. SQLite's LIKE treats `_` as a single-character wildcard and `%` as a multi-character wildcard, and is ASCII-case-insensitive by default. PluginKVGet/Set/Delete use `key = ?` (BINARY, case-sensitive), so the two halves of the same KV API disagree about which keys are the same key. + +**Repro:** Wildcard case: a plugin holding the `storage` capability calls StoragePut("cfg_a", v1) and StoragePut("cfgXa", v2) — two distinct rows. StorageScan("cfg_", 100) issues `key LIKE 'cfg_%'`; `_` matches any single character, so the map comes back with BOTH "cfg_a" and "cfgXa" even though only the first has the requested prefix. Underscore-separated key namespaces are the common case, so this over-matches routinely. + +Case case: StoragePut("Key1", v1) and StoragePut("key1", v2) create two distinct rows (BINARY PK, and PluginKVGet's `key = ?` reads them back as two distinct keys). StorageScan("Key", 100) issues `key LIKE 'Key%'`, which is ASCII-case-insensitive, and returns both rows — so the scan reports keys that the plugin's own get/delete calls treat as different keys. + +**Evidence:** Server/db/plugin_queries.go:114-118 — +func (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { + rows, err := d.reader.QueryContext(ctx, + `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`, + pluginID, prefix+"%", limit, + ) + +Siblings on the same table use exact match: +Server/db/plugin_queries.go:87 `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?` +Server/db/plugin_queries.go:99-100 `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value` +Server/db/plugin_queries.go:108 `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?` + +No `PRAGMA case_sensitive_like` is set — Server/db/db.go:57-64 `filePragmas` lists busy_timeout, journal_mode, foreign_keys, synchronous, temp_store, mmap_size, cache_size only, and openMemory (db.go:150-160) Execs the same set. + +plugin_kv's key collation is the default BINARY: Server/migrations/015_plugins.sql declares `key TEXT NOT NULL` with `PRIMARY KEY (plugin_id, key)`. + +Caller contract: Server/plugin/host_storage.go:59-67 — "StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit." + +**Suggested fix:** Make the scan a BINARY prefix comparison instead of a pattern match, in the one shared query. In Server/db/plugin_queries.go:115-118 replace `key LIKE ?` / `prefix+"%"` with `key >= ? AND substr(key, 1, length(?)) = ?`, binding prefix three times (pluginID, prefix, prefix, prefix, limit). The `key >= ?` term keeps the (plugin_id, key) primary-key index usable for the seek, and substr() compares under BINARY, so wildcards and ASCII case-folding both disappear. Do not fix by adding `ESCAPE '\\'` — that handles `_`/`%` but leaves LIKE's case-insensitivity, which is the half that disagrees with PluginKVGet/Delete. + +**Fixed:** `a4d5be62` · test `Server/db/plugin_queries_test.go` · revert-proof pass + +### OC-0169 — low — A failed keyring write deletes the existing secret before the fallback write that may also fail, permanently destroying a good stored credential/identity key + +`Client/tauri-client/src-tauri/src/secret_store.rs:164` · found 2026-08-19 · hunt `2026-08-19-general` · lens `tauri-rust` + +In `set_with`'s failed-write arm the destructive `keyring_delete(account)` runs unconditionally at line 164, but the write that is supposed to take over — `fallback_set(account, secret)?` — only runs afterwards at line 173 and can itself fail. When both fail, the previously stored (and perfectly readable) keyring entry has already been erased and nothing replaced it, so the next `get()` returns `Ok(None)` — the exact "indistinguishable from first login" signal this module's own `get_with` tests (lines 512-521) declare must never be produced. + +**Repro:** Linux/macOS machine whose `credential_fallback.key` is present but truncated (e.g. a prior ENOSPC before the `finish_new_key_file` cleanup landed, or a partially-restored backup). `fallback_crypto::load_or_create_key` then returns Err("credential fallback key file has N bytes, expected 32 — refusing to use it") on every call, so `protect_secret` -> `set_fallback` always fails. 1) The user's voice-E2EE identity private key is already stored fine in the Secret Service under account `identity:{userId}@{host}`. 2) A reconnect triggers `save_identity_key`; the Secret Service write fails transiently (keyring locked after screen-lock, or a D-Bus timeout) -> `keyring_set` Err. 3) Line 164 deletes the good keyring entry. 4) Line 173 `fallback_set` fails, `set_with` returns Err, `save_identity_key` returns Err. 5) On the next launch `load_identity_key` -> `get_with`: `keyring_get` Ok(None) (deleted), `get_fallback` None -> `Ok(None)`. 6) `identity.ts`'s `getOrCreateIdentityKeyPair` reads that as first login and mints a brand-new keypair; every peer's pinned identity key now mismatches and `livekitE2EE.verifyPeerAnnounce` rejects the announce as a possible MITM until each peer manually re-pins. Fix: run `fallback_set` first and delete the keyring entry only once the fallback copy is committed. The existing test `set_with_deletes_any_stale_keyring_entry_when_the_write_fails` (line 537) still passes under that order — it only asserts the delete happened and the result is FALLBACK_BACKEND. + +**Evidence:** Err(e) => { + log::error!("{SERVICE}: credential store write failed for '{account}': {e}"); + // An older secret may already sit in the keyring from a prior + // successful write. get() reads the keyring first, so leaving + // that stale entry in place would shadow the fresh secret parked + // in the fallback below — mirrors the read-back-mismatch arm + // above, which purges for the same reason. + if let Err(de) = keyring_delete(account) { // <-- line 164, destructive, runs first + log::warn!(...); + } + } + } + + fallback_set(account, secret)?; // <-- line 173, fallible, runs second + +**Suggested fix:** In set_with's `Err(e)` arm (secret_store.rs:161-170), do not delete yet — record the intent (e.g. `let purge_stale_keyring = true;`) and run the delete only after `fallback_set(account, secret)?` at line 173 has committed the replacement copy: `fallback_set(account, secret)?; if purge_stale_keyring { if let Err(de) = keyring_delete(account) { log::warn!(...) } }`. That keeps the anti-shadowing invariant the delete exists for, keeps the existing set_with_deletes_any_stale_keyring_entry_when_the_write_fails test green (it only asserts the delete ran and the result is FALLBACK_BACKEND), and leaves the old keyring copy intact when the fallback write fails. Leave the read-back-mismatch arm's delete where it is — the entry it purges is a value the store never received from us. + +**Fixed:** `d1068247` · test `Client/tauri-client/src-tauri/src/secret_store.rs (mod tests: set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail)` · revert-proof pass + +### OC-0170 — low — roles_update fan-out is tied to the request context, so a role create/update/delete commits with no client ever told + +`Server/admin/handlers_roles.go:262` · found 2026-08-19 · hunt `2026-08-19-general` · lens `error-paths` + +broadcastRoles re-reads the role list with `database.ListRoles(r.Context())` after the mutation has already committed. A request context cancelled between the commit and this read makes ListRoles return context.Canceled, and the roles_update broadcast is skipped for every connected client. This is the exact bug already fixed in Server/api/emoji_handler.go:273 (broadcastEmojiSet now uses context.WithoutCancel with a comment naming this hazard) and in Server/api/dm_handler.go:257 (broadcastDMOpen); handlers_roles.go was not converted. + +**Repro:** Admin deletes a role via DELETE /admin/api/roles/{id}. roles.DeleteRole commits and the moved members get their per-user BroadcastMemberUpdate. The admin then closes the tab, cancelling r.Context(). broadcastRoles' ListRoles fails with context.Canceled, logs a Warn and returns without broadcasting. Every connected client keeps the deleted role in its role list — so role colors, member-list grouping and permission-gated affordances stay keyed on a role that no longer exists — until each client reconnects. The fan-out is also only half applied: the member_update frames went out, the roles_update that gives them meaning did not. + +**Evidence:** func broadcastRoles(r *http.Request, database *db.DB, hub HubBroadcaster) { + if hub == nil || database == nil { + return + } + list, err := database.ListRoles(r.Context()) + if err != nil { + slog.Warn("admin: roles_update broadcast skipped, role list unreadable", "err", err) + return + } + hub.BroadcastRolesUpdate(list) +} + +**Suggested fix:** In broadcastRoles, read with context.WithoutCancel(r.Context()) instead of r.Context() — one change in the shared helper covers all three call sites. + +**Fixed:** `6cd7c01a` · test `Server/admin/handlers_roles_test.go` · revert-proof pass + +### OC-0171 — low — Optimistic send reconciles in place, so a message that arrives while the send is in flight is rendered permanently out of id/time order + +`Client/tauri-client/src/stores/messages.store.ts:257` · found 2026-08-19 · hunt `2026-08-19-general` · lens `ordering-boundary` + +The store keeps messages in pure array order and never re-sorts: `addOptimisticMessage` appends a pending row at the tail before its real id exists, `addMessage` appends every later broadcast after it, and both `confirmSend` (line 759) and `addMessage`'s two reconciliation branches (lines 226 and 245) replace the optimistic row *at its existing index* (`existing.map((m, i) => (i === idIdx ? message : m))`). If any other user's message commits and is broadcast between the optimistic insert and the local echo, the local row ends up holding a higher server id and a later server timestamp than the row sitting after it, and nothing ever repairs the inversion for the life of the loaded window. + +**Repro:** Channel 1 loaded with [id 100]. (1) User A types and sends -> addOptimisticMessage appends {id:0, status:'pending'}; array = [100, pending]. (2) Before A's send commits server-side, user B's message commits as id 101 and is broadcast; A's client dispatches chat_message(101) -> addMessage: no id match (pending row still id 0), isUnreconciledEcho is false (different author), so it appends -> array = [100, pending, 101]. (3) A's chat_send_ok arrives with message_id 102 -> confirmSend rewrites the pending row in place -> array = [100, 102, 101]. (4) A's own chat_message(102) echo arrives -> idIdx finds 102 at index 1 and replaces it in place. Final array = [100, 102, 101]. MessageList's buildVirtualItems walks this array verbatim (no sort anywhere in messages.store.ts or MessageList.ts), so A sees their own newer message rendered above B's older one, with the timestamp header going backwards; the day-divider/grouping logic (isSameDay(prevMsg.timestamp, msg.timestamp), shouldGroup) also runs on the non-monotonic sequence. The state only self-corrects on a full window refetch (channel switch + invalidateChannelMessageWindow, or reconnect). + +**Evidence:** messages.store.ts:226-233 const idIdx = existing.findIndex((m) => m.id !== 0 && m.id === message.id); if (idIdx !== -1) { const replaced = existing.map((m, i) => (i === idIdx ? message : m)); ... } +messages.store.ts:257 let updatedMsgs = [...existing, message]; +messages.store.ts:759-763 const updatedList = existing.map((m) => m.correlationId === correlationId ? { ...m, id: messageId, timestamp, status: "sent" as const, errorCode: null } : m,); + +**Suggested fix:** One guard in addMessage's append branch (messages.store.ts:257): keep unreconciled rows at the tail instead of appending blindly — walk back over trailing rows whose status !== "sent" and splice the server message in before them, e.g. `let at = existing.length; while (at > 0 && existing[at-1]!.status !== "sent") at--; let updatedMsgs = [...existing.slice(0,at), message, ...existing.slice(at)];` leaving the MAX_MESSAGES_PER_CHANNEL trim below unchanged. With the pending row kept last, confirmSend's in-place stamp can no longer produce an id/timestamp inversion, and MessageList's tryAppendMessages simply falls back to a full rebuild (it already returns false for any non-suffix change). + +**Fixed:** `5980a38b` · test `Client/tauri-client/tests/unit/messages.store.test.ts` · revert-proof pass + +### OC-0172 — low — A GetChannelVoiceStates error aborts the voice_join tail after the join is already committed and broadcast, so the joiner never receives existing peers' E2EE announces and is guaranteed to fail the key exchange + +`Server/ws/voice_join.go:480` · found 2026-08-19 · hunt `2026-08-19-general` · lens `flow-voice` + +voiceJoinComplete has already subscribed the client to the voice topic, re-elected the key holder, and broadcast the joiner's voice_state to everyone when it reads the existing participants. A read error makes it `return` with no error frame, no rollback, and — critically — without relaying any existing participant's ECDH public key. That relay is the ONLY place the server ever ships a peer's stored `voice_e2ee_announce` to a joiner (mid-call peers never counter-announce; handleAnnounceInner replies with an offer), so the joiner's `_peerPublicKeys` stays empty and the key holder's offer is dropped by handleOfferInner's unknown-peer guard. + +**Repro:** Channel 5 has key holder A (uid 10) live in voice. B (uid 20) sends voice_join for channel 5. B passes every gate, the voice_states row commits, c.setVoiceState runs, voice_token (is_key_holder=false) is delivered, updateKeyHolder keeps A, and B's voice_state is broadcast to the whole READ audience. Now the GetChannelVoiceStates read at voice_join.go:479 fails (SQLite busy/IO error). handleVoiceJoin returns silently: B receives no voice_state for A, no voice_e2ee_announce for A, and no voice_config. B's client runs setupKeyExchange as a non-holder, announces, and A duly answers with voice_e2ee_offer — which B drops at livekitE2EE.ts:860 as "received offer from unknown peer" because A's ECDH key was never relayed. B waits 10s, re-announces, waits 5s more, then setupKeyExchange returns false and connectAndSetup fires onErrorCallback("e2ee_timeout") and leaveVoice(true) (livekitSession.ts:1103-1138). Every other client saw B join and then leave 15s later; B got no error frame explaining why, and each retry fails identically while the read error persists. (Compare the sibling fix at Server/ws/serve_ready.go:242, where buildReady's swallowed DB errors were made fatal for exactly this reason.) + +**Evidence:** Server/ws/voice_join.go:479-483 + existing, err := h.db.GetChannelVoiceStates(ctx, channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + return + } + +…the announce relay it skips (voice_join.go:493): + if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != "" { + c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig)) + } + +…the receiving guard that then drops the holder's offer (Client/tauri-client/src/lib/livekitE2EE.ts:859): + const peerKey = this._peerPublicKeys.get(fromUserId); + if (!peerKey) { + log.warn("E2EE: received offer from unknown peer", { fromUserId }); + return; + } + +**Suggested fix:** Treat the read as fatal, matching every other post-commit failure in this handler, instead of returning silently. In voiceJoinComplete (Server/ws/voice_join.go:479-483): + + existing, err := h.db.GetChannelVoiceStates(ctx, channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + // The joiner's own voice_state was already broadcast above, so this + // rollback must broadcast the compensating voice_leave. + h.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) + return + } + +One guard in the shared function; rollbackVoiceJoin already re-runs updateKeyHolder and scopes the delete to state.JoinedAt. + +**Fixed:** `dddd0560` · test `Server/ws/oc_0172_voice_join_getchannelvoicestates_error_test.go` · revert-proof pass + +### OC-0173 — low — Setup wizard writes server_name/motd HTML-escaped while the admin Settings page writes them raw, so a first-run server name renders as literal ' entities + +`Server/admin/setup_wizard.go:104` · found 2026-08-19 · hunt `2026-08-19-general` · lens `flow-session` + +wizardValidateIdentity runs server_name and motd through the same bare `setupSanitizer.Sanitize`, and applyWizardSettings persists that escaped string into the settings table verbatim. The admin PATCH /admin/api/settings path (handlePatchSettings -> normalizeSettingUpdates) applies no sanitizer at all, so the two writers of the same two keys disagree: a name set at first run is stored escaped, the identical name set later in the Settings page is stored raw. The escaped copy is what the hub caches and ships in auth_ok, and the client renders it as text. + +**Repro:** Run the first-run wizard with server_name = "Bob's Place" and motd = "Say \"hi\" & relax". settings.server_name becomes "Bob's Place" and settings.motd becomes "Say "hi" & relax". Every client's connected overlay / server banner shows the literal entity text, and the overlay's one-letter server icon is derived from 'B' of the escaped string. Setting the identical values afterwards through the admin Settings page stores them unescaped, so the same input produces two different stored values depending on which path wrote it. + +**Evidence:** Server/admin/setup_wizard.go:104 name := strings.TrimSpace(setupSanitizer.Sanitize(*wr.ServerName)) +Server/admin/setup_wizard.go:114 motd := strings.TrimSpace(setupSanitizer.Sanitize(*wr.Motd)) +Server/admin/setup_wizard.go:203-208 updates["server_name"] = *wr.ServerName ; updates["motd"] = *wr.Motd + +vs. the other writer of the exact same keys, with no sanitizer anywhere in the file: +Server/admin/handlers_settings.go:93-110 normalizeSettingUpdates — only require_2fa/registration_open are normalized +Server/admin/handlers_settings.go:66-72 INSERT INTO settings (key, value) ... ON CONFLICT DO UPDATE SET value = excluded.value + +consumer side: +Server/ws/hub.go:172-176 refreshSettingsLocked reads settings server_name/motd into the hub cache +Server/ws/serve_ready.go:19 buildAuthOK ships them to every client +Client/tauri-client/src/components/ConnectedOverlay.ts:62 setText(srvIcon, serverName.charAt(0).toUpperCase()) + +**Suggested fix:** Same one-line change as the username: in Server/admin/setup_wizard.go:104 and :114 use `service.SanitizeText(...)` instead of `setupSanitizer.Sanitize(...)`, so the wizard stores the same string the Settings page would. (Deleting setupSanitizer entirely once setup_handler.go:175 is also converted keeps the escaping sanitizer out of every storage path.) + +**Fixed:** `5b2211c5` · test `Server/admin/setup_wizard_test.go` · revert-proof pass + +### OC-0174 — low — Every automatic channel selection filters to type "text", stranding users whose only visible channels are announcement channels on a blank pane + +`Client/tauri-client/src/lib/dispatcher.ts:338` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-server-ws` + +Announcement channels are full message channels everywhere else — the server ships them with unread_count/last_message_id (Server/ws/serve_ready.go:226 `type == "text" || type == "announcement"`), computes can_send for them (channelCanSend), and the client renders them in the sidebar (ChannelSidebar.ts:253) and gives them a composer (ChannelController.ts:435). But all five automatic selection/fallback paths pick only `ch.type === "text"`, so when no text channel is visible the client calls setActiveChannel with nothing (or null) and shows an empty main pane even though readable channels exist in the sidebar. + +**Repro:** Give a role READ_MESSAGES on one announcement channel and deny it on every text channel (channel_overrides), or run an announcement-only server. Log in as that user: `ready` arrives with `currentActive === null` and `payload.channels.length > 0`, `payload.channels.find(ch => ch.type === "text")` is undefined, so setActiveChannel is never called and the app opens with no channel selected. Same in three sibling paths: dispatcher.ts:716 (active channel deleted → `firstTextId` is null → `setActiveChannel(null)` plus a "This channel was deleted" toast and an empty pane), dispatcher.ts:543 (last DM closed), SidebarArea.ts:474 and :565 (back out of DM mode). The unit test at tests/unit/dispatcher.test.ts:993 only asserts a *voice* channel is skipped in favour of a text one — nothing locks the exclusion of announcement, so this is not intended behaviour. + +**Evidence:** dispatcher.ts:338 const firstText = payload.channels.find((ch) => ch.type === "text"); +dispatcher.ts:543 .filter((ch) => ch.type === "text") +dispatcher.ts:716 .filter((ch) => ch.type === "text") +SidebarArea.ts:474 if (ch.type === "text") { +SidebarArea.ts:565 if (ch.type === "text") { +(vs Server/ws/serve_ready.go:226 if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" {) + +**Suggested fix:** Add one shared predicate next to the Channel type (e.g. `export const isTextLikeChannel = (ch: {type: ChannelType}) => ch.type === "text" || ch.type === "announcement";` in Client/tauri-client/src/lib/types.ts) and use it at dispatcher.ts:338, :543, :716 and SidebarArea.ts:474, :565 in place of the inline `ch.type === "text"` comparisons. DM channels are handled by separate branches and are unaffected. + +**Fixed:** `d066591a` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass + +### OC-0175 — low — applySetChannelID's post-Subscribe revalidation re-checks READ but not `archived`, so a channel archived mid-focus leaves the socket permanently subscribed to it + +`Server/ws/handlers.go:265` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-server-ws` + +The OC-0024 comment on applySetChannelID explains that HandleChannelFocus's admission gate and this Subscribe are separated by SQLite round trips, so the subscription is re-validated afterwards. HandleChannelFocus refuses an archived channel (Server/service/channel.go:255-263, OC-0070), but the re-validation here only mirrors the DM-participant and READ_MESSAGES legs — it already has `ch` in hand and never looks at `ch.Archived`. Archiving is exactly a visibility change whose fan-out (RefreshChannelVisibility) can only Unsubscribe topics a socket holds at the instant it runs, so a Subscribe landing after it is never revisited. + +**Repro:** Client sends channel_focus(C) for a non-archived channel C. handleChannelFocusV2 → HandleChannelFocus reads C (not yet archived), passes the archived check, then runs GetLatestMessageID + GetReadState + UpdateReadState (UpdateReadState is a write that queues on the single SQLite writer). During that window an admin PATCHes C with archived=true (Server/admin/handlers_channels.go:229-271): AdminUpdateChannel commits, then hub.RefreshChannelVisibility(C) runs, takes the `case ch.Archived: visible = false` branch for every client, finds nothing subscribed for this user (the Subscribe has not happened yet) and c.channelID still 0, so it sends channel_delete and unsubscribes nothing. handleMessageApply then calls applySetChannelID(c, C): it sets c.channelID = C, Subscribes to ChannelTopic(C), re-reads C (now archived), takes the `else if ch != nil && hasChannelAccess(... ReadMessages)` branch — READ_MESSAGES is unaffected by archiving — and returns, leaving the subscription and focus in place. Result: the connection is focused on and subscribed to a channel the server has already told it was deleted and that every other surface (ready, VisibleChannelIDs, voice_join, channelReadAudience) hides, for the life of the socket. + +**Evidence:** Server/ws/handlers.go:257 ch, chErr := h.db.GetChannel(c.ctx, newChID) +Server/ws/handlers.go:261 if ch != nil && ch.Type == "dm" { ... } +Server/ws/handlers.go:265 } else if ch != nil && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) { +Server/ws/handlers.go:266 return // no ch.Archived check — HandleChannelFocus has one, this mirror does not + +**Suggested fix:** One word in the shared applier: Server/ws/handlers.go:265 → `} else if ch != nil && !ch.Archived && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {`. The DM branch above needs no change (DMs carry no archived concept, matching HandleChannelFocus's DM exemption locked by TestHandleChannelFocus_DMExemptFromArchiveGate). + +**Fixed:** `eb70032d` · test `Server/ws/handler_focus_revoke_race_test.go` · revert-proof pass + +### OC-0176 — low — Tray Status menu sends presence_update directly, bypassing the shared presence rate limiter and its retry + +`Client/tauri-client/src/main.ts:263` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-client-tauri-client-src-lib` + +The tray's status-change handler calls ws.send({type:"presence_update"}) raw instead of going through the session's single PresenceSender. lib/presence.ts's module doc states every producer MUST share one PresenceSender built from one RateLimiter, because the server enforces a single 1-update/10s budget keyed by user id and silently drops the excess (no DB write, no broadcast, no retry). With the tray outside that budget, the tray's frame and any in-app frame within the same 10s window each believe they hold the token; whichever the server sees second is rejected and permanently lost. + +**Repro:** 1. Sign in; MainPage builds one PresenceSender over limiters.presence (1 token / 10s). +2. Right-click the tray icon -> Status -> "Do Not Disturb". main.ts:262 persists "dnd" locally and main.ts:263 sends presence_update raw. The server accepts it and opens its 10s window for this user. The client-side limiter's bucket is still EMPTY (it never saw this send). +3. Within 10s, open the in-app status picker in the UserBar and choose "Online". UserBar.ts:177 -> presence.ts:56 limiter.tryConsume() returns TRUE (empty bucket), so ws.send fires immediately and NO retry is scheduled. presence.ts:52 has already applied the optimistic local update. +4. The server rejects that frame with ErrRateLimited: no users.status write, no presence broadcast. Result: this client shows/behaves as "online", the server row and every other member's list still show "dnd", and nothing re-sends — the retry path that exists for exactly this (presence.ts:65-71) was never armed. + +The reverse order fails the same way: pick a status in the UserBar (shared token consumed), then within 10s set a different one from the tray -> the tray's raw send is dropped by the server, while saveUserStatus() has already flipped the local pref, so notifications.ts:82 gates on the new DND while no peer ever learns of it. + +Secondary symptom on the same line: the tray path never calls updatePresence(), which every PresenceSender caller does (presence.ts:52), so the user's own row in membersStore is not updated either when the frame is dropped. + +The desync only self-heals on the next reconnect (MainPage.ts:334 restoreSavedPresence), i.e. potentially for the whole session. No test locks this: tests/unit/main.test.ts:194-214 asserts only the saveUserStatus half of the tray handler, never the wire path. + +**Evidence:** main.ts:255-265 + void listen("status-change", (e) => { + const status = e.payload; + if (status === "online" || status === "idle" || status === "dnd" || status === "offline") { + const mapped = status === "offline" ? "invisible" : status; + saveUserStatus(mapped); + ws.send({ type: "presence_update", payload: { status: mapped } }); // <-- raw send, no limiter, no retry, no updatePresence() + } + }); + +Contrast every other producer: + pages/MainPage.ts:133 const presenceSender = createPresenceSender(ws, limiters.presence); + pages/MainPage.ts:209 function applyPresence(status) { presenceSender.send(status); } // settings Account tab + auto-idle + components/UserBar.ts:177 sender.send(status); // UserBar status picker + +lib/presence.ts:56-73 is the budget + retry the tray skips: + if (limiter.tryConsume()) { ws.send({ type: "presence_update", payload: { status } }); } + else { retry = setTimeout(() => { retry = null; send(loadUserStatus(), customStatus); }, limiter.getRemainingMs()); } + +Server/service/channel.go:171-174 (the budget being blown): + ratKey := auth.Key("presence", userID) + if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) { return nil, ErrRateLimited } + +The rejection reaches the client only as a generic error frame (Server/ws/handlers_chat.go:171-172 -> ErrCodeRateLimited). lib/dispatcher.ts:1035-1060 only routes an error id to a pending optimistic *message* send; a presence frame has no pendingSends entry, so the branch falls through to a toast and nothing re-sends the status. + +**Suggested fix:** Give main.ts access to the session's single PresenceSender instead of adding a second budget. Smallest shape: add a module-level slot in Client/tauri-client/src/lib/presence.ts (e.g. `let active: PresenceSender | null = null; export function setActivePresenceSender(s: PresenceSender | null) { active = s; } export function getActivePresenceSender() { return active; }`), have MainPage.ts:133 call `setActivePresenceSender(presenceSender)` right after constructing it and `setActivePresenceSender(null)` next to `presenceSender.destroy()` in its teardown (MainPage.ts:804), then replace main.ts:263 with `getActivePresenceSender()?.send(mapped);`. saveUserStatus(mapped) on line 262 stays exactly as is, so OC-0037's assertions still pass, and the tray now shares the one token bucket, the coalescing retry, and the optimistic updatePresence with every other producer. When no session is mounted the optional call is a no-op, matching today's "ws.send is a safe no-op" behavior. + +**Fixed:** `b9b86067` · test `Client/tauri-client/tests/unit/main.test.ts` · revert-proof pass + +### OC-0177 — low — The voice roster is the only identity surface that ignores nicknames — VoiceUser carries no display name at all + +`Client/tauri-client/src/stores/voice.store.ts:253` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +`VoiceUser` (voice.store.ts:17-31) has a `username` field and no `displayName`, and every writer fills it with the raw handle: `setVoiceStates` uses `member?.username` (line 165), `updateVoiceState` uses `payload.username` (line 226), and `updateVoiceUserProfile` only accepts `{ username }` (line 253), which is all `dispatcher.ts:813` passes on `user_update`. Every other surface renders the nickname — `memberDisplayName` for the member list, `resolveAuthor` for message rows, `dmDisplayName` for the DM sidebar — so the same person appears under two different names at the same time. + +**Repro:** User Bob sets display_name = "Bobby" (PATCH /profile). The member list, his message rows, the profile popup and any DM row all render "Bobby". Bob joins a voice channel: `ChannelSidebar.ts:410` renders `createElement("span", {class:"vu-name"}, user.username || "Unknown")` from `VoiceUser.username`, so the voice roster (and the moderation context menu at ChannelSidebar.ts:489/532, and the identity-mismatch modal at :471) shows "Bob". Renaming to "Bobby" mid-call changes every other surface and leaves the voice row unchanged, because `updateVoiceUserProfile` has no nickname to write. + +**Evidence:** voice.store.ts:253 export function updateVoiceUserProfile(userId: number, patch: { readonly username: string }): void +voice.store.ts:165 username: member?.username ?? "", +ChannelSidebar.ts:410 const nameEl = createElement("span", { class: "vu-name" }, user.username || "Unknown"); +members.store.ts:182 export function memberDisplayName(member: ...): string // "The one place that answers it, so the member list, message rows and the profile popup cannot disagree." + +**Suggested fix:** Resolve at render time in ChannelSidebar.ts:410 — it already imports membersStore (line 29): `const m = membersStore.getState().members.get(user.userId); const label = (m ? memberDisplayName(m) : user.username) || "Unknown";` and use `label` for the .vu-name text (leave the E2EE mismatch modal and moderation menu on user.username). One call site, no store/type/protocol change, and the "Unknown" empty-username test still passes. + +**Fixed:** `81b22f56` · test `Client/tauri-client/tests/unit/channel-sidebar.test.ts` · revert-proof pass + +### OC-0178 — low — The typing indicator prints raw usernames instead of nicknames, despite holding full Member objects + +`Client/tauri-client/src/components/TypingIndicator.ts:20` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +`formatTypingText` receives `readonly Member[]` — the exact type `memberDisplayName()` in members.store.ts:182 exists to render — and reads `.username` directly. members.store's own doc calls that helper "the one place that answers it, so the member list, message rows and the profile popup cannot disagree"; this call site disagrees. + +**Repro:** Bob has display_name "Bobby". He starts typing in #general. `getTypingUsers` returns his `Member` (which carries `displayName: "Bobby"`), but the bar renders "Bob is typing..." while his previous message directly above it is attributed to "Bobby" by `resolveAuthor` (formatting.ts:139). Two names for one person on the same screen. + +**Evidence:** TypingIndicator.ts:18 function formatTypingText(users: readonly Member[]): string { +TypingIndicator.ts:20 return `${users[0]?.username ?? "Someone"} is typing...`; +members.store.ts:182 export function memberDisplayName(member: Pick): string + +**Suggested fix:** In TypingIndicator.ts, import memberDisplayName from @stores/members.store and replace `users[0]?.username` / `users[1]?.username` with `users[0] ? memberDisplayName(users[0]) : undefined` (same for [1]), keeping the `?? "Someone"` fallback. Two expressions in one function; the helper already handles blank/whitespace displayName. + +**Fixed:** `e2ae20c0` · test `Client/tauri-client/tests/unit/components/TypingIndicator.test.ts` · revert-proof pass + +### OC-0179 — low — onDeafenToggle's undeafen path is missing the localServerMuted guard its sibling onMuteToggle has, so a server-muted user gets an error toast every time they undeafen + +`Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:96` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-2` + +onMuteToggle refuses to send an unmute while `localServerMuted === true` ("the server refuses the unmute, so don't spend the round-trip"), but the undeafen branch of onDeafenToggle unconditionally calls voiceSessionSetMuted(false) and sends `voice_mute {muted:false}`. The session-layer guard in LiveKitSession.setMuted (livekitSession.ts:1591) swallows the local half, so state stays consistent — but the frame still goes to the server, which refuses it in refuseIfServerSilenced (Server/ws/voice_controls.go:273) with SERVER_MUTED and no broadcast. The dispatcher's catch-all error branch turns that into a user-visible error toast for an action that actually succeeded. + +**Repro:** 1. Moderator issues voice_mod_mute on user U (server_muted=1, server_deafened=0); U's client sets localMuted=true, localServerMuted=true. +2. U self-deafens (allowed — refuseIfServerSilenced only consults ServerDeafened for the deafen direction). onDeafenToggle takes the else branch; localMuted is already true so no voice_mute is sent. +3. U un-deafens. localServerDeafened is false so the guard at line 92 passes; the voice_deafen frame is accepted, then line 96/97 fire. +4. voiceSelfToggleV2 -> refuseIfServerSilenced(deafen=false) sees ServerMuted -> returns ClientError{SERVER_MUTED, "you were muted by a moderator"} with no voice_state broadcast. +5. dispatcher.ts's S.ERROR handler falls through every correlated branch (no envelope id on this send, voiceStatus is "connected", code is neither CHANNEL_FULL nor VIDEO_LIMIT) and reaches showToast(payload.message, "error") — U sees "you were muted by a moderator" as an error toast for a successful undeafen, every single time. No test covers this: tests/unit/voice-callbacks.test.ts's makeVoiceState fixture never sets localServerMuted. + +**Evidence:** onDeafenToggle: () => { + if (!limiters.voice.tryConsume()) return; + const state = voiceStore.getState(); + if (state.localServerDeafened === true) return; // guards deafen only + if (state.localDeafened) { + voiceSessionSetDeafened(false); + ws.send({ type: "voice_deafen", payload: { deafened: false } }); + voiceSessionSetMuted(false); // no localServerMuted check + ws.send({ type: "voice_mute", payload: { muted: false } }); + +// contrast, onMuteToggle (same file, line 76): +// if (state.localServerMuted === true) return; + +**Suggested fix:** Mirror onMuteToggle's guard on the unmute half of the undeafen branch in Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:96-97 — wrap those two lines in `if (state.localServerMuted !== true) { ... }` so no voice_mute{muted:false} frame is sent while the moderator mute stands (the deafen clear itself still goes through). + +**Fixed:** `5c0606bf` · test `Client/tauri-client/tests/unit/voice-callbacks.test.ts` · revert-proof pass + +### OC-0180 — low — Avatar URL is HTML-escaped by the bare bluemonday sanitizer, corrupting every avatar URL with a query string + +`Server/api/profile_handler.go:202` · found 2026-08-19 · hunt `2026-08-19-general` · lens `hotspot-server-admin` + +handleUpdateProfile fixed the username path to use service.SanitizeText precisely because bluemonday's output is always HTML-escaped, but the avatar branch three lines below still calls the bare sanitizer.Sanitize. `&` becomes `&` (and `'` becomes `'`), so any https avatar URL carrying more than one query parameter is persisted mangled. validateAvatarURL still parses it fine, so the write succeeds and the corruption is silent. + +**Repro:** PATCH /api/v1/users/me with {"username":"alice","avatar":"https://www.gravatar.com/avatar/abc?s=256&d=identicon"}. sanitizer.Sanitize rewrites it to "https://www.gravatar.com/avatar/abc?s=256&d=identicon"; url.Parse accepts it, so it is stored and shipped in ready/member payloads. Every client then requests a URL with a literal `&` parameter and the avatar fails to load. Same for any presigned/CDN URL (`?size=128&quality=lossless`). + +**Evidence:** // Use the fixpoint sanitizer (service.SanitizeText), not the bare +// sanitizer.Sanitize below — Sanitize's output is always HTML-escaped +req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) +... +if req.Avatar != nil { + trimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar)) + if err := validateAvatarURL(trimmed); err != nil { + +**Suggested fix:** Server/api/profile_handler.go:202 — replace `sanitizer.Sanitize(*req.Avatar)` with `service.SanitizeText(*req.Avatar)`, matching the username path at line 186 (service is already imported). + +**Fixed:** `78b81b1b` · test `Server/api/profile_handler_test.go` · revert-proof pass + +### OC-0181 — low — Settings overlay opened for the first time from the Connect page never moves focus into the dialog, and its focus trap is inert + +`Client/tauri-client/src/components/SettingsOverlay.ts:393` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +`mount()` runs the initial `show()` (which calls `focusDialog(panel)`) at line 393, but the overlay's `root` is not attached to the document until line 397. `HTMLElement.focus()` on a detached subtree is a no-op, so neither the first focusable control nor the panel itself receives focus. Because `trapFocus` is registered on `panel`, keydown events from the still-focused element outside the panel never reach the trap, so Tab walks freely through the page underneath the full-screen overlay. + +**Repro:** ConnectPage creates the SettingsOverlay lazily: `ensureSettingsOverlay()` only runs once `uiStore.settingsOpen` is already true (ConnectPage.ts:256 and :259), and then calls `settingsOverlay.mount(root)` (ConnectPage.ts:243). So on the very first click of the Settings gear on the connect page, `SettingsOverlay.mount()` executes with `uiStore.getState().settingsOpen === true`: line 375 `renderActiveTab()`, line 393-395 `show()` -> `restoreFocus = focusDialog(panel)` while `root` is still detached (`queryFocusable(panel)[0].focus()` does nothing), and only at line 397 `container.appendChild(root)`. Result: the Settings panel is visible, `document.activeElement` is still the connect-page host input (ConnectPage.mount ends with `loginForm.focusHost()`), Tab cycles through the hidden login form instead of the dialog, and a screen reader is never moved into the dialog. Every later open goes through the uiStore subscription with `root` already attached and works correctly — which is why tests/unit/settings-overlay.test.ts:839 (`mount` then `open()`) passes and does not cover this path. MainPage is unaffected because it mounts the overlay while `settingsOpen` is false. + +**Evidence:** // Sync initial state + if (uiStore.getState().settingsOpen) { + show(); + } + + container.appendChild(root); // <- root is only attached AFTER show()/focusDialog(panel) + +**Suggested fix:** Move the attach above the initial sync in mount(): do `container.appendChild(root);` first, then `if (uiStore.getState().settingsOpen) show();`. One reorder in the shared mount() fixes every caller. + +**Fixed:** `9e8719dc` · test `Client/tauri-client/tests/unit/settings-overlay.test.ts` · revert-proof pass + +### OC-0182 — low — Member profile popup shows a stale presence status after an in-place presence patch + +`Client/tauri-client/src/components/MemberList.ts:248` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-2` + +`createMemberItem` closes over the `Member` object captured at render time, and `updatePresence` (members.store.ts:170) replaces that object with a new one instead of mutating it. A presence-only change takes the `patchPresence` fast path (MemberList.ts:434-452), which repaints only the row's dot/`offline` class and never rebuilds the row — so the click handler keeps handing `UserProfilePopup` the *old* `status`. The dot and the popup, both driven from `membersStore`, disagree indefinitely. + +**Repro:** Open the member list with Bob offline. Bob connects → server sends a presence frame → `updatePresence(bobId, "online")`. `isPresenceOnlyChange` is true (username/role/avatar/displayName/customStatus/identityPublicKey all unchanged), so `patchPresence` recolors Bob's dot green in place and no row is rebuilt. Left-click Bob's row: the profile popup renders a grey dot and the label "Offline" next to the green dot in the list behind it. It stays wrong until some *structural* member change (a join/leave, a role change, a roles_update) forces `renderList`. tests/unit/member-list.test.ts:525-566 only asserts the dot/class patch and row identity — nothing locks the popup's status, so this is not intended behavior. + +**Evidence:** MemberList.ts:471-475 — `if (isPresenceOnlyChange(prevMembers, members)) { patchPresence(prevMembers, members, rowsByUserId); } else { renderList(...) }` +MemberList.ts:243-251 — `activePopup = createUserProfilePopup({ user: { … status: member.status, … } })` where `member` is the argument captured by `createMemberItem(member, …)` at line 190/396. +members.store.ts:169-174 — `const next = new Map(prev.members); next.set(userId, { ...existing, status, … });` (new object each time). +UserProfilePopup.ts:169/238/239 render `STATUS_COLORS[user.status]` / `STATUS_LABELS[user.status]` from that captured snapshot. + +**Suggested fix:** Resolve the live member inside the click handler instead of using the render-time snapshot: at MemberList.ts:242 insert `const live = membersStore.getState().members.get(member.id) ?? member;` and build the popup's `user` from `live` (at minimum `status: live.status`). One change in createMemberItem covers every row. + +**Fixed:** `9fedef5b` · test `Client/tauri-client/tests/unit/member-list.test.ts` · revert-proof pass + +### OC-0183 — low — platformInit's page-count conversion overflows uint32, so plugins.max_memory_mb >= 4096 silently yields a 0-page (or far smaller) memory limit + +`Server/plugin/sandbox_wazero.go:87` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-3` + +`uint32(memMB) * 1024 * 1024 / wazeroPageBytes` does the whole multiplication in uint32, which wraps at 4 GiB. `plugins.max_memory_mb` is an unbounded `int` from the config file (config.go:126, default 64) with no upper-bound validation anywhere, so any value at or above 4096 wraps to a limit far below what was asked for — and exactly 4096 (or 8192, …) wraps to zero. + +**Repro:** Set `plugins.max_memory_mb: 4096` in the server config and start with `-tags wazero`. `uint32(4096) * 1024 * 1024` == 2^32 == 0 in uint32, so `memPages` is 0 and the shared runtime is built with `WithMemoryLimitPages(0)`. Every plugin whose WASM declares a memory section (i.e. every plugin with a usable JSON ABI) then fails `rt.InstantiateModule`, `activateWithRuntime` returns the wrapped instantiate error, and `activateAll` (registry.go:508) only logs `"plugin: activation failed"` per plugin — the server starts with all plugins silently dark. With `plugins.max_memory_mb: 5000` the same wrap yields 14464 pages (~904 MiB) instead of 5000 MiB, again with no warning. + +**Evidence:** memMB := cfg.MaxMemoryMB + if memMB <= 0 { + memMB = 64 // default 64 MiB per plugin runtime + } + memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes + + rt := wazero.NewRuntimeWithConfig(ctx, + wazero.NewRuntimeConfig(). + WithMemoryLimitPages(memPages). + +**Suggested fix:** Compute in 64-bit and clamp to wasm32's page ceiling before narrowing: `pages := uint64(memMB) * 1024 * 1024 / wazeroPageBytes; if pages > 65536 { pages = 65536 }; memPages := uint32(pages)` — one change in platformInit, no caller changes. + +**Fixed:** `549088d5` · test `Server/plugin/sandbox_wazero_test.go` · revert-proof pass + +### OC-0184 — low — urlEnd gives back a trailing single '*' but not a trailing single '_', so `_https://…_` never italicizes + +`Client/tauri-client/src/components/message-list/markdown.ts:99` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-3` + +The trailing-delimiter give-back has two clauses: a regex that only matches runs of two or more (`([*_~|])\1+$`) and a hand-written special case for exactly one `*`. `_` is the other single-character emphasis marker in DELIMS, and it has no such clause — so a bare URL swallows the closing `_` and `scanClose` never finds it, which is precisely the failure the comment above the function says it exists to prevent. + +**Repro:** `parseInline("*https://example.com/a*")` → `[{type:"em", children:[{type:"text", value:"https://example.com/a"}]}]` (urlEnd stops at index 21 via the `src[end-1] === "*"` clause, scanClose finds the closer). `parseInline("_https://example.com/a_")` → `[{type:"text", value:"_https://example.com/a_"}]`: urlEnd's tail is `"example.com/a_"`, the run regex needs two or more so it does not match, the next clause only tests for `"*"`, so urlEnd returns end-of-string, scanClose returns -1 and the emphasis is dropped. The user sees literal underscores around the link. `~~url~~`, `||url||`, `__url__` and `**url**` all work, only the single `_` form does not. + +**Evidence:** const min = i + 8; + for (;;) { + const tail = src.slice(min, end); + const run = /([*_~|])\1+$/.exec(tail); + if (run !== null) { + end -= run[0].length; + continue; + } + if (end > min && src[end - 1] === "*") { + end--; + continue; + } + break; + } + +**Suggested fix:** Widen the single-character clause to the other one-character emphasis marker: `if (end > min && (src[end - 1] === "*" || src[end - 1] === "_")) { end--; continue; }` at markdown.ts:99. The doubled-marker cases still short-circuit through the run regex above it, so `__url__` and `https://x/a_b_c` are unchanged. + +**Fixed:** `fd09f4e6` · test `Client/tauri-client/tests/unit/content-markdown.test.ts` · revert-proof pass + +### OC-0185 — low — Every message's hover action bar (React/Reply/Pin/Edit/Delete/Copy link) is invisible to keyboard users while still sitting in the tab order + +`Client/tauri-client/src/styles/app.css:2160` · found 2026-08-19 · hunt `2026-08-19-general` · lens `explore-1` + +`.msg-actions-bar` is revealed only by `.message:hover`. There is no `:focus-within` (or `:focus`) rule anywhere in src/styles — `grep -rn "focus-within" src/styles` returns exactly one hit, `.message-input-box:focus-within`. The children are real `