mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(admin): owner gate consumes the context role — no second lookup (OC-0379)
OC-0345's fix kept the redundant GetRoleByID its own title named (its
suggestedFix says why: reading adminRoleKey broke two tests that injected
only the user). Finish the job: ownerOnlyMiddleware now consumes the
*db.Role adminAuthMiddleware stored, exactly like requirePerm — missing
role fails closed as 401, position below Owner stays 403, and the query
plus its 503 branch are gone (a role read fault surfaces once, at the
perimeter). The signature drops *db.DB at all nine call sites, so a
reintroduced lookup is a compile-visible change.
Test-first: TestOwnerOnlyMiddleware_NoSecondRoleLookup renames the roles
table away with the role in context and demands 200 — red 503 against the
old code, green now. The old RoleLookupFailureIs503 test guarded a branch
that no longer exists in any form; a tombstone comment records where its
contract lives on (the perimeter's default branch). Added below-owner 403
and user-without-role 401 pins; the blackbox owner-route tests pass
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
* chore(ledger): record OC-0379 fixed; counts to 321/379 everywhere watched
New record for the residue bcdc0ef3 fixes, citing OC-0345's deliberate
half-fix; the register's truth table, its enumeration paragraph, and the
watched count lines in plans README, HP-0 and b0-baseline all move to
321 fixed / 379 total (check-doc-counts green, 21 claims agree).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
* docs(hp-0): date the 379-record provenance honestly (Codex P2 on #1467)
The path-resolution row and the count caption attributed OC-0379 to sweeps
that predate it; both now say what actually happened — 378 re-verified
2026-08-29, OC-0379 path-verified at its own 2026-08-31 fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
---------
Co-authored-by: Claude <noreply@anthropic.com>
8593 lines
1.2 MiB
Plaintext
8593 lines
1.2 MiB
Plaintext
{
|
||
"nextId": 380,
|
||
"findings": [
|
||
{
|
||
"id": "OC-0001",
|
||
"title": "Wrapped room keys have no freshness binding, so old offers replay forever",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 772,
|
||
"severity": "high",
|
||
"why": "The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.",
|
||
"repro": "Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.",
|
||
"evidence": "e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "crypto-primitives",
|
||
"fix": {
|
||
"commit": "84033139",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0002",
|
||
"title": "A dead E2EE worker is invisible; the Secured badge cannot detect it",
|
||
"file": "Client/src/components/VoiceWidget.ts",
|
||
"line": 196,
|
||
"severity": "high",
|
||
"why": "The badge is derived purely from voiceStatus === 'connected', never from the SDK's live encryption state. livekit-client emits EncryptionEvent.EncryptionError from E2eeManager.onWorkerError, and src/ subscribes to none of it (zero grep hits for EncryptionEvent, ParticipantEncryptionStatusChanged, EncryptionError, isE2EEEnabled).",
|
||
"repro": "The e2ee worker constructs successfully then fails asynchronously (CSP on a lazily-loaded chunk, WASM load failure, WebView2 quirk). keyProvider.setKey still resolves because it is local WebCrypto plus an EventEmitter.emit that never round-trips through the worker. Join completes, status goes connected, badge shows Secured.",
|
||
"evidence": "VoiceWidget.ts:196 display toggle; livekitSession.ts:376-427 createRoom; E2eeManager.ts:242-245 emits EncryptionError",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "degradation-observability",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/voice-widget.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a0358f41"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0003",
|
||
"title": "Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 477,
|
||
"severity": "high",
|
||
"why": "The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.",
|
||
"repro": "A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.",
|
||
"evidence": "livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "tofu-trust-chain",
|
||
"fix": {
|
||
"commit": "bf7612fb",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0004",
|
||
"title": "Key-holder promotion silently no-ops when the client's own voice_state has not arrived",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 864,
|
||
"severity": "medium",
|
||
"why": "handleParticipantLeft early-returns when voiceUsers.get(channelId) is missing or empty. That roster is populated only by voice_state broadcasts, including the client's own. The server sends voice_token directly at voice_join.go:312 but enqueues the joiner's own voice_state on the hub broadcast queue at :337, with a GetChannelVoiceStates query in between.",
|
||
"repro": "Client Y joins a channel where X is holder. Y starts setupKeyExchange on the token. X leaves inside the window before Y's own voice_state is delivered; X's voice_leave arrives first, removeVoiceUser empties the channel entry (voice.store.ts:245-246 deletes it), handleParticipantLeft returns at :864 and never promotes. The server has elected Y holder; Y never learns. setupKeyExchange times out at 15s and Y is ejected with e2ee_timeout.",
|
||
"evidence": "livekitE2EE.ts:859-864; Server/ws/voice_join.go:312 vs :337",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "keyholder-election",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0005",
|
||
"title": "Rotation offers exceed the server rate limit in large channels, permanently starving the same peers",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 817,
|
||
"severity": "medium",
|
||
"why": "voiceE2EEOfferRateLimit is 64 per (sender, channel) per second, but voice_max_users defaults to 0 (unlimited) and admins may set up to maxVoiceLimit 99. distributeRoomKey loops over every peer with no pacing, awaiting only a fast WebCrypto wrap, so all sends land in one window. ws.send is fire-and-forget; onSendFailure covers local transport failures only, never a server ErrCodeRateLimited.",
|
||
"repro": "80-person voice channel, key holder rotates, 79 offers fire inside one second, the server drops everything past 64. _peerPublicKeys iterates in stable insertion order, so the same tail peers are starved on every subsequent rotation and stay on the old key.",
|
||
"evidence": "Server/ws/voice_e2ee.go:23, :213-216; migrations/004_voice_optimization.sql:6; Server/admin/handlers_channels.go:148",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "rotation-forward-secrecy",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0006",
|
||
"title": "Both rotation paths call keyProvider.setKey with no session-generation guard",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 900,
|
||
"severity": "medium",
|
||
"why": "handleParticipantLeft (:900) and rotateKeyPeriodically (:998) never capture or re-check _sessionGeneration around their setKey await. Every other destructive write in the file does; setupKeyExchange does it three times (:156, :174, :209). clearState bumps _sessionGeneration but does not touch keyProvider, which is one instance shared across Room objects.",
|
||
"repro": "A rotation's setKey is in flight when the user leaves and rejoins. The new session installs its own key; the stale setKey resolves afterwards and leaves the live encryptor holding an abandoned key. Narrow: needs two setKey promises to resolve out of order, and distributeRoomKey's ownership check already blocks the network half.",
|
||
"evidence": "livekitE2EE.ts:900, :998, :990 entry-only guard, :1038-1051 clearState",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "rotation-forward-secrecy",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0007",
|
||
"title": "Reconnect reaches the Secured state without confirming the room key is current",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 329,
|
||
"severity": "medium",
|
||
"why": "reannounceForReconnect re-applies the pre-disconnect room key and fires a single voice_e2ee_announce with no wait, no timeout, and no retry. The join path blocks on a confirmed key with a 10s attempt plus a 5s retry and aborts if it never arrives.",
|
||
"repro": "Network blip; the key rotates during the outage; the re-announce is lost or races the holder's own reconnect. The client sits on a dead key while the widget shows Secured, with no recovery bound short of the 5-minute rotation timer.",
|
||
"evidence": "livekitE2EE.ts:309-342; livekitSession.ts:535 awaited before :549/556 set connected",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "degradation-observability",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0008",
|
||
"title": "restoreLocalVoiceState has no internal supersession guard",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 834,
|
||
"severity": "medium",
|
||
"why": "await room.localParticipant.setMicrophoneEnabled can block for seconds on the mic-permission prompt. applyMicMuteState re-reads this._room fresh, so it acts on whichever room is live at resume time. connectAndSetup's checkpoint 3 (:1110) runs after the call returns and cannot prevent writes that happen mid-call.",
|
||
"repro": "Join channel A; the permission prompt stalls; the user switches to channel B; A's continuation resumes and unpublishes B's live mic using A's captured muted value.",
|
||
"evidence": "livekitSession.ts:834 await, unguarded writes at :845, :866-868, :871",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "reconnect-stale-continuations",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "db7d518b"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0009",
|
||
"title": "attemptAutoReconnect's tail has no supersession checkpoints after connected",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 564,
|
||
"severity": "low",
|
||
"why": "reconnectSuperseded is used exhaustively before newRoom.connect and never called again after the success setState. The tail runs unguarded, and startTokenRefreshTimer clobbers a single shared timer field that a newer session may have armed.",
|
||
"repro": "Reconnect for channel 5 succeeds and sets connected. During restoreLocalVoiceState or switchActiveDevice the user joins channel 9. The stale tail resumes and runs setupAudioPipeline, reapplyMuteGain, and startTokenRefreshTimer against channel 9's session.",
|
||
"evidence": "livekitSession.ts:564-589, no reconnectSuperseded call after :548",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "reconnect-stale-continuations",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "db7d518b"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0010",
|
||
"title": "handleOfferInner and handleAnnounceInner re-check generation before their final await, not after",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 784,
|
||
"severity": "low",
|
||
"why": "handleOfferInner's guard at :763 precedes the setKey await; the writes at :784 and :793 follow it. A teardown-and-rejoin-as-holder landing inside that await means :784 reads the new session's _isKeyHolder and stands it down. handleAnnounceInner has the same shape at :643 versus the write at :668.",
|
||
"repro": "Non-holder in channel A receives a valid offer, passes :763, and during setKey the user leaves and rejoins channel B as holder. The stale continuation sets _isKeyHolder=false for channel B and kills its rotation timer.",
|
||
"evidence": "livekitE2EE.ts:763 guard, :773 await, :784/:793 writes; :643 guard, :655-665 awaits, :668 write. The dangerous announce variant is blocked server-side by sendToUserIfInVoiceChannel's atomic same-channel check.",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "reconnect-stale-continuations",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "15d3c9c2"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0011",
|
||
"title": "A replayed announce overwrites a peer's live ephemeral key",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 651,
|
||
"severity": "low",
|
||
"why": "The signed announce message is domain || userId || ephemeralPubRaw with no channel, epoch, or nonce, so an old validly-signed announce replays cleanly. handleAnnounceInner sees a changed key and overwrites the live one, logging 'peer public key changed (reconnect?)'.",
|
||
"repro": "A malicious server re-emits a recorded announce carrying a retired ephemeral key. Subsequent offers are wrapped to a key nobody holds, silently denying that peer audio. Low because a malicious server can deny service more directly by not relaying.",
|
||
"evidence": "livekitE2EE.ts:651-670; e2eeCrypto.ts:41-45, :101-117 buildAnnounceMessage",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "tofu-trust-chain",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "15d3c9c2"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0012",
|
||
"title": "CleanupVoiceForChannel never clears voiceKeyHolders",
|
||
"file": "Server/ws/hub_sweep.go",
|
||
"line": 290,
|
||
"severity": "low",
|
||
"why": "Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.",
|
||
"repro": "Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.",
|
||
"evidence": "Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102",
|
||
"status": "fixed",
|
||
"found": "2026-08-09",
|
||
"hunt": "voice-e2ee-2026-08-09",
|
||
"lens": "keyholder-election",
|
||
"fix": {
|
||
"commit": "6f3485e7",
|
||
"test": "TestCleanupVoiceForChannel_ClearsKeyHolder",
|
||
"revertProof": "pass"
|
||
},
|
||
"fixed": "2026-08-19",
|
||
"note": "CleanupVoiceForChannel now re-elects via updateKeyHolder"
|
||
},
|
||
{
|
||
"id": "OC-0013",
|
||
"title": "REST DM events never bump the visibility watermark — *ws.Hub does not implement dmVisibilityMarker",
|
||
"file": "Server/api/dm_handler.go",
|
||
"line": 45,
|
||
"severity": "high",
|
||
"why": "markDMVisibilityChanged reaches the watermark bump through a type assertion to dmVisibilityMarker, but *ws.Hub has no MarkVisibilityChanged method anywhere in the repo (grep: only api/dm_handler.go and a test double define it), so the assertion always misses. The WS-side emitter of the same unsequenced, targeted dm_channel_open does bump it unconditionally (Server/ws/emit.go:41-48), so the two sibling paths disagree: hub.visibilityChangeSeq tracks WS-originated DM opens but never REST-originated ones, and mustFullResync therefore lets a client warm-resume across a REST DM change it can never be re-sent.",
|
||
"repro": "Alice calls POST /api/v1/dms/group with Bob among recipient_ids while Bob's socket is momentarily down (or Bob's socket drops during the call). broadcastDMOpen (dm_handler.go:265) calls markDMVisibilityChanged — a no-op — then SendToUser(bob) returns false. Bob reconnects with last_seq>0; h.mustFullResync(lastSeq) is false because visibilityChangeSeq never moved, so handleReconnect serves a seq replay and sends auth_ok, NOT ready. dispatcher.ts's setDmChannels therefore never runs, so Bob's dmStore has no entry for the group. Chat messages in that channel do replay (computeAllowedChannels includes it via dm_open_state) but updateDmLastMessage/updateDmLastMessagePreview early-return on a channelId not in dmStore and incrementUnread no-ops, so the group DM is invisible in Bob's sidebar with no badge and no way to open it until a full logout/login. The same no-op affects handleCloseDM (dm_handler.go:218), PATCH rename, and the group-leave refresh. Note api/dm_handler_watermark_voice_test.go:57 asserts markCalls>=1 using a double that DOES implement the interface, so the suite is green while production is inert.",
|
||
"evidence": "type dmVisibilityMarker interface { MarkVisibilityChanged() }\n\nfunc markDMVisibilityChanged(broadcaster DMBroadcaster) {\n\tif vm, ok := broadcaster.(dmVisibilityMarker); ok {\n\t\tvm.MarkVisibilityChanged()\n\t}\n}\n\n// contrast, same file:\nvar _ dmVoiceEvictor = (*ws.Hub)(nil) // sibling capability IS compile-time asserted; dmVisibilityMarker is not",
|
||
"suggestedFix": "Add `func (h *Hub) MarkVisibilityChanged() { h.bumpVisibilityWatermark() }` in Server/ws (e.g. hub.go next to bumpVisibilityWatermark), and add `var _ dmVisibilityMarker = (*ws.Hub)(nil)` in Server/api/dm_handler.go mirroring the existing dmVoiceEvictor compile-time assertion at line 63 so a future rename cannot silently re-break it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "state-desync",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/hub_visibility_watermark_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "108bbe42",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0014",
|
||
"title": "Client refreshes the LiveKit token every 23 hours while the server mints it with a 5-minute TTL, so auto-reconnect fails for any voice session older than 5 minutes",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 714,
|
||
"severity": "high",
|
||
"why": "Two sources of truth for the same credential disagree by three orders of magnitude. `Server/ws/livekit.go` sets `tokenTTL = 5 * time.Minute` and documents \"The client requests a refresh via voice_token_refresh before expiry\"; the client's only periodic refresh is `TOKEN_REFRESH_MS = 23h`. Nothing else re-requests a token: `requestTokenRefresh()` is called only from that timer and once right after a successful reconnect, and it early-returns when `this._room === null` (which is the case throughout \"reconnecting\"). `handleDisconnected` hands `deps.getLatestToken()` straight to `attemptAutoReconnect`, which passes it to `newRoom.connect(resolvedUrl, token)`.",
|
||
"repro": "Join voice, stay connected for >5 minutes, then drop the SFU connection (Wi-Fi blip, laptop sleep, SFU restart). `handleDisconnected` (roomEventHandlers.ts:161-197) starts `attemptAutoReconnect` with the join-time token, which expired at T+5min. Both attempts fail JWT validation at LiveKit, the loop exhausts, and line 640 runs `this.leaveVoice(true); leaveVoiceChannel(); onErrorCallback(\"Voice connection lost — failed to reconnect\")`. The user is ejected from the call for a blip that the reconnect path exists to absorb. The stale comment at livekitSession.ts:780-786 (\"Sessions longer than the 4h TTL…\", \"The 23h refresh timer ensures a fresh token is always ready *before* the original expires\") describes a TTL the server no longer uses. Note tests/unit/livekit-session.test.ts:2817 hardcodes the 23h advance, so it locks the constant but asserts nothing about the interop contract.",
|
||
"evidence": "Client/src/lib/livekitSession.ts:714\n private static readonly TOKEN_REFRESH_MS = 23 * 60 * 60 * 1000;\n\nServer/ws/livekit.go:28\n // Short-lived (5 min) to limit replay window (BUG-127). The client requests\n // a refresh via voice_token_refresh before expiry.\n const tokenTTL = 5 * time.Minute",
|
||
"suggestedFix": "Lower LiveKitSession.TOKEN_REFRESH_MS below the server TTL — e.g. 4 * 60 * 1000 (refresh 1 min before the 5-min expiry) — and update the stale KNOWN LIMITATION comment (livekitSession.ts:777-786) plus the three test constants that advance the timer by 23h. Optionally also have handleDisconnected request a refresh before starting attemptAutoReconnect, but the timer change alone restores the invariant the server comment documents.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "df221814"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0015",
|
||
"title": "A failed voice channel-switch leaves the client live in a voice call (mic hot, audio flowing) with the voice UI completely hidden and no way to leave",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 812,
|
||
"severity": "high",
|
||
"why": "The VOICE_LEAVE handler unconditionally calls the store's leaveVoiceChannel() whenever the event is about the local user (`if (isSelf) leaveVoiceChannel();`), even though the sibling effect three lines above — actually tearing down the LiveKit session — is correctly gated on `shouldTeardownSession` (payload.channel_id matching the store's *current* channel). During a channel switch the store's currentChannelId has already been optimistically set to the NEW channel by VoiceCallbacks.onVoiceJoin before the server responds, so the self voice_leave broadcast for the OLD channel (which the server always sends first, per voice_join.go's `h.handleVoiceLeave(ctx, c)` call before minting a token for the new channel) makes shouldTeardownSession false — but leaveVoiceChannel() still runs and blanks voiceStore.currentChannelId to null. In the normal success path this is harmless because a voice_state broadcast for the new channel (VOICE_STATE handler, dispatcher.ts:745, `joinVoiceChannel(payload.channel_id)`) arrives shortly after and restores currentChannelId. But when the switch fails server-side — e.g. voice_join.go:158-179's `LeaveVoiceChannelIfMatch` retry for the old channel's DB row fails, so GetVoiceState still returns the old row and the join is aborted — the server never sends a voice_token or a voice_state for either channel; it only sends a generic ErrCodeInternal error ('voice channel switch failed — please try again'), which dispatcher.ts's S.ERROR handler does not specially handle for this code (it falls through to the generic setTransientError toast at dispatcher.ts:1002, with no voiceStore write). So voiceStore.currentChannelId is permanently stuck at null with nothing left to restore it. Meanwhile voice_leave.go's finishVoiceLeave() unconditionally calls `h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken)` (voice_leave.go:108-114) regardless of whether the DB delete succeeded, forcibly kicking the client's still-live LiveKit Room object (the client never ran connectAndSetup()/leaveVoice() for this failed switch, since it never got a voice_token) out of the SFU. That kick fires roomEventHandlers.ts's handleDisconnected with a non-CLIENT_INITIATED reason; its auto-reconnect branch (roomEventHandlers.ts:172-198) decides whether to reconnect using `deps.getCurrentChannelId()`, which is LiveKitSession's own internal `_currentChannelId` getter (livekitSession.ts:207-211, derived from `_state`) — completely independent of voiceStore.currentChannelId. Since `_state` was never touched by the failed switch, `_currentChannelId` still points at the old channel with a valid cached token/URL, so attemptAutoReconnect silently reconnects the client back into the old channel's LiveKit room, republishes the microphone (restoreLocalVoiceState), and sets voiceStatus to 'connected' (livekitSession.ts:548-556) — all without ever calling joinVoiceChannel() to resync voiceStore.currentChannelId.",
|
||
"repro": "User is in voice channel A (fully joined, mic live). They click to switch to channel B (VoiceCallbacks.onVoiceJoin optimistically sets voiceStore.currentChannelId=B and sends voice_join). Server-side, handleVoiceJoin leaves channel A first; the DB's LeaveVoiceChannelIfMatch delete for the A row transiently fails (busy DB, timeout, etc.) but RemoveParticipant(A) and the voice_leave(A) broadcast still fire unconditionally. The client's VOICE_LEAVE(A) handler blanks voiceStore.currentChannelId to null (dispatcher.ts:812) since shouldTeardownSession is false (currentChannelId was already B) so it never calls session.leaveVoice(). Server then finds the stale A row still present, aborts the switch, restores its own hub state to channel A, and returns only a generic error — no voice_token/voice_state ever reaches the client, so nothing ever sets currentChannelId back to A or B. The client's still-live Room for channel A, kicked by RemoveParticipant, fires handleDisconnected, which — driven by LiveKitSession's own internal channel state, not the store — auto-reconnects back into channel A's LiveKit room and republishes the microphone. End state: voiceStore.currentChannelId is null (VoiceWidget.render() at VoiceWidget.ts:220 hides the entire widget when null, and ChannelSidebar.ts:335's `isJoined` is also false for row A) while the user is actually connected to channel A's SFU with a live, transmitting microphone and an intact E2EE session — invisible to the user, who has no on-screen mute/leave/status affordance until they happen to click channel A's row again (which itself would only start a *new* join attempt, tearing down the phantom session as a side effect of `connectAndSetup`'s `if (this._room !== null) this.leaveVoice(false)`).",
|
||
"suggestedFix": "Gate the teardown on the SESSION's live channel rather than only the store's: in the VOICE_LEAVE handler, tear down when isSelf and the LiveKit session's current channel id equals payload.channel_id (expose it from livekitSession alongside leaveVoice). The stale-leave protection test still holds (after a completed rejoin the session's channel is the new one), and every failed-switch variant then converges to a clean idle state instead of a hidden live session. Server-side hardening (send a voice_state resync in the voice_join abort branch) can follow, but the client guard alone removes the hot-mic state.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-voice",
|
||
"finder": "sonnet",
|
||
"round": 3,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "1a47c85a"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0016",
|
||
"title": "Re-opening a channel visited earlier in the session renders a permanently stale message window — loadMessages short-circuits on isChannelLoaded and nothing invalidates on switch",
|
||
"file": "Client/src/pages/main-page/MessageController.ts",
|
||
"line": 76,
|
||
"severity": "high",
|
||
"why": "`loadMessages` returns immediately when the channel is already in `loadedChannels`, and `loadedChannels` is only ever cleared by `invalidateLoadedMessageWindows()` (dispatcher's second-`ready` full-resync path) and `clearChannelMessages()` — which has no caller anywhere in `src/`. Combined with the focus-scoped fan-out above, every message posted in a channel while the user was viewing a different one is absent from the store, never delivered live, and never refetched. The stale window is what MessageList renders on the way back, with no gap indicator and no way for the user to force a refresh short of restarting the app.",
|
||
"repro": "Open channel A (50 messages fetched, `loadedChannels` = {A}). Switch to channel B — the server unsubscribes the socket from `channel:A`. Ten messages are posted in A; none reach this client. Switch back to A: `MainPage`'s activeChannelId subscriber calls `mountChannel(A)` → `loadMessages(A)` → `isChannelLoaded(A)` is true → early return. MessageList renders the 50-message snapshot from the first visit; the 10 new messages are missing with no \"has more below\" affordance, and stay missing for the rest of the session (scroll-up only calls `loadOlderMessages`, which prepends).",
|
||
"evidence": "Client/src/pages/main-page/MessageController.ts:76-79 `if (isChannelLoaded(channelId)) { log.debug(\"Messages already loaded\", { channelId }); return; }`\nClient/src/pages/main-page/ChannelController.ts:245 `void msgCtrl.loadMessages(channelId, signal);` — the only load on mount; `mountChannel` does not clear the window\nClient/src/stores/messages.store.ts:746 `clearChannelMessages` — `grep -rn \"clearChannelMessages\" src/` matches only its own definition\nClient/src/lib/dispatcher.ts:311 `invalidateLoadedMessageWindows();` — reached only when `hasReceivedReadyBefore` (a full-ready resync)",
|
||
"suggestedFix": "In ChannelController.mountChannel, when previousChannelId !== null, drop that channel from loadedChannels (export a store helper mirroring reattachToPresent's Set-delete, without requiring the detached flag) so the next visit refetches the live tail; setMessages' existing merge already preserves pending/failed rows and newer live rows, so the refetch cannot clobber in-flight state.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "ff17920",
|
||
"test": "Client/tests/unit/channel-controller.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0017",
|
||
"title": "Virtual scroll window never follows the scroll position — rows outside the initial ±20-item overscan render as blank space",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 536,
|
||
"severity": "high",
|
||
"why": "renderWindow() only rebuilds DOM when `renderedStart < 0`, and the only two callers that set that sentinel are renderAll() and scrollToMessage(). Every scroll-driven call therefore lands in the `else` branch, which is a pure no-op — it does not even update the spacers its own comment on line 496-497 claims it updates. The rendered window is frozen wherever the last data-change rebuild left it, so scrolling into the top/bottom spacer shows an empty region with no rows, and nothing ever fills it.",
|
||
"repro": "Open a channel whose full history is loaded (hasMoreMessages(channelId) === false) and that holds ~300 messages. mount() → renderAll() positions the window at the tail (~41 items ≈ 2.5k px). Scroll up past that: handleScroll → requestAnimationFrame → renderWindow() → renderedStart is 0-or-greater → else branch → nothing rendered. The area above the frozen window is the top spacer (offsetBefore(renderedStart) px of empty div) and stays blank indefinitely, because the scroll-top fetch is gated on hasMoreMessages and no store update fires. The only escape is an unrelated store event (new message, role revision bump) that triggers renderAll and re-centres the window on the current scrollTop. tests/unit/message-list.test.ts:244 (\"scrollToMessage renders a target that was outside the rendered window\") documents the same frozen-window behaviour rather than locking it as intended.",
|
||
"evidence": "if (renderedStart < 0) { … full rebuild … } else {\n // Scroll-driven: no-op. The ResizeObserver handles measurement and\n // spacer updates when element sizes change.\n}\n// called from: handleScroll → scrollRafId = requestAnimationFrame(() => { scrollRafId = 0; renderWindow(); })",
|
||
"suggestedFix": "In renderWindow's else branch, detect that the freshly computed [start,end) range is not contained in [renderedStart,renderedEnd) and take the rebuild path in that case (the existing >30-rebuilds-per-2s renderWindowCount breaker already guards the image-height oscillation loop the no-op was written to avoid); keep the no-op only when the target range is already fully rendered.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "7e22fe7",
|
||
"test": "Client/tests/unit/message-list.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0018",
|
||
"title": "voice_join into a 1:1 DM has no block gate — a blocked user can enter the blocker's DM voice room and publish audio to them",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 64,
|
||
"severity": "high",
|
||
"why": "Every other 1:1-DM interaction sink routes through service.requireDMNotBlocked (send, edit, delete, react, pin, typing, and call_ring — see service/message_perms.go:92-118, whose own doc comment claims it is \"called from every DM interaction sink\"). The voice path's only gate is hasChannelAccess, which by construction never consults blocks, and grep shows the entire ws/ package contains no IsEitherBlocked / requireDMNotBlocked call. Blocking never touches dm_participants (service/block.go:48 calls only st.BlockUser), so IsDMParticipant still returns true and the blocked user passes straight through.",
|
||
"repro": "Bob blocks Alice (PUT /api/v1/blocks/{alice}). Alice sends {\"type\":\"voice_join\",\"payload\":{\"channel_id\":<their 1:1 DM channel id>}} over WS. hasChannelAccess passes: the default Member role holds CONNECT_VOICE 0x200 (migrations/007_member_video_permissions.sql sets Member = 0x1E63) and IsDMParticipant(alice, dm) is still true because BlockUser does not remove participant rows. handleVoiceJoin then persists a voice_states row, mints a LiveKit token with RoomJoin + CanPublishSources [\"microphone\", \"camera\", \"screen_share\"] for room channel-<dmID> (ws/livekit.go:110-121), and broadcastVoiceEvent resolves the DM audience to its participants (ws/hub_broadcast.go:149-166), so Bob's client receives Alice's voice_state and renders her as present in that DM's call (Client dispatcher.ts:740 -> updateVoiceState). Alice can repeat this within the 5/s voice_join limit to spam Bob's UI with voice_state/voice_leave, and if Bob is in that room her microphone audio reaches him. The identical channel's call_ring is correctly refused with FORBIDDEN — the block is enforced on the doorbell but not on the door.",
|
||
"evidence": "ws/voice_join.go:64 — `if !h.requireChannelAccess(ctx, c, channelID, permissions.ConnectVoice, \"CONNECT_VOICE\") {` is the only authorization on the join; ws/deps.go:190-193 — \"Blocking is deliberately not consulted here: it is the message paths' rule (service.requireDMNotBlocked), it is two-party only, and a blocked user is still a participant, so it is orthogonal to the non-participant hole this closes.\"; the same gate is reused for re-minting at ws/voice_join.go:418 (`hasChannelAccess(... permissions.ConnectVoice)` in handleVoiceTokenRefreshV2). Contrast service/dm.go:368 (`requireDMNotBlocked` inside RingTargets), added for A-2026-08-03 and locked by ws/dm_group_call_test.go:344 TestCallRing_BlockedOneToOneForbidden.",
|
||
"suggestedFix": "Expose service.requireDMNotBlocked (e.g. a DMService method) and call it in handleVoiceJoin right after the ch.Type == \"dm\" branch (voice_join.go:82-85), refusing with FORBIDDEN; group DMs are already exempt inside requireDMNotBlocked. Reuse the same call in handleVoiceTokenRefreshV2 next to its hasChannelAccess gate (voice_join.go:418) so a mid-session block also evicts on refresh.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/ws/voice_dm_access_test.go",
|
||
"revertProof": "pass (manual: voice_join.go reverted alone, DMBlocked tests red, green at HEAD)",
|
||
"branchCommit": "423f9cbc"
|
||
},
|
||
"declinedDate": "2026-08-14",
|
||
"notes": "Reopened 2026-08-14: advisory-path routing dropped per user decision; fix lands in normal PR with sanitized messaging.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0019",
|
||
"title": "Disconnect teardown decides `replaced` before a multi-second voice cleanup, then stamps the already-reconnected user offline",
|
||
"file": "Server/ws/serve_pumps.go",
|
||
"line": 186,
|
||
"severity": "medium",
|
||
"why": "readPump's defer samples `replaced := hub.unregisterNow(c)` at line 148 and then reuses that stale boolean at line 186 to gate `MarkUserDisconnected` (196) and the global offline presence broadcast (203). Between those two points it runs `hub.handleVoiceLeave(cleanupCtx, c)` (157), which does a DB delete, a per-connected-user permission scan in `channelReadAudience`, and a `livekit.RemoveParticipant` HTTP call bounded only by `lkTimeout = 5s` (Server/ws/livekit.go:151). A reconnect that registers during that window is invisible to the stale flag, so the dead socket's teardown marks the live session offline. `hub_sweep_test.go:87` documents that `replaced` exists precisely so \"a reconnect's teardown does not mark the live connection's user offline\" — the guard is simply evaluated too early to hold.",
|
||
"repro": "User U is connected as client A and is in voice channel V; LiveKit is unreachable/slow. (1) A's socket drops. readPump's defer snapshots voiceChID=V and calls unregisterNow(A), which finds A in h.clients, deletes it, and returns replaced=false. (2) The defer enters handleVoiceLeave, which blocks up to 5s in RemoveParticipant. (3) U's client reconnects: authenticateConn succeeds, handleReconnect (or handleFreshConnect) calls registerNow(B) so h.clients[U]=B, then applyConnectStatus writes users.status='online' and announceConnectPresence broadcasts presence{U, online}. (4) A's defer resumes with the stale replaced=false: MarkUserDisconnected(U) flips users.status back to 'offline' (db/dbgen/users.sql.go:185) and BroadcastToAll(presence{U, offline}) reaches every peer. Result: U is live on socket B but renders offline on every already-connected client, and a client that connects later reads users.status='offline' from ListMembers (presentableMembers only downgrades non-connected users, never upgrades). Nothing re-announces until U changes status or reconnects again.",
|
||
"evidence": "148: replaced := hub.unregisterNow(c)\n156:\t\t\tif voiceChID != 0 && !replaced {\n157:\t\t\t\thub.handleVoiceLeave(cleanupCtx, c) // DB + audience scan + 5s LiveKit call\n186:\t\t\tif !replaced {\n196:\t\t\t\t_ = hub.db.MarkUserDisconnected(cleanupCtx, c.userID)\n203:\t\t\t\thub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, nil))",
|
||
"suggestedFix": "In readPump's defer (and unregisterFailedHandshake), re-evaluate liveness at decision time instead of reusing the pre-cleanup snapshot: gate the MarkUserDisconnected + offline broadcast on `!replaced && hub.GetClient(c.userID) == nil` (any entry present after unregisterNow removed c is necessarily a newer connection), evaluated immediately before line 196 — after handleVoiceLeave returns.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/ws/serve_pumps_reconnect_race_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "250a7819"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0020",
|
||
"title": "Stale `_isKeyHolder` survives a voice-channel switch made while the SFU is reconnecting, so the client joins the new channel as a phantom key holder",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 188,
|
||
"severity": "medium",
|
||
"why": "`setupKeyExchange` ORs the server-authoritative `is_key_holder` with whatever `_isKeyHolder` already holds. Its stated justification is that `clearState()` always runs between sessions (so a non-false residue can only be an in-window `handleParticipantLeft` promotion). That invariant is broken by `connectAndSetup`, which only tears E2EE state down via `if (this._room !== null) this.leaveVoice(false);` (livekitSession.ts:933) — and `_room` (livekitSession.ts:202) is null in the `reconnecting` state. A join issued while the LiveKit auto-reconnect loop is running therefore reaches `setupKeyExchange` with `_isKeyHolder` still true from the previous channel, and the server's `false` is discarded.",
|
||
"repro": "1. User (uid 5) is alone/lowest in voice channel A, so the server sent `is_key_holder=true`; `_isKeyHolder === true`, rotation timer armed.\n2. The LiveKit SFU connection drops (network blip). `handleDisconnected` -> `setRoom(null)` -> `setReconnectAc(ac)` puts `_state` in `reconnecting` (livekitSession.ts:319-334). The WS socket is unaffected, so the sidebar's `onVoiceJoin` guard (`socketLive()`, VoiceCallbacks.ts:173) still passes.\n3. During the reconnect loop (MAX_RECONNECT_ATTEMPTS=2, RECONNECT_DELAY_MS=3000, plus URL resolution/connect time) the user clicks voice channel B, which already has a lower-uid participant. Server: `computeIsKeyHolder(B, 5)` -> false, sends `voice_token` with `is_key_holder=false`.\n4. `handleVoiceToken` -> state is `reconnecting`, so neither the `connected` fast path nor the `_connecting` queue applies -> `connectAndSetup(...)`. `this._room` is null, so `leaveVoice(false)` is skipped and `_e2ee.clearState()` never runs.\n5. `setupKeyExchange(false, B)` executes `this._isKeyHolder = false || true` -> true. The client bumps the epoch, generates its OWN room key, applies it to the shared `keyProvider`, arms a 5-minute rotation timer, sends only an announce, and returns true immediately — skipping the entire non-key-holder wait/timeout path.\n6. `connectAndSetup` proceeds to `room.connect()` and `setVoiceStatus(\"connected\")`. The client now publishes SFrame-encrypted audio under a key nobody in B holds and cannot decrypt any peer, while the UI reports the call connected/secured. Every `voice_e2ee_offer` it sends is rejected server-side with `NOT_KEY_HOLDER` (Server/ws/voice_e2ee.go:198). Recovery depends entirely on B's real key holder answering the announce with an offer (handleOfferInner's stand-down at livekitE2EE.ts:783); if that offer never arrives — holder is TOFU-blocked on us, rate-limited (voice_e2ee.go:214-223), or mid-join — the client stays silently deaf and mute forever, because the 10s+5s `e2ee_timeout` safety net that would have ejected a real non-holder was never entered. Meanwhile the phantom rotation timer regenerates a fresh useless room key every 5 minutes.",
|
||
"evidence": "livekitE2EE.ts:188 `this._isKeyHolder = isKeyHolder || this._isKeyHolder;`\nlivekitE2EE.ts:190-203 `if (this._isKeyHolder) { this._e2eeEpoch++; this._roomKey = generateRoomKey(); await this.keyProvider.setKey(...); this.startKeyRotationTimer(); }`\nlivekitE2EE.ts:230-232 `if (this._isKeyHolder) { ...send announce... } else { /* wait up to 10s+5s for an offer, else return false */ }`\nlivekitSession.ts:933 `if (this._room !== null) this.leaveVoice(false);`\nlivekitSession.ts:202-204 `private get _room(): Room | null { return this._state.type === \"connected\" ? this._state.room : null; }`\nlivekitSession.ts:329-357 `teardownForReconnect` — tears down the audio pipeline/tracks, never calls `_e2ee.clearState()`\nlivekitSession.ts:1370-1379 `leaveVoice()` is the sole caller of `this._e2ee.clearState()`",
|
||
"suggestedFix": "In connectAndSetup, treat superseding an in-flight reconnect the same as superseding a live room: change livekitSession.ts:933 to `if (this._room !== null || this._state.type === \"reconnecting\") this.leaveVoice(false);`. leaveVoice(false) aborts the stale reconnect AbortController and runs _e2ee.clearState(), bumping _sessionGeneration so line 188's OR can only preserve promotions that land during THIS setupKeyExchange call (the B3-2 behavior), never residue from a prior session.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0021",
|
||
"title": "Login builds a rate-limiter key from the unvalidated username, so an unauthenticated caller pins ~1 MiB of heap per request for 6 hours",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 353,
|
||
"severity": "medium",
|
||
"why": "handleLogin never length-checks req.Username (its sibling handleRegister calls auth.ValidateUsername, max 32 runes, before touching anything). The raw string becomes a RateLimiter map key, and RateLimiter.Allow inserts that key into the shard map before the limit test, while RateLimiter.Cleanup only evicts an entry once every recorded timestamp is older than rateLimiterCleanupMaxWindow — 6 hours. An attacker-chosen, body-sized key is therefore retained for 6 hours per attempt on an endpoint that requires no credentials.",
|
||
"repro": "POST /api/v1/auth/login with body {\"username\":\"<1 MiB of 'a'>\",\"password\":\"x\"}. The user does not exist, so GetUserByUsername returns (nil,nil), execution reaches line 366-367, and \"login_user_fail:\" + the 1 MiB string is stored in RateLimiter.shards[h].windows. The response is 401, but the key stays resident until a Cleanup pass finds its timestamp older than 6 hours. The route's own IP limiter permits loginRateLimitPerMinute = 5 such requests per minute per source, i.e. ~5 MiB/min retained, ~1.8 GiB resident at steady state from a single IP (more from several). Sending 10 identical oversized usernames additionally trips the per-username lockout, which persists the same ~1 MiB key into the lockouts table via RateLimiter.Lockout -> UpsertLockout, and that row is reloaded into memory by NewPersistentRateLimiter on every restart. The identical input to POST /api/v1/auth/register is rejected at api/auth_handler.go:192 before any allocation.",
|
||
"evidence": "api/auth_handler.go:323 unameKey := strings.ToLower(req.Username)\napi/auth_handler.go:324 userLockKey := \"login_user_lock:\" + unameKey\napi/auth_handler.go:353 userFailKey := \"login_user_fail:\" + unameKey\napi/auth_handler.go:366 if !limiter.Allow(failKey, loginFailureThreshold+1, loginFailureWindow) ||\napi/auth_handler.go:367 !limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {\n\nauth/ratelimit.go:134 e, ok := s.windows[key]\nauth/ratelimit.go:136 e = &entry{}\nauth/ratelimit.go:137 s.windows[key] = e // inserted even when the call is then refused\n\nauth/ratelimit.go:249 cutoff := time.Now().Add(-maxWindow) // maxWindow == 6h in production\nauth/ratelimit.go:255 for key, e := range s.windows { ... if ts.After(cutoff) { allStale = false } }\nauth/ratelimit.go:263 if allStale { delete(s.windows, key) } // entry survives ~6h after its last use\n\napi/constants.go:133 rateLimiterCleanupMaxWindow = 6 * time.Hour\napi/router.go:52 r.Use(MaxBodySizeUnless(defaultMaxBodySize, ...)) // defaultMaxBodySize = 1 MiB, /auth/login not exempt",
|
||
"suggestedFix": "In handleLogin, reject or clamp an over-long username before building unameKey — e.g. after the empty check add `if len(req.Username) > maxUsernameKeyLen { return 400 }` (or truncate the value used to key the limiter), so an unbounded body-sized string can never become a retained map/DB key. maxUsernameLength (32 runes) is the natural bound.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/auth_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "47eeb930"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0022",
|
||
"title": "The archived-channel read-only gate exists only on SendMessage; edit, reaction, pin and purge still mutate an archived channel",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 268,
|
||
"severity": "medium",
|
||
"why": "SendMessage refuses an archived channel (message_crud.go:54) because \"any caller that still held the id ... could keep posting into an archive indefinitely\". EditMessage routes its non-DM gate through checkSendPermission, which carries no archived check, and the same is true of handleReaction, SetMessagePinned and PurgeMessages. So the archive is still writable: an author can inject arbitrary new text into an archived channel and it is fanned out as chat_edited to every reader with READ_MESSAGES, and a MANAGE_MESSAGES holder can still pin or bulk-delete there.",
|
||
"repro": "Admin PATCHes /admin/api/channels/{id} with archived=true. Alice, who previously posted message M in that channel and still holds its id, sends the WS chat_edit command for M with new content: EditMessage passes checkSendPermission (her base READ|SEND bits are untouched by archiving) and commits the new text, broadcasting chat_edited to every client that can read the channel — while the identical chat_send is refused with ErrForbidden \"channel is archived\" (locked by service/archived_channel_readonly_test.go). The same holds over REST: POST /api/v1/channels/{id}/pins/{messageId} (api/channel_handler.go:73) and POST /api/v1/channels/{id}/messages/purge (api/channel_handler.go:70) both succeed against the archived channel.",
|
||
"evidence": "message_crud.go:54 if !isDM && ch.Archived { return ...ErrForbidden: channel is archived } // send only\nmessage_crud.go:268 } else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil {\n // comment: \"an edit injects new text into the channel and is fanned out to every\n // reader, so it must clear the same gate as a send\" — but checkSendPermission\n // (message_perms.go:69-90) never consults ch.Archived\nmessage_query.go:215 } else if !s.perms.HasChannelPerm(ctx, userID, channelID, ReadMessages|ManageMessages) // SetMessagePinned, no archived check\nmessage_purge.go:55 same, PurgeMessages\nmessage_reactions.go:116 same, handleReaction\nadmin/handlers_channels.go:260 \"Archiving hides a voice channel the same way deleting it does — nobody can see it or reach it afterward\"",
|
||
"suggestedFix": "Add the archived read-only check to the shared write policy so all mutation paths inherit it: put `if ch.Type != \"dm\" && ch.Archived { return ErrForbidden }` inside checkSendPermission (covers EditMessage and CanPost), and add the same guard to SetMessagePinned, PurgeMessages and handleReaction (which bypass checkSendPermission), ideally via one requireWritableChannel(ch) helper called from every write sink.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/service/archived_channel_readonly_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "8ad4f59d"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0023",
|
||
"title": "ListMembers hides users whose temporary ban has lapsed, while every other path treats them as active",
|
||
"file": "Server/db/queries/sqlite/users.sql",
|
||
"line": 58,
|
||
"severity": "medium",
|
||
"why": "ListMembers filters on the raw `u.banned = 0` column, but nothing ever clears `banned` when `ban_expires` passes — expiry is evaluated lazily by `auth.IsEffectivelyBanned` (auth/helpers.go:73) and by `db.notBannedClause` (db/mention_queries.go:40). A user whose temp ban has lapsed can therefore authenticate (ws/serve_auth.go:85), post, and be resolved as an @mention/@everyone target, yet is absent from the `members[]` roster the ready payload is built from (ws/serve_ready.go:153 -> db/auth_queries.go:545). The two sources of truth disagree — exactly the hazard the notBannedClause comment was written to close, applied to mentions but not to the roster.",
|
||
"repro": "Ban user B with a 1-hour expiry (ModerationService.BanUser -> db.BanUser writes banned=1, ban_expires=now+1h). Wait for the expiry to pass. B logs in: api/middleware.go:131 and ws/serve_auth.go:85 both call auth.IsEffectivelyBanned, which returns false, so the connection is accepted. B sends a message and is a valid @mention target (GetUserIDsByUsernames uses notBannedClause). But every connected client's `ready` payload — B's own included — omits B from members[], because ListMembers still sees banned=1. Result: B's messages render with no member entry (no avatar, no role colour), B is missing from the member sidebar and from mention autocomplete, and B cannot be opened from the roster. TestListMembers_ExcludesBanned (db/auth_queries_test.go:755) only covers a permanent ban (expires=nil), so this case is not test-locked.",
|
||
"evidence": "users.sql:53-59\n-- name: ListMembers :many\nSELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key,\n u.display_name, u.custom_status\nFROM users u\nJOIN roles r ON u.role_id = r.id\nWHERE u.banned = 0\nORDER BY u.username ASC;\n\n-- vs db/mention_queries.go:40 (the same question, answered differently)\nconst notBannedClause = `(banned = 0 OR (ban_expires IS NOT NULL AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))`",
|
||
"suggestedFix": "Via the db-change skill, change ListMembers' WHERE clause in Server/db/queries/sqlite/users.sql to the same lapsed-ban test as db.notBannedClause: WHERE (u.banned = 0 OR (u.ban_expires IS NOT NULL AND replace(u.ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))), then regenerate Server/db/dbgen/.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/db/auth_queries_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "45dd39f7"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0024",
|
||
"title": "channel_focus re-subscribes after a concurrent visibility revoke, leaving a demoted user permanently subscribed to a channel they can no longer READ",
|
||
"file": "Server/ws/handlers.go",
|
||
"line": 170,
|
||
"severity": "medium",
|
||
"why": "The READ_MESSAGES check for channel_focus happens inside the handler (service/channel.go:245), but the pub/sub Subscribe that acts on it happens later, in the applier, with two SQLite round-trips in between. Nothing re-validates at Subscribe time, and the revoke sweeps (Hub.RefreshChannelVisibility / Hub.revokeUnreadableChannels) only ever Unsubscribe what the socket holds at the instant they run — so a Subscribe landing after the sweep is never undone.",
|
||
"repro": "User U is a member of role R, currently focused on channel 5. U's client sends channel_focus{channel_id:7} (7 is readable at that moment). On U's readPump goroutine, HandleChannelFocus passes the READ check at service/channel.go:245 and then blocks in GetLatestMessageID/UpdateReadState. Concurrently an admin POSTs a channel_overrides change denying R READ_MESSAGES on channel 7: admin/handlers_channel_perms.go:164 calls permInvalidator.InvalidateAll(), then :167 calls hub.RefreshChannelVisibility(ch7), which sends U a channel_delete, runs pubsub.Unsubscribe(c, ChannelTopic(7)) (a no-op — U is not subscribed yet, focus is still 5) and clears c.channelID if it equals 7 (it does not). The admin request finishes. U's handler now returns SetChannelID=7, and handlers.go:170 runs pubsub.Subscribe(c, ChannelTopic(7)) plus sets c.channelID=7. U is now subscribed to channel 7's topic with no READ permission and nothing left to revoke it: every subsequent chat_message / chat_edited / chat_deleted / reaction_update published to channel 7 is delivered to U for the remaining lifetime of the socket. The same window exists for revokeUnreadableChannels on a role reassignment (hub_broadcast.go:534-562).",
|
||
"evidence": "handlers.go applier:\n\tif result.SetChannelID != nil {\n\t\toldChID := c.getChannelID()\n\t\tc.mu.Lock(); c.channelID = *result.SetChannelID; c.mu.Unlock()\n\t\tnewChID := *result.SetChannelID\n\t\tif oldChID != newChID {\n\t\t\tif oldChID > 0 { c.hub.pubsub.Unsubscribe(c, ChannelTopic(oldChID)) }\n\t\t\tif newChID > 0 { c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) } // <- line 170, no re-check\n\t\t}\n\t}\n\nservice/channel.go HandleChannelFocus (the only gate):\n\t} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { // line 245\n\t\treturn nil, fmt.Errorf(\"%w: access denied\", ErrForbidden)\n\t}\n\tlatestID, err := s.st.GetLatestMessageID(ctx, channelID) // DB round trip 1\n\tif err == nil { _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } // DB round trip 2\n\nhub_broadcast.go RefreshChannelVisibility (the revoke, line 350-356):\n\t\tc.sendMsg(buildChannelDelete(ch.ID))\n\t\th.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))\n\t\tc.mu.Lock(); if c.channelID == ch.ID { c.channelID = 0 }; c.mu.Unlock()",
|
||
"suggestedFix": "In the handlers.go applier, after pubsub.Subscribe(c, ChannelTopic(newChID)), re-validate access with a live check (hasChannelAccess, as used by requireChannelAccess) and on failure Unsubscribe + clear c.channelID. Subscribe-then-recheck closes the window in both orders: a revoke committing before the recheck is seen by the recheck; a revoke committing after finds the subscription present and its sweep removes it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/handler_focus_revoke_race_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "3026ddf2",
|
||
"branchCommit": "cc82417f",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0025",
|
||
"title": "enableCamera has no supersession re-check after publishTrack, so a concurrent disableCamera leaves the server and every peer believing the camera is on",
|
||
"file": "Client/src/lib/screenShare.ts",
|
||
"line": 243,
|
||
"severity": "medium",
|
||
"why": "The generation guard is checked only after device acquisition (line 235), not after the awaited publishTrack. A disableCamera that runs during the publish round-trip bumps the generation, unpublishes/stops the track and sends voice_camera{enabled:false}; the superseded enableCamera then resumes and sends voice_camera{enabled:true}, so the last frame the server sees says the camera is on while the local store says off and no track exists.",
|
||
"repro": "In a live voice channel the user clicks the camera toggle on. enableCamera acquires the device, sets state.manualCameraTrack and awaits room.localParticipant.publishTrack (an SFU negotiation round trip, tens to hundreds of ms). Before it resolves the user clicks the toggle off (or the dispatcher's VIDEO_LIMIT/error handler calls disableCamera — dispatcher.ts:992/1018). disableCamera bumps state.generation, stopManualCameraTrack clears state.manualCameraTrack and stops the MediaStreamTrack, setLocalCamera(false) runs and voice_camera{enabled:false} is sent. publishTrack then resolves; enableCamera continues past line 243 with no generation check and sends voice_camera{enabled:true} at line 251. Server-side ordering is false then true, so the DB row and the voice_state broadcast say camera=true: every peer renders a camera tile for a participant whose track was stopped, while the local voiceStore has localCamera=false, so the user's next toggle click sends enabled:true again and there is no single click that turns it off.",
|
||
"evidence": " if ((state.generation ?? 0) !== generation) { // 235 - only guard\n videoTrack.stop();\n return;\n }\n state.manualCameraTrack = videoTrack; // 242\n await room.localParticipant.publishTrack(videoTrack, { // 243 - awaited, no guard after\n ...\n });\n const sendId = ws.send({ type: \"voice_camera\", payload: { enabled: true } }); // 251\n\nexport async function disableCamera(state, deps) {\n bumpGeneration(state); // 276\n stopManualCameraTrack(state, room); // unpublishes + stops the in-flight track\n ...\n finally { setLocalCamera(false); ws.send({ type: \"voice_camera\", payload: { enabled: false } }); }",
|
||
"suggestedFix": "After the awaited publishTrack (and before the ws.send at 251), re-check (state.generation ?? 0) !== generation; on supersession, unpublish and stop videoTrack, clear state.manualCameraTrack if it still points at it, and return without sending voice_camera(true).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "0db0433",
|
||
"test": "Client/tests/unit/screen-share-tracks.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0026",
|
||
"title": "enableScreenshare has no supersession re-check across its publish loop, so a concurrent stop still announces the share as on",
|
||
"file": "Client/src/lib/screenShare.ts",
|
||
"line": 345,
|
||
"severity": "medium",
|
||
"why": "Same missing post-await guard as enableCamera, but worse: the loop publishes tracks from the local `screenTracks` array while disableScreenshare has already emptied state.manualScreenTracks, so the remaining publishes are made against tracks the disable path can no longer reach, and the final ws.send announces enabled:true after the disable already announced enabled:false.",
|
||
"repro": "The user picks a window in the OS share picker; every quality preset requests audio alongside video, so enableScreenshare enters the loop at line 345 with two tracks and awaits the first publishTrack. The user then hits the app's Stop Sharing button (or the OS 'Stop sharing' bar fires the 'ended' listener registered at line 358 for a previous share). disableScreenshare bumps the generation, stopManualScreenTracks sets state.manualScreenTracks = [] and stops/unpublishes both tracks, setLocalScreenshare(false) runs, and voice_screenshare{enabled:false} is sent. The loop resumes and publishes the second track — held only by the local `screenTracks` closure variable, which state.manualScreenTracks no longer references, so no later disable can unpublish it — and line 370 sends voice_screenshare{enabled:true}. The server's last observed state is enabled:true, peers keep a screenshare tile for the participant, and the local store says screenshare off.",
|
||
"evidence": " if ((state.generation ?? 0) !== generation) { // 334 - only guard\n for (const t of screenTracks) t.stop();\n return;\n }\n state.manualScreenTracks = screenTracks; // 341\n for (const track of screenTracks) {\n await room.localParticipant.publishTrack(track, { // 345 - awaited per track, no guard\n ...\n });\n }\n const sendId = ws.send({ type: \"voice_screenshare\", payload: { enabled: true } }); // 370\n\nexport async function disableScreenshare(state, deps) {\n bumpGeneration(state); // 399\n stopManualScreenTracks(state, room); // sets state.manualScreenTracks = [] and stops them\n ...\n finally { setLocalScreenshare(false); ws.send({ type: \"voice_screenshare\", payload: { enabled: false } }); }",
|
||
"suggestedFix": "Re-check (state.generation ?? 0) !== generation after each awaited publishTrack in the loop (and before the ws.send at 370); on supersession, unpublish/stop every track in the local screenTracks array, clear state.manualScreenTracks if it still references them, and return without sending voice_screenshare(true).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "62792c7",
|
||
"test": "Client/tests/unit/screen-share-tracks.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0027",
|
||
"title": "HTTP listen failure returns from run() without hub.GracefulStop(), orphaning the companion livekit-server process and leaving the maintenance goroutine's stop channel unclosed",
|
||
"file": "Server/main.go",
|
||
"line": 383,
|
||
"severity": "medium",
|
||
"why": "hub.GracefulStop() — the only caller of LiveKitProcess.Stop() — is a plain statement at line 401, not a defer, and the serve-error branch returns at line 383 before reaching it. LiveKitProcess.Stop() is what cancels the context passed to exec.CommandContext, so skipping it leaves the spawned livekit-server child alive; Go does not kill children when the parent exits, so it is reparented and keeps holding :7880 and the 50000-60000 UDP range. `close(stopMaintenance)` (line 407) is likewise skipped, so the 15-minute maintenance goroutine survives the whole deferred teardown, including `database.Close()` at line 133.",
|
||
"repro": "Configure voice.livekit_binary (or voice.auto_download_livekit: true) and start the server while another process holds the configured server.port. api.NewRouter spawns livekit-server via LiveKitProcess.Start. The listen loop retries 20 times, then pushes the bind error onto serveErr; run() returns at line 383, main() calls os.Exit(1) — hub.GracefulStop() never runs, so LiveKitProcess.Stop() never cancels its context and the livekit-server child outlives the OwnCord process. Restarting OwnCord then fails LiveKit startup with :7880 already in use. The same return also strands the maintenance goroutine, which can be mid-DeleteExpiredSessions(bgCtx) when the deferred database.Close() executes.",
|
||
"evidence": "select { case err := <-serveErr: if err != nil { return fmt.Errorf(\"server error: %w\", err) } ... } // line 380-387\n...\nhub.GracefulStop() // line 401 — never reached on the serveErr path\nif err := srv.Shutdown(shutdownCtx); err != nil { return ... }\nclose(stopMaintenance) // line 407 — also never reached\n\n// router.go:164-169 already started the companion process by this point:\nproc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)\nif startErr := proc.Start(); startErr != nil { ... } else { hub.SetLiveKitProcess(proc) }",
|
||
"suggestedFix": "Add `defer hub.GracefulStop()` immediately after `router, hub, routerCleanup := api.NewRouter(...)` (main.go:198). gracefulOnce makes it idempotent with the explicit call at line 401 on the normal path, and it guarantees LiveKitProcess.Stop() runs on every early return.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/main_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "d74e9861",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0028",
|
||
"title": "buildReady drops the user's own live voice room when it is not READ-visible, wiping the client's call roster on a full resync",
|
||
"file": "Server/ws/serve_ready.go",
|
||
"line": 276,
|
||
"severity": "medium",
|
||
"why": "buildReady filters every voice_state through visibleSet = READ-visible non-DM channels ∪ the user's *open* DM channels. Voice membership is gated on CONNECT_VOICE alone (voice_join.go:64) and DM visibility comes from dm_open_state, so the room the user is currently in can be absent from visibleSet — the exact hole handleReconnect patches on the replay tier via liveVoiceEventsSince (serve.go:341-343, whose comment names 'a DM voice call after the DM was closed' as the stock case). The full-ready tier has no equivalent supplement, so the ready payload asserts the user is in no voice channel while the server's voice_states row, the hub's c.voiceChID and the LiveKit session all say otherwise.",
|
||
"repro": "Alice and Bob are in a 1:1 DM voice call. Alice closes the DM from the sidebar (DELETE /api/v1/dms/{id}); CloseDM's non-group branch only deletes her dm_open_state row and leaves result.Left false, so no voice eviction runs — she stays in the call. Alice's socket then drops and her resume takes the full-ready path (mustFullResync, or a buffer/cold-tier miss). buildReady's dmChannels comes from GetUserDMChannels (dm_open_state), so the DM id is not in visibleSet and BOTH voice_state rows are filtered out; payload.voice_states is empty. Client-side setVoiceStates (Client/src/stores/voice.store.ts:185) then does `voiceUsers: channelMap` — a full replacement with an empty map — while `currentChannelId: autoJoinChannel ?? prev.currentChannelId` keeps her in the channel, and selfState is undefined so localServerMuted/localServerDeafened are reset to false. Result: a live, audible call rendering zero participants (including herself), setLocalSpeaking permanently a no-op (it early-returns when voiceUsers.get(channelId) is undefined), and any moderator server-mute gate silently lifted in the UI. Nothing repopulates her own row until she toggles mute herself. The same happens for any voice channel where an override grants CONNECT_VOICE but denies READ_MESSAGES.",
|
||
"evidence": "visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels))\nfor i := range visibleChannels { visibleSet[visibleChannels[i].ID] = struct{}{} }\nfor i := range dmChannels { visibleSet[dmChannels[i].ChannelID] = struct{}{} }\nvoiceStates := make([]db.VoiceState, 0, len(allVoiceStates))\nfor i := range allVoiceStates {\n\tif _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok {\n\t\tvoiceStates = append(voiceStates, allVoiceStates[i])\n\t}\n}",
|
||
"suggestedFix": "In buildReady, before filtering, seed visibleSet with the channel of the user's own voice row: scan allVoiceStates for a row with UserID == userID and add its ChannelID to visibleSet (the user's own live room can never leak — they are in it). This mirrors liveVoiceEventsSince's rationale on the replay tier.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "state-desync",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/serve_ready_own_voice_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "5c4b338d",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0029",
|
||
"title": "buildReady swallows three DB errors and ships an authoritative-looking empty snapshot; the client wipes its DM list, member list and unread badges",
|
||
"file": "Server/ws/serve_ready.go",
|
||
"line": 242,
|
||
"severity": "medium",
|
||
"why": "Inside one function, `ListChannels`/`ListRoles`/`GetChannelOverridesFor` failures abort the handshake (`return nil, err`), but `ListMembers` (l.153), `GetChannelUnreadCounts` (l.188) and `GetUserDMChannels` (l.242) failures are downgraded to `slog.Warn` plus an empty value, and the `ready` frame is then built and sent as if it succeeded. `ready` is the protocol's full-state snapshot, so the client cannot distinguish \"the query failed\" from \"you genuinely have none\" — the error is mapped to success on the wire.",
|
||
"repro": "A server restart makes every client reconnect at once and take the full-ready path; under that load one `GetUserDMChannels` read returns SQLITE_BUSY (or hits the request ctx deadline). The server logs a warning and sends `ready` with `dm_channels: []`. Client/src/lib/dispatcher.ts:331-335 documents the exact opposite contract — \"the server always sends the field, so an empty array is an authoritative 'no open DMs' ... and must clear ghosts from dmStore\" — so `setDmChannels([])` wipes the user's whole DM list, and the reconcile loop at dispatcher.ts:347-366 then deletes every dm-typed mirror row from channelsStore. If the user was viewing a DM, `stillPresent` at dispatcher.ts:285-292 is false, so `setActiveChannel(null)` tears down the open conversation. Every DM is unreachable for the rest of the session: a `ready` is only re-sent on a fresh connect or a full resync, and successful seq-replay reconnects never send one. The same interleaving on `ListMembers` empties the member sidebar (removing the \"Message\" affordance that is the only way back to a DM), and on `GetChannelUnreadCounts` zeroes every channel's unread_count/mention_count/last_message_id.",
|
||
"evidence": "members, err := database.ListMembers(ctx)\nif err != nil {\n\tslog.Warn(\"buildReady ListMembers\", \"err\", err)\n\tmembers = []db.MemberSummary{}\n}\n...\nunreadMap, err := database.GetChannelUnreadCounts(ctx, userID)\nif err != nil {\n\tslog.Warn(\"buildReady GetChannelUnreadCounts\", \"err\", err)\n\tunreadMap = map[int64]db.ChannelUnread{}\n}\n...\ndmChannels, err := database.GetUserDMChannels(ctx, userID)\nif err != nil {\n\tslog.Warn(\"buildReady GetUserDMChannels\", \"err\", err)\n\tdmChannels = []db.DMChannelInfo{}\n}\n\n// contrast, same function, lines 144-151:\nchannels, err := database.ListChannels(ctx)\nif err != nil { return nil, fmt.Errorf(\"buildReady ListChannels: %w\", err) }",
|
||
"suggestedFix": "In buildReady, treat the three per-user loads like ListChannels: on error from ListMembers, GetChannelUnreadCounts, or GetUserDMChannels, return nil, fmt.Errorf(...) so the handshake fails and the client's reconnect logic retries, instead of shipping empty values the protocol defines as authoritative.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/ws/serve_ready_error_propagation_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "6c0a7caa"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0030",
|
||
"title": "prependMessages trims the tail at the 500-row cap, silently destroying the user's pending/failed optimistic rows",
|
||
"file": "Client/src/stores/messages.store.ts",
|
||
"line": 602,
|
||
"severity": "medium",
|
||
"why": "Optimistic rows (status \"pending\"/\"failed\") are appended at the END of a channel's array by addOptimisticMessage, and prependMessages trims with `combined.slice(0, MAX_MESSAGES_PER_CHANNEL)` — i.e. it drops the tail. Every other writer that replaces a channel window (setMessages L415-424, setAroundMessages L492, invalidateLoadedMessageWindows L545) deliberately carries non-\"sent\" rows across, with the comment \"they are the only copy of the user's composed text\". prependMessages is the one path that does not, so an unsent/failed message and its Retry draft are deleted with no server copy to restore them (the comment at L597-599 claims the dropped tail is \"restored via the detached-window machinery\", which is only true for rows the server actually has).",
|
||
"repro": "1. Open a channel with plenty of history. MAX_MESSAGES_PER_CHANNEL = 500, PAGE_SIZE = 50.\n2. Send a message while the socket is down (or let a send fail): addOptimisticMessage appends a row with id 0 and status \"pending\"/\"failed\" at the end of messagesByChannel[ch]; the composer text now exists ONLY in that row (Retry/Delete render off it).\n3. Scroll up repeatedly. Each loadOlderMessages -> prependMessages adds up to 50 older rows at the head. After ~10 pages the array reaches 500.\n4. On the next scroll-up, combined.length = 550 > 500, so wasTrimmed is true and combined = combined.slice(0, 500) keeps the first 500 (oldest) rows and discards the last 50 — which include the optimistic row.\n5. The failed message and its text are gone from the store forever; pendingSends still holds the correlationId, and nothing re-renders a Retry affordance. Contrast step 4 with setMessages, which slices merged.slice(merged.length - MAX) and therefore preserves the same rows.",
|
||
"evidence": "let combined = [...converted, ...existing];\n// ...\nconst wasTrimmed = combined.length > MAX_MESSAGES_PER_CHANNEL;\nif (wasTrimmed) {\n combined = combined.slice(0, MAX_MESSAGES_PER_CHANNEL);\n}",
|
||
"suggestedFix": "In prependMessages' trim branch, carry non-'sent' rows out of the dropped tail: `if (wasTrimmed) { const kept = combined.slice(0, MAX_MESSAGES_PER_CHANNEL); const carried = combined.slice(MAX_MESSAGES_PER_CHANNEL).filter((m) => m.status !== \"sent\"); combined = carried.length > 0 ? [...kept, ...carried] : kept; }` — mirroring the carry every other window-replacing writer already performs.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "e8bf022",
|
||
"test": "Client/tests/unit/messages-store-detached.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0031",
|
||
"title": "Channel drag-reorder assumes distinct positions; tied positions make the drop a silent no-op or land the channel in the wrong slot",
|
||
"file": "Client/src/components/channel-sidebar/drag-reorder.ts",
|
||
"line": 151,
|
||
"severity": "medium",
|
||
"why": "The mouseup handler reassigns \"the group's own existing position slots\" by sorting the category's position values ascending and zipping them onto the new id order. That is only correct when the positions are distinct. `channels.position` has no uniqueness constraint (Server/db/queries/sqlite/channels.sql has no unique index, AdminUpdateChannel/CreateChannel store whatever is given, and the admin panel's Create Channel modal ships `value=\"0\"` for the Position field — Server/admin/static/index.html:925). When every channel in a category shares position 0, slots is [0,0,0,...] and `ch.position !== newPosition` is false for every row, so `reorders` stays empty, `drag.onReorder` is never called, no PATCH is sent, and updateChannelPosition is never applied — the drag silently does nothing and the row snaps back. With partial ties the zip assigns the wrong slot to the wrong channel, so the dragged channel lands somewhere other than where it was dropped.",
|
||
"repro": "1. In the admin panel, create three text channels in category \"Text Channels\" without editing the Position field: general, random, dev. All three are stored with position = 0 (Server/admin/handlers_channels.go:117 -> AdminCreateChannel with req.Position = 0).\n2. In the desktop client, signed in as a MANAGE_CHANNELS holder, getChannelsByCategory sorts them by position (all 0, so stable Map-insertion order): [general, random, dev].\n3. Drag `dev` and drop it on the top half of `general`.\n4. reorderedIds = [dev, general, random]; slots = [0,0,0].\n5. Loop: i=0 -> dev, newPosition 0, dev.position is already 0 -> skipped. i=1 -> general, 0 == 0 -> skipped. i=2 -> random, 0 == 0 -> skipped.\n6. reorders.length === 0, so `drag.onReorder(reorders)` at L166-168 never fires. No adminUpdateChannel PATCH is issued and the store is never updated; the sidebar re-renders in the original order. The drag is unrecoverably a no-op for as long as the tie exists.\nPartial-tie variant: positions [general=0, random=0, dev=5]; dragging dev to the front yields dev->0 (changed, sent), general->0 (skipped), random->5 (changed, sent), leaving general and dev both at 0 — the resulting order depends on Map iteration order rather than the drop.",
|
||
"evidence": "const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);\nconst reorders: ChannelReorderData[] = [];\nfor (let i = 0; i < reorderedIds.length; i++) {\n const id = reorderedIds[i];\n const newPosition = slots[i];\n ...\n const ch = drag.channels.find((c) => c.id === id);\n if (ch !== undefined && ch.position !== newPosition) {\n reorders.push({ channelId: id, newPosition });\n updateChannelPosition(id, newPosition);\n }\n}\nif (reorders.length > 0) {\n drag.onReorder(reorders);\n}",
|
||
"suggestedFix": "After sorting, make the slot list strictly increasing before zipping: `for (let i = 1; i < slots.length; i++) { if (slots[i]! <= slots[i - 1]!) slots[i] = slots[i - 1]! + 1; }` — tied groups then get distinct positions, the reorder fires, and subsequent renders order deterministically, while categories with already-distinct slots keep their exact existing range (the behavior the offset test at drag-reorder.test.ts:360 locks).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "df82fca",
|
||
"test": "Client/tests/unit/drag-reorder.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0032",
|
||
"title": "Client's `lastSeq` watermark is never reset by a full-ready resync, so it desyncs permanently from the server's seq counter (and then silently skips events)",
|
||
"file": "Client/src/lib/ws.ts",
|
||
"line": 307,
|
||
"severity": "medium",
|
||
"why": "`lastSeq` is monotone-increasing (`if (seq > lastSeq) lastSeq = seq`) and is only ever zeroed by `disconnect()` (logout). The server answers an unusable `last_seq` by sending a full `ready` and stamps `replay_source: \"none\"` into auth_ok, but the client ignores that field and keeps the stale watermark forever. Once the server's counter is *below* the client's watermark (any restart where `MAX(events.seq)` is 0 — `event_persistence.enabled=false`, an events table emptied by the 24h pruner, a restored DB), the two counters never re-converge, and while the server's counter climbs back through the stale value the client asks for a range the server happily answers as a complete resume.",
|
||
"repro": "Server with `event_persistence.enabled=false` (or an events table emptied by the pruner). Client is connected long enough to reach lastSeq=5000, then the server restarts, so `h.seq` starts at 0. (a) Immediate effect: every subsequent reconnect takes the full-ready tier (ringbuffer.go:66 `afterSeq > newestSeq`), and dispatcher.ts:301-327 fires `invalidateLoadedMessageWindows()` + a full `getMessages` refetch each time, forever. (b) Data loss: the client stays connected while the server's counter climbs to 4990 (received live, lastSeq stays pinned at 5000 because 4990 < 5000). The socket drops; during the reconnect backoff the server broadcasts up to seq 5090. The client reconnects with `last_seq=5000`; the 1000-entry ring buffer holds 4091..5090, so `afterSeq(5000) > oldestSeq(4091)` and `afterSeq <= newestSeq(5090)` both pass and the server replays only 5001..5090 with `replay_source: \"buffer\"`. Events 4991..5000 — real chat_message/chat_deleted/channel_update frames the client missed while offline — are never delivered, no `ready` arrives, and no history refetch is triggered.",
|
||
"evidence": "ws.ts:256-260 `const seq = ...; if (seq > lastSeq) { lastSeq = seq; }` — the only write outside disconnect().\nws.ts:307-320 auth_ok branch: `replayDedup = null; setState(\"connected\"); reconnectAttempt = 0; startHeartbeat();` — `payload.replay_source` (Server/ws/serve_ready.go:49, `\"none\"` for fresh/full resync) is never read and lastSeq is never reset.\nws.ts:427 `last_seq: lastSeq` is sent unconditionally on every auth frame.\nws.ts:598 `lastSeq = 0;` inside `disconnect()` only.\nServer side: Server/ws/ringbuffer.go:66 `if afterSeq > rb.newestSeqLocked() { return nil }` forces full ready while the server is behind, and Server/main.go:208-213 only seeds `h.seq` when `cfg.EventPersistence.Enabled` and `maxSeq > 0`.",
|
||
"suggestedFix": "In ws.ts's auth_ok branch (line ~307), reset the watermark when the server declares a full resync: `if ((msg.payload as { replay_source?: string }).replay_source === \"none\") lastSeq = 0;` before setState(\"connected\"). The next sequenced frame then adopts the server's current epoch via the existing seq > lastSeq update.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/ws-reconnect.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "bdbbed65"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0033",
|
||
"title": "A DM send survives a transient GetDMParticipantIDs failure by silently dropping live fan-out to everyone, including the sender",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 174,
|
||
"severity": "medium",
|
||
"why": "SendMessage already committed the message row via CreateMessageWithMentions before this block runs. If s.st.GetDMParticipantIDs then errors (transient DB hiccup, lock contention), the function logs and does `return result, nil` with result.ParticipantIDs left nil and result.IsDM=true. handleChatSendV2 (Server/ws/handlers_chat.go:101-105) unconditionally builds MessageSentDMEvent{participantIDs: result.ParticipantIDs} from that nil slice. EmitEvents routes it as a SequencedDMEvent to Hub.sendSequencedToUsers(channelID, nilUserIDs, payload) (Server/ws/hub_broadcast.go:607-619), which still allocates a seq and pushes into the replay ring buffer/EventPersister, but its `for _, userID := range userIDs` loop is a no-op over the empty slice, so h.SendToUser is never called for anyone -- not even the sender. SendMessage returns err=nil, so chat_send_ok still goes to the sender (their optimistic row reconciles fine), but the other DM participant(s) get no chat_message frame, no unread/mention bump, no last-message preview update, and no notification. They only learn about the message on their own NEXT reconnect, because only a fresh 'ready' recomputes unread_count from the DB independent of WS delivery -- a recipient who stays continuously connected never sees the message land at all.",
|
||
"repro": "Users A and B share a DM channel, both connected. A sends a message at the moment s.st.GetDMParticipantIDs(ctx, channelID) returns a transient error for this one call (Server/service/message_crud.go:174-178). CreateMessageWithMentions already succeeded, so the row is in the DB. SendMessage returns (result, nil) with ParticipantIDs=nil; handleChatSendV2 emits MessageSentDMEvent{participantIDs: nil}; sendSequencedToUsers allocates seq N, stores it in the replay buffer, and iterates zero recipients. A's client gets chat_send_ok and shows the message locally; B's client (still connected, no reconnect) never receives seq N live, never bumps its DM badge, and never shows the message -- until B happens to disconnect and reconnect, which is the only path that recomputes unread_count from the DB.",
|
||
"suggestedFix": "Query the participants with a cancellation-proof context — participantIDs, pErr := s.st.GetDMParticipantIDs(context.WithoutCancel(ctx), p.ChannelID) — matching the pattern SendMessage already uses for its other post-commit side effects (compensating deletes, applyMentionCounts, audit writes), which eliminates the deterministic sender-disconnect trigger; for the residual genuine-DB-error case, have handleChatSendV2 fall back to emitting MessageSentChannelEvent when result.IsDM && result.ParticipantIDs is empty, so ChannelTopic delivery still reaches any participant currently viewing the DM.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-message",
|
||
"finder": "sonnet",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "9cdef406",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0034",
|
||
"title": "Aborted voice-channel switch restores the server's voice state but never undoes the voice_leave it already broadcast — the user is stuck in a phantom voice session nobody (including themselves) can see",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 174,
|
||
"severity": "medium",
|
||
"why": "When the pre-switch leave fails to delete the voice_states row, handleVoiceJoin aborts and restores the client's voice state, VoiceTopic subscription and key-holder entry. But finishVoiceLeave has already broadcast voice_leave to an audience that explicitly includes the leaver themselves (voice_leave.go:96-99), and nothing re-broadcasts a voice_state afterwards. Server, DB and the SFU keep the user in the channel while every client — the user's own included — has removed them.",
|
||
"repro": "1. User U is in voice channel A; voice_states holds (U, A, joined_at=T) and U's client holds token T.\n2. U sends voice_join{channel_id: B}.\n3. handleVoiceJoin (voice_join.go:151) runs handleVoiceLeave. Inside finishVoiceLeave, leaveVoiceChannelWithRetry fails to remove the row — either the synchronous DELETE errors (SQLITE_BUSY under concurrent writes; the background retries have not landed yet) or the client's join token is empty, in which case voice_leave.go:129 skips the DELETE entirely and returns nil.\n4. finishVoiceLeave still broadcasts voice_leave{A, U} to the READ audience, the remaining room participants, AND U (voice_leave.go:96).\n5. Back in handleVoiceJoin, GetVoiceState still returns the row for A, so the abort branch at voice_join.go:165 runs: c.setVoiceState(A, T), Subscribe(VoiceTopic(A)), updateKeyHolder(A), and an INTERNAL error to U. No voice_state is broadcast.\n\nEnd state: the hub, the voice_states row and the LiveKit participant all still have U in channel A, but every connected client removed U from A's roster, and U's own client ran leaveVoice(false) + leaveVoiceChannel() and shows \"not in voice\". U cannot rejoin A — handleVoiceJoin:124 answers ALREADY_JOINED — while still consuming a slot in JoinVoiceChannelIfCapacity's COUNT(*) and still appearing in every freshly built ready payload (buildReady reads GetAllVoiceStates). The stale-voice sweep cannot heal it either: sweepStaleVoiceStates only reaps rows whose channel disagrees with the client's voiceChID, and the abort deliberately made them agree.",
|
||
"evidence": "voice_join.go:151-179\n\t\th.handleVoiceLeave(ctx, c) // -> finishVoiceLeave broadcasts voice_leave (incl. to c)\n\t\tvs, err := h.db.GetVoiceState(ctx, c.userID)\n\t\t...\n\t\tif vs != nil {\n\t\t\tslog.Warn(\"handleVoiceJoin: stale voice state persists after leave, aborting switch\", ...)\n\t\t\tc.setVoiceState(vs.ChannelID, vs.JoinedAt)\n\t\t\th.pubsub.Subscribe(c, VoiceTopic(vs.ChannelID))\n\t\t\th.updateKeyHolder(vs.ChannelID)\n\t\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"voice channel switch failed — please try again\"))\n\t\t\treturn // <-- no broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs))\n\t\t}\n\nvoice_leave.go:96-99 (the leaver is always in the voice_leave audience)\n\tif _, ok := seen[c.userID]; !ok {\n\t\taudience = append(audience, c.userID)\n\t}\n\th.broadcastChannelScopedTo(oldChID, buildVoiceLeave(oldChID, c.userID), audience, \"voice event\")\n\nvoice_leave.go:129-134 (an empty join token makes the delete a silent no-op, guaranteeing the abort branch)\n\tif joinToken == \"\" {\n\t\tslog.Warn(\"LeaveVoiceChannelIfMatch skipped due to missing join token\", ...)\n\t\treturn nil\n\t}\n\nClient/src/lib/dispatcher.ts:790-815 (a self voice_leave tears the session down)\n\tconst shouldTeardownSession = isSelf && voiceStore.getState().currentChannelId === payload.channel_id;\n\t... if (shouldTeardownSession) void leaveVoice(false);\n\tif (isSelf) { leaveVoiceChannel(); }",
|
||
"suggestedFix": "In the abort branch (voice_join.go:165-179), stop restoring the session: the voice_leave already broadcast has made every client (and the user's own media session) treat the user as departed, so restoring resurrects a session that no longer exists anywhere else. Delete the c.setVoiceState/Subscribe/updateKeyHolder restore and just send the error — with the client state left cleared, sweepStaleVoiceStates' DB loop (row present, voiceChID=0 mismatch) removes the stale row within one tick and re-broadcasts voice_leave, and the user_id-PK upsert lets the user rejoin immediately. If the restore must stay for some reason, the alternative is to add h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs)) after updateKeyHolder so clients re-add the participant.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/ws/voice_handlers_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c67d25ed"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0035",
|
||
"title": "Deleting a voice channel races a concurrent voice_join, producing a permanent hub/SFU ghost participant that no sweep can ever detect or heal",
|
||
"file": "Server/admin/handlers_channels.go",
|
||
"line": 288,
|
||
"severity": "medium",
|
||
"why": "handleDeleteChannel evicts the CURRENT voice participants via hub.CleanupVoiceForChannel(id) and only afterward calls database.AdminDeleteChannel(id), which deletes the channel row and relies purely on the voice_states FK cascade to clean up (Server/db/admin_queries.go:192-196 — a plain DELETE FROM channels, no check for live voice participants). Nothing marks the channel as going away between those two calls (contrast with the archive path in the same file, lines 257-269, which sets Archived=true in the DB *before* calling CleanupVoiceForChannel, so a racing voice_join sees ch.Archived==true and is refused — voice_join.go:92). The delete path has no such guard, so a voice_join that reads the still-live channel row via GetChannel (voice_join.go:70) during this window proceeds to insert a voice_states row and set the hub client's in-memory voice state (voice_join.go:222, c.setVoiceState — done deliberately *before* the LiveKit token round trip per the BUG-088 comment) exactly as it would for a channel that isn't being deleted. If AdminDeleteChannel's cascade fires after that insert commits, the freshly-created voice_states row is silently deleted by the cascade, but the hub client's in-memory voiceChID, its VoiceTopic subscription, and (if the channel had capacity) the LiveKit room are left completely untouched — nothing in handleVoiceJoin re-checks that the channel still exists after the insert. The resulting ghost is then invisible to both of sweepStaleVoiceStates's healing loops (Server/ws/hub_sweep.go:140-250): the DB-driven loop (lines 193-249) only iterates rows returned by GetAllVoiceStates, and the cascade-deleted row is no longer among them, so it can never flag a hub client with no matching DB row; the permission-revocation loop (lines 152-191) calls hasChannelPermChecked, whose GetChannelPermissions query (Server/db/channel_queries.go:141-153) returns (0,0,nil) — not an error — for a nonexistent channel ID (it's a plain lookup keyed by channel_id with sql.ErrNoRows mapped to a clean zero), so the effective-permission check collapses to the user's bare role bits; any role whose base permissions include CONNECT_VOICE (the common default) is reported 'allowed' and never evicted. The user is left stuck 'in voice' forever (mic hot if publishing, SFU room orphaned) with the client UI showing nothing to leave from (client-side channel_delete handling in dispatcher.ts:617-633 only redirects the sidebar/active-channel view; it performs no voice teardown at all), until they manually reconnect the whole client.",
|
||
"repro": "1) Create a voice channel with at least one connected participant so cleanup takes real wall-clock time (CleanupVoiceForChannel's per-participant LiveKit RemoveParticipant call can take up to lkTimeout=5s each — livekit.go:153,181). 2) As an admin, DELETE that channel via the admin API/UI. 3) While CleanupVoiceForChannel is still evicting the existing participants (i.e., before AdminDeleteChannel's DELETE has executed), have a different, already-connected user send voice_join for that same channel_id — it passes GetChannel, permission, and archived checks (all still see the live row) and its JoinVoiceChannel/JoinVoiceChannelIfCapacity insert commits before the channel row is deleted. 4) AdminDeleteChannel then runs, cascading away the new voice_states row along with the channel. 5) Observe: the joining client's hub-side voiceChID stays set to the deleted channel, its VoiceTopic subscription and (if configured) LiveKit SFU membership are never torn down, and it is never picked up by either loop of sweepStaleVoiceStates on any subsequent tick — it stays a permanent ghost until that client's socket disconnects on its own.",
|
||
"suggestedFix": "Mirror the archive path's guard: in handleDeleteChannel, persist archived=1 on the channel (e.g. via AdminUpdateChannel with Archived:true, or a dedicated UPDATE) BEFORE calling hub.CleanupVoiceForChannel, so any voice_join racing the cleanup is refused by the existing archived gate at voice_join.go:92; then delete the row as today. (Defense-in-depth alternative: in handleVoiceJoin, re-fetch GetChannel after the voice_states insert commits and call rollbackVoiceJoin if the channel is gone or archived.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "sonnet",
|
||
"round": 4,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/admin/api_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "4bb0cbf1"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0036",
|
||
"title": "Slow mode consumes its cooldown token before content and attachment validation, so a rejected send locks the composer for the full window",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 64,
|
||
"severity": "medium",
|
||
"why": "SendMessage calls limiter.Allow on the per-(user, channel) slow-mode key — which records a timestamp — before sanitizeContent, before the ATTACH_FILES check, and before the insert. Any of those can reject the send, but the cooldown has already been spent, so the user is refused with SLOW_MODE for up to maxSlowModeSeconds (21600 s = 6 h) without ever having posted anything.",
|
||
"repro": "Set slow_mode = 3600 on #general. As a member without MANAGE_MESSAGES, send a 5000-rune chat_send. Line 66 records the slow-mode timestamp, then sanitizeContent (message.go:216) returns ErrBadRequest \"message content exceeds maximum length\" — nothing is stored and nothing is broadcast. Shorten the text and resend immediately: line 66 now returns false and the send is refused with ErrSlowMode for the next hour. The same holds for an attachment-only send by a user lacking ATTACH_FILES (rejected at line 80) and for a CreateMessageWithMentions failure at line 93. No test locks the current ordering — Server/ws/coverage_chat_test.go:241 only asserts that a second *successful* send is throttled.",
|
||
"evidence": "Server/service/message_crud.go:63-82 — the Allow (which records the timestamp) precedes both validations:\n\t// Slow mode (non-DM only).\n\tif !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) {\n\t\tslowKey := auth.Key(auth.Key(\"slow\", p.UserID), p.ChannelID)\n\t\tif s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {\n\t\t\treturn nil, fmt.Errorf(\"%w: channel has %ds slow mode\", ErrSlowMode, ch.SlowMode)\n\t\t}\n\t}\n\n\t// Validate and sanitize content.\n\tcontent, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Attachment permission (non-DM).\n\tif !isDM && len(p.AttachmentIDs) > 0 {\n\t\tif !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) {\n\t\t\treturn nil, fmt.Errorf(\"%w: missing ATTACH_FILES permission\", ErrForbidden)\n\t\t}\n\t}\n\nServer/auth/ratelimit.go:149-154 — Allow appends the timestamp on the permitted path, so the token is spent even though the caller then errors out:\n\tif len(e.timestamps) >= limit { return false }\n\te.timestamps = append(e.timestamps, now)\n\treturn true\n\nServer/ws/command.go:401-441 — the chat_send constructor validates only channel_id and the attachment count/length; content length and emptiness are never checked before the service call, so an over-length body reaches line 72.\n\nServer/admin/handlers_channels.go:147 — maxSlowModeSeconds = 21600.",
|
||
"suggestedFix": "Move the slow-mode block (message_crud.go:63-69) below the sanitizeContent call and the ATTACH_FILES permission check (i.e., to just after line 82, before resolveMentions), so the once-per-window token is only consumed once the send has passed every request-shaped validation.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "86acf049"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0037",
|
||
"title": "Tray Status menu bypasses the client's own status state, so a tray-set Do Not Disturb neither silences notifications nor survives the idle timer or a reconnect",
|
||
"file": "Client/src/main.ts",
|
||
"line": 251,
|
||
"severity": "medium",
|
||
"why": "The `status-change` listener only puts a `presence_update` on the wire. Unlike both in-app status surfaces (UserBar.ts:150-157 and settings/AccountTab.ts:883-884, which call `saveUserStatus(status)` and `applyPresence(status)`), it never writes the `userStatus` preference and never calls `updatePresence()`. `lib/userStatus.ts` is documented as \"the single client-side source of truth\" for the chosen status, and three separate consumers read it — so after a tray selection the server and the client disagree permanently, and two independent code paths then silently undo the user's choice.",
|
||
"repro": "Pick \"Do Not Disturb\" from the tray Status submenu while signed in. The server stores dnd and every other client sees dnd, but `loadUserStatus()` still returns \"online\" with origin \"manual\". Consequences, all reachable: (1) every incoming message still raises a desktop notification and plays the chime, because notifications.ts:85 gates on `loadUserStatus() === \"dnd\"` — DND set from the tray does nothing it promises; (2) after ten quiet minutes autoIdle's `apply(true)` computes `nextAutoStatus(\"online\", \"manual\", true) === \"idle\"` and sends `presence_update {status:\"idle\"}`, overwriting the DND — the module's own doc comment states \"a manually chosen Do Not Disturb or Invisible is never touched\"; (3) on the next WS reconnect, `auth_ok` carries the user's own true status (\"dnd\", serve_ready.go:45), so `restoreSavedPresence()` sees serverStatus \"dnd\" != loadUserStatus() \"online\" and sends `presence_update {status:\"online\"}` plus a local `updatePresence(online)`, silently reverting the tray choice; (4) the UserBar dot and the settings Account tab keep rendering the pre-tray status for the whole session, because both re-render off `onUserStatusChange`, which only fires from `saveUserStatus`.",
|
||
"evidence": "main.ts:251-256\n void listen<string>(\"status-change\", (e) => {\n const status = e.payload;\n if (status === \"online\" || status === \"idle\" || status === \"dnd\" || status === \"offline\") {\n ws.send({ type: \"presence_update\", payload: { status } });\n }\n });\n\ncontrast — UserBar.ts:150-157\n onStatusChange: (status: UserStatus) => {\n saveUserStatus(status);\n updateFromState();\n ... ws.send({ type: \"presence_update\", payload: { status } })\n }\n\nconsumers of the pref the tray path never writes:\n lib/notifications.ts:85 const dnd = loadUserStatus() === \"dnd\";\n lib/autoIdle.ts:374 const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);\n pages/MainPage.ts:180-191 restoreSavedPresence(): compares loadUserStatus() with authStore.user.status and re-sends the local value on every transition to \"connected\"",
|
||
"suggestedFix": "In the main.ts status-change listener, mirror UserBar's path instead of raw-sending: map the tray's legacy \"offline\" to \"invisible\" (matching userStatus.ts's migration), call saveUserStatus(mapped) (origin \"manual\") before ws.send({type:\"presence_update\",payload:{status: mapped}}). Persisting via saveUserStatus makes notifications, autoIdle, restoreSavedPresence, and the UserBar/Account-tab renders (via onUserStatusChange) all agree with the wire state in one place.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/main.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c3a20a95"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0038",
|
||
"title": "The LiveKit participant_left teardown never tells the leaver, unlike every sibling eviction path",
|
||
"file": "Server/ws/livekit_webhook.go",
|
||
"line": 229,
|
||
"severity": "medium",
|
||
"why": "handleWebhookParticipantLeft clears the client's voice state, drops its VoiceTopic subscription, deletes the DB row and re-elects the key holder, then announces the departure with the plain broadcastVoiceEvent. That helper's audience is (READ_MESSAGES holders) ∪ (clients whose getVoiceChID() still equals the channel) — and the leaver was just removed from the second set. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES on that voice channel receives nothing. The two sibling teardown paths, finishVoiceLeave (voice_leave.go:96-98) and CleanupVoiceForChannel (hub_sweep.go:337-342), both explicitly append the evicted user to the audience for exactly this reason; this path does not, leaving the client believing it is still in a call the server has already torn down.",
|
||
"repro": "1. Configure voice channel V so role R has CONNECT_VOICE but a channel_overrides deny on READ_MESSAGES (the configuration the code repeatedly documents as supported — see the audience comments in voice_leave.go:74-82 and hub_broadcast.go:61-66). 2. User U (role R) joins V: voice_states row committed, c.voiceChID=V, c.voiceJoinToken=JoinedAt, subscribed to VoiceTopic(V). 3. U's SFU connection drops (network blip, media-port loss) while the WebSocket stays up; LiveKit fires participant_left with identity \"user-U:JoinedAt\" for room \"channel-V\". 4. matched is true, so the server clears c.voiceChID/voiceJoinToken/e2eePubKey, unsubscribes VoiceTopic(V), deletes the voice_states row, and re-elects the key holder. 5. broadcastVoiceEvent resolves the audience: channelReadAudience takes the non-DM role-scan branch and excludes U (no READ_MESSAGES); the participant union cannot see U because step 4 already zeroed getVoiceChID(). U receives no voice_leave. 6. U's client still renders itself in the call with the mic hot and keeps auto-reconnecting to LiveKit with a token whose voice_states row no longer exists — every retry is ejected by handleWebhookParticipantJoined's rogue-participant check (livekit_webhook.go:132), and nothing on U's socket ever reports the eviction.",
|
||
"evidence": "c.voiceMu.Lock()\nmatched := c.voiceChID == channelID && c.voiceJoinToken != \"\" && c.voiceJoinToken == joinToken\nif matched {\n\tc.voiceChID = 0\n\t...\n}\nc.voiceMu.Unlock()\n\nif matched {\n\th.pubsub.Unsubscribe(c, VoiceTopic(channelID))\n\t...\n\th.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID))\n\n// hub_broadcast.go:67-79 — broadcastVoiceEvent's audience, with no leaver term:\naudience := h.channelReadAudience(ctx, channelID)\n...\nfor uid, c := range h.clients {\n\tif _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {\n\t\taudience = append(audience, uid)\n\t}\n}\n\n// voice_leave.go:96-98 — the sibling path that DOES include the leaver:\nif _, ok := seen[c.userID]; !ok {\n\taudience = append(audience, c.userID)\n}",
|
||
"suggestedFix": "Extract finishVoiceLeave's audience construction (voice_leave.go:83-99: channelReadAudience ∪ remaining participants ∪ the leaver) into a shared helper, and call it from handleWebhookParticipantLeft's matched branch in place of the bare h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID)) at livekit_webhook.go:229.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9981220f"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0039",
|
||
"title": "DeleteMessage treats a GetChannel read error as \"not a DM\", letting a moderator hard-delete another user's private DM message",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 346,
|
||
"severity": "medium",
|
||
"why": "`isDM` collapses a lookup failure into false, so a DM falls into the non-DM branch where authority is a plain role check against a channel id that has no override rows. The DM-participant gate is skipped entirely, and `isMod` is then passed to `s.st.DeleteMessage`, which documents that it \"skips the ownership check when ismod is true\".",
|
||
"repro": "Alice and Bob have a 1:1 DM; Bob posts message 42. Moderator Mallory (role holds READ_MESSAGES|MANAGE_MESSAGES, not a participant) sends chat_delete{message_id:42} at a moment when the reader pool returns an error for GetChannel (SQLITE_BUSY / \"database is locked\" under write contention, or a pool error). chErr != nil -> isDM=false -> HasChannelPermBatch(rolePerms, overrides, dmChannelID, READ|MANAGE) sees no override entry for a DM channel, so it answers from the base mask and returns true -> canManage=true, isMod=true -> db.DeleteMessage skips the ownership check and soft-deletes Bob's message in a DM Mallory is not in. Any ADMINISTRATOR passes unconditionally via checker.go:70-72. The correct answer, and the one every ws-layer sibling gives for the same failed lookup, is Forbidden.",
|
||
"evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nisDM := chErr == nil && ch != nil && ch.Type == \"dm\"\n...\n} else {\n\tisMsgOwner := msg.UserID == userID\n\tcanManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages)\n\tcanDelete := canManage || (isMsgOwner && ...)\n\tif !canDelete { return nil, fmt.Errorf(\"%w: cannot delete this message\", ErrForbidden) }\n\tisMod = canManage\n}",
|
||
"suggestedFix": "Fail closed like SendMessage does: replace lines 345-346 with `ch, chErr := s.st.GetChannel(ctx, msg.ChannelID); if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: cannot delete this message\", ErrForbidden) }; isDM := ch.Type == \"dm\"`.",
|
||
"status": "declined",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": null,
|
||
"rationale": "Already fixed by db0275a2 (#1369, 2026-08-14): DeleteMessage GetChannel error path fails closed with ErrForbidden before isDM is computed; locked by TestDeleteMessage_FailsClosedWhenChannelLookupErrors (message_crud_test.go:288), confirmed passing on current code. No failing test can be written."
|
||
},
|
||
{
|
||
"id": "OC-0040",
|
||
"title": "Scroll-to-bottom button and \"Jump to Present\" pill are absolutely positioned inside the scroll container, so they scroll out of view exactly when they are shown",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 810,
|
||
"severity": "medium",
|
||
"why": "Both controls are appended to `root` (.messages-container), which is itself the `overflow-y: auto` scroller, and are styled `position: absolute; bottom: 8px`. Per CSS Overflow, boxes whose containing block is the scroll container are part of its scrollable overflow region, so they translate with the scrolled content: the button sits at the viewport bottom only at scrollTop ≈ 0 and is painted scrollTop px above the visible area otherwise. Both controls are only made visible when the user is NOT at the bottom, i.e. precisely when scrollTop is large and they are off-screen.",
|
||
"repro": "Open a channel with ~5000px of content. Scroll up 2000px: updateScrollToBottomBtn() adds .visible (opacity 1, pointer-events auto) but the button's painted position is (clientHeight - 48) - 2000 px, far above the scrollport, clipped away by the container's overflow/`contain: strict` — the user has a \"visible\" control they can neither see nor click. Same for the pill: jump to an old message via scrollToMessage (which sets root.scrollTop = offsetBefore(idx)), updateJumpToPresentPill() adds .visible, and the only signal that the loaded window is detached from the live tail is painted off-screen. jsdom has no layout, so tests/unit/message-jump.test.ts:636-677 assert only the class, not visibility.",
|
||
"evidence": "root.appendChild(scrollToBottomBtn);\nroot.appendChild(jumpToPresentPill);\n// src/styles/app.css:896 .messages-container { flex:1; overflow-y:auto; contain:strict; position:relative; }\n// src/styles/app.css:913 .scroll-to-bottom-btn { position:absolute; bottom:8px; right:16px; … }\n// src/styles/app.css:948 .jump-to-present-pill { position:absolute; bottom:8px; left:50%; … }",
|
||
"suggestedFix": "Give the controls a non-scrolling positioned ancestor: in mount(), wrap the scroller in a position:relative wrapper div and append scrollToBottomBtn and jumpToPresentPill to the wrapper instead of root (root keeps the scroll listener and children; the wrapper becomes what is appended to parentContainer).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "a50a9d3",
|
||
"test": "Client/tests/unit/message-list.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0041",
|
||
"title": "Any user whose username is exactly \"System\" has every message rendered as a server system notice, with no author and no moderation controls",
|
||
"file": "Client/src/components/message-list/renderers.ts",
|
||
"line": 182,
|
||
"severity": "medium",
|
||
"why": "renderMessage dispatches to renderSystemMessage purely on `msg.user.username === \"System\"` — it never checks user.id (the tests use id 0) and the server never emits such messages, so the only way to reach that branch in production is a real account named \"System\". Server-side ValidateUsername (Server/auth/helpers.go:19) only rejects control/invisible characters and the \"[deleted-…]\" namespace, so the name is registrable.",
|
||
"repro": "Register an account with username \"System\" and post \"Your session was flagged — re-enter your password at …\". Every client renders it through renderSystemMessage: a system icon, muted italic text and a timestamp, with no avatar, no author name and no role colour — visually identical to a server notice. Because renderSystemMessage returns before the hover action bar is built, the row also carries no react/reply/pin/edit/delete buttons and there is no message context menu anywhere in the client, so a moderator with canManageMessages() has no UI path to delete it.",
|
||
"evidence": "export function renderMessage(msg, isGrouped, allMessages, opts, signal) {\n if (msg.user.username === \"System\") {\n return renderSystemMessage(msg);\n }\n// renderSystemMessage builds only icon + text + time and returns — the\n// `if (!msg.deleted && msg.status === \"sent\")` action-bar block is unreachable.",
|
||
"suggestedFix": "Reserve the name server-side in auth.ValidateUsername: reject strings.EqualFold(strings.TrimSpace(username), \"System\") alongside the existing \"[deleted-\" reservation (covers both register and rename since both funnel through it). Per docs/security.md, route the fix through a GitHub Security Advisory rather than a public issue.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/renderers.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "e25fe56a"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0042",
|
||
"title": "leaveVoice() stops manual camera/screen tracks without bumping the enable/disable race-guard generation, so a camera/screenshare enable that is mid-flight (awaiting the OS permission prompt / device picker) when the user leaves voice resurrects a track after the room is gone",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 1361,
|
||
"severity": "medium",
|
||
"why": "The `generation` counter on CameraTrackState/ScreenTrackState exists specifically so a concurrent enable() that captured its value before a multi-second device-acquisition await (getUserMedia/getDisplayMedia) can detect it was superseded by a disable and discard the track instead of publishing over it (see the doc comment at screenShare.ts:152-159). doDisableCamera/doDisableScreenshare (screenShare.ts:276, screenShare.ts:399) correctly call bumpGeneration(state) before stopping tracks. leaveVoice() performs the exact same operation — it calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 1361-1362) and even resets setLocalCamera(false)/setLocalScreenshare(false) (lines 1386-1387) — but never touches state.generation. Because `_cameraState`/`_screenState` are single per-session fields never reinitialized across join/leave cycles (declared once at lines 259-260), a stale enableCamera()/enableScreenshare() continuation that resumes after leaveVoice() ran will pass the `(state.generation ?? 0) !== generation` check at screenShare.ts:235/334, believe it is still current, set state.manualCameraTrack/manualScreenTracks to the newly created track(s), and attempt room.localParticipant.publishTrack() against `room` — a reference captured before the leave, i.e. a room that leaveVoice() has already called room.disconnect() on (line 1373). If that publish does not synchronously throw, the store is left saying camera/screenshare is whatever enableCamera set (or, worse, a mismatched state: the track object sits in state.manualCameraTrack referencing a track published to an already-disconnected room), and the physical camera/mic-capture device stays open. Nothing frees it: the next enableCamera()/disableCamera() call only calls stopManualCameraTrack when `deps.getRoom()` is non-null (screenShare.ts:206, :220), i.e. only once the user has rejoined a voice channel — until then the camera hardware (LED) stays active after the user has already left the call.",
|
||
"repro": "1) Join a voice channel (room R1 live). 2) Click 'Enable camera' — enableCamera() runs setLocalCamera(true), captures room=R1 and generation=0, then awaits createLocalVideoTrack(...), which blocks on the browser's camera permission prompt. 3) Before responding to the prompt, click 'Leave Voice' — leaveVoice() runs synchronously: stopManualCameraTrack no-ops (nothing published yet), room.disconnect() is called on R1, setLocalCamera(false) is set, generation stays 0. 4) Grant camera permission — createLocalVideoTrack resolves; enableCamera() checks `(state.generation ?? 0) !== generation` → 0 !== 0 → false (not superseded), sets state.manualCameraTrack = videoTrack, and calls `room.localParticipant.publishTrack(videoTrack, ...)` on the already-disconnected R1. The camera device is now held open by a track that was never cleaned up, and the app has already visually left the voice call.",
|
||
"suggestedFix": "Export a supersede helper from screenShare.ts (e.g. `export function supersedeVideoEnable(state: GenerationGuarded): void { state.generation = (state.generation ?? 0) + 1; }` — reuse it inside bumpGeneration) and call it on this._cameraState and this._screenState in leaveVoice immediately before the stopManualCameraTrack/stopManualScreenTracks calls at livekitSession.ts:1361-1362, so the stale enable discards its track at the existing screenShare.ts:235/334 check.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "sonnet",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "db7d518b"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0043",
|
||
"title": "The built-in \"light\" theme overrides only 4 of the ~45 design tokens and has no stylesheet, so the message composer and every form input render near-invisible dark-on-dark",
|
||
"file": "Client/src/components/settings/helpers.ts",
|
||
"line": 37,
|
||
"severity": "medium",
|
||
"why": "`applyThemeByName` applies built-in themes by adding a `body.theme-<name>` class, but `src/styles/` contains a rule for `body.theme-neon-glow` only — there is no `body.theme-light` (or `body.theme-midnight`) block anywhere. The entire \"light\" theme is therefore the 4 inline custom properties `applyTheme` writes onto `document.documentElement`. `--text-normal` flips to the dark `#313338` while `--bg-input` keeps the dark-theme `#383a40` from `tokens.css:11`, so every surface painted with `var(--bg-input)` ends up carrying dark text on a dark box (contrast ≈1.1:1).",
|
||
"repro": "Settings -> Appearance -> click \"Light\". `applyTheme(\"light\")` sets `--bg-primary:#ffffff` and `--text-normal:#313338` on `<html>`; `--bg-input` stays `#383a40`. The chat composer (`.message-input-box` + `.msg-textarea`) now paints `#313338` glyphs on a `#383a40` background — typed text is unreadable. Same for the login form's Server Address / Username / Password fields (`login.css:586`) and the reply bar. The setting persists (`applyStoredAppearance` re-runs `applyTheme` on startup), so the state survives restart.",
|
||
"evidence": "helpers.ts:37-42 — light: { \"--bg-primary\": \"#ffffff\", \"--bg-secondary\": \"#f2f3f5\", \"--bg-tertiary\": \"#e3e5e8\", \"--text-normal\": \"#313338\" } (4 keys, applied via applyTheme -> root.style.setProperty)\nthemes.ts:61-62 — if (BUILT_IN_THEMES.includes(name)) { document.body.classList.add(`theme-${name}`); } // no CSS backs theme-light / theme-midnight\n`grep -rn \"theme-light\\|theme-midnight\" src/styles/` -> no matches; only `theme-neon-glow.css:4 body.theme-neon-glow { ... }`\ntokens.css:11 — --bg-input: #383a40; (never overridden by the light map)\napp.css:2342 — .message-input-box { background: var(--bg-input); }\napp.css:2377-2380 — .msg-textarea { background: transparent; color: var(--text-normal); }\nlogin.css:586-590 — .form-input { background: var(--bg-input); color: var(--text-normal); }\napp.css:2173-2183 — .reply-bar-inner { background: var(--bg-input); } / .reply-bar-inner strong { color: var(--text-normal); }",
|
||
"suggestedFix": "Give the light theme a complete palette: add a body.theme-light block (new theme-light.css, mirroring theme-neon-glow.css) that overrides every dark token used against --text-normal — at minimum --bg-input, --bg-hover, --bg-active, --bg-modifier-*, --border, --border-strong, --text-muted, --text-faint, --text-micro, --header-primary, --header-secondary, --interactive-* — with light-mode values. (Extending the THEMES.light map works too, but the CSS block matches how neon-glow already ships its extra tokens.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/settings-helpers.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "2c0960cb"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0044",
|
||
"title": "rollbackVoiceJoin deletes voice_states by userID alone, letting a stale/failed join's rollback destroy a concurrently-established newer voice membership",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 464,
|
||
"severity": "medium",
|
||
"why": "rollbackVoiceJoin (called from handleVoiceJoin's two failure paths at lines 208 and 300, both after the DB row for `channelID` has already been inserted and, on the second path, after c.setVoiceState has already been applied) unconditionally clears c's in-memory voice channel via c.clearVoiceChID() and deletes the user's voice_states row via `h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID)` — a plain `DELETE FROM voice_states WHERE user_id = ?` with no channel_id/joined_at condition. Every sibling leave path in this same package (finishVoiceLeave -> leaveVoiceChannelWithRetry, handleVoiceLeaveIfStillIn) instead uses `LeaveVoiceChannelIfMatch(userID, expectedChannelID, expectedJoinedAt)` specifically, per that function's own comment, 'to prevent a race where a delayed retry could wipe a newer voice membership.' rollbackVoiceJoin never received that same protection, despite its own comment acknowledging that a dying connection ('the join failed BECAUSE the connection died — that cancellation is the most common rollback trigger') is its main trigger, which is exactly the scenario where a second, independent connection for the same user can already have re-established a legitimate new voice_states row by the time this delayed rollback runs (context.WithoutCancel is used precisely so the delete keeps running after the original connection and its context are gone).",
|
||
"repro": "User A's connection c1 sends voice_join for channel X; the DB insert for X succeeds and (on the token-generation failure path, after line 222) c1.setVoiceState(X, joinedAt) has already run. Before GenerateToken/GetVoiceState-verify on c1 completes, the underlying connection drops (network blip); c1's readPump goroutine is still live and blocked inside handleVoiceJoin. The client immediately opens a new connection c2 for the same user; the server's registerNow (hub.go:399) swaps h.clients[userID] to c2. The user then sends voice_join for channel Y on c2, which succeeds and inserts a fresh voice_states row (Y, newJoinedAt), with c2 now subscribed to VoiceTopic(Y) and live in the LiveKit room. Meanwhile c1's stalled call finally errors (GetVoiceState fails at voice_join.go:205-211, or GenerateToken fails at voice_join.go:297-303), so `rollbackVoiceJoin(ctx, c1, X, false)` runs and executes `h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), userID)` — deleting the voice_states row for Y that c2 legitimately just created. Result: c2 is still marked in-memory as voiceChID=Y, still subscribed to the voice topic, and still present in the LiveKit room, but its DB voice_states row is gone — a permanent DB/hub/SFU desync (missing from GetChannelVoiceStates, `ready` resyncs, and channel-capacity counts for Y) that nothing subsequently repairs, matching the same ghost-state class as the already-known CleanupVoiceForChannel non-atomicity bug but triggered from the opposite (failed-join rollback) direction.",
|
||
"suggestedFix": "Scope the compensating delete to the join instance it is undoing: thread the join's identity into rollbackVoiceJoin (pass state.JoinedAt at the voice_join.go:300 call site; at the :208 site, where GetVoiceState failed, re-read the row with context.WithoutCancel and proceed only if it still names channelID) and replace h.db.LeaveVoiceChannel(ctx, c.userID) with h.db.LeaveVoiceChannelIfMatch(ctx, c.userID, channelID, joinedAt), mirroring leaveVoiceChannelWithRetry.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "sonnet",
|
||
"round": 7,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/coverage_voice_lifecycle_test.go",
|
||
"revertProof": "pass",
|
||
"residual": "review-confirmed ceiling matching this record's own suggestedFix: on the empty-joinedAt rollback path the re-read adopts the row's current joined_at, so a concurrent re-join of the SAME channel by a second connection can still be deleted; cross-channel re-joins are fully protected. Hardening option: have JoinVoiceChannel return the joined_at it wrote and thread it to the rollback call site.",
|
||
"branchCommit": "e694a8ad",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0045",
|
||
"title": "Role demotion's live-subscription revocation is gated on a cosmetic role re-read, so a failed lookup leaves the demoted user subscribed to channels they can no longer read",
|
||
"file": "Server/admin/handlers_users.go",
|
||
"line": 192,
|
||
"severity": "medium",
|
||
"why": "After ChangeUserRole commits, the only call that revokes the user's live pub/sub subscriptions (hub.BroadcastMemberUpdate -> revokeUnreadableChannels) and the only call that re-derives visibility (hub.RefreshAllChannelVisibility) are both nested inside `if role, err := database.GetRoleByID(...); err == nil && role != nil`, a lookup whose only real product is the role NAME for the member_update payload. When that read fails or returns nil the demotion is committed and the permission cache is invalidated, but the socket keeps every ChannelTopic subscription its old role earned — and READ_MESSAGES is only ever checked at channel_focus, never again on the delivery path.",
|
||
"repro": "User U holds Moderator, which has a channel_overrides ALLOW of READ_MESSAGES on private #staff; U has #staff focused, so ws/handlers.go:170 has subscribed the socket to ChannelTopic(#staff). Admin A demotes U to a non-default role R via PATCH /admin/api/users/{U} {\"role_id\": R}. ModerationService.ChangeUserRole commits and InvalidateUser(U) runs. Admin B now deletes role R (DELETE /admin/api/roles/{R}) in the window before A's handler reaches line 192 — or the single-writer SQLite pool returns SQLITE_BUSY for that one read. GetRoleByID returns (nil, nil) or (nil, err), so the whole block is skipped: no member_update, no revokeUnreadableChannels, no RefreshAllChannelVisibility, no bumpVisibilityWatermark. U's socket stays in ChannelTopic(#staff) and keeps receiving every chat_message, chat_edited, chat_deleted and reaction_update posted in #staff for the entire life of the connection; every other client also still renders U as Moderator. The sibling handlers handleDeleteRole (handlers_roles.go:206-208) and handlePatchRole's permsChanged branch (handlers_roles.go:160-174) both run the identical fan-out unconditionally.",
|
||
"evidence": "if permInvalidator != nil {\n\tpermInvalidator.InvalidateUser(id)\n}\nif role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil {\n\tif hub != nil {\n\t\thub.BroadcastMemberUpdate(id, role.Name)\n\t\thub.RefreshAllChannelVisibility()\n\t}\n}\n\n// hub_broadcast.go:470 — the only caller of the revocation routine\nfunc (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {\n\th.BroadcastToAll(buildMemberUpdate(userID, roleName))\n\th.revokeUnreadableChannels(userID)\n}",
|
||
"suggestedFix": "Decouple the fan-out from the name lookup: have ModerationService.ChangeUserRole return the *db.Role it already loads at moderation.go:159, then in handlePatchUser run hub.BroadcastMemberUpdate(id, role.Name) and hub.RefreshAllChannelVisibility() unconditionally (when hub != nil), deleting the GetRoleByID re-read entirely.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/admin/handlers_users_broadcast_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "93d4790b"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0046",
|
||
"title": "The Font Size slider and the \"Large Font\" accessibility toggle are no-ops — `--font-size` is written but no stylesheet ever reads it",
|
||
"file": "Client/src/styles/base.css",
|
||
"line": 22,
|
||
"severity": "medium",
|
||
"why": "Three separate code paths write the `--font-size` custom property (`applyStoredAppearance` at startup, the Appearance-tab slider on input, and the `.large-font` rule in app.css), but `var(--font-size)` appears nowhere in the repository. `base.css:22` sets `body { font-size: 14px }` as a hard literal, and every other rule uses the fixed `--font-size-xxs … --font-size-xxl` scale from tokens.css. The token is a dead end, so both user-facing font-size controls change persisted state and nothing else.",
|
||
"repro": "Open Settings -> Appearance and drag the Font Size slider from 16 to 20. `document.documentElement.style.getPropertyValue(\"--font-size\")` becomes \"20px\" and localStorage records `owncord:settings:fontSize = 20`, but no text in the app changes size — the computed font-size of body stays 14px from base.css:22 and every component stays on the fixed --font-size-* scale. Identically, Settings -> Accessibility -> \"Large Font\" adds the `large-font` class to <html>, whose only declaration is `--font-size: 18px`, and nothing renders larger. The existing tests (tests/unit/settings-overlay.test.ts:189, tests/unit/accessibility-tab.test.ts:321) assert only that the property/class is set, never that a rendered size changes, so they pass while the feature does nothing. Note also that the default pref is 16px while base.css hard-codes 14px, so the two sources already disagree.",
|
||
"evidence": "src/styles/base.css:22 -> body { font-family: var(--font-body); font-size: 14px; ... }\nsrc/styles/app.css:5211-5213 -> .large-font { --font-size: 18px; }\nsrc/lib/appearance.ts:36-39 -> document.documentElement.style.setProperty(\"--font-size\", `${loadPref<number>(\"fontSize\", 16)}px`);\nsrc/components/settings/AppearanceTab.ts:100 -> document.documentElement.style.setProperty(\"--font-size\", `${size}px`);\nsrc/components/settings/AccessibilityTab.ts:57 -> document.documentElement.classList.toggle(\"large-font\", nowOn);\n\nVerification: `grep -rn \"var(--font-size)\" . --include=*.css --include=*.html --include=*.ts` (excluding node_modules) returns zero hits. tokens.css defines only --font-size-xxs/xs/sm/md/lg/xl/xxl, never --font-size.",
|
||
"suggestedFix": "Make the variable actually feed the type scale: in tokens.css derive the scale from it (e.g. --font-size-md: var(--font-size, 14px) and the other steps via calc() multipliers of --font-size), and change base.css:22 to `font-size: var(--font-size-md)`. Align the appearance.ts default (16) with the actual base (14) so the slider's initial position matches what is rendered.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/base-font-size-css.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "ca1e7d93"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0047",
|
||
"title": "Attachment/avatar fetches lose their bearer token and their cert-pinned proxy when the server host is stored with an explicit :443",
|
||
"file": "Client/src/components/message-list/attachments.ts",
|
||
"line": 158,
|
||
"severity": "medium",
|
||
"why": "`isServerUrl` compares `new URL(url).host` against the raw `_serverHost` string without the \":443\"-stripping normalization the rest of the client applies (`normalizeHostForCertCompare` in lib/ws.ts, `cert_store_key` in src-tauri/src/tofu.rs). WHATWG `URL` drops the default port for `https:`, so a host stored as `example.com:443` never matches, `fetchServerFile` takes the \"external host\" branch, and every server file is fetched with no `Authorization` header and outside the TOFU-pinned loopback proxy.",
|
||
"repro": "1. Add/enter the server host as `chat.example.com:443` (accepted: `isValidHost` in lib/api.ts:82 is `/^[\\w.-]+(:\\d+)?$/`; ServerPanel.ts:310 uses the same regex).\n2. Log in. MainPage.ts:96 calls `setServerHost(\"chat.example.com:443\")`.\n3. Open any channel with an image attachment, or any user with an uploaded avatar. `resolveServerUrl(\"/api/v1/files/abc\")` yields `https://chat.example.com:443/api/v1/files/abc`; `new URL(...).host` is `\"chat.example.com\"` (default port dropped) which !== `\"chat.example.com:443\"`.\n4. `fetchServerFile` therefore returns `tauriFetch(url)` with no Authorization header and bypassing `ensureHttpProxy`. The server's AuthMiddleware answers 401, `res.ok` is false, `fetchImageAsDataUrl` returns null — every attachment image, custom emoji and uploaded avatar silently falls back to its placeholder for the whole session. On a self-signed deployment the direct https fetch also fails TLS in the webview, which is the exact failure the TOFU proxy exists to avoid.\nSame root cause makes `isTrustedServerUrl` (attachments.ts:169) return false, so embeds.ts:135's trusted-server exemption stops applying and link previews to a LAN-hosted OwnCord server are blocked as SSRF.\nExisting tests only cover the port-less form (tests/unit/attachments-auth.test.ts:63 `setServerHost(\"chat.example.com\")`), so nothing locks the current behavior.",
|
||
"evidence": "attachments.ts:36-48,152-186\n export function setServerHost(host: string): void { _serverHost = host.toLowerCase(); } // no \":443\" strip\n export function resolveServerUrl(url) { ... return `https://${_serverHost}${url}`; }\n function isServerUrl(url: string): boolean {\n if (_serverHost === null) return false;\n try { const parsed = new URL(url); return parsed.host === _serverHost; } catch { return false; }\n }\n async function fetchServerFile(url: string): Promise<Response> {\n if (!isServerUrl(url)) return tauriFetch(url); // <- no token, no TOFU proxy\n ...\n headers[\"Authorization\"] = `Bearer ${token}`;\n return tauriFetch(`${origin}${parsed.pathname}${parsed.search}`, { headers });\n }\n\nContrast — lib/ws.ts:120-127 documents that config hosts are stored verbatim in this exact shape:\n /** ... Profile/config hosts are stored verbatim (e.g. \"Example.COM:443\"), but the proxies\n * always emit the normalized (stripped, lowercased) form ... */\n export function normalizeHostForCertCompare(host: string): string {\n return host.replace(/:443$/, \"\").toLowerCase();\n }\n\nServer side, the endpoint is auth-gated — Server/api/upload_handler.go:123\n r.With(AuthMiddleware(database)).Get(\"/api/v1/files/{id}\", handleServeFile(...))",
|
||
"suggestedFix": "Normalize once at the single entry point: in setServerHost (attachments.ts:37), store `_serverHost = host.replace(/:443$/, \"\").toLowerCase();` (mirroring normalizeHostForCertCompare). resolveServerUrl then emits the port-less form (same effective https origin), isServerUrl's parsed.host comparison matches for both port-less and explicit-:443 input URLs, and ensureHttpProxy(parsed.host) resolves the same TOFU pin because cert_store_key strips :443 anyway. Non-default ports are preserved on both sides.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/attachments-auth.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "687c51ff"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0048",
|
||
"title": "Self-account-deletion emits no member_ban: router.go never supplies the optional AuthBroadcaster, so every other client keeps the deleted user and the deleted user's own socket survives",
|
||
"file": "Server/api/router.go",
|
||
"line": 104,
|
||
"severity": "medium",
|
||
"why": "`MountAuthRoutes` accepts a variadic `AuthBroadcaster` that `handleDeleteAccount` uses to fan out `member_ban` (and, via `Hub.BroadcastMemberBan`, to force-disconnect the target). The only production call site omits it because it is mounted at line 104, before the hub exists at line 141 — so `ab` is always nil and the `if broadcaster != nil` guard in `handleDeleteAccount` is never taken in a real server. The event the code was written to send is dead in production; only tests ever pass a broadcaster.",
|
||
"repro": "Users A and B are both connected over WebSocket. B calls `DELETE /api/v1/auth/account` with the correct password. The row is anonymised + banned and B's DB sessions are revoked (auth_handler.go:560-604), and the handler reaches `if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) }` at auth_handler.go:616 with `broadcaster == nil`. Result: (1) A's member list, DM sidebar and message authorship keep showing B under B's pre-deletion username indefinitely — the admin ban path (admin/handlers_users.go → hub.BroadcastMemberBan) removes them instantly for the byte-identical DB state; (2) `Hub.DisconnectUser` (hub_broadcast.go:430) is never called, so B's already-open WebSocket stays live and can keep sending frames until the periodic re-validation fires — `SessionCheckInterval = 10` (ws/client.go:21), so up to 9 further messages (chat_message, voice_join, …) are accepted from the deleted, banned account.",
|
||
"evidence": "router.go:104 `MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey)` // no broadcaster\nauth_handler.go:91 `func MountAuthRoutes(..., broadcaster ...AuthBroadcaster) { var ab AuthBroadcaster; if len(broadcaster) > 0 { ab = broadcaster[0] } ...`\nauth_handler.go:120 `Delete(\"/account\", handleDeleteAccount(database, limiter, ab))`\nauth_handler.go:616 `if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) }`\nws/hub_broadcast.go:428-431 `func (h *Hub) BroadcastMemberBan(userID int64) { h.BroadcastToAll(buildMemberBan(userID)); h.DisconnectUser(userID) }`\nGrep for `MountAuthRoutes` shows the only non-test call site is router.go:104; `api/auth_handler_delete_broadcast_test.go:54` even labels the no-broadcaster form \"the shape every existing MountAuthRoutes call\".",
|
||
"suggestedFix": "In Server/api/router.go, move the MountAuthRoutes call from line 104 to after `hub := ws.NewHub(database, limiter, svc)` (line 141) and pass the hub: `MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey, hub)`. chi allows route registration in any order before serving, so no other change is needed.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/router_delete_account_broadcast_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "aa8cd13a"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0049",
|
||
"title": "High Contrast accessibility toggle's main effect is dead: `.high-contrast { --text-normal }` is on <html>, which already carries an inline --text-normal written by applyTheme()",
|
||
"file": "Client/src/lib/appearance.ts",
|
||
"line": 21,
|
||
"severity": "medium",
|
||
"why": "`applyStoredAppearance()` calls `applyTheme(name)`, which writes the theme's four tokens — including `--text-normal` — as an *inline* style on `document.documentElement`, and then toggles the `high-contrast` class on the *same* element. An inline declaration always beats a class rule on the same element, so `.high-contrast { --text-normal: #ffffff }` (app.css:5204) can never take effect. The toggle's headline promise (pure-white body text) is silently a no-op; only `--text-muted` and `--bg-active`, which `THEMES` does not set inline, actually change.",
|
||
"repro": "Fresh install, no stored theme. main.ts:100 calls `applyStoredAppearance()`. `getActiveThemeName()` returns \"neon-glow\", which is in `THEMES`, so line 21 runs `applyTheme(\"neon-glow\")` → helpers.ts:95 sets `document.documentElement.style['--text-normal'] = '#dbdee1'`. Line 50 then sets `document.documentElement.classList.add('high-contrast')` when the pref is on. Computed `--text-normal` on <html> is `#dbdee1`, not `#ffffff` — inspect any message body text with High Contrast enabled and it is identical to High Contrast off. Same for every other built-in theme, and re-triggered every time the Appearance tab renders (AppearanceTab.ts:230) or a theme is clicked (AppearanceTab.ts:49). The existing tests (tests/unit/accessibility-tab.test.ts:276, tests/unit/stored-appearance.test.ts:49) only assert the class is toggled, never the resulting token value.",
|
||
"evidence": "lib/appearance.ts:19-24 `const activeThemeName = getActiveThemeName(); if (activeThemeName in THEMES) { applyTheme(activeThemeName as ThemeName); }`\nlib/appearance.ts:49-52 `document.documentElement.classList.toggle(\"high-contrast\", loadPref<boolean>(\"highContrast\", false));`\ncomponents/settings/helpers.ts:93-96 `const root = document.documentElement; for (const [key, value] of Object.entries(theme)) { root.style.setProperty(key, value); }`\ncomponents/settings/helpers.ts:22 / :27 / :34 / :40 every THEMES entry defines `\"--text-normal\"`\nstyles/app.css:5203-5207 `.high-contrast { --text-normal: #ffffff; --text-muted: #cccccc; --bg-active: rgba(255,255,255,0.15); }`",
|
||
"suggestedFix": "In styles/app.css, make the high-contrast tokens important and cover the body-level custom-theme case: `.high-contrast, .high-contrast body { --text-normal: #ffffff !important; --text-muted: #cccccc !important; --bg-active: rgba(255,255,255,0.15) !important; }` — important author declarations beat normal inline styles, which is exactly the relationship needed here.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/appearance-high-contrast.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a2387480"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0050",
|
||
"title": "CleanupVoiceForChannel's check-then-clear is not atomic, so a concurrent voice_join is silently wiped from the hub while its DB row survives",
|
||
"file": "Server/ws/hub_sweep.go",
|
||
"line": 316,
|
||
"severity": "low",
|
||
"why": "The function's own comment claims \"the client-state clear [is] conditional on the participant still being in THIS channel: a user who moved to another voice channel between the snapshot above and this loop must not be clobbered\". The implementation reads `client.getVoiceChID()` (one voiceMu acquisition), then calls `clearVoiceAndUnsubscribe`, whose `c.clearVoiceState()` (client.go:148) clears unconditionally under a *second* voiceMu acquisition. Nothing spans the compare and the clear. Every sibling site got this right — `sweepStaleVoiceStates` uses `handleVoiceLeaveIfStillIn` → `clearVoiceStateIfMatch` (client.go:164), and the LiveKit webhook inlines a token-aware compare-and-clear under one voiceMu (livekit_webhook.go:203-211).",
|
||
"repro": "User U is in voice channel A. An admin archives or deletes A, so an HTTP handler goroutine runs CleanupVoiceForChannel(A). At the same moment U sends voice_join for channel W on their readPump goroutine. Interleaving: (1) cleanup reads client.getVoiceChID() == A → passes the guard; (2) U's handleVoiceJoin completes, running c.setVoiceState(W, joinedAt) (voice_join.go:222), subscribing VoiceTopic(W) and broadcasting voice_state for W; (3) cleanup calls clearVoiceAndUnsubscribe(client), which unconditionally zeroes voiceChID/voiceJoinToken/e2eePubKey and returns oldChID=W, then does pubsub.Unsubscribe(client, VoiceTopic(W)). U is now in voice W per voice_states but not per the hub: their VoiceTopic(W) subscription is gone (so every voice_e2ee_announce/offer relay for W is missed), broadcastVoiceEvent's participant union can no longer see them, and within 60s sweepStaleVoiceStates sees c.getVoiceChID()==0 != W, deletes the row, broadcasts voice_leave and removes them from the SFU — silently ejecting them from the call they just joined.",
|
||
"evidence": "313:\t\t\th.mu.RLock()\n314:\t\t\tclient, ok := h.clients[vs.UserID]\n315:\t\t\th.mu.RUnlock()\n316:\t\t\tif ok && client.getVoiceChID() == channelID {\n317:\t\t\t\th.clearVoiceAndUnsubscribe(client) // clearVoiceState() clears unconditionally\n318:\t\t\t}",
|
||
"suggestedFix": "Replace the check+clear at hub_sweep.go:316-318 with the compare-and-clear primitive under one voiceMu acquisition: `if ok { if _, cleared := client.clearVoiceStateIfMatch(channelID); cleared { h.pubsub.Unsubscribe(client, VoiceTopic(channelID)) } }` (also clear the E2EE fields, which clearVoiceStateIfMatch already does).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/ws/hub_sweep_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "5cf6d1c5"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0051",
|
||
"title": "handleReconnect returns true after a failed handshake write, so the full disconnect teardown runs twice",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 353,
|
||
"severity": "low",
|
||
"why": "`unregisterFailedHandshake` is documented (serve.go:413-414) as safe because \"No readPump ever starts for this connection\". That invariant holds on the fresh-connect branch (handleFreshConnect returns an error and ServeWS returns without starting pumps) but is false on the reconnect branch: handleReconnect returns `true` after calling unregisterFailedHandshake and closing the socket, and ServeWS treats `true` as success and calls startPumps() (serve.go:69-72). readPump then runs against the closed conn, returns immediately, and its defer executes the whole teardown a second time — because unregisterNow(c) now finds no entry and reports replaced=false (a deliberate distinction locked by hub_sweep_test.go:74). This also doubles the window described in the previous finding.",
|
||
"repro": "A client resumes with last_seq > 0 and replay succeeds, so handleReconnect calls registerNow(c) and then conn.Write(auth_ok) — which fails (peer already gone / write timeout). Path: (1) unregisterFailedHandshake(ctx, c) removes c from h.clients, runs handleVoiceLeave if a voice session was transferred, writes MarkUserDisconnected and broadcasts presence{offline} (serve.go:422-439); (2) handleReconnect returns true; (3) ServeWS calls startPumps(), spawning writePump and running readPump on the closed conn; (4) readPump returns on the first Read error and its defer calls unregisterNow(c) again — c is absent, so replaced=false — and issues a second MarkUserDisconnected plus a second BroadcastToAll(presence offline), which consumes a second hub seq, a second replay-buffer slot and a second persisted event row for a duplicate of an event already sent.",
|
||
"evidence": "serve.go:349-353\n\t\tif err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(...)); err != nil {\n\t\t\th.unregisterFailedHandshake(ctx, c)\n\t\t\t_ = conn.Close(websocket.StatusInternalError, \"handshake failed\")\n\t\t\treturn true\n\nserve.go:69-72\n\t\tif lastSeq > 0 {\n\t\t\tif hub.handleReconnect(ctx, conn, c, database, lastSeq) {\n\t\t\t\tstartPumps()\n\t\t\t\treturn\n\nserve.go:413-414 (contradicted invariant)\n\t// ... No readPump ever starts for this connection, ...",
|
||
"suggestedFix": "Make the two handshake-write-failure paths in handleReconnect signal 'handled, do not start pumps' — e.g. change its return to (handled, startPumps bool) returning (true, false) there and (true, true) on success, with ServeWS calling startPumps() only when both are true. (Equivalently: drop the unregisterFailedHandshake+Close calls on those two paths and let readPump's defer perform the single teardown, since pumps do start on this branch.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/serve_reconnect_double_teardown_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "14de5f22",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0052",
|
||
"title": "GET /channels/{id}/pins has no LIMIT and no pin cap; past ~32k pins the endpoint fails permanently",
|
||
"file": "Server/db/message_queries.go",
|
||
"line": 635,
|
||
"severity": "low",
|
||
"why": "GetPinnedMessages is the only read path into scanAndEnrichMessages with no LIMIT — GetMessagesForAPI, GetMessagesAroundForAPI and both search queries are all clamped to <=100 by the service layer. Nothing caps how many messages may be pinned in a channel either (SetMessagePinned has no count check and, unlike SendMessage/handleReaction, no rate limiter), and the handler hardcodes `HasMore: false`. Because scanAndEnrichMessages then builds three `IN (?,?,...)` lists with one bound parameter per returned message, a channel with more pins than SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) makes the request fail with \"too many SQL variables\" — the pins endpoint then returns 500 for that channel forever, with no way to unpin through the UI that lists them.",
|
||
"repro": "Any ordinary user, no moderator role required: service/message_query.go:207-214 lets any DM participant pin, so open a 1:1 DM with another user, post N messages, then PUT the pin route once per message. At N >= 32766 pins, GET /api/v1/channels/{dmId}/pins runs GetPinnedMessages, gets 32766 rows, and getReactionsBatch builds an IN list with 32767 bound parameters; SQLite rejects it with \"too many SQL variables\", scanAndEnrichMessages returns an error, and the endpoint answers 500 on every subsequent call for that channel. Below that threshold the same call still loads and JSON-serialises every pinned message with its reactions, attachments and mentions in one unpaginated response (has_more is hardcoded false, so no client can page past it).",
|
||
"evidence": "db/message_queries.go:636-644\n\trows, err := d.reader.QueryContext(ctx,\n\t\t`SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, ...\n\t\t FROM messages m JOIN users u ON m.user_id = u.id\n\t\t WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0\n\t\t ORDER BY m.id DESC`, // <- no LIMIT\n\t\tchannelID,\n\t)\n\ndb/message_queries.go:529-537 (one bound parameter per pinned message)\n\tquery := fmt.Sprintf(\n\t\t`SELECT r.message_id, r.emoji, COUNT(*) as cnt, ...\n\t\t FROM reactions r WHERE r.message_id IN (%s) GROUP BY ...`, placeholders)\n\targs = append([]any{requestingUserID}, args...)\n\napi/channel_handler.go:373\n\twriteJSON(w, http.StatusOK, response{Messages: msgs, HasMore: false})",
|
||
"suggestedFix": "Chunk the IN-list batches in the shared enrichment path (getReactionsBatch, getAttachmentsBatch, GetMentionsByMessageIDs) at the existing 500-id chunk size used by auth_queries.go/mention_queries.go — one guard in the shared functions covers every caller; optionally also add a pins-per-channel cap in SetMessagePinned.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/db/message_queries_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "87bf3e3a"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0053",
|
||
"title": "Quick-switch overlay's teardown guard never fires — an orphaned modal is mounted on document.body after MainPage is destroyed",
|
||
"file": "Client/src/pages/main-page/SidebarArea.ts",
|
||
"line": 722,
|
||
"severity": "low",
|
||
"why": "`openQuickSwitch` awaits `profileManager.loadProfiles()` and then checks `sidebarWrapper.parentElement === null` as its \"were we torn down while awaiting?\" test. That check can never be true: MainPage tears down by removing an *ancestor* (`root.remove()` in MainPage.destroy) and never removes `sidebarWrapper` from its parent `app` div, so `sidebarWrapper.parentElement` stays non-null forever. The overlay is then created and mounted to `document.body` — outside the removed subtree — after every reference to it (`quickSwitchInstance`, still `null` when `closeQuickSwitch` ran during teardown) is gone.",
|
||
"repro": "1. Signed in, MainPage mounted. Click the disconnect/switch button in UserBar -> `openQuickSwitch()` runs and awaits `profileManager.loadProfiles()` (a Tauri IPC round trip).\n2. While that await is pending, the session ends asynchronously — e.g. a REST 401 fires main.ts's `onUnauthorized` -> `clearAuth()`, or the server broadcasts `server_restart` with reason \"shutdown\" (dispatcher.ts:879 `clearAuth(\"server_shutdown\")`), or an `auth_error`/`BANNED` frame arrives.\n3. main.ts's authStore subscriber runs `router.navigate(\"connect\")` -> `renderPage(\"connect\")` -> `currentPage.destroy()` -> MainPage.destroy(). That runs `closeQuickSwitch()` (no-op: `quickSwitchInstance` is still null) and then `root.remove()`.\n4. `loadProfiles()` resolves. `sidebarWrapper.parentElement` is still the `app` div, so the guard passes. `createQuickSwitchOverlay(...).mount(document.body)` runs.\nResult: a full-screen `.quick-switch-backdrop` modal plus its document-level `keydown` listener (QuickSwitchOverlay.ts:167) and focus trap sit on top of the freshly-rendered ConnectPage. Nothing holds a reference to it any more, so nothing can call its `destroy()`; its own \"Switch\"/\"Add server\" buttons call `clearAuth()` against a session that no longer exists. Fix: use a `destroyed` flag set from the teardown callback (or `document.contains(sidebarWrapper)`) instead of `parentElement === null`.",
|
||
"evidence": "function openQuickSwitch(): void {\n if (quickSwitchInstance !== null || openingQuickSwitch) return;\n openingQuickSwitch = true;\n ...\n void (async () => {\n try {\n ...\n await profileManager.loadProfiles();\n ...\n // Ensure we haven't been cleaned up while awaiting\n if (sidebarWrapper.parentElement === null) return; // <-- never true\n quickSwitchInstance = createQuickSwitchOverlay({ ... });\n quickSwitchInstance.mount(document.body); // <-- escapes the removed subtree\n } finally { openingQuickSwitch = false; }\n })();\n}\n\n// MainPage.ts destroy():\n// for (const unsub of unsubscribers) unsub(); // includes closeQuickSwitch() -> no-op, instance is null\n// ...\n// finally { if (root !== null) { root.remove(); root = null; } } // sidebarWrapper.parentElement is still `app`",
|
||
"suggestedFix": "In createSidebarArea, add `let tornDown = false;` and change the pushed unsubscriber to `unsubscribers.push(() => { tornDown = true; closeQuickSwitch(); });`, then replace the dead guard at line 722 with `if (tornDown) return;`. (A flag beats `isConnected`, which would change behavior for unit tests that mount into a detached container.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Client/tests/unit/sidebar-area.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a1a1aa96",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0054",
|
||
"title": "blocksStore survives clearAuth(), so a previous server's block list can gate DM composers on the next server",
|
||
"file": "Client/src/stores/auth.store.ts",
|
||
"line": 93,
|
||
"severity": "low",
|
||
"why": "`clearAuth()` deliberately resets voiceStore, messagesStore and channelsStore because their ids are per-server, but leaves `blocksStore.blockedByMe` untouched. Block state is keyed by *user id*, which is also only unique per server. The only thing that restates it on the next session is dispatcher.ts's fire-and-forget `api.listBlocks()`, whose failure is swallowed with a `log.warn` — so a single failed request leaves the previous server's blocked-user ids applied for the whole new session.",
|
||
"repro": "1. On server A, block the user whose id is 7 -> `setUserBlockedByMe(7, true)`, `blockedByMe = {7}`.\n2. Log out (UserBar disconnect / Settings logout / quick-switch) -> `clearAuth()`. `blockedByMe` is still `{7}`.\n3. Log into server B, where user id 7 is an unrelated person. `ready` arrives; `api.listBlocks()` is issued but rejects (transient network blip, 500, or the proxy not yet warm) — the rejection is only logged.\n4. Open a 1:1 DM with server B's user 7. ChannelController.ts:412 calls `dmComposerBlockReason(blocksStore.getState(), 7)`, which returns BLOCKED_BY_ME_REASON, so the composer is disabled for the rest of the session with \"You've blocked this user. Unblock to send messages.\" for a user that was never blocked here.\n5. MemberList.ts:289 reads the same set (`isBlocked = blockedByMe.has(member.id)`), so that member's context menu offers \"Unblock\"; clicking it sends DELETE /blocks/7 to server B.",
|
||
"evidence": "// auth.store.ts clearAuth():\n resetVoiceStore();\n resetMessagesStore();\n resetChannelsStore();\n clearNsfwAcknowledgements();\n cleanupNotificationAudio();\n authStore.setState(() => ({ ...INITIAL_STATE, ... }));\n // <-- blocksStore is never reset\n\n// dispatcher.ts READY handler — the only repopulation path:\n clearBlockedByThem(); // only the *other* direction is cleared\n if (api !== undefined) {\n api.listBlocks()\n .then((r) => setBlockedByMe(r.blocked_user_ids))\n .catch((err) => log.warn(\"Failed to load block list\", { error: String(err) })); // stale set survives\n }",
|
||
"suggestedFix": "Add `export function resetBlocksStore(): void { blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); }` to blocks.store.ts and call it in clearAuth alongside resetChannelsStore() (auth.store.ts:93). Same-server reconnects don't go through clearAuth, so the keep-until-refetch behavior there is preserved.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/auth-store.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "478dd94b"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0055",
|
||
"title": "InviteManagerController.open() re-uses a pre-await root reference; a page teardown during the getInvites() fetch resurrects the overlay with a live document-level keydown listener that outlives the page",
|
||
"file": "Client/src/pages/main-page/OverlayManagers.ts",
|
||
"line": 166,
|
||
"severity": "low",
|
||
"why": "open() captures `const root = opts.getRoot()` and checks `instance !== null` BEFORE `await opts.api.getInvites()` (line 167-171), but never re-checks getRoot()/liveness after the await. If MainPage.destroy() runs while the fetch is in flight, its unsubscribers call headerInviteCtrl.cleanup(), but `instance` is still null (createInviteManager hasn't run yet) so cleanup() no-ops; destroy() then nulls its own `root` variable and detaches the DOM node, but the closure-local `root` const in open() still references the now-detached node. When the fetch resolves after teardown, open()'s continuation runs unconditionally: it creates a new InviteManager instance and mounts it on the stale, detached `root` (the `if (root !== null)` check on line 201 only checks the stale local, never re-derives liveness). createInviteManager's mount() (Client/src/components/InviteManager.ts:218) registers `document.addEventListener('keydown', ...)` for Escape-to-close, scoped to that instance's own AbortController. Because InviteManagerController.cleanup() already fired and is never invoked again for this newly-created instance, that global keydown listener is never torn down — it lives on `document` indefinitely, closing over the destroyed page's `api`/`getToast`, and will fire options.onClose() the next time Escape is pressed anywhere in the app (e.g. after the user has navigated back to the connect/login page). SidebarArea.ts's own openQuickSwitch() (same file family, lines ~703-745) demonstrates the intended fix: it re-checks `sidebarWrapper.parentElement === null` AFTER the await before mounting, exactly the guard missing here (and in PinnedPanelController.toggle at OverlayManagers.ts:252-298, which has the identical pattern though its component has no document-level listener so the blast radius is smaller — a detached, un-destroyable component instance rather than a global listener leak).",
|
||
"repro": "1) Open the sidebar, click the Invite button (SidebarArea.ts headerInviteBtn) while the network is slow, so `opts.api.getInvites()` is pending. 2) Before it resolves, log out / get banned / server-shutdown-kick (any path that calls MainPage.destroy()). destroy() runs headerInviteCtrl.cleanup() while `instance` is still null, so nothing happens; destroy() proceeds to null/remove `root`. 3) The pending getInvites() promise resolves; open()'s continuation creates a fresh InviteManager instance and mounts it onto the now-detached root, registering a document-level 'keydown' listener via createInviteManager's own AbortController. 4) The user is now on ConnectPage (or a new MainPage from re-login). Pressing Escape anywhere triggers the zombie instance's onClose→close(), which is the only thing that will ever call its destroy() — until then this dangling document listener is a real leak that nothing in MainPage's teardown chain can reach.",
|
||
"suggestedFix": "In open(), after the await re-derive the mount target: `const liveRoot = opts.getRoot(); if (liveRoot === null) return;` and mount on liveRoot instead of the pre-await const (delete the dead `if (root !== null)` check). getRoot() returns MainPage's `root`, which destroy() nulls, so this is an exact liveness signal. Apply the same two-line change in PinnedPanelController.toggle.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "client-state",
|
||
"finder": "sonnet",
|
||
"round": 1,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Client/tests/unit/overlay-managers.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "8875523c",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0056",
|
||
"title": "ws.disconnect() cannot cancel an in-flight connect(), so a cancelled session still opens its WebSocket and re-registers Tauri listeners after teardown",
|
||
"file": "Client/src/lib/ws.ts",
|
||
"line": 585,
|
||
"severity": "low",
|
||
"why": "connect() is async and has three await points (ensureTauriApis, setupEventListeners' 3 tauriListen IPC round-trips, then ws_connect) after it has already bumped wsGeneration. disconnect() is fully synchronous and does NOT bump wsGeneration, so it has no way to invalidate an attempt that is mid-await: it drains eventUnsubs while that array is still partially filled, nulls config, and returns — then the suspended connect() resumes, pushes fresh (never-cleaned) unsub handles into eventUnsubs, and calls invoke(\"ws_connect\"), opening the very socket the teardown was meant to prevent.",
|
||
"repro": "main.ts's ConnectPage `onAutoLoginCancel` (main.ts:572-588) is the exact interleaving, and its own comment says so: \"by the time a click reaches here the session is already in flight (wirePostAuth has called ws.connect and registered listeners)\". Click Cancel while auto-login is connecting → disconnect() runs during connect()'s awaits → connect() resumes and invokes ws_connect → Rust's WsState.begin_connection claims a fresh generation and completes the WSS handshake to the server the user just cancelled. The \"open\" event then flips the UI to `authenticating` (mapped to \"reconnecting\" by toConnectionStatus, so ServerBanner shows \"Reconnecting...\" on the connect page) and, because `config === null`, no auth frame is ever sent, so the socket sits unauthenticated until the server's 10s authDeadline closes it. The three tauriListen unsubs registered after disconnect()'s cleanupEventListeners() are never removed until the next connect(). The same shape applies to the logout path (main.ts:746, 814).",
|
||
"evidence": "connect(): `wsGeneration++; config = cfg; intentionalClose = false; ... await ensureTauriApis(); ... cleanupEventListeners(); await setupEventListeners(); try { await tauriInvoke(\"ws_connect\", { url: wsUrl }) }`. disconnect(): `intentionalClose = true; certMismatchBlock = false; cancelReconnect(); stopHeartbeat(); cleanupEventListeners(); void disconnectProxy(); setState(\"disconnected\"); config = null; lastSeq = 0; reconnectAttempt = 0;` — no `wsGeneration++`, no cancellation token consulted by connect(). The ws-state handler then hits `setState(\"authenticating\"); if (config === null) return;`",
|
||
"suggestedFix": "In disconnect(), add `wsGeneration++;`. In connect(), capture `const gen = wsGeneration;` after the initial increment and bail (`if (gen !== wsGeneration) return;`) after `await ensureTauriApis()` and after `await setupEventListeners()`, before invoking ws_connect.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "28aa0da",
|
||
"test": "Client/tests/unit/ws-lifecycle.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0057",
|
||
"title": "showContextMenu registers a permanent \"abort\" listener on the caller's component-lifetime signal on every open, pinning each removed menu subtree",
|
||
"file": "Client/src/lib/context-menu.ts",
|
||
"line": 88,
|
||
"severity": "low",
|
||
"why": "The teardown hook is attached with `signal.addEventListener(\"abort\", ...)` with no `{ once: true }` and no removal path — and, unlike the per-menu `dismissAc`, the caller's `signal` is the component's whole lifetime. Each invocation therefore adds one more listener to that signal, and each listener's closure retains its `menu` element, so menus removed from the DOM (by dismissal, by the `querySelectorAll(...).remove()` sweep at line 36, or by an item click) stay reachable until the component is destroyed.",
|
||
"repro": "Right-click DM rows N times without the DM sidebar being rebuilt: DmSidebar's `ac.signal` accumulates N abort listeners, each holding a detached `.dm-context-menu` div (plus its item children) that was already removed from the document. Nothing releases them until DmSidebar.destroy() fires the abort. Secondary consequence on the same line: if `signal` is already aborted when showContextMenu is called, the freshly-appended `menu` on document.body gets no teardown at all, because addEventListener(\"abort\") on an already-aborted signal never fires.",
|
||
"evidence": " // Clean up if parent component is destroyed\n signal.addEventListener(\"abort\", () => {\n menu.remove();\n dismissAc.abort();\n });\n\n// caller (DmSidebar.ts:268) passes the sidebar-lifetime signal:\nshowContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: \"dm-context-menu\" });\n// where signal === ac.signal, aborted only in destroy() (DmSidebar.ts:347-348)",
|
||
"suggestedFix": "Register the teardown hook so menu dismissal releases it: `signal.addEventListener(\"abort\", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal })`, and abort dismissAc whenever the menu is removed (item click and outside-click already do).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "d02e647",
|
||
"test": "Client/tests/unit/context-menu.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0058",
|
||
"title": "Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster",
|
||
"file": "Server/admin/handlers_users.go",
|
||
"line": 169,
|
||
"severity": "low",
|
||
"why": "The ban path calls hub.BroadcastMemberBan(id) directly (a method *ws.Hub really has), but the unban path routes through a type assertion to memberUnbanBroadcaster, and BroadcastMemberUnban exists nowhere on *ws.Hub (grep finds it only in this file and admin/handlers_users_broadcast_test.go). The assertion always misses, so the DB (users.banned=0, the user is back in ListMembers) and every already-connected client's membersStore — which hard-deleted the row on member_ban — permanently disagree.",
|
||
"repro": "Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {\"banned\": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.",
|
||
"evidence": "case *req.Banned && hub != nil:\n\thub.BroadcastMemberBan(id) // real method on *ws.Hub\ncase !*req.Banned && hub != nil:\n\tif mub, ok := hub.(memberUnbanBroadcaster); ok {\n\t\tmub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false\n\t}",
|
||
"suggestedFix": "Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "state-desync",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "d2289560",
|
||
"test": "TestBroadcastMemberUnban_FansOutMemberJoin",
|
||
"revertProof": "pass"
|
||
},
|
||
"fixed": "2026-08-19",
|
||
"note": "BroadcastMemberUnban implemented on *ws.Hub; compile-time wiring assertion added in admin"
|
||
},
|
||
{
|
||
"id": "OC-0059",
|
||
"title": "Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to",
|
||
"file": "Client/src/pages/main-page/ChannelController.ts",
|
||
"line": 450,
|
||
"severity": "low",
|
||
"why": "The `chat_send_ok` and `error`/SLOW_MODE listeners registered in mountChannel are global ws.on subscriptions (no per-channel filter is possible: ChatSendOkPayload has only message_id/timestamp, ErrorPayload has only code/message — neither carries channel_id). They are torn down and re-registered on every channel switch, so a frame that was actually produced by a send in the *previous* channel gets delivered to the *newly mounted* channel's handler, which unconditionally calls startSlowMode(ch.slowMode) for the channel currently mounted — desyncing the client's local slow-mode countdown (source: WS-listener side effect) from the server's actual per-channel rate-limit state (source of truth: the server's limiter, correctly scoped by channel_id there).",
|
||
"repro": "1) Open channel A, which has slow_mode > 0. 2) Send a message in A (chat_send is sent, correlationId cid_A pending). 3) Immediately switch to channel B before the server's chat_send_ok (or a SLOW_MODE error, if A was already on cooldown) for cid_A arrives. destroyChannel() unsubscribes A's chat_send_ok/error listeners; mountChannel(B) installs B's. 4) The late chat_send_ok (or SLOW_MODE error) for the A-message arrives and is delivered only to B's handler, which reads channelsStore.get(channelId=B) and calls startSlowMode(B.slowMode) — disabling B's composer with 'Slow mode — Ns' even though B was never sent to and has no active server-side cooldown. This reproduces even with B.slowMode=0 replaced by any nonzero value; with B.slowMode=0 the call is a harmless no-op, but any channel with its own slow mode configured is falsely gated whenever the user switches into it right after posting in a slow-mode channel.",
|
||
"suggestedFix": "Record the originating channel per correlation id (the send path already keys draftByCorrelation by correlation id — add a channelId field, or keep a controller-scoped Map<correlationId, channelId>). In both handlers, gate startSlowMode on that recorded channel equaling the mounted channelId; the server echoes the request id on SLOW_MODE errors too (buildErrorMsgWithID, Server/ws/handlers_chat.go:165), so the error handler can use the same correlation check via the second listener argument.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "state-desync",
|
||
"finder": "sonnet",
|
||
"round": 2,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/channel-controller.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "88033f02"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0060",
|
||
"title": "A single malformed stored server profile makes the client discard all saved profiles, and the next login overwrites the on-disk list with that empty set",
|
||
"file": "Client/src/lib/profiles.ts",
|
||
"line": 112,
|
||
"severity": "low",
|
||
"why": "`createTauriBackend().load()` returns `null` both for \"nothing stored\" and \"stored payload failed validation\", and `isValidStoredData` is all-or-nothing over the whole array — one bad entry rejects every profile. `loadProfiles` then does nothing (`if (data !== null)`), leaving the store empty rather than surfacing a read failure, and `saveProfiles` unconditionally writes that empty in-memory list back over the stored record. `importProfiles` on the same file shows the intended tolerance (it counts `skipped` per item); the load path has none.",
|
||
"repro": "A user has five saved server profiles. One stored entry fails `isValidProfileShape` — e.g. it was written by a build predating the `color` field, or a profile whose `name` is empty (`obj.name.length > 0`), or any hand-edit/partial write of the Tauri settings store. On launch, main.ts:625 calls `loadProfiles()`; `load()` returns null, so `profiles` stays `[]` and the connect page falls back to the synthetic \"Local Server\" entry (main.ts:439). Note main.ts:624-628's `catch` never fires — nothing throws. The user logs in; `ensureProfileExists` (main.ts:454) adds one profile and calls `persistProfiles()` (main.ts:449) → `save_settings(\"owncord:profiles\", {schemaVersion:1, profiles:[the one new profile]})`. All five originals are now permanently gone from disk. Note tests/unit/profiles.test.ts:757 pins `load()` returning null for an invalid shape, but nothing pins the manager's behaviour after that null — the destructive overwrite is untested.",
|
||
"evidence": "async load(): Promise<StoredData | null> {\n const raw = settings[STORAGE_KEY];\n if (raw === undefined || raw === null) return null;\n if (isValidStoredData(raw)) return raw;\n return null; // validation failure == \"nothing stored\"\n},\n...\nasync loadProfiles(): Promise<void> {\n const data = await backend.load();\n if (data !== null) { setProfiles(data.profiles); } // silently keeps []\n},\nasync saveProfiles(): Promise<void> {\n await backend.save(toStoredData()); // writes [...currentProfiles()]\n},",
|
||
"suggestedFix": "Make load() salvage instead of discard: when the envelope shape is valid, return { schemaVersion, profiles: obj.profiles.filter(isValidProfileShape) } (mirroring importProfiles' per-item tolerance) so one corrupt entry drops only itself rather than nulling the whole store that the next save then overwrites.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"round": 2,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "f1923eb",
|
||
"test": "Client/tests/unit/profiles.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0061",
|
||
"title": "NewPersistentRateLimiter silently discards LoadActiveLockouts errors, dropping all active login lockouts with no log line",
|
||
"file": "Server/auth/ratelimit.go",
|
||
"line": 78,
|
||
"severity": "low",
|
||
"why": "The constructor only populates the in-memory lockout map inside `if ... err == nil`; there is no `else` branch, so a failed DB read (SQLITE_BUSY, disk I/O error, or any other transient error from the underlying `SELECT` in LoadActiveLockouts) is dropped with zero logging anywhere in the call chain. This is inconsistent with the same package's other persistence paths (Lockout/Reset also swallow their UpsertLockout/DeleteLockout errors silently) and starkly inconsistent with this codebase's own D8 'a drop is never silent' policy that the audit writer and event persister enforce for comparable best-effort persistence. The practical effect: every account currently serving a login/password/TOTP lockout (auth/ratelimit.go callers in api/auth_handler.go, api/profile_handler.go, api/totp_handler.go) has that lockout wiped from the in-memory limiter on any server restart where the load query errors — silently re-opening the account to brute force with no operator-visible signal that recovery failed.",
|
||
"repro": "1) An operator has an account under an active login lockout (auth_handler.go's `limiter.Lockout(...)` after repeated failed logins), persisted via UpsertLockout into the lockout_log-style table. 2) The server restarts (deploy, crash-restart, container recycle) while the SQLite writer is briefly busy/locked or the disk hiccups, so `d.q.LoadActiveLockouts` returns an error. 3) `router.go`'s `auth.NewPersistentRateLimiter(database)` call hits the `err != nil` branch of the `if` in NewPersistentRateLimiter, which has no body — the function returns a RateLimiter with an empty lockouts map for every shard, and nothing is logged. 4) The account that was mid-lockout is now immediately unlocked, and there is no log entry anywhere indicating the load failed, so the gap is invisible until someone notices the lockout 'reset itself'.",
|
||
"suggestedFix": "Add an else branch: else { slog.Warn(\"ratelimit: failed to load persisted lockouts; starting with none\", \"err\", err) } — one log line in the constructor makes the degradation operator-visible without changing the constructor's signature.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "error-paths",
|
||
"finder": "sonnet",
|
||
"round": 2,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/auth/ratelimit_persist_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c8e6252a"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0062",
|
||
"title": "Cold-tier reconnect replay has no interior-gap detection, so events the EventPersister dropped are silently skipped and presented as a complete resume",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 210,
|
||
"severity": "low",
|
||
"why": "`EventPersister.Enqueue` drops on a full queue and `PersistEvents` can lose individual rows on a per-row insert failure, so the `events` table can contain interior holes. handleReconnect's cold tier guards only two of the three gap shapes: a *prefix* gap (the `GetEventsSince(ctx, 0, 1)` oldest-seq probe, serve.go:198-209) and a *tail* gap (the ring-buffer coverage check, serve.go:223-233). Nothing checks for a hole in the middle, so the `default:` branch accepts a lossy result as authoritative, sends `replay_source: \"db\"`, and the client — which tracks only `max(seq)` — advances past the missing seq and can never ask for it again.",
|
||
"repro": "A broadcast burst overflows the 4096-entry persister queue (or one `PersistEvents` row fails), so seq 1005 is never written to `events` while 1001..1004 and 1006..1200 are. A client disconnected at last_seq=1000 stays offline long enough for the 1000-entry ring buffer to evict seq 1000, forcing the cold tier. `GetEventsSinceForChannels(1000, ...)` returns 1001..1004,1006..1200; the oldest-seq probe returns a row with seq <= 1001 so serve.go:203 passes; the buffer covers the tail so serve.go:224 passes. The client receives auth_ok with `replay_source:\"db\"`, applies 199 frames, and sets lastSeq=1200. If seq 1005 was a `chat_deleted`, `channel_update` or `member_update`, that state is permanently wrong on this client with no `ready` and no refetch to repair it.",
|
||
"evidence": "Server/ws/serve.go:210-214 `default: persistedTail = make([][]byte, 0, len(persisted)); for _, p := range persisted { persistedTail = append(persistedTail, p.Payload) }` — accepted with no contiguity/loss check.\nServer/ws/event_persister.go:114-118 `select { case p.queue <- ...: default: p.dropped.Add(1) }` — silent drop on full queue.\nServer/ws/event_persister.go:191-196 `if failed := len(batch) - persisted; failed > 0 { p.errors.Add(...); slog.Warn(\"event persister: flush lost events\", ...) }` — rows lost on insert failure are counted and logged, never surfaced to the replay path.\nCompare serve.go:188-197, whose own comment says \"Accepting it as-is would present a hole as a complete resume, since the client tracks only max(seq)\" — the exact hazard, guarded only for the prefix case.",
|
||
"suggestedFix": "In serve.go's cold tier, before accepting persistedTail (the default branch at ~210), verify unfiltered contiguity of the covered range: query the store for COUNT(*) of events with lastSeq < seq <= maxPersistedSeq (unfiltered) and require it to equal maxPersistedSeq - lastSeq; on mismatch, log and force the full-ready fallback like the sibling guards. Contiguity is guaranteed absent losses because every allocated seq is persisted.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/ws/reconnect_interior_gap_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "1740a148"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0063",
|
||
"title": "Connected overlay reads authStore before the auth_ok payload is dispatched, so server_name and motd are always the pre-handshake values",
|
||
"file": "Client/src/main.ts",
|
||
"line": 388,
|
||
"severity": "low",
|
||
"why": "`ws.onStateChange` listeners are invoked synchronously inside `setState(\"connected\")`, which ws.ts runs *before* `dispatch(msg)` — and `setAuth(token, payload.user, payload.server_name, payload.motd)` only runs inside the dispatcher's auth_ok handler during that later `dispatch`. So `authStore.getState()` at main.ts:388 still holds the pre-auth_ok state. On a first login `wirePostAuth` has written only `token`, leaving `serverName`/`motd` at their `INITIAL_STATE` value of `null`, so both `?? ` fallbacks fire and the overlay renders the raw host string and a blank MOTD even though auth_ok carried both. Nothing later repairs it: the `ws.on(\"ready\", ...)` handler at main.ts:403 only calls `markReady()`, and `createConnectedOverlay` captures its options by value at construction.",
|
||
"repro": "Configure a server with `server_name = \"My Guild\"` and a non-empty `motd`. Launch the client fresh and log in to `192.168.1.10:8443`. auth_ok carries `server_name:\"My Guild\"` and the motd, but the connected overlay shows the title/avatar initial derived from `\"192.168.1.10:8443\"` (initial `1`) and an empty MOTD line, because setAuth has not run when line 388 executes. ChannelSidebar/SidebarArea, which subscribe to `authStore.serverName`, show \"My Guild\" once MainPage mounts — proving the value did arrive and only the overlay read it too early.",
|
||
"evidence": "ws.ts:307-322 `if (msg.type === \"auth_ok\") { ... setState(\"connected\"); ... } dispatch(msg);` and ws.ts:171-182 `setState` → `for (const listener of stateListeners) listener(state)` (synchronous).\ndispatcher.ts:192-197 `ws.on(S.AUTH_OK, (payload) => { ... setAuth(authStore.getState().token ?? \"\", payload.user, payload.server_name, payload.motd); ...})` — the only writer of serverName/motd.\nmain.ts:344 `authStore.setState((prev) => ({ ...prev, token }));` — the only pre-connect write; serverName/motd untouched.\nstores/auth.store.ts:43-48 `const INITIAL_STATE: AuthState = { token: null, user: null, serverName: null, motd: null, isAuthenticated: false };`\nmain.ts:388-394 `const auth = authStore.getState(); ... serverName: auth.serverName ?? host, ... motd: auth.motd ?? \"\",`",
|
||
"suggestedFix": "Create the overlay from the auth_ok payload instead of the store: in wirePostAuth, replace the onStateChange(\"connected\") trigger with a one-shot ws.on(\"auth_ok\", (payload) => { ... serverName: payload.server_name ?? host, motd: payload.motd ?? \"\" ... }) (registered after wireDispatcher), keeping the same self-unsubscribe and destroy-before-create logic.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/main.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c3a20a95"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0064",
|
||
"title": "The dispatcher's catch-all server-error branch writes to `transientError`, which only ConnectPage renders — every unhandled error is invisible in-app and then resurfaces stale on the login screen",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 1002,
|
||
"severity": "low",
|
||
"why": "`setTransientError` has exactly one consumer in the whole client: ConnectPage's subscription/mount read (ConnectPage.ts:267 and :276), which pushes it into the login form. MainPage never subscribes and never clears it. So the branch that the code comments call \"the one place every remaining server error lands ... so it must not be silently dropped\" does in fact drop it while the user is in the app, and leaves it latched in the store until the login page next mounts.",
|
||
"repro": "grep confirms only two read sites for `transientError` (ui.store.ts declaration aside): ConnectPage.ts:267,276. In a voice call, have a user with a LOWER user id join — the incumbent key holder's `voice_e2ee_offer` is answered with NOT_KEY_HOLDER (Server/ws/voice_e2ee.go:199), which matches none of the special-cased codes above (BANNED / pendingSends / reaction / CHANNEL_FULL / VIDEO_LIMIT) and falls into line 1002. Nothing is shown. Later the user hits Disconnect/logout; ConnectPage mounts, reads the latched value at line 276 and shows `loginForm.showError(\"only the key holder may send key offers\")` on the login form — an error from a different screen, minutes earlier, presented as a login failure.",
|
||
"evidence": "dispatcher.ts:1002\n setTransientError(payload.message || \"Server error\");\n\nConnectPage.ts:274-280\n const pendingError = uiStore.getState().transientError;\n if (pendingError) {\n loginForm.showError(pendingError);\n setTransientError(null);\n }\n\n(no other module reads uiStore.transientError)",
|
||
"suggestedFix": "In the catch-all branch (dispatcher.ts:1002), surface in-app errors the same way the sibling CHANNEL_FULL/VIDEO_LIMIT branches do — showToast(payload.message || \"Server error\", \"error\") — keeping setTransientError only for flows that also leave the session (BANNED, shutdown), and update the dispatcher tests that assert the store write for the catch-all codes.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "4aa9c4fc"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0065",
|
||
"title": "participant_joined webhook treats a GetVoiceState read error as proof of a rogue participant and ejects a legitimate one from the SFU",
|
||
"file": "Server/ws/livekit_webhook.go",
|
||
"line": 132,
|
||
"severity": "low",
|
||
"why": "`stateErr != nil` is OR'd into the same condition as `state == nil || state.ChannelID != channelID`, so a transient DB read failure is indistinguishable from \"no membership row\" and results in `RemoveParticipant`. The sibling eviction path deliberately refuses to make that conflation: `sweepStaleVoiceStates` uses `hasChannelPermChecked` precisely because \"a transient read failure (I/O error, lock contention, a maintenance window) is not a revocation\" and skips the tick instead of evicting. The webhook has no such guard, and its removal is one-sided: it does not delete the voice_states row and does not broadcast voice_leave, so the server keeps believing the user is in voice.",
|
||
"repro": "User joins voice; the client connects to the SFU; LiveKit posts participant_joined. If `h.db.GetVoiceState(ctx, userID)` returns a transient error (SQLITE_BUSY under concurrent writes, an I/O error, a maintenance window), the handler logs \"rogue participant_joined\" and calls `h.livekit.RemoveParticipant(...)`. The user is kicked out of the SFU mid-call while their voice_states row and hub voice state stay intact; other participants see no voice_leave, and their E2EE key holder does not rotate. The victim's client sees a non-CLIENT_INITIATED Disconnected and enters attemptAutoReconnect — which, per the 5-minute token TTL finding, also fails for any session older than 5 minutes.",
|
||
"evidence": "Server/ws/livekit_webhook.go:131-142\n state, stateErr := h.db.GetVoiceState(ctx, userID)\n if stateErr != nil || state == nil || state.ChannelID != channelID {\n slog.Warn(\"livekit webhook: rogue participant_joined — no matching voice state, removing\", ...)\n if h.livekit != nil { h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken) }\n return\n }\n\n// contrast, Server/ws/hub_sweep.go:166-177\n if err != nil {\n // A transient read failure ... is not a revocation ... Skip this client this tick\n continue\n }",
|
||
"suggestedFix": "Split the condition in handleWebhookParticipantJoined: on stateErr != nil, slog.Error and return WITHOUT calling RemoveParticipant (optionally retry the read once), so only a definitive nil row or channel mismatch is treated as rogue — mirroring sweepStaleVoiceStates' skip-on-error guard.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9981220f"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0066",
|
||
"title": "applyMentionCounts runs after SendMessage returns, so a mark_read that lands in between leaves a permanent mention badge on a channel with zero unread",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 218,
|
||
"severity": "low",
|
||
"why": "`applyMentionCounts` is dispatched to a background goroutine after the message is committed, and `IncrementMentionCounts` upserts `mention_count = mention_count + 1` unconditionally — it never compares against the recipient's read state. `UpdateReadState` (the only writer that zeroes `mention_count`) can therefore run *before* the increment. The in-code rationale asserts the opposite outcome (\"if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear\"); it does reappear, and because `GetChannelUnreadCounts` reports `mention_count` straight from the row while `unread_count` is computed from `last_message_id`, the next `ready` ships mention_count=1 with unread_count=0.",
|
||
"repro": "User U has channel C focused (read_states row: last_message_id=500, mention_count=0). User A posts message 501 containing `@U`; `SendMessage` commits row 501 and spawns the badge goroutine. Before that goroutine reaches `IncrementMentionCounts`, U switches channels — `ChannelController.mountChannel` fires `markChannelRead(C)` → `mark_read` → `HandleChannelFocus` → `UpdateReadState(U, C, 501)`, setting last_message_id=501 and mention_count=0. The goroutine then runs and sets mention_count=1. The row is now (last=501, mention=1): `GetChannelUnreadCounts` reports unread_count=0, mention_count=1, so the next `ready` paints a red mention badge on C with nothing unread behind it, `hasUnread(C)` stays true and \"Mark All as Read\" stays lit until U opens C again.",
|
||
"evidence": "Server/service/message_crud.go:214-220 `// The count is advisory: if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear` … `s.bg(func() { s.applyMentionCounts(context.WithoutCancel(ctx), channelID, authorID, mentions, isDM, participantIDs) })` with `bg: func(fn func()) { go fn() }` (Server/service/message.go:144)\nServer/db/mention_queries.go:214-218 `INSERT INTO read_states (…) VALUES %s ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1`\nServer/db/dbgen/messages.sql.go:254-260 `UpdateReadState … DO UPDATE SET last_message_id = excluded.last_message_id, mention_count = 0`\nServer/db/message_queries.go:588-594 ready's unread query: `COUNT(*) … m.id > COALESCE(rs.last_message_id, 0)` for unread, but `COALESCE(rs.mention_count, 0)` verbatim for mentions",
|
||
"suggestedFix": "Thread the triggering message id into applyMentionCounts → IncrementMentionCounts and make the upsert read-state-aware: ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1 WHERE read_states.last_message_id < ?msgID. A reader whose read state already advanced past the mentioning message then gets a no-op instead of a phantom badge — one guard in the shared query, no caller changes beyond passing msgID.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"round": 3,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/db/mention_queries_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0067",
|
||
"title": "The identical GetDMParticipantIDs-failure gap silently drops chat_edited fan-out for DM edits",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 316,
|
||
"severity": "low",
|
||
"why": "EditMessage already wrote the new content via s.st.EditMessage before this block. When s.st.GetDMParticipantIDs then errors, the function logs and falls through (no early return) leaving result.ParticipantIDs at its nil zero value while still returning (result, nil). handleChatEditV2 (Server/ws/handlers_chat.go:122-128) builds MessageEditedDMEvent{participantIDs: result.ParticipantIDs} from that nil slice; EmitEvents -> sendSequencedToUsers iterates zero recipients (same code path as the SendMessage finding above), so the chat_edited frame reaches nobody, not even the editor's own other sessions. The other DM participant's client keeps showing the pre-edit content indefinitely (there is no other WS signal that would prompt a refetch of that message), even though the DB row and any REST re-fetch of channel history would already show the edited text -- a live desync between what is persisted and what every connected client displays.",
|
||
"repro": "A and B share a DM; A previously sent message M. A edits M while s.st.GetDMParticipantIDs(ctx, channelID) transiently fails inside EditMessage (Server/service/message_crud.go:316-321). The DB row for M is updated with the new content and edited_at, but result.ParticipantIDs stays nil, so MessageEditedDMEvent fans out to zero users. B's already-loaded message list keeps showing the original, pre-edit text with no edited marker, and nothing server-side ever pushes a correction to B's live session.",
|
||
"suggestedFix": "Same shared guard as the send path: use context.WithoutCancel(ctx) for the GetDMParticipantIDs lookup (the edit is already committed, so the fan-out bookkeeping must not die with the editor's socket), and in handleChatEditV2 fall back to MessageEditedChannelEvent when result.IsDM && result.ParticipantIDs is empty so focused DM viewers still receive the edit live.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "flow-message",
|
||
"finder": "sonnet",
|
||
"round": 3,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "9cdef406",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0068",
|
||
"title": "A DM message delete is committed but its chat_deleted fan-out is silently dropped when GetDMParticipantIDs fails",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 388,
|
||
"severity": "low",
|
||
"why": "DeleteMessage logs a GetDMParticipantIDs error and returns a DeleteMessageResult with a nil ParticipantIDs, after the soft-delete has already committed. handleChatDeleteV2 builds MessageDeletedDMEvent from that nil slice and EmitEvents routes it to sendSequencedToUsers, whose recipient loop then iterates zero users — the delete succeeds server-side and reaches nobody, with no error returned to the deleter.",
|
||
"repro": "User A deletes their own message in a DM with B. The DeleteMessage row commits (message_crud.go:371) and the audit row is written, then GetDMParticipantIDs returns an error (SQLITE_BUSY under write contention, or a context deadline on a loaded server). ParticipantIDs stays nil, so MessageDeletedDMEvent carries no recipients and sendSequencedToUsers delivers to zero clients. The client only removes a row from messages.store on the CHAT_DELETED dispatcher event (dispatcher.ts:547-551) — ChannelController's onDeleteClick just sends chat_delete and toasts \"Message deleted\" — so A sees the success toast while the message stays on screen for A and for B until a full refetch. The frame did consume a seq and sits in the replay buffer, but every connected client's lastSeq watermark advances past it on the next frame, so a later reconnect can never request it back.",
|
||
"evidence": "service/message_crud.go:387-394\n\tif isDM {\n\t\tparticipantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)\n\t\tif pErr != nil {\n\t\t\tslog.Error(\"MessageService.DeleteMessage GetDMParticipantIDs\", \"err\", pErr, ...)\n\t\t} else {\n\t\t\tresult.ParticipantIDs = participantIDs\n\t\t}\n\t}\n\treturn result, nil\n\nws/hub_broadcast.go:607-619\nfunc (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) {\n\t...\n\tseq := h.nextSeq()\n\twrapped := wrapWithSeq(msg, seq)\n\th.replayBuf.Push(seq, channelID, wrapped)\n\th.persistEvent(seq, channelID, wrapped)\n\tfor _, userID := range userIDs { // empty -> delivered to nobody\n\t\th.SendToUser(userID, wrapped)\n\t}\n}",
|
||
"suggestedFix": "In DeleteMessage, call s.st.GetDMParticipantIDs BEFORE s.st.DeleteMessage (participants do not change as a result of the delete) and return an error on failure, so no committed-but-unbroadcast state can exist. At minimum, wrap the existing post-commit fetch in context.WithoutCancel(ctx) to close the disconnect-after-commit window.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "9cdef406",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0069",
|
||
"title": "A DM reaction is persisted but its reaction_update fan-out is silently dropped when GetDMParticipantIDs fails",
|
||
"file": "Server/service/message_reactions.go",
|
||
"line": 147,
|
||
"severity": "low",
|
||
"why": "Same shape as DeleteMessage: handleReaction commits the AddReaction/RemoveReaction row, then leaves result.ParticipantIDs nil on a GetDMParticipantIDs error. reactionV2Handler builds ReactionDMEvent from that nil slice, so the reaction_update reaches no participant while the DB row exists.",
|
||
"repro": "User A reacts to a message in a DM with B. s.st.AddReaction commits, then GetDMParticipantIDs errors (transient DB failure/context deadline). ParticipantIDs is nil, so sendSequencedToUsers delivers the reaction_update to nobody. B never sees the pill. On A's side the optimistic pill from addOptimisticReaction stays rendered but its pendingReactions entry (keyed by the WS envelope id) is never consumed by updateReaction, so it lingers until a disconnect rolls it back — at which point A's pill reverts even though the reaction is persisted server-side, and the two sides disagree until a refetch. No error is returned to A.",
|
||
"evidence": "service/message_reactions.go:146-155\n\tif isDM {\n\t\tparticipantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)\n\t\tif pErr != nil {\n\t\t\tslog.Error(\"MessageService.handleReaction GetDMParticipantIDs\", \"err\", pErr, ...)\n\t\t} else {\n\t\t\tresult.ParticipantIDs = participantIDs\n\t\t}\n\t}\n\treturn result, nil\n\nws/handlers_reaction.go:46-52\n\t\tif result.IsDM {\n\t\t\treturn Result{Events: []Event{ReactionDMEvent{\n\t\t\t\tchannelID: result.ChannelID,\n\t\t\t\tparticipantIDs: result.ParticipantIDs, // nil\n\t\t\t\tpayload: reactionPayload,\n\t\t\t}}}\n\t\t}",
|
||
"suggestedFix": "In handleReaction, fetch GetDMParticipantIDs before performing the AddReaction/RemoveReaction mutation and fail the request on error (participants are unaffected by the mutation), eliminating the committed-but-unbroadcast state. At minimum, use context.WithoutCancel(ctx) for the post-commit fetch.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_reactions_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "76d94578",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0070",
|
||
"title": "channel_focus has no archived-channel gate, so a client can subscribe to the live event stream of a channel every visibility surface hides — and reconnect replay then filters those same events out",
|
||
"file": "Server/service/channel.go",
|
||
"line": 245,
|
||
"severity": "low",
|
||
"why": "HandleChannelFocus gates only on READ_MESSAGES, and permissions.Checker.HasChannelPerm ignores ch.Archived (only VisibleChannelIDs applies the archived rule, checker.go:119). Every sibling path — buildReady, REST ListVisibleChannels, computeAllowedChannels, RefreshChannelVisibility and handleVoiceJoin — refuses or hides archived channels explicitly, so focus is the one path that lets a socket re-attach to one.",
|
||
"repro": "1. Admin archives #foo via PATCH /admin/api/channels/{id}. handlePatchChannel calls RefreshChannelVisibility, which sends channel_delete to every client and calls pubsub.Unsubscribe(c, ChannelTopic(foo)) plus clears c.channelID — the channel is now hidden from ready, REST ListVisibleChannels and computeAllowedChannels.\n2. A user who still holds READ_MESSAGES on #foo sends {\"type\":\"channel_focus\",\"payload\":{\"channel_id\":<fooID>}}.\n3. HandleChannelFocus passes (the archived flag is never consulted), so handlers.go:170 re-subscribes the socket to ChannelTopic(foo) and UpdateReadState advances the user's read state on a channel that is not in their ready payload.\n4. The socket now receives every chat_edited / reaction_update / chat_deleted / chat_bulk_deleted broadcast for #foo live (the archived read-only rule exists only on SendMessage), while computeAllowedChannels — used to filter reconnect replay — excludes #foo. On the next resume those identical events are dropped from replay, so the live stream and the resume path permanently disagree about the same channel, and the client's focused channel points at one its own channel list no longer contains.",
|
||
"evidence": "service/channel.go:240-247 (no ch.Archived branch)\n\tif ch.Type == \"dm\" {\n\t\tok, err := s.st.IsDMParticipant(ctx, userID, channelID)\n\t\tif err != nil || !ok { return nil, fmt.Errorf(\"%w: access denied\", ErrForbidden) }\n\t} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {\n\t\treturn nil, fmt.Errorf(\"%w: access denied\", ErrForbidden)\n\t}\n\npermissions/checker.go:116-119 (archived is applied ONLY in VisibleChannelIDs)\n\t\t// Archived channels are hidden from every client surface (admins ...)\n\t\tif ch.Archived {\n\nws/voice_join.go:92-95 (the sibling gate that does exist)\n\tif ch.Archived {\n\t\tc.sendMsg(buildErrorMsg(ErrCodeBadRequest, \"channel is archived\"))\n\t\treturn\n\t}\n\nws/handlers.go:158-172 (a successful focus subscribes the socket to ChannelTopic)\n\tif result.SetChannelID != nil { ... c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) }",
|
||
"suggestedFix": "One guard in the shared service function (covers both channel_focus and mark_read): in HandleChannelFocus after the GetChannel lookup, add `if ch.Type != \"dm\" && ch.Archived { return nil, fmt.Errorf(\"%w: access denied\", ErrForbidden) }`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/service/channel_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "56bdc1d2"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0071",
|
||
"title": "A sidebar re-render during a channel drag detaches the drop container, so the reorder silently no-ops",
|
||
"file": "Client/src/components/channel-sidebar/drag-reorder.ts",
|
||
"line": 111,
|
||
"severity": "low",
|
||
"why": "`activeDrag` captures the `channelsContainer` element that existed when the row was rendered. `ChannelSidebar.renderChannels()` does `clearChildren(channelList)` and rebuilds every category group, so any re-render while the mouse button is down leaves `drag.containerEl` (and `drag.sourceEl`, and the stale `drag.channels` snapshot) detached from the document. The global `mouseup` hit-test then queries the detached subtree, where `getBoundingClientRect()` returns an all-zero DOMRect for every row, so no row can ever satisfy `e.clientY >= rect.top && e.clientY <= rect.bottom` and `dropTargetId` stays null. The handler returns at the `dropTargetId === null` guard, and the drag is discarded with no error, no toast and no visual trace. The same staleness silently kills the drop indicator during `mousemove` (line 74-89 also queries the detached container), so the user watches the indicator disappear and then the drop does nothing.",
|
||
"repro": "Sign in with MANAGE_CHANNELS. Press the mouse down on a channel row and move >5px to start a drag. While still holding the button, have another user post a message in any channel that is not the active one (this calls `incrementUnread`, which builds a new `channels` Map, which fires the `subscribeSelector` at ChannelSidebar.ts:847, which runs `renderChannels()` and detaches the container captured in `activeDrag`). Release the mouse over a different channel row. Expected: the channel moves. Actual: `drag.containerEl.querySelectorAll(...)` returns rows whose `getBoundingClientRect()` is `{top:0,bottom:0}`, `dropTargetId` stays null, the handler returns, `onReorder` is never called and no position is written — the drag is lost with no feedback. Same happens on a category collapse, a connection-status flip, or any voice mute/camera change during the drag.",
|
||
"evidence": "drag-reorder.ts:100-125\n const drag = activeDrag;\n activeDrag = null;\n ...\n const items = drag.containerEl.querySelectorAll(\"[data-drag-channel-id]\");\n let dropTargetId: number | null = null;\n ...\n for (const item of items) {\n const rect = item.getBoundingClientRect();\n if (e.clientY >= rect.top && e.clientY <= rect.bottom) { ... }\n }\n if (dropTargetId === null || dropTargetId === drag.channelId) {\n return;\n }\n\nChannelSidebar.ts:751-796 (renderChannels)\n clearChildren(channelList);\n ...\n for (const [category, channels] of grouped) {\n channelList.appendChild(renderCategoryGroup(...)); // new channelsContainer every time\n }\n\nrenderChannels() is wired to high-frequency stores:\nChannelSidebar.ts:847 channelsStore.subscribeSelector((s) => s.channels, () => renderChannels());\nChannelSidebar.ts:891 voiceStore.subscribeSelector(<structural sig>, () => renderChannels());\n\nchannels.store.ts:337-353 (incrementUnread) replaces the channel object AND the Map on every\nmessage delivered to a non-active channel, so the selector above fires.",
|
||
"suggestedFix": "In the global mousemove/mouseup handlers, when !drag.containerEl.isConnected, re-resolve the live container via document.querySelector(`[data-drag-channel-id=\"${drag.channelId}\"]`)?.closest('.category-channels-container') (and rebuild the channel snapshot for that group from channelsStore) before hit-testing; or equivalently have renderChannels() re-target activeDrag's containerEl/sourceEl/channels when it rebuilds while a drag it owns is in flight.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 4,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "4b4fc0a",
|
||
"test": "Client/tests/unit/drag-reorder.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0072",
|
||
"title": "voice_mod_move's pre-flight omits the archived-channel gate that voice_join enforces, so the move drops the target out of voice for nothing",
|
||
"file": "Server/ws/voice_moderation.go",
|
||
"line": 295,
|
||
"severity": "low",
|
||
"why": "handleVoiceModMoveV2 documents itself as a pre-flight that \"refuse[s] a move the re-join would only bounce, so the target is never dropped from voice for nothing\", and it validates destination existence, type, the TARGET's CONNECT_VOICE, and capacity. It never checks dest.Archived, but the re-join it depends on (handleVoiceJoin, voice_join.go:92) refuses an archived channel with BAD_REQUEST. The handler has `dest` (a *db.Channel carrying Archived) in hand and simply does not consult it, so the move commits its destructive half — DB row deleted, LiveKit participant removed, voice_leave broadcast — for a re-join that is guaranteed to be rejected.",
|
||
"repro": "1. Admin PATCHes voice channel B to archived=true (admin/handlers_channels.go:266 fires CleanupVoiceForChannel, so B is empty; B stays type=\"voice\" with Archived=1). 2. Target user T is in voice channel A. 3. A moderator with MUTE_MEMBERS outranking T sends {\"type\":\"voice_mod_move\",\"payload\":{\"user_id\":T,\"to_channel_id\":B}} (to_channel_id is client-supplied; no UI is needed). 4. Pre-flight passes: dest != nil, dest.Type == \"voice\", T holds CONNECT_VOICE on B (Archived is not part of permission resolution), capacity is free. 5. disconnectFromVoiceIn evicts T from A — voice_states row deleted, LiveKit participant removed, voice_leave broadcast — and voice_moved is sent. 6. T's client answers with voice_join B, which handleVoiceJoin rejects at voice_join.go:92 with BAD_REQUEST \"channel is archived\". T ends the sequence out of voice entirely, with an error and no way back to A except a manual rejoin — exactly the outcome the handler's doc comment says the pre-flight exists to prevent.",
|
||
"evidence": "dest, err := d.DB.GetChannel(ctx, c.ToChannelID())\n...\nif dest.Type != \"voice\" {\n\treturn Result{Error: ClientError{Code: ErrCodeBadRequest, Message: \"destination is not a voice channel\"}}\n}\n// ...no `if dest.Archived` branch anywhere in handleVoiceModMoveV2...\nif !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) { ... }\nd.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))\n\n// voice_join.go:92 — the gate the re-join actually applies:\nif ch.Archived {\n\tc.sendMsg(buildErrorMsg(ErrCodeBadRequest, \"channel is archived\"))\n\treturn\n}",
|
||
"suggestedFix": "In handleVoiceModMoveV2, immediately after the dest.Type check (voice_moderation.go:295-297), add: if dest.Archived { return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: \"channel is archived\"}} } — same error shape voice_join.go:92 uses.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/ws/voice_moderation_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "6e21556f",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0073",
|
||
"title": "channelReadAudience does not exclude archived channels, so admin edits to an archived channel are broadcast directly to every user whose base role has READ_MESSAGES",
|
||
"file": "Server/ws/hub_broadcast.go",
|
||
"line": 126,
|
||
"severity": "low",
|
||
"why": "channelReadAudience (used by broadcastChannelScoped -> BroadcastChannelUpdate/BroadcastChannelCreate, by broadcastVoiceEvent, and by CleanupVoiceForChannel's audience build) fetches the channel row at line 141 and only special-cases ch.Type==\"dm\"; it never checks ch.Archived. Its sibling RefreshChannelVisibility (same file, ~line 321: `case ch.Archived: visible = false`) explicitly treats an archived channel as invisible to everyone regardless of role, matching VisibleChannelIDs (permissions/checker.go:119, `if ch.Archived { continue }`) and the doc comment on RefreshChannelVisibility ('Archived channels are hidden from every client regardless of permissions'). channelReadAudience's own doc comment claims it 'Mirrors RefreshChannelVisibility, which resolves visibility the same way' but the archived check present there was never added here. The underlying HasChannelPerm/HasChannelPermBatch calls it delegates to (permissions/checker.go:69, service/permission.go:53-68) also never consult Archived — only the higher-level VisibleChannelIDs does. Delivery bypasses pub/sub entirely: deliverBroadcast's bm.recipients!=nil branch calls h.SendToUser per audience member directly (hub_broadcast.go ~line 667), so even a client that was never subscribed to the channel's topic (and never had it in its ready payload / sidebar) still receives the frame on its live socket.",
|
||
"repro": "1) Admin archives voice channel #42 (handlePatchChannel, admin/handlers_channels.go, Archived: true committed to DB). Ordinary members' role has base READ_MESSAGES on #42 but the channel is now invisible everywhere else (VisibleChannelIDs excludes it from ready/reconnect, RefreshChannelVisibility sent them channel_delete/never showed it). 2) Admin PATCHes #42 again while it stays archived (e.g. edits topic/nsfw/slow_mode) — `existing.Archived == updated.Archived` so RefreshChannelVisibility is never called, but `hub.BroadcastChannelUpdate(updated)` always runs (admin/handlers_channels.go line 253) -> ws/hub_broadcast.go broadcastChannelScoped -> channelReadAudience(ctx, 42) returns every connected user whose role has READ_MESSAGES (archived not checked) -> deliverBroadcast SendToUser's the channel_update JSON (id, name, topic, category, archived flag) straight to those sockets, none of whom ever had #42 in their store or subscribed to its topic. Same gap fires if the admin archives a voice channel while people are still in it: CleanupVoiceForChannel (hub_sweep.go:332) calls channelReadAudience on the now-archived channel and broadcasts each evicted participant's voice_leave to the same over-broad, archived-blind audience, disclosing who was in the hidden voice channel to users who should never learn it exists.",
|
||
"suggestedFix": "In channelReadAudience, inside the h.db != nil block after the GetChannel error handling (hub_broadcast.go:149), add: if ch != nil && ch.Archived { return []int64{} } — one guard in the shared audience function covers BroadcastChannelCreate/Update, broadcastVoiceEvent, finishVoiceLeave and CleanupVoiceForChannel at once (the archive-transition voice_leave fan-out keeps working because CleanupVoiceForChannel's audience is resolved before the eviction ordering matters only client-side, and its evicted-participant append is unconditional).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "sonnet",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/ws/hub_broadcast_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "96ac3992"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0074",
|
||
"title": "EditMessage's DM detection fails open on a GetChannel error, skipping the block gate and misrouting the edit fan-out",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 253,
|
||
"severity": "low",
|
||
"why": "`chanType` stays \"\" when the channel read fails, so a DM edit takes the non-DM branch: `requireDMNotBlocked` and `IsDMParticipant` never run, and `result.IsDM` is false, so the ws layer emits MessageEditedChannelEvent (topic fan-out) instead of MessageEditedDMEvent (participant fan-out).",
|
||
"repro": "Bob has blocked Alice. Alice edits her own older DM message while GetChannel returns a transient error. isDM=false, so the `requireDMNotBlocked` branch at line 265 is skipped and `checkSendPermission(ctx, userID, msg.ChannelID, \"\")` runs the non-DM path, which passes on the base role mask (no override rows exist for a DM channel). The edit commits with arbitrary new text and, because result.IsDM is false, handleChatEditV2 (ws/handlers_chat.go:129) returns MessageEditedChannelEvent -> BroadcastToChannel(dmChannelID), delivering it to whoever holds the DM topic subscription rather than to the participant list — the exact channel back to the blocker that the requireDMNotBlocked doc comment says it exists to close.",
|
||
"evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nchanType := \"\"\nif chErr == nil && ch != nil {\n\tchanType = ch.Type\n}\nisDM := chanType == \"dm\"",
|
||
"suggestedFix": "Fail closed: after line 253, `if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: cannot edit this message\", ErrForbidden) }` and derive chanType from ch.Type unconditionally.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0075",
|
||
"title": "handleReaction's DM detection fails open on a GetChannel error, letting a non-participant react inside a private DM",
|
||
"file": "Server/service/message_reactions.go",
|
||
"line": 105,
|
||
"severity": "low",
|
||
"why": "Identical swallowed-error pattern: a failed channel read makes a DM take the role-based branch, bypassing IsDMParticipant and requireDMNotBlocked, and the reaction is then fanned out as ReactionChannelEvent instead of ReactionDMEvent.",
|
||
"repro": "Mallory (any role with READ_MESSAGES|ADD_REACTIONS, or any ADMINISTRATOR) sends reaction_add for a message id belonging to Alice and Bob's private DM while GetChannel errors. isDM=false -> HasChannelPerm resolves from the base mask on a channel with no override rows -> true -> the reaction row is written to a DM Mallory is not a participant of, and reactionV2Handler (ws/handlers_reaction.go:53) emits ReactionChannelEvent, publishing it onto the DM's channel topic where Alice and Bob see an outsider's reaction on their private message.",
|
||
"evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nisDM := chErr == nil && ch != nil && ch.Type == \"dm\"\n\nif isDM {\n\tok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)\n\t...\n} else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) {",
|
||
"suggestedFix": "Fail closed: after line 105, `if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: message not found\", ErrBadRequest) }` and compute `isDM := ch.Type == \"dm\"`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/service/message_reactions_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "8243e60d"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0076",
|
||
"title": "The admin setup rate limiter is never reaped, so its window map grows without bound for the life of the process",
|
||
"file": "Server/admin/api.go",
|
||
"line": 34,
|
||
"severity": "low",
|
||
"why": "`setupLimiter` is a dedicated auth.RateLimiter that nothing ever calls Cleanup or StartCleanup on — unlike the API limiter, which api/router.go:76 puts on a 5-minute reaper. Its `windows` map therefore accumulates one permanently-live entry per distinct source IP.",
|
||
"repro": "handleSetup calls limiter.Allow(\"setup:\"+host, 5, time.Minute) at setup_handler.go:125, BEFORE the CreateOwnerIfEmpty check at :172 that rejects an already-configured server. So on a fully set-up, production server, every unauthenticated POST /admin/api/setup still allocates an `entry` in setupLimiter.shards[...].windows keyed by the peer IP and appends a time.Time — and nothing ever deletes it (RateLimiter.Cleanup is the only eviction path and is never invoked on this instance). A host reachable on an IPv6 /64 sees the map grow one entry (~key string + up to 5 time.Time) per source address indefinitely; the entries survive even though every request is 403ing.",
|
||
"evidence": "// admin/api.go:34\nsetupLimiter := auth.NewRateLimiter()\nr.Post(\"/setup\", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts))\n\n// vs api/router.go:76\ngo limiter.StartCleanup(rateLimiterCleanupInterval, rateLimiterCleanupMaxWindow, limiterStopCh)",
|
||
"suggestedFix": "Mirror api/router.go: in NewAdminAPI start `go setupLimiter.StartCleanup(5*time.Minute, 15*time.Minute, stopCh)` (plumbing the router's existing limiterStopCh through, or reusing the router's already-reaped limiter for the setup endpoint).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/admin/setup_limiter_reap_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "8d6dee6b"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0077",
|
||
"title": "DeleteMessage has no archived-channel gate — the read-only invariant has a fifth hole",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 329,
|
||
"severity": "low",
|
||
"why": "SendMessage was fixed to refuse writes into an archived channel (message_crud.go:54-56, locked by TestSendMessage_RefusedInArchivedChannel), and the already-known finding at message_crud.go:268 documents that EditMessage, handleReaction, SetMessagePinned and PurgeMessages were left uncovered by that fix. DeleteMessage (lines 329-397) has the identical gap and was not named in that list: it fetches the channel at line 345 (`ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)`) purely to determine `isDM`, and `ch.Archived` is never read anywhere in the function. Both the ownership-only DM path and the READ_MESSAGES|MANAGE_MESSAGES / owner-SEND_MESSAGES channel path proceed straight to `s.st.DeleteMessage(ctx, msgID, userID, isMod)` at line 371 regardless of the channel's archived flag, and the underlying db.DeleteMessage (db/message_queries.go:165) has no archived check either. This lets a member soft-delete their own message, or a moderator soft-delete anyone's message, in a channel the send path and every visibility surface treat as frozen — directly contradicting the SendMessage comment's stated invariant that 'History stays readable; only writes are refused.'",
|
||
"repro": "Archive channel 10 (`UPDATE channels SET archived = 1 WHERE id = 10`) after it has message history. A member who authored a message in channel 10 (or a moderator with READ_MESSAGES|MANAGE_MESSAGES on it) sends chat_delete for that message id. GetChannel returns Archived=true but DeleteMessage never inspects it; ownership/permission checks pass as normal; s.st.DeleteMessage soft-deletes the row and the handler broadcasts chat_deleted to the channel — the archive's history is silently mutated exactly the way SendMessage was fixed to prevent.",
|
||
"suggestedFix": "In DeleteMessage, after the (fail-closed) GetChannel fetch: `if !isDM && ch.Archived { return nil, fmt.Errorf(\"%w: channel is archived\", ErrForbidden) }` — matching SendMessage lines 54-56 (and the same one-line gate belongs in the sibling write sinks already tracked in the ledger).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "sonnet",
|
||
"round": 5,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "9cdef406",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0078",
|
||
"title": "renderAll's rapid-fire breaker discards the update instead of deferring it, leaving the message list permanently stale",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 658,
|
||
"severity": "low",
|
||
"why": "When more than 20 renderAll calls occur inside the 2s window the function returns before rebuildItems(), so the store change that triggered it is simply dropped. Nothing re-schedules a render when renderAllResetTimer clears the counter, so the DOM keeps showing pre-burst state until some later, unrelated store event happens to arrive.",
|
||
"repro": "In a busy channel, have 21+ non-append store updates land in separate microtask notifications inside 2s — e.g. a moderator purge that arrives as 25 individual chat_deleted frames, or a burst of reaction_update frames. tryAppendMessages() returns false for all of them (prefix comparison fails / next.length <= prev.length), so each one calls renderAll(). Calls 21-25 log \"renderAll called >20 times in 2s\" and return; allMessages/virtualItems still contain the deleted rows. Two seconds later the counter resets but no render is queued, so the deleted messages stay on screen — and stay clickable — until the next unrelated update (a new message, a roleRevision bump) triggers another renderAll.",
|
||
"evidence": "renderAllCount++;\nif (renderAllCount > 20) {\n log.error(\"[MessageList] renderAll called >20 times in 2s — breaking loop\");\n return; // <- update dropped, nothing re-queued\n}\nif (renderAllResetTimer === 0) {\n renderAllResetTimer = window.setTimeout(() => { renderAllCount = 0; renderAllResetTimer = 0; }, 2000);\n}",
|
||
"suggestedFix": "When the breaker trips, remember it (e.g. renderAllSuppressed = true) and have the 2s reset timeout call renderAll() once if the flag is set, so the final state of a burst is always rendered.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 5,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "a910975",
|
||
"test": "Client/tests/unit/message-list.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0079",
|
||
"title": "An emptied message edit is submitted (and edit mode torn down) when an attachment is queued in the composer",
|
||
"file": "Client/src/components/MessageInput.ts",
|
||
"line": 472,
|
||
"severity": "low",
|
||
"why": "The empty-content early return is disabled by `hasAttachments`, but `pendingAttachments` is only meaningful for a new message — the edit branch never reads it. So with a file queued, an edit whose text the user cleared reaches `onEditMessage(id, \"\")`, which the host rejects with a toast, and `cancelEdit()` then runs unconditionally, dropping the user out of edit mode and wiping the textarea. The identical keystroke with no attachment queued is a harmless no-op that preserves edit state.",
|
||
"repro": "In a channel with uploads wired: (1) click \"+\" and attach any small image — the preview bar shows it and pendingAttachments.length === 1; (2) press ArrowUp on the empty composer (or click Edit on one of your messages) to enter edit mode — startEdit fills the textarea with the original content, pendingAttachments is untouched; (3) select all and delete the text, then press Enter. Expected (and what happens with no attachment queued): the send is refused at line 472 and the user stays in edit mode. Actual: `hasAttachments` is true, so the guard is skipped, `onEditMessage(messageId, \"\")` fires, a \"Message cannot be empty\" error toast appears, and `cancelEdit()` immediately exits edit mode and clears the textarea — the user has lost the edit and must re-open it.",
|
||
"evidence": "function handleSend(): void {\n if (disabledReason !== null) return;\n if (textarea === null) return;\n const content = textarea.value.trim();\n const hasAttachments = pendingAttachments.length > 0;\n if (content.length === 0 && !hasAttachments) return; // <-- line 472\n ...\n if (state.editing !== null) {\n options.onEditMessage(state.editing.messageId, content); // content === \"\"\n cancelEdit(); // runs regardless\n }\n\n// ChannelController.ts:357-362 (the onEditMessage host):\n// const trimmed = content.trim();\n// if (trimmed === \"\") { showToast(\"Message cannot be empty\", \"error\"); return; }\n\n// handlePasteFile only refuses attachments queued DURING an edit (MessageInput.ts:539):\n// if (state.editing !== null) { showUploadError(\"Can't attach files while editing a message\"); return; }\n// It does not cover attach-then-edit, so pendingAttachments can be non-empty while state.editing !== null.",
|
||
"suggestedFix": "In handleSend, make the empty-content guard ignore attachments when editing (edits are text-only): change line 472 to `if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "760686c",
|
||
"test": "Client/tests/unit/message-input.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0080",
|
||
"title": "teardownForReconnect() has the same generation-guard gap as leaveVoice(), so a camera/screenshare enable racing an unexpected LiveKit disconnect can publish a track to the room being torn down for auto-reconnect",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 348,
|
||
"severity": "low",
|
||
"why": "roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForReconnect() on every unexpected disconnect that is eligible for auto-reconnect, before nulling the room and calling room.disconnect() (roomEventHandlers.ts:187-193). teardownForReconnect (livekitSession.ts:329-352) mirrors leaveVoice: it tells the server camera/screenshare are off, then calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 348-349) and resets setLocalCamera(false)/setLocalScreenshare(false) (lines 350-351) — again without bumping state.generation, unlike doDisableCamera/doDisableScreenshare. An enableCamera()/enableScreenshare() call that is awaiting device acquisition when an unexpected disconnect fires will, on resuming, pass the stale-generation check and attempt to publish onto the room object that is about to be (or already was) disconnected and replaced by attemptAutoReconnect's fresh Room, leaving a leaked/orphaned local track and a store state that can disagree with what is actually being sent once the new room comes up.",
|
||
"repro": "1) Join voice (room R1). 2) Click 'Enable camera'; enableCamera() captures room=R1, generation=0, and is awaiting createLocalVideoTrack() (device prompt already granted previously, so this await is just the getUserMedia latency, still enough for the race). 3) The LiveKit connection drops unexpectedly (network blip) — RoomEvent.Disconnected fires handleDisconnected, which calls teardownForReconnect(): stops manual tracks (no-op, nothing published yet), sends voice_camera(false)/voice_screenshare(false) if they were on, resets the store, but leaves this._cameraState.generation at 0; then the room is disconnected and replaced via attemptAutoReconnect. 4) createLocalVideoTrack resolves; enableCamera()'s stale-generation check still reads 0 === 0, so it sets state.manualCameraTrack and calls publishTrack on the old, disconnected R1 reference — a publish that races the reconnect instead of being cleanly superseded the way an explicit disableCamera() would have caused.",
|
||
"suggestedFix": "Same one-line-per-state fix as the leaveVoice finding: call the shared supersede/bumpGeneration helper on this._cameraState and this._screenState at the top of the teardownForReconnect callback (before livekitSession.ts:348-349).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "sonnet",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "db7d518b"
|
||
},
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0081",
|
||
"title": "voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable",
|
||
"file": "Server/db/queries/sqlite/voice.sql",
|
||
"line": 92,
|
||
"severity": "low",
|
||
"why": "EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from \"no voice_states row for this channel\", and handleVoiceCameraV2 maps both to VIDEO_LIMIT \"maximum N video streams reached\".",
|
||
"repro": "Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT \"maximum 1 video streams reached\" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.",
|
||
"evidence": "Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.",
|
||
"suggestedFix": "Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "6a5a3a7c",
|
||
"test": "TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap",
|
||
"revertProof": "pass"
|
||
},
|
||
"fixed": "2026-08-19",
|
||
"note": "own-flag exclusion in both Enable*IfUnderLimit gates; sqlc regenerated"
|
||
},
|
||
{
|
||
"id": "OC-0082",
|
||
"title": "Pinning a soft-deleted message returns HTTP 500: SetMessagePinned leaks db.ErrNotFound unwrapped, and lacks the deleted-message guard its siblings have",
|
||
"file": "Server/service/message_query.go",
|
||
"line": 227,
|
||
"severity": "low",
|
||
"why": "`SetMessagePinned` is the only method in MessageService that returns a raw store error to its caller instead of wrapping it in the service error taxonomy. The pin SQL carries `AND deleted = 0`, so a soft-deleted target produces `db.ErrNotFound`, which `errors.Is(err, service.ErrNotFound)` does not match — `writeServiceError` falls through to `default:` and answers 500 INTERNAL_ERROR instead of 404. It is also the only message mutation with no `msg.Deleted` check: EditMessage returns ErrDeletedMessage (message_crud.go:248) and handleReaction returns ErrBadRequest (message_reactions.go:101).",
|
||
"repro": "Moderator A has the pinned-messages panel open showing message M (GET /channels/{id}/pins). Moderator B deletes M (soft delete: `deleted = 1`, pinned still 1). A clicks unpin → DELETE /api/v1/channels/{id}/pins/{M}. GetMessage still returns the row (db.GetMessage deliberately returns soft-deleted rows), so the channel/message checks pass; the UPDATE matches 0 rows, db.ErrNotFound propagates unwrapped, and the client gets `500 {\"error\":\"INTERNAL_ERROR\"}` plus a server-side `slog.ErrorContext(\"service error\")` line, rather than the 404 the sibling not-found paths return (locked by TestSetPinned_MessageNotFound / TestSetPinned_ChannelNotFound in api/channel_handler_test.go — neither covers the deleted case).",
|
||
"evidence": "service/message_query.go:223-227\n```\n\tmsg, err := s.st.GetMessage(ctx, msgID)\n\tif err != nil || msg == nil || msg.ChannelID != channelID {\n\t\treturn fmt.Errorf(\"%w: message not found in this channel\", ErrNotFound)\n\t}\n\treturn s.st.SetMessagePinned(ctx, msgID, pinned)\n```\n(no `msg.Deleted` branch; raw store error returned)\n\ndb/queries/sqlite/messages.sql:27 `UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;`\ndb/message_queries.go:732 `return fmt.Errorf(\"SetMessagePinned: message %d: %w\", id, ErrNotFound)` — that is `db.ErrNotFound` (db/errors.go:11), a distinct sentinel from `service.ErrNotFound` (service/message.go:24).\napi/channel_handler.go:407-426 (`writeServiceError`) has no `db.ErrNotFound` arm, so this lands in `default:` → 500.",
|
||
"suggestedFix": "In service.SetMessagePinned, wrap the store call: `if err := s.st.SetMessagePinned(ctx, msgID, pinned); err != nil { if errors.Is(err, db.ErrNotFound) { return fmt.Errorf(\"%w: message not found in this channel\", ErrNotFound) }; return fmt.Errorf(\"%w: %v\", ErrInternal, err) }`. This maps the deleted case to 404, keeps genuine failures as 500, and — unlike only adding `|| msg.Deleted` to the line-224 guard — also covers a delete racing between GetMessage and the UPDATE.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "6a32319a",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0083",
|
||
"title": "ConnectPage.destroy() never clears uiStore.settingsOpen, so the settings panel pops open over MainPage immediately after login",
|
||
"file": "Client/src/pages/ConnectPage.ts",
|
||
"line": 286,
|
||
"severity": "low",
|
||
"why": "MainPage.destroy() explicitly calls `closeSettings()` for exactly this reason (\"the next page to mount an (initially hidden) SettingsOverlay off that flag — ConnectPage, after logout — would show it over the login screen\"). ConnectPage.destroy() only destroys its own lazily-created overlay and leaves `settingsOpen === true` in the store. MainPage eagerly mounts a SettingsOverlay whose `mount()` ends with `if (uiStore.getState().settingsOpen) show()`, so the stale flag opens the full settings panel on top of the freshly loaded app.",
|
||
"repro": "On the connect page, type credentials and press Login; while the request is in flight (or during an auto-login), click the settings gear. `openSettings()` sets `settingsOpen = true` and the SettingsOverlay chunk starts loading. Login succeeds -> `wirePostAuth` -> WS `ready` -> `ConnectedOverlay.markReady()` fires `onReady` after READY_DELAY_MS (800 ms, ConnectedOverlay.ts:26/116) with no user interaction -> `router.navigate(\"main\")` -> `renderPage` destroys ConnectPage (settingsOpen still true) -> MainPage mounts and its SettingsOverlay calls `show()`. The user lands in the app with the settings panel covering it, having never asked for it there.",
|
||
"evidence": "ConnectPage.ts:286-306 — destroy() { abortController.abort(); unsubSettingsOpen?.(); unsubTransientError?.(); settingsOverlay?.destroy?.(); settingsOverlay = null; setTransientError(null); ... } // no closeSettings()\nMainPage.ts:756-762 — closeSettings(); // with the comment naming the symmetric ConnectPage case\nMainPage.ts:416 + 501 — const settingsOverlay = createSettingsOverlay({...}); settingsOverlay.mount(root);\nSettingsOverlay.ts:392-395 — // Sync initial state\\n if (uiStore.getState().settingsOpen) { show(); }\nConnectPage.ts:78 — onSettingsOpen: () => openSettings() // the gear is never disabled during \"loading\"/\"connecting\" (LoginForm.updateFormInputsDisabled only touches host/username/password/invite)",
|
||
"suggestedFix": "In ConnectPage.destroy() (ConnectPage.ts:286), call closeSettings() alongside the existing setTransientError(null), mirroring MainPage.destroy().",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Client/tests/unit/connect-page.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "152a32f9",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0084",
|
||
"title": "VideoGrid's track-mute handler adds a `track-muted` class that no stylesheet defines, so a stalled remote camera keeps showing a frozen frame",
|
||
"file": "Client/src/components/VideoGrid.ts",
|
||
"line": 151,
|
||
"severity": "low",
|
||
"why": "The handler's stated job is to hide the tile's video while the remote track is muted, but the hiding is expressed purely by toggling `track-muted`, and there is no `.track-muted` rule in app.css, base.css, login.css, tokens.css or theme-neon-glow.css. Nothing else in the mute path touches the element's visibility, so the branch is a no-op.",
|
||
"repro": "Join a video call with a remote peer, then have that peer's camera track fire `mute` (network stall, or the sender pausing the track). `onTrackMute` runs and adds `track-muted` to the `.video-cell`. Because no CSS matches that class, the `<video>` element keeps rendering its last decoded frame at full opacity — the viewer sees a live-looking but frozen tile with no indication the stream stalled, which is the exact state the handler was written to hide.",
|
||
"evidence": "VideoGrid.ts:148-156 —\n const onTrackMute = (): void => {\n // Temporarily hide video — track may unmute after network recovery\n const cell = cells.get(userId);\n if (cell !== undefined) cell.el.classList.add(\"track-muted\");\n };\n const onTrackUnmute = (): void => { ... classList.remove(\"track-muted\"); };\n`grep -rn \"track-muted\" src/styles/` -> no matches (compare `.video-focus-main .video-cell` at app.css:5091 and `.video-focus-strip .video-cell` at app.css:5104, which do exist).",
|
||
"suggestedFix": "Add a rule to app.css alongside the other .video-cell styles, e.g. `.video-cell.track-muted video { visibility: hidden; }` (optionally with a background on .video-cell.track-muted), so the tile blanks while the track is muted and reappears on unmute.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 6,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/video-grid-track-muted-css.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "3ba8cd36"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0085",
|
||
"title": "Collapsed sidebar categories are persisted under the server's display name, not its host, so two default-named servers share one localStorage entry",
|
||
"file": "Client/src/pages/main-page/SidebarArea.ts",
|
||
"line": 230,
|
||
"severity": "low",
|
||
"why": "`loadCollapsedCategories(serverHost)` builds the localStorage key `owncord:collapsed:<arg>` and `saveCollapsedCategories` writes back under the same `currentServerHost`. The one caller passes `authStore.getState().serverName` — the operator-configured display name from `auth_ok` — not `api.getConfig().host`. Every sibling per-server preference in this client is host-scoped for exactly this reason (`setChannelMutesHost`, `setNsfwGateHost`, `setAudioVolumeHost`, all wired from `MainPage.ts:105-107` with `apiConfig.host`), and channel-mutes.ts's own doc comment names `ui.store.ts`'s `loadCollapsedCategories` as following that per-connection host convention. The server's default name is `\"OwnCord Server\"` (Server/config/config.go:212), so unmodified instances all collide on one key.",
|
||
"repro": "Save two profiles pointing at two different self-hosted servers, both left at the default `server.name = \"OwnCord Server\"`, each with a category named e.g. \"Text Channels\". 1. Connect to server A, collapse \"Text Channels\" -> `toggleCategory` writes localStorage[\"owncord:collapsed:OwnCord Server\"] = [\"Text Channels\"]. 2. Log out, connect to server B. SidebarArea calls loadCollapsedCategories(\"OwnCord Server\") -> reads A's entry -> B's \"Text Channels\" renders collapsed although the user never collapsed it there. 3. Expand it on B -> the same key is rewritten to [] -> A's collapse state is destroyed. Renaming either server also orphans all of that host's saved state, because the key follows the name.",
|
||
"evidence": "// SidebarArea.ts:228-230\n// Load per-server collapsed category state from localStorage\nconst initialServerName = authStore.getState().serverName ?? \"Server\";\nloadCollapsedCategories(initialServerName);\n\n// ui.store.ts:122-133\nconst COLLAPSED_KEY_PREFIX = \"owncord:collapsed:\";\nlet currentServerHost: string | null = null;\nexport function loadCollapsedCategories(serverHost: string): void {\n currentServerHost = serverHost;\n const raw = localStorage.getItem(COLLAPSED_KEY_PREFIX + serverHost);\n\n// MainPage.ts:105-107 — the sibling prefs, all host-scoped\nsetChannelMutesHost(apiConfig.host ?? null);\nsetNsfwGateHost(apiConfig.host ?? null);\nsetAudioVolumeHost(apiConfig.host ?? null);",
|
||
"suggestedFix": "Scope the key to the connected host like the sibling prefs: in MainPage.ts next to setChannelMutesHost/setNsfwGateHost/setAudioVolumeHost (lines 105-107), call loadCollapsedCategories(apiConfig.host ?? \"\") and delete the loadCollapsedCategories(initialServerName) call at SidebarArea.ts:228-230 (matching the per-connection wiring channel-mutes.ts:55-57 already describes).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-client-tauri-client-src",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/sidebar-area.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "4d167fd4"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0086",
|
||
"title": "sweepStaleVoiceStates classifies ghost rows from a snapshot and then deletes them without re-checking, so an in-flight voice_join is ejected from the SFU while the hub still believes the user is in the room",
|
||
"file": "Server/ws/hub_sweep.go",
|
||
"line": 220,
|
||
"severity": "low",
|
||
"why": "The ghost loop decides staleness under one h.mu.RLock (lines 202-218) and then acts on that decision in a second, much longer loop that performs a DB delete, a channelReadAudience resolution, a broadcast and a LiveKit RemoveParticipant (5s timeout) per entry. The LeaveVoiceChannelIfMatch guard only protects against the user having moved to a *different* row — it matches the very row the in-flight join just created, because joined_at is identical. The revocation loop in the same function (lines 182-187) applies exactly the opposite discipline via handleVoiceLeaveIfStillIn, with a comment explaining why (\"a voice_join to a still-permitted channel may have committed while it ran\"); that reasoning was never carried into the ghost loop below it.",
|
||
"repro": "Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the `stale` loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.\n1. The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.\n2. At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to `stale`.\n3. X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.\n4. Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.\n5. Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).\nOutcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.",
|
||
"evidence": "hub_sweep.go:202-249\n\th.mu.RLock()\n\tvar stale []struct{ userID, channelID int64; joinedAt string }\n\tfor _, vs := range allStates {\n\t\tc, ok := h.clients[vs.UserID]\n\t\tif !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock\n\t\t\tstale = append(stale, ...)\n\t\t}\n\t}\n\th.mu.RUnlock()\n\n\tfor _, s := range stale { // <-- acted on here, seconds later, no re-check\n\t\tdeleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)\n\t\t...\n\t\th.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))\n\t\th.updateKeyHolder(s.channelID)\n\t\tif h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }\n\t}\n\ncontrast, same function, hub_sweep.go:181-187\n\t\t// The permission check is a DB round-trip; a voice_join to a\n\t\t// still-permitted channel may have committed while it ran. The\n\t\t// eviction is conditional on the client still being in the checked\n\t\t// channel — never on whatever channel it is in by now.\n\t\tif !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }\n\nthe window being raced, voice_join.go:196-222\n\t\tif err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed\n\t\tstate, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here\n\t\t...\n\t\tc.setVoiceState(channelID, state.JoinedAt) // only now non-zero",
|
||
"suggestedFix": "In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b8b7a2a1",
|
||
"test": "TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow",
|
||
"revertProof": "covered-by-OC-0017"
|
||
},
|
||
"fixed": "2026-08-19",
|
||
"note": "already fixed on main by the OC-0017 pre-delete live-client re-check (PR #1374); verified against HEAD 2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0087",
|
||
"title": "Global search silently drops every DM hit when the DM-id lookup fails, and reports success",
|
||
"file": "Server/service/message_perms.go",
|
||
"line": 44,
|
||
"severity": "low",
|
||
"why": "GetAccessibleChannelIDs discards the error from GetUserDMChannelIDs entirely — no return, no log, not even a slog.Warn — so a transient failure yields an accessible-channel set with every DM missing. SearchMessages then runs SearchMessagesInChannels over that truncated set and returns HTTP 200 with an authoritative-looking result list. The identical lookup in the WS sibling (ws/serve.go:505, computeAllowedChannels) is deliberately fatal, with a comment stating that a silently DM-stripped set is a permanent hole.",
|
||
"repro": "User U has one DM containing the message \"deploy key rotated\". GET /api/v1/search?q=deploy normally returns that hit via SearchMessages' global branch (message_query.go:100). Make GetUserDMChannelIDs fail once — SQLITE_BUSY against the single writer, or the request ctx being cancelled between the ListChannels/role reads and this call. GetAccessibleChannelIDs returns only the guild-channel ids, SearchMessagesInChannels is scoped to those, and the handler writes 200 {\"results\":[]}. Nothing is logged, and the user reads it as 'that message does not exist'. Worse: if U's role also has no visible guild channels, accessibleIDs is empty and SearchMessages returns (nil, nil) at message_query.go:104, i.e. a successful empty search rather than a 500.",
|
||
"evidence": "// Server/service/message_perms.go:43-49\n// Also include DM channels the user participates in. Only the IDs are\n// needed here, so skip the full DM query's preview/unread work.\ndmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)\nif err == nil {\n\tids = append(ids, dmIDs...)\n}\n\nreturn ids, nil\n\n// Server/ws/serve.go:505-508 — same call, opposite posture\ndmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)\nif dmErr != nil {\n\treturn nil, fmt.Errorf(\"computeAllowedChannels GetUserDMChannelIDs: %w\", dmErr)\n}",
|
||
"suggestedFix": "Fail like the three sibling lookups in the same function: `if err != nil { return nil, fmt.Errorf(\"%w: failed to fetch DM channels: %v\", ErrInternal, err) }`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/service/message_perms_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "bd3b9e6b"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0088",
|
||
"title": "Registry.DispatchCommand reads runtimePlatform without the registry lock that Close() writes it under",
|
||
"file": "Server/plugin/host_commands.go",
|
||
"line": 71,
|
||
"severity": "low",
|
||
"why": "DispatchCommand dereferences r.runtimePlatform (an interface-typed field, two words) with no lock, while Registry.Close sets it to nil under r.mu.Lock(). Registry.activate reads the same field under r.mu.RLock() and its doc comment states explicitly that the guard exists so a concurrent Close cannot be observed mid-use — DispatchCommand is the one reader that skips it, so the nil check can pass against a runtime Close has already torn down.",
|
||
"repro": "Build with -tags wazero and a plugin owning /roll. A WS client sends {\"type\":\"chat_command\",\"payload\":{\"command\":\"roll\",...}} on a readPump goroutine at the moment shutdown runs Registry.Close on the main goroutine. DispatchCommand's unsynchronised read of r.runtimePlatform races Close's write: `go test -race` reports a data race on the field, and on a torn or stale-non-nil read invokeCommand proceeds into a wazero runtime whose modules Close has already deactivated (registry.go:120), panicking the readPump goroutine instead of returning the ErrRuntimeUnavailable diagnostic.",
|
||
"evidence": "// host_commands.go:71 — unguarded read\nif r.runtimePlatform == nil {\n\treturn &CommandResult{...}, true\n}\nreturn r.invokeCommand(ctx, inst, userID, channelID, cmd, args)\n\n// registry.go:132-135 — concurrent write under r.mu\nr.platformClose = nil\nr.runtimePlatform = nil\nr.mu.Unlock()\n\n// registry.go:447-452 — the sibling reader, guarded\nr.mu.RLock()\nplatform := r.runtimePlatform\nr.mu.RUnlock()\nif platform == nil {\n\treturn ErrRuntimeUnavailable\n}",
|
||
"suggestedFix": "Capture the field under the RLock already held for the map lookup: `r.mu.RLock(); inst, ok := r.commands[cmd]; platform := r.runtimePlatform; r.mu.RUnlock()` and test `platform == nil` at line 71 (matching activate's pattern).",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/plugin/host_commands_race_test.go",
|
||
"revertProof": "pass",
|
||
"revertProofNote": "race-class fix: proven red/green manually under go test -race; plain-run verify-fixes reported a false VACUOUS before the -race escalation was added",
|
||
"branchCommit": "7071c64d",
|
||
"pr": 1369
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0089",
|
||
"title": "`additionalBrowserArgs` silently drops wry's default `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` on Windows",
|
||
"file": "Client/src-tauri/tauri.conf.json",
|
||
"line": 22,
|
||
"severity": "low",
|
||
"why": "Tauri/wry pass `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` to the WebView2 browser process by default; setting `additionalBrowserArgs` REPLACES that default rather than appending to it (documented on `WindowConfig::additional_browser_args` / `WebViewBuilder::with_additional_browser_args`: \"you also need to disable these components by yourself\"). This config supplies only the two media flags, so the three suppressed WebView2 features are re-enabled in every Windows build — including SmartScreen, which performs URL-reputation lookups against a Microsoft service for navigations and downloads inside the webview. For a client whose entire threat model is a self-hosted, TOFU-pinned server, that leaks the operator's server and attachment URLs off-box, and the msWebOOUI overlays reappear in the chrome-less window.",
|
||
"repro": "Build the Windows (nsis) bundle and run it. Because the config's `additionalBrowserArgs` overrides wry's default argument string, the WebView2 process starts without `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection`: SmartScreen is active for in-webview navigations/downloads (attachment and OG-preview URLs from the self-hosted server are submitted for reputation checks), and the msWebOOUI out-of-process UI surfaces render over the custom window. Fix is to prepend `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection` to the same string. (Confidence note: verified against the documented Tauri/wry contract and the absence of the flag anywhere in the repo, not by running a Windows build.)",
|
||
"evidence": "Client/src-tauri/src-tauri/tauri.conf.json:22 (app.windows[0]):\n \"additionalBrowserArgs\": \"--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream\"\n\nNo occurrence of `--disable-features` anywhere in the repo:\n grep -rn \"disable-features\" Client -> no hits\ntauri = 2.11.5 (src-tauri/Cargo.lock:4809-4810), which carries the replace-not-append semantics.",
|
||
"suggestedFix": "Prepend the dropped default to the same string in tauri.conf.json:22: \"additionalBrowserArgs\": \"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream\".",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 7,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/tauri-conf-webview2-args.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "7bb555a5"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0090",
|
||
"title": "channelReadAudience falls through to a server-wide role scan when the channel row is gone, leaking a private group-DM's voice_leave to every connected member",
|
||
"file": "Server/ws/hub_broadcast.go",
|
||
"line": 149,
|
||
"severity": "low",
|
||
"why": "The DM-audience branch is guarded on `ch != nil && ch.Type == \"dm\"`. A deleted channel yields `ch == nil` (db.GetChannel returns (nil, nil) on sql.ErrNoRows), so control drops into the generic role scan, which — for an id whose channel row and ON DELETE CASCADE'd channel_overrides no longer exist — resolves to \"every connected user whose base role holds READ_MESSAGES\". The read error above is deliberately failed closed; the missing-row case is not.",
|
||
"repro": "Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the `ch.Type == \"dm\"` branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {\"type\":\"voice_leave\",\"payload\":{\"channel_id\":G,\"user_id\":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.",
|
||
"evidence": "ch, err := h.db.GetChannel(ctx, channelID)\nif err != nil {\n slog.Error(\"ws: channelReadAudience GetChannel failed, denying\", ...)\n return []int64{}\n}\nif ch != nil && ch.Type == \"dm\" { // <- nil (deleted) channel skips the DM audience entirely\n participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)\n ...\n return audience\n}\n...\nfor _, uid := range userIDs {\n if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {\n audience = append(audience, uid)\n }\n}",
|
||
"suggestedFix": "In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == \"dm\"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b22ad84d",
|
||
"test": "TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant",
|
||
"revertProof": "pass"
|
||
},
|
||
"fixed": "2026-08-19",
|
||
"note": "fail closed on a missing channel row in channelReadAudienceImpl"
|
||
},
|
||
{
|
||
"id": "OC-0091",
|
||
"title": "chat_command is the only client message type registered without a rate limiter, and each frame runs a WASM plugin invocation",
|
||
"file": "Server/ws/hub.go",
|
||
"line": 155,
|
||
"severity": "low",
|
||
"why": "`PluginDeps` (deps.go:86) carries only `Registry` and `MessageSvc` — no `*auth.RateLimiter` — while every other deps struct built alongside it gets `h.limiter` (`PingDeps` line 130, `ChatDeps` line 132, `PresenceDeps` line 135, `CallDeps` line 138, `VoiceDeps` line 160). `handleChatCommandV2` correspondingly contains no `Allow(...)` call, and there is no connection-level inbound message-rate cap either: `serve.go:50` only sets `SetReadLimit` (a per-frame byte cap), and `TopicRateLimiter` meters outbound per-topic broadcast, not inbound frames. So one authenticated socket can drive unbounded `Registry.invokeCommand` executions.",
|
||
"repro": "An authenticated client loops `{\"type\":\"chat_command\",\"payload\":{\"command\":\"/<any registered plugin command>\",\"args\":[]}}` as fast as the socket allows. handleMessage → DispatchV2 → handleChatCommandV2 → `reg.DispatchCommand` → `invokeCommand` runs the wazero guest module once per frame with no throttle. Compare `handleChatSendV2`, which is capped at 10/s via `auth.Key(\"chat\", userID)` (service/message_crud.go:32-35), and `handleCallRingV2`, capped at 1 per 3s (handlers_call.go:40-43). The command is not even required to be broadcast-eligible: the `canPluginBroadcast` gate is reached only after the plugin has already executed.",
|
||
"evidence": "ws/hub.go:155-158\n\treg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{\n\t\tRegistry: func() *plugin.Registry { return h.pluginRegistry },\n\t\tMessageSvc: h.messageSvc,\n\t})\n\nws/deps.go:86-89\n\ttype PluginDeps struct {\n\t\tRegistry func() *plugin.Registry\n\t\tMessageSvc *service.MessageService\n\t} // no Limiter field\n\nws/hub.go:130-138 (every sibling gets one)\n\tregisterPingHandler(reg, PingDeps{Limiter: h.limiter})\n\tchatDeps := ChatDeps{Limiter: h.limiter}\n\tpresenceDeps := PresenceDeps{Limiter: h.limiter}\n\tcallDeps := CallDeps{Limiter: h.limiter}",
|
||
"suggestedFix": "Add `Limiter *auth.RateLimiter` to PluginDeps in Server/ws/deps.go, populate it with h.limiter in the RegisterV2 call at Server/ws/hub.go:155, and at the top of handleChatCommandV2 (Server/ws/handlers_command.go) add the standard idiom: `if d.Limiter != nil && !d.Limiter.Allow(auth.Key(\"plugin_cmd\", cc.userID), 5, time.Second) { return Result{Error: ClientError{Code: ErrCodeRateLimited, ...}} }` before DispatchCommand.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/ws/handlers_command_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "d28c124c"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0092",
|
||
"title": "Plugin slash-command broadcast can post live content into an archived (read-only) channel",
|
||
"file": "Server/service/message_perms.go",
|
||
"line": 57,
|
||
"severity": "low",
|
||
"why": "CanPost (and the checkSendPermission it delegates to) checks only DM participation/blocks or HasChannelPerm(ReadMessages|SendMessages) for non-DM channels — neither ever consults ch.Archived, and permissions.Checker.HasChannelPermBatch (checker.go:93-98) has no notion of archived state either (only VisibleChannelIDs filters archived, and that's a separate, unrelated predicate used for channel *listing*). SendMessage itself was hardened against this at message_crud.go:54 ('Archived channels are read-only'), but CanPost is a distinct entrypoint used only by the WS plugin slash-command broadcast path (Server/ws/handlers_command.go:68-72, canPluginBroadcast -> MessageSvc.CanPost) and never goes through SendMessage at all: on success it builds a PluginBroadcastEvent (Server/ws/event.go:333-343) and fans it out directly as a channel-scoped WS event, with no DB write and no archived check anywhere in the path.",
|
||
"repro": "Archive a text channel (channel_archive), leaving a plugin registered that owns a slash command. Have any user with ordinary SEND_MESSAGES on that (now-archived, UI-hidden) channel invoke `/command` there over the WS connection (the client still holds the channel id even though it's no longer listed — same premise as the already-confirmed SendMessage archived-gate hole). handleChatCommandV2 -> canPluginBroadcast -> CanPost -> checkSendPermission passes because ReadMessages|SendMessages is present and archived is never checked; the plugin's Broadcast text is wrapped in a plugin_broadcast event and delivered live to every subscriber of the channel (per the already-confirmed hub_broadcast.go:126 finding, archived channels are not excluded from that audience either), producing new, visible traffic in a channel every other surface treats as frozen/hidden — without ever touching the messages table or the SendMessage gate that was supposed to make archived channels read-only.",
|
||
"suggestedFix": "In MessageService.CanPost (Server/service/message_perms.go:57), after the GetChannel lookup succeeds, mirror message_crud.go:54: `if ch.Type != \"dm\" && ch.Archived { return fmt.Errorf(\"%w: channel is archived\", ErrForbidden) }` before delegating to checkSendPermission.",
|
||
"status": "declined",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "sonnet",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": null,
|
||
"declinedDate": "2026-08-14",
|
||
"rationale": "Already fixed on dev before this run: PR #1370 (OC-0022) routed every write sink through a shared requireChannelWritable gate, which CanPost reaches via checkSendPermission. A probe test per this finding's repro passes against current code, so there is no red state to pin."
|
||
},
|
||
{
|
||
"id": "OC-0093",
|
||
"title": "Registration records the reverse-proxy's address as the session IP while login records the real client IP",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 241,
|
||
"severity": "low",
|
||
"why": "`handleRegister` is constructed without the `trustedProxies` list and uses `clientIP(r)`, which is hardcoded to `clientIPWithProxies(r, nil)` — i.e. always `r.RemoteAddr`. The sibling `handleLogin` resolves the same value with `clientIPWithProxies(r, proxyNets)`. Both feed the identical `sessions.ip` column that the client's \"Active sessions\" screen shows the user, so the two session-creation paths store two different things behind the same reverse proxy.",
|
||
"repro": "Deploy behind nginx at 10.0.0.2 with `server.trusted_proxies: [\"10.0.0.2/32\"]`. A client at 203.0.113.9 registers via `POST /api/v1/auth/register`: line 241 yields `ip = \"10.0.0.2\"` and line 257 stores that in the session row (and the audit/slog line). The same client then logs in via `POST /api/v1/auth/login`: line 307 yields `ip = \"203.0.113.9\"`, stored by `issueSession` at line 451. The user's session-management list now shows one bogus 10.0.0.2 entry alongside correct ones, defeating the \"is this session from somewhere I recognise?\" purpose of the column. Note `handleRegister(database)` (line 137) does not even receive `trustedProxies`, although `MountAuthRoutes` has it (line 91) and passes it to every rate-limit middleware on the same route.",
|
||
"evidence": "auth_handler.go:137 `func handleRegister(database *db.DB) http.HandlerFunc {` // trustedProxies not threaded in\nauth_handler.go:241 `ip := clientIP(r)`\nauth_handler.go:257 `database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip)`\nauth_handler.go:284 `proxyNets := parseCIDRList(trustedProxies)`\nauth_handler.go:307 `ip := clientIPWithProxies(r, proxyNets)`\nauth_handler.go:451 `issueSession(r.Context(), database, user.ID, truncateDevice(...), ip)`\nmiddleware.go:251-253 `func clientIP(r *http.Request) string { return clientIPWithProxies(r, nil) }`\nmiddleware.go:274-276 `if len(trustedNets) == 0 { return remoteHost }`",
|
||
"suggestedFix": "Thread the proxy list into the register handler: change the signature to handleRegister(database *db.DB, trustedProxies []string), parse once at construction like handleLogin does (proxyNets := parseCIDRList(trustedProxies)), replace line 241 with `ip := clientIPWithProxies(r, proxyNets)`, and update the mount at auth_handler.go:104 to pass trustedProxies.",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "opus",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/api/auth_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "e5d75fac"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0094",
|
||
"title": "\"Back\" from DM sidebar can silently jump the user to an unrelated channel when DM mode was entered via \"View all messages\"",
|
||
"file": "Client/src/pages/main-page/SidebarArea.ts",
|
||
"line": 523,
|
||
"severity": "low",
|
||
"why": "`channelBeforeDm` (declared line 90, only ever written at line 393 inside `selectDmConversation`'s `setChannelBeforeDm` callback) is meant to remember which channel to restore when leaving the DM sidebar. But `SidebarDmSection.ts`'s \"View all messages\" button (line 66-68) switches to DM mode via a bare `setSidebarMode(\"dms\")`, bypassing `selectDmConversation` entirely, so `channelBeforeDm` is never recorded for that entry path. When the user then clicks \"Back\" without selecting a specific DM, `onBack` (lines 523-536) sees `channelBeforeDm === null` and falls into the `else` branch, which activates the *first* `type === \"text\"` channel found in `channelsStore` Map-iteration order — not the channel that was actually on screen. If that first channel differs from the one the user was viewing, `setActiveChannel` (line 531) silently switches them to a different channel/conversation they never asked to leave.",
|
||
"repro": "User is viewing channel #random (not the first text channel in the sidebar's insertion order, e.g. #general is first). They click \"View all messages\" in the embedded DM section (SidebarDmSection.ts onclick -> setSidebarMode(\"dms\") with no channelBeforeDm set). The DM sidebar opens, still showing #random underneath. They click \"Back\" without picking a DM. onBack sees channelBeforeDm === null and calls setActiveChannel(<id of #general>), so the app now displays #general instead of #random, with no user action having asked to leave #random.",
|
||
"suggestedFix": "In onBack's else branch (SidebarArea.ts:528-535), keep the current channel when it is already a non-DM channel and only fall back when it is null or a DM: `const st = channelsStore.getState(); const cur = st.activeChannelId !== null ? st.channels.get(st.activeChannelId) : undefined; if (cur === undefined || cur.type === \"dm\") { /* existing first-text-channel loop */ }`. (fallBackFromDm at line 437 has the same pattern but is only reached after the active DM was closed, where the fallback is the intended behavior.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-12",
|
||
"hunt": "general-2026-08-12",
|
||
"lens": "fresh-eyes",
|
||
"finder": "sonnet",
|
||
"round": 8,
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/sidebar-area.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "4d167fd4"
|
||
},
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0095",
|
||
"title": "Voice E2EE is never actually enabled — room.setE2EEEnabled(true) is never called, so every frame reaches the SFU in plaintext while the UI shows \"🔒 Secured\"",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 410,
|
||
"severity": "critical",
|
||
"why": "createRoom() passes `e2ee: { keyProvider, worker }` to the livekit-client Room, but nothing in the client ever calls `room.setE2EEEnabled(true)`. In livekit-client 2.21.0 the Room constructor's `setupE2EE()` only wires the manager; it never enables encryption. `LocalParticipant.encryptionType` therefore stays `Encryption_Type.NONE`, so on `SignalConnected` the E2EEManager calls `setParticipantCryptorEnabled(localParticipant.isE2EEEnabled /* false */, localIdentity)` and the worker's `encodeFunction` takes the `if (!this.isEnabled()) { ... return controller.enqueue(encodedFrame); }` branch — the frame is forwarded unencrypted. Published tracks are also advertised with `encryption: NONE`, so every remote peer's `setParticipantCryptorEnabled(pub.trackInfo.encryption !== Encryption_Type.NONE, ...)` is false too and their decode transforms pass through as well. The entire ECDH/HKDF/AES-GCM key exchange, the TOFU identity layer, key-holder election and 5-minute rotation distribute a room key that is never used to encrypt or decrypt a single media frame. The call still works end to end, which is why this has never surfaced as a symptom.",
|
||
"repro": "Join any voice channel with two clients. setupKeyExchange() completes, the key holder generates a room key, the peer unwraps it and keyProvider.setKey() succeeds, and both clients display \"🔒 Secured\". Because room.setE2EEEnabled(true) was never called, the local cryptor is disabled (`encryptionEnabledMap` has no true entry for the local identity), so the worker's encodeFunction enqueues each frame unmodified. Capture the RTP payload at the LiveKit SFU (or run livekit-server with track egress / a recording) and the audio/video decodes as ordinary Opus/VP8 — no SFrame header, no AES-GCM tag. Equivalent one-line check in the running client: `room.isE2EEEnabled` is false and `room.localParticipant.encryptionType === Encryption_Type.NONE` at every point of a \"Secured\" call.",
|
||
"evidence": "livekitSession.ts:408-413\n // End-to-end encryption: SFrame-based E2EE using a server-distributed\n // per-channel symmetric key. The SFU only sees encrypted frames.\n e2ee: {\n keyProvider: this._e2ee.keyProvider,\n worker: this._e2eeWorker,\n },\n\n(no `setE2EEEnabled` / `isE2EEEnabled` anywhere under Client/src or tests/)\n\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:29027\n this.encryptionType = Encryption_Type.NONE; // LocalParticipant default\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:29268\n get isE2EEEnabled() { return this.encryptionType !== Encryption_Type.NONE; }\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:29424 (LocalParticipant.setE2EEEnabled — the ONLY writer of encryptionType)\n this.encryptionType = enabled ? Encryption_Type.GCM : Encryption_Type.NONE;\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:15632 (E2EEManager.setupEventListeners, on SignalConnected)\n this.setParticipantCryptorEnabled(this.room.localParticipant.isE2EEEnabled, this.room.localParticipant.identity);\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:15602\n room.on(RoomEvent.TrackPublished, (pub, participant) => this.setParticipantCryptorEnabled(pub.trackInfo.encryption !== Encryption_Type.NONE, participant.identity));\nnode_modules/livekit-client/dist/livekit-client.e2ee.worker.mjs:6741-6751 (Cryptor.encodeFunction)\n if (!this.isEnabled()) {\n this.appendFrameMetadata(encodedFrame);\n return controller.enqueue(encodedFrame); // <-- plaintext passthrough\n }\n\nClient/src/components/VoiceWidget.ts:196\n securedBadge.style.display = status === \"connected\" ? \"inline-flex\" : \"none\"; // \"🔒 Secured\" shown purely on connection state",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "df221814"
|
||
},
|
||
"suggestedFix": "Enable E2EE on the one room construction site so both connect paths get it: make createRoom() async and `await newRoom.setE2EEEnabled(true);` immediately after `new Room({...})` (livekitSession.ts:414), then `await this.createRoom()` at the two call sites (486, 950 — both already async). Pre-connect this only sets localParticipant.encryptionType = GCM (identity is still '' so the manager call is skipped); the manager's SignalConnected handler then posts enable=true with the real identity, and published tracks advertise encryption: GCM so peers enable their decode cryptors. The key is already set before connect (setupKeyExchange at 977 runs ahead of room.connect at 1009), so there is no encode-before-key window.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0096",
|
||
"title": "Message search 500s on any query containing a hyphen — sanitizeFTSQuery allowlists the one character that is an FTS5 operator",
|
||
"file": "Server/db/message_queries.go",
|
||
"line": 36,
|
||
"severity": "high",
|
||
"why": "sanitizeFTSQuery is documented as stripping \"FTS5 operator characters\" but explicitly keeps '-'. In FTS5's MATCH grammar '-' is not a bareword character: it introduces a column-filter (`-col : expr`), so `well-known` parses as the term `well` followed by a filter on a column named `known` and SQLite raises `no such column: known`. That error propagates out of SearchMessages/SearchMessagesInChannels as service.ErrInternal, and the handler's isInvalidSearchQueryError (api/channel_handler.go:25-34) only matches \"fts5\"/\"malformed\"/\"syntax error\"/\"unterminated string\" — \"no such column\" matches none of them — so writeServiceError returns HTTP 500 instead of results.",
|
||
"repro": "Verified against the repo's exact FTS table (`fts5(content, content='messages', content_rowid='id')`) on modernc.org/sqlite v1.56.0:\n MATCH 'well-known' -> ERROR: SQL logic error: no such column: known\n MATCH 'e-mail' -> ERROR: SQL logic error: no such column: mail\n MATCH '2026-08-13' -> ERROR: SQL logic error: no such column: 08\n MATCH '-' -> ERROR: fts5: syntax error near \"\"\n MATCH 'well known' -> ok, 1 row\nSo: GET /api/v1/messages/search?q=well-known (or any hyphenated term, e-mail address fragment, ISO date, \"state-of-the-art\", a username with a hyphen) returns 500 INTERNAL_ERROR. Only the pure-punctuation case (q=\"-\") happens to produce an \"fts5:\"-prefixed message and gets the intended 400; every realistic hyphenated search is a 500. Global search (SearchMessagesInChannels, same MATCH) fails identically.",
|
||
"evidence": "db/message_queries.go:32-47\n\tfor _, r := range q {\n\t\tif unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' { // <- '-' kept\n\t\t\tsb.WriteRune(r)\n\t\t}\n\t}\n\ndb/message_queries.go:348 / :360 / :410\n\tWHERE messages_fts MATCH ? AND m.deleted = 0\n\ndb/message_queries.go:366\n\treturn nil, fmt.Errorf(\"SearchMessages: %w\", err)\nservice/message_query.go:94 / :110\n\treturn nil, fmt.Errorf(\"%w: search failed: %v\", ErrInternal, err)\napi/channel_handler.go:29-33\n\tmsg := strings.ToLower(err.Error())\n\treturn strings.Contains(msg, \"fts5\") || ... || strings.Contains(msg, \"syntax error\")",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/db/message_queries_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "96a98b8b"
|
||
},
|
||
"suggestedFix": "One line in sanitizeFTSQuery: emit a space for '-' instead of keeping it, so the term still matches the indexed tokens (dropping it entirely would turn \"well-known\" into \"wellknown\", which matches nothing). At Server/db/message_queries.go:35-39: `for _, r := range q { switch { case unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ': sb.WriteRune(r); case r == '-': sb.WriteRune(' ') } }`. Both SearchMessages and SearchMessagesInChannels route through it, so one edit covers both. Also fix the vacuous guard: insert one message row in fuzzOpenMigratedMemory (Server/db/sanitize_fuzz_test.go:23-34) so the FTS cursor actually opens — without that, the seed corpus will keep passing on the next regression.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0097",
|
||
"title": "Admin \"Restore backup\" writes to a hardcoded data/chatserver.db, so a restore silently no-ops on any server with a configured database.path",
|
||
"file": "Server/admin/handlers_backup.go",
|
||
"line": 177,
|
||
"severity": "high",
|
||
"why": "The restore handler hardcodes the destination as `filepath.Join(\"data\", \"chatserver.db\")` while the live database is opened from `cfg.Database.Path` (main.go:129, default `data/chatserver.db` but a documented, editable config key). The pre-restore safety copy and the WAL checkpoint operate on the *real* DB, then `copyFile(target, dbPath)` writes the backup over an unrelated (possibly newly created) file. The handler then returns 200 \"database restored — server restarting\" and respawns. After the restart the server reopens `cfg.Database.Path`, which was never touched — an irreversible-looking operation is reported as success while doing nothing.",
|
||
"repro": "Set `database.path: data/oc.db` in config.yaml (or any non-default path) and start the server. Create a backup from the admin panel, make further changes (post messages, delete a channel), then click Restore on that backup. Response is 200 \"database restored\", the server restarts, and every post-backup change is still present — the backup was copied to `data/chatserver.db`, a file the server never opens. The operator, believing the rollback happened, may delete the backup.",
|
||
"evidence": "dbPath := filepath.Join(\"data\", \"chatserver.db\")\n...\nif err := copyFile(target, dbPath); err != nil { ... }\n...\nwriteJSON(w, http.StatusOK, map[string]string{\"message\": \"database restored — server restarting\", \"backup\": name})",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/admin/handlers_backup_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "19b3df2a"
|
||
},
|
||
"suggestedFix": "Give db.DB the path it was opened with (store `path` in openFile/openMemory, add `func (d *DB) Path() string`) and use `database.Path()` in handleRestoreBackup instead of the hardcoded join — one accessor fixes both the copy and the rollback, and the existing tests keep passing since the temp DB is opened at data/chatserver.db.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0098",
|
||
"title": "A joining key holder sends its room-key offers before its own announce, so every existing participant drops them as \"unknown peer\"",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 230,
|
||
"severity": "high",
|
||
"why": "setupKeyExchange drains the queued announces (which, for a key holder, emit a voice_e2ee_offer per peer) at lines 223-228, and only afterwards sends the holder's own voice_e2ee_announce at line 232. The receiver needs the sender's ephemeral ECDH public key to unwrap, so handleOfferInner's `_peerPublicKeys.get(fromUserId)` guard (line 737-741) discards every one of those offers. Nothing re-requests them: handleAnnounce answers an announce with an offer, never a counter-announce, and mid-call peers never re-announce on their own.",
|
||
"repro": "Server has users 1 (owner) and 50. User 50 joins voice channel #7 alone -> becomes key holder, roomKey R50. User 1 then joins the same channel. Server's computeIsKeyHolder (Server/ws/voice_join.go:311) returns true for uid 1 and voice_join relays user 50's stored announce to user 1 (voice_join.go:353), which queues in `_pendingAnnounces` because `_ecdhKeyPair` is still null. User 1's setupKeyExchange: `_isKeyHolder=true`, generates R1, keyProvider.setKey(R1), then the drain at line 224 calls handleAnnounce(50) -> wrap-and-offer branch (line 677-701) sends `voice_e2ee_offer{target_user_id:50}`; the announce for user 1 goes out only at line 232, i.e. AFTER that offer, on the same WS connection and therefore behind it in user 50's inbound FIFO (both relays land in the same `c.send` queue, see Server/ws/voice_e2ee.go:239 and :270). User 50 processes the offer first: `_peerPublicKeys.get(1)` is undefined -> logs \"E2EE: received offer from unknown peer\" and returns. User 50 then processes user 1's announce, stores the key, and — still `_isKeyHolder` — offers R50 back, which the server rejects with NOT_KEY_HOLDER (voice_e2ee.go:198, the map already names uid 1). Result: user 1 encrypts with R1, user 50 with R50, neither can decrypt the other, and the only repair is user 1's KEY_ROTATION_INTERVAL_MS timer 5 minutes later. Reproducible on every join where the joiner has the lowest user id in an ongoing call. The existing unit test (tests/unit/livekit-e2ee.test.ts:128) asserts only that an offer is sent during the drain, not its order relative to the announce, so moving the holder's announce above line 223 does not break it.",
|
||
"evidence": " this._ecdhKeyPair = ecdhKeyPair;\n const queued = this._pendingAnnounces.splice(0);\n for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {\n await this.handleAnnounce(qId, qKey, qSig); // <- key holder sends voice_e2ee_offer here\n log.info(\"E2EE: drained queued announce\", { userId: qId });\n }\n\n if (this._isKeyHolder) {\n // Announce our (signed) key so existing participants can see us.\n this.deps.getWs()?.send({ type: \"voice_e2ee_announce\", payload: announcePayload });\n\n// ...and the receiving side, line 737:\n const peerKey = this._peerPublicKeys.get(fromUserId);\n if (!peerKey) {\n log.warn(\"E2EE: received offer from unknown peer\", { fromUserId });\n return;\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9e375c93"
|
||
},
|
||
"suggestedFix": "Hoist the key holder's announce above the drain: send `{type:\"voice_e2ee_announce\", payload: announcePayload}` immediately after `this._ecdhKeyPair = ecdhKeyPair;` (:217) when `this._isKeyHolder`, and delete the send at :232. Do NOT hoist the non-holder send at :244 — it must stay after the _roomKeyResolver is installed at :237-240, or an immediate offer resolves nothing and setupKeyExchange stalls to timeout.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0099",
|
||
"title": "Registration HTML-escapes the username but login does not, so any account whose name contains ' \" & is permanently unloggable",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 180,
|
||
"severity": "high",
|
||
"why": "handleRegister runs the username through the bare `sanitizer.Sanitize` (bluemonday StrictPolicy), whose output is always HTML-escaped, and stores the escaped form. handleLogin at line 295 does the opposite — it only trims — and GetUserByUsername matches the column exactly (`username = ? COLLATE NOCASE`). The two paths therefore canonicalize the same typed name differently. The codebase already solved exactly this for every other user-facing text field: `sanitizeToFixpoint` (service/message.go:167) exists because \"a plain sanitizer.Sanitize call would persist and display literal '/>/& entities\" (service/user.go:107-110), and display_name/about/custom_status all go through it. Username is the one field left on the raw call.",
|
||
"repro": "POST /api/v1/auth/register {username:\"O'Brien\", password:…, invite_code:…}. auth.ValidateUsername accepts it (only control/Cf runes are rejected; length 2-32). Line 180 stores `O'Brien` — verified against the vendored bluemonday: StrictPolicy().Sanitize(\"O'Brien\") == \"O'Brien\", Sanitize(\"Bob & Alice\") == \"Bob & Alice\", Sanitize(`Say \"hi\"`) == \"Say "hi"\". Now POST /api/v1/auth/login {username:\"O'Brien\", password:…}: GetUserByUsername(\"O'Brien\") returns (nil,nil), storedHash stays \"\", CheckPassword fails, 401 \"invalid credentials\" — forever. After 9 attempts the per-IP and per-username lockouts (auth_handler.go:389-395) lock the account for 15 minutes. Secondary symptom on the same line: escaping runs BEFORE auth.ValidateUsername (line 192), so a legal 30-rune name like \"Sean O'Brien & Mary O'Sullivan\" expands to 42 runes and is rejected with \"username must be at most 32 characters\". Third symptom: the stored name renders as the literal entity everywhere (client renders usernames via textContent) and cannot be @mentioned by typing the real name.",
|
||
"evidence": "api/auth_handler.go:180 req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username))\napi/auth_handler.go:295 req.Username = strings.TrimSpace(req.Username) // login — no Sanitize\ndb/queries/sqlite/users.sql:5 FROM users WHERE username = ? COLLATE NOCASE;\nservice/message.go:167 return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) // the fixpoint helper username never uses",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/auth_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "47eeb930"
|
||
},
|
||
"suggestedFix": "Stop HTML-escaping the username. Export the existing fixpoint helper (service/message.go:167 sanitizePass/sanitizeToFixpoint) as e.g. service.SanitizeText and replace the bare call at auth_handler.go:180 with `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` — one shared helper used by both this call site and profile_handler.go:180. That keeps tag-stripping, drops the entity encoding, and keeps the length check operating on the real rune count.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0100",
|
||
"title": "Profile rename applies the same bare Sanitize, so renaming to a name with an apostrophe locks the user out of their own account",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 180,
|
||
"severity": "high",
|
||
"why": "handleUpdateProfile escapes the new username with the same bare `sanitizer.Sanitize` and hands the escaped string straight to svc.Users.UpdateProfile, which writes it verbatim (service/user.go: `s.st.UpdateUserProfile(ctx, userID, patch.Username, …)` — username is the one ProfilePatch field that never goes through cleanText/sanitizeToFixpoint, unlike display_name and about right beside it). The login path never applies the same transform, so the rename silently changes the credential the account is reachable by. This is a separate call site from the register one: fixing either leaves the other broken.",
|
||
"repro": "Logged in as \"alice\", PATCH /api/v1/users/me {\"username\":\"O'Brien\"}. Line 180 turns it into `O'Brien`; UpdateProfile commits that to users.username and returns 200 with the escaped name. The session keeps working (it is token-based), so the damage is invisible until the token expires or the user signs out. On the next login with \"O'Brien\" the row is not found and the user is locked out permanently. Same line also inflates length before auth.ValidateUsername at line 187, so renaming to a 30-rune name containing three apostrophes 400s with \"username must be at most 32 characters\". Every subsequent profile save re-sends the stored escaped name (MainPage.ts:433 fills patch.username from authStore) — verified idempotent, so it does not compound, but the escaped name is now permanent.",
|
||
"evidence": "api/profile_handler.go:180 req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username))\napi/profile_handler.go:187 if err := auth.ValidateUsername(req.Username); err != nil { // runs on the escaped string\nservice/user.go (UpdateProfile) s.st.UpdateUserProfile(ctx, userID, patch.Username, avatar, displayName, about) // username not cleanText'd\nservice/user.go:107-110 // \"a plain sanitizer.Sanitize call would persist and display literal '/>/& entities\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/profile_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a5c99209"
|
||
},
|
||
"suggestedFix": "Same shared helper: replace the bare Sanitize at profile_handler.go:180 with the exported fixpoint sanitizer used at auth_handler.go:180, so both write-paths canonicalize identically to what login reads. (Existing rows already escaped need a one-off unescape migration, but the code fix is the single helper.)",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0101",
|
||
"title": "Channel-visibility watermark is bumped after the fan-out loop, so a client reconnecting during the loop replays past the change and never converges",
|
||
"file": "Server/ws/hub_broadcast.go",
|
||
"line": 365,
|
||
"severity": "medium",
|
||
"why": "RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via `defer h.bumpVisibilityWatermark()` at hub_broadcast.go:497.",
|
||
"repro": "Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.",
|
||
"evidence": "hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: \"Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path\" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "ea0430c5",
|
||
"test": "TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady",
|
||
"revertProof": "covered-by-OC-0206"
|
||
},
|
||
"suggestedFix": "Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the `defer` on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.",
|
||
"fixed": "2026-08-19",
|
||
"note": "already fixed on main by the OC-0206 early watermark bump in both fan-out paths (PR #1375); verified against HEAD 2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0102",
|
||
"title": "Avatar upload rewrites the username from a pre-lock snapshot, silently reverting a concurrent rename",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 575,
|
||
"severity": "medium",
|
||
"why": "UserService.UpdateProfile serializes its read-merge-write under a per-user keyedMutex precisely because \"PATCH /users/me can race POST /users/me/avatar ... silently reverting whatever the winner just changed\". That lock only protects the fields merged from the row read *inside* it (avatar, display_name, about). `Username` is not merged — it is taken verbatim from the caller, and handleUploadAvatar supplies `user.Username`, a snapshot AuthMiddleware read at the *start* of the request, before the lock and before the whole multipart parse / image decode / disk write. So the exact lost update the lock was added to fix still happens, for the username column.",
|
||
"repro": "User \"alice\" is logged in on two clients. Client B starts POST /api/v1/users/me/avatar with a ~1 MiB PNG (AuthMiddleware snapshots user.Username = \"alice\"). While the upload is still parsing/decoding/writing, client A sends PATCH /api/v1/users/me {\"username\":\"bob\"} — it takes the lock, commits, and broadcasts user_update{username:\"bob\"}. Client B's handler then reaches UpdateProfile, takes the now-free lock, and writes patch.Username = \"alice\", reverting the rename. broadcastUserUpdate then pushes username \"alice\" to every connected client, so the rename is undone in the DB and on every peer with no error reported to either client. Same mechanism reverts db.DeleteAccount's \"[deleted-<id>]\" anonymisation if a self-delete lands during an in-flight avatar upload.",
|
||
"evidence": "api/profile_handler.go:573-577\n avatarURL := service.AvatarFileURL(fileID)\n updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{\n Username: user.Username, // <- snapshot taken by AuthMiddleware, outside profileLocks\n Avatar: &avatarURL,\n })\n\nservice/user.go:156-171 (UpdateProfile)\n unlock := s.profileLocks.lock(userID)\n defer unlock()\n current, err := s.st.GetUserByID(ctx, userID) // avatar/display_name/about merged from HERE\n ...\n s.st.UpdateUserProfile(ctx, userID, patch.Username, avatar, displayName, about) // username NOT merged",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/service/profile_fields_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a5c99209"
|
||
},
|
||
"suggestedFix": "Make the username a merge like the other fields, in the shared function rather than at each caller: in UpdateProfile after `current` is read, `username := patch.Username; if username == \"\" { username = current.Username }`, pass `username` to UpdateUserProfile and to the audit/log lines, and drop `Username: user.Username` from the avatar handler's ProfilePatch. Empty is a safe sentinel because the only other caller (PATCH) already rejects an empty username at api/profile_handler.go:180-186.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0103",
|
||
"title": "WAF inline engine rejects every request body >= 1 MiB, breaking plugin install and large avatar uploads when waf_enabled is on",
|
||
"file": "Server/api/waf.go",
|
||
"line": 213,
|
||
"severity": "medium",
|
||
"why": "The inline Coraza engine sets `SecRequestBodyLimit 1048576` but never sets `SecRequestBodyLimitAction`, and coraza's default is Reject (internal/corazawaf/waf.go:320 `RequestBodyLimitAction: types.BodyLimitActionReject`), so ReadRequestBodyFrom returns a 413 interruption as soon as the buffer reaches the limit. Its body-inspection exclusion (rule 900003) covers only `/api/v1/uploads`, while `bodyCapExemptPrefixes` exempts three routes from the app's own 1 MiB cap — `/api/v1/uploads`, `/api/v1/admin/plugins/install` (16 MiB) and `/api/v1/users/me/avatar` (2 MiB). The CRS engine built immediately below sets `SecRequestBodyLimitAction ProcessPartial` with the comment \"never reject on size — request size enforcement belongs to the app middleware\"; the inline engine contradicts its own sibling.",
|
||
"repro": "Set server.waf_enabled: true. As an Administrator on an allowed CIDR, POST a 2 MiB plugin .zip to /api/v1/admin/plugins/install (documented cap 16 MiB): the request never reaches PluginAdminHandler.install — the middleware answers 413 {\"error\":\"request blocked by security rules\"}. Same for POST /api/v1/users/me/avatar with an image at or just under maxAvatarFileBytes (1 MiB), whose multipart envelope pushes the body past 1,048,576 bytes. /api/v1/uploads is unaffected because rule 900003 turns requestBodyAccess off for it.",
|
||
"evidence": "api/waf.go:209-244 (inline engine — no SecRequestBodyLimitAction)\n SecRequestBodyLimit 1048576\n ...\n SecRule REQUEST_URI \"@beginsWith /api/v1/uploads\" \"id:900003,phase:1,pass,nolog,ctl:requestBodyAccess=Off\"\n\napi/waf.go:111-112 (CRS engine, right next to it)\n SecRequestBodyLimit 1048576\n SecRequestBodyLimitAction ProcessPartial\n\napi/constants.go:144-151\n var bodyCapExemptPrefixes = []string{\"/api/v1/uploads\", \"/api/v1/admin/plugins/install\", \"/api/v1/users/me/avatar\"}\n\ncoraza v3 internal/corazawaf/transaction.go ReadRequestBodyFrom:\n if tx.requestBodyBuffer.length == tx.RequestBodyLimit {\n if tx.WAF.RequestBodyLimitAction == types.BodyLimitActionReject {\n return setAndReturnBodyLimitInterruption(tx, 413)\n\napi/waf.go:364-367\n it, written, err := tx.ReadRequestBodyFrom(r.Body)\n if it != nil { handleWAFInterruption(w, it); return }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/waf_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "0a38a3fe"
|
||
},
|
||
"suggestedFix": "Extend the existing body-access exclusion instead of changing the limit action: make inline rule 900003 (and CRS rule 1001 for symmetry) cover the same prefixes as bodyCapExemptPrefixes, i.e. add `SecRule REQUEST_URI \"@beginsWith /api/v1/admin/plugins/install\" \"id:900004,phase:1,pass,nolog,ctl:requestBodyAccess=Off\"` and the same for /api/v1/users/me/avatar. Do NOT 'fix' this by copying SecRequestBodyLimitAction ProcessPartial onto the inline engine: with written > 0 the middleware replaces r.Body with the truncated 1 MiB buffer (api/waf.go:394-400), which would silently hand the handler a corrupt zip/image instead of a 413.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0104",
|
||
"title": "Hot plugin re-install leaves plugins.enabled = 1 while the runtime instance is deactivated, so the plugin silently stops working and the admin panel still shows it enabled",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 379,
|
||
"severity": "medium",
|
||
"why": "InstallPlugin's upsert deliberately does not touch the `enabled` column, and installFromDisk tears down the old *Instance and registers a fresh one with Enabled:false without re-activating it. At startup that is fine because LoadAll follows installFromDisk with activateAll, which re-reads the store rows and re-activates every enabled one. InstallFromZip (the runtime upgrade path) calls installFromDisk and returns — nothing re-activates. The store row and the runtime therefore disagree: DB says enabled, the module is unloaded and its command bindings were dropped.",
|
||
"repro": "1. Install plugin \"foo\" and enable it (POST /admin/plugins/{id}/enable): plugins.enabled = 1, module compiled, its slash commands registered in r.commands.\n2. Upload a new foo.zip to POST /admin/plugins/install. InstallFromZip renames the new dir in and calls installFromDisk, which calls platformDeactivate(old), deletes old's command bindings, and inserts a new Instance with Enabled:false. The upsert leaves plugins.enabled = 1.\n3. Result: foo's slash commands 404/no-op and it receives no events, but GET /admin/plugins still returns enabled:true, so the operator has no reason to click Enable. The plugin only comes back on the next server restart (LoadAll -> activateAll).",
|
||
"evidence": "plugin/registry.go:378-385 (InstallFromZip, Stage 4)\n\tif err := r.installFromDisk(ctx, foundPlugin{...}); err != nil { ... }\n\treturn manifest.Name, nil // no activateAll / activate\n\nplugin/registry.go:199-217 (installFromDisk)\n\tif old := r.byName[found.Manifest.Name]; old != nil {\n\t\tr.platformDeactivate(ctx, old)\n\t\tfor cmd, owner := range r.commands { if owner == old { delete(r.commands, cmd) } }\n\t...\n\tinst := &Instance{ ID: id, ..., Enabled: false }\n\ndb/plugin_queries.go:20-23\n\tINSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?)\n\tON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json\n\t-- enabled is NOT reset, so the row stays 1\n\napi/plugins_handler.go:118 (list)\n\trows, err := h.store.ListPlugins(ctx) // reports enabled=1",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/plugin/registry_test.go",
|
||
"revertProof": "pass",
|
||
"followUp": "cac4d13e",
|
||
"branchCommit": "9075f248",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Re-activate at the one place that skips it, reusing the existing lifecycle call instead of duplicating activation logic. In InstallFromZip after the installFromDisk block (Server/plugin/registry.go:385): `if row, err := r.cfg.Store.GetPluginByName(ctx, manifest.Name); err == nil && row != nil && row.Enabled { if err := r.EnablePlugin(ctx, row.ID); err != nil { slog.Warn(\"plugin: reactivate after upgrade failed\", \"name\", manifest.Name, \"err\", err) } }`. EnablePlugin already sets inst.Enabled, activates, and rolls the DB flag back on failure, so the store row and the runtime can no longer disagree. Do not put this inside installFromDisk — LoadAll would then activate twice.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0105",
|
||
"title": "secret_store::delete reports success while the fallback credential survives on disk and resurrects on next launch",
|
||
"file": "Client/src-tauri/src/secret_store.rs",
|
||
"line": 407,
|
||
"severity": "medium",
|
||
"why": "`clear_fallback` removes the entry from the tauri-plugin-store's in-memory map and then flushes with `store.save()`. A `save()` failure is only `log::warn!`-ed, never propagated, so `delete()` (line 215–219) still returns the keyring result — `Ok(())`. The on-disk fallback file still holds the sealed secret, so the \"deleted\" login token/password or voice-E2EE identity key comes back the next time the app starts. This is exactly the failure `delete`'s own doc comment claims to prevent (\"a delete that left the fallback copy behind would resurrect a 'deleted' secret on the next read\").",
|
||
"repro": "On a machine where the fallback engaged (Linux with no Secret Service, or after a keyring write failed its read-back), a credential is sealed into the fallback store file. Make that file (or its directory) read-only, or fill the disk. Call `delete_credential` (\"Forget this server\" / logout): keyring delete succeeds, `store.save()` fails, only a warn is logged, and the command resolves Ok so the UI reports the credential removed. Restart the client — `get_fallback` returns the still-present sealed blob and auto-login uses the supposedly deleted credential.",
|
||
"evidence": "fn clear_fallback(app: &AppHandle, account: &str) {\n ...\n if store.delete(account) {\n if let Err(e) = store.save() {\n log::warn!(\"failed to flush credential fallback removal for '{account}': {e}\");\n }\n }\n}\n\npub fn delete(app: &AppHandle, account: &str) -> Result<(), String> {\n let keyring_result = keyring_delete(account);\n clear_fallback(app, account);\n keyring_result\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/src-tauri/src/secret_store.rs",
|
||
"revertProof": "pass (manual hunk-level: behavioral line reverted, propagation test red, green at HEAD)",
|
||
"branchCommit": "66862cca"
|
||
},
|
||
"suggestedFix": "Make clear_fallback return Result<(), String> (propagate the save() error) and have delete() return `keyring_result.and(clear_fallback(app, account))`; keep set_with's fallback_clear closure best-effort with `let _ = ...`, since there the keyring copy is authoritative.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0106",
|
||
"title": "Every DM message re-emits dm_channel_open and bumps the global visibility watermark, so ordinary DM traffic forces every other client's next reconnect onto the full-resync tier",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 205,
|
||
"severity": "medium",
|
||
"why": "`OpenDM` is `INSERT OR IGNORE ... :exec` (dm.sql:2) and returns nil whether or not a row was actually inserted; `dm_queries.go:427-435` discards the result too. SendMessage therefore appends *every* recipient to `OpenedDMFor` on *every* DM send, not just on a genuine (re)open. handlers_chat.go:75 then emits a `DMChannelOpenEvent` per recipient, and emit.go:41-47 calls `h.bumpVisibilityWatermark()` for each one, ratcheting `visibilityChangeSeq` up to the current global `h.seq`. `mustFullResync(lastSeq)` is `w > 0 && lastSeq <= w` (hub_events.go:86), so after any DM message every client whose `lastSeq` sits at or below that seq is denied both replay tiers on its next reconnect. This is distinct from the known hub_broadcast.go:365 ordering defect: here the watermark is a *global* gate being tripped by a strictly per-user event that only the addressee could ever have missed.",
|
||
"repro": "Alice and Bob have an already-open DM and exchange messages continuously. Each Alice→Bob send re-appends Bob to OpenedDMFor, emits a redundant dm_channel_open, and pushes visibilityChangeSeq to the current h.seq. Carol, unrelated, is idle in #general with lastSeq equal to the newest seq she has received. Her wifi blips; she reconnects with that last_seq. handleReconnect hits `mustFullResync` first (serve.go:125), logs \"replay skipped (visibility changed since last_seq)\", increments reconnectTierFull, and returns false — the ring buffer and the cold-tier EventStore are never consulted even though both fully cover her gap. handleFreshConnect sends a full `ready`; the dispatcher's second-ready branch (dispatcher.ts:301-327) then calls `invalidateLoadedMessageWindows()`, dropping every loaded channel's message window and refetching only the active channel. With steady DM traffic on the server the seq-resume design is effectively unreachable for every user who is not currently posting.",
|
||
"evidence": "Server/db/queries/sqlite/dm.sql:2 INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?);\nServer/service/message_crud.go:201-206\n if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { ...continue }\n result.OpenedDMFor = append(result.OpenedDMFor, pid) // appended even when the row already existed\nServer/ws/handlers_chat.go:75 if len(result.OpenedDMFor) > 0 { ... events = append(events, DMChannelOpenEvent{...}) }\nServer/ws/emit.go:41-47 if _, isOpen := ev.(DMChannelOpenEvent); isOpen { h.bumpVisibilityWatermark() }\nServer/ws/hub_events.go:84-87 func (h *Hub) mustFullResync(lastSeq uint64) bool { w := h.visibilityChangeSeq.Load(); return w > 0 && lastSeq <= w }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "081bb169",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Make the insert report whether it actually inserted, and gate the event on it. Change dm.sql OpenDM to `:execrows`, regen via the db-change skill, have db.OpenDM return (bool, error) (only production caller is message_crud.go:201; the two db tests just check err), and append to result.OpenedDMFor only when rows == 1. One guard in the shared path; handlers_chat.go and emit.go stay untouched.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0107",
|
||
"title": "Session termination before MainPage mounts never tears down the WS client, so a banned/revoked token reconnects forever",
|
||
"file": "Client/src/main.ts",
|
||
"line": 729,
|
||
"severity": "medium",
|
||
"why": "The only place that calls `ws.disconnect()`, `deleteCredential(host)` and `router.navigate(\"connect\")` on logout is the `authStore` subscriber, and its entire body is gated on `router.getCurrentPage() === \"main\"`. MainPage is not mounted until the connected overlay's `onReady` fires, so every session that dies during login / auto-login / the connected-overlay window skips the teardown: `intentionalClose` stays false in `ws.ts`, and `rustState === \"closed\"` therefore calls `scheduleReconnect()` (bounded delay 30s, no attempt cap) with the same dead token, forever. The server-side ban rejection makes this the default path: `authenticateConn` answers a banned user with `buildErrorMsg(ErrCodeBanned, ...)` — a generic `error` frame — not `auth_error`, and `ws.ts` only sets `intentionalClose = true` for `auth_error`.",
|
||
"repro": "Ban a user (admin panel), then start their client with auto-login enabled for that server (or have them log in and let the ban land while the 'Connected' overlay is still showing). Flow: `wirePostAuth` -> `ws.connect` -> server `authenticateConn` sees `IsEffectivelyBanned` -> writes `{type:\"error\",payload:{code:\"BANNED\"}}` -> closes with StatusPolicyViolation. Client: dispatcher's `S.ERROR` handler calls `clearAuth()`. In the auto-login case `isAuthenticated` was never true, so `subscribeSelector` (false->false) never fires at all; in the overlay case it fires but `router.getCurrentPage()` is still \"connect\", so the `if` body is skipped. Either way `ws.disconnect()` is never called, `intentionalClose` stays false, and the `closed` proxy event calls `scheduleReconnect()`. Result: the client re-sends the banned token every <=30s indefinitely, the UI stays stuck on 'Auto-connecting...', and the stale credential is never deleted (`deleteCredential` lives inside the same gated block). The only escape is the user clicking Cancel (main.ts:585) or killing the app.",
|
||
"evidence": "main.ts:726-746\n authStore.subscribeSelector(\n (s) => s.isAuthenticated,\n (isAuthenticated) => {\n if (!isAuthenticated && router.getCurrentPage() === \"main\") { // <-- gate\n ...\n ws.disconnect();\n ...\n if (host && authStore.getState().logoutReason !== \"server_shutdown\") {\n void deleteCredential(host);\n\nlib/ws.ts:296-303 (only auth_error sets intentionalClose)\n if (msg.type === \"auth_error\") { intentionalClose = true; ... }\nlib/ws.ts:439-441\n if (!intentionalClose) { scheduleReconnect(); }\nlib/ws.ts:209-210\n function scheduleReconnect(): void { if (intentionalClose || certMismatchBlock || !config) return; // no attempt cap\n\nServer/ws/serve_auth.go:85-87\n if auth.IsEffectivelyBanned(user) {\n _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, \"you are banned\"))\n\nlib/dispatcher.ts:930-934 (BANNED handler calls clearAuth() only)\n if (payload.code === \"BANNED\") { setTransientError(...); clearAuth(); return; }\n\nlib/store.ts subscribeSelector fires only when the slice changes, so false->false never notifies.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "38fd7475"
|
||
},
|
||
"suggestedFix": "In dispatcher.ts's S.ERROR BANNED branch (line ~930), call `ws.disconnect()` before `clearAuth()` — `ws` is already in wireDispatcher's scope and disconnect() sets intentionalClose/cancels the reconnect timer, so it is idempotent with the main-page subscriber's own ws.disconnect(). One guard in the shared handler covers every router state instead of adding a third hand-copied teardown block.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0108",
|
||
"title": "A username/nickname/avatar change never repaints the open message list — updateMemberProfile is the one members.store mutator that does not bump roleRevision, and MessageList subscribes to nothing else",
|
||
"file": "Client/src/stores/members.store.ts",
|
||
"line": 152,
|
||
"severity": "medium",
|
||
"why": "`updateMemberProfile` returns `{ ...prev, members: next }` while every sibling mutator (`setMembers`:78, `addMember`:102, `removeMember`:111, `updateMemberRole`:122) returns `roleRevision: (prev.roleRevision ?? 0) + 1`. `MessageList` holds exactly one members subscription — `membersStore.subscribeSelector((s) => s.roleRevision ?? 0, () => renderAll())` at MessageList.ts:897-903 — so the profile patch mutates the store without ever waking the only renderer that reads it. That renderer does read it live: `renderers.ts:200` calls `resolveAuthor(msg.user)`, and `formatting.ts:143` resolves the name/avatar out of `membersStore.getState().members`, with a doc comment (formatting.ts:131-135) stating the store is preferred precisely because \"a rename or an avatar change arrives as a user_update and patches every member\". The store is patched; the pixels are not.",
|
||
"repro": "1. User A and user B are both in #general; B has posted several messages that are on A's screen. 2. B renames themselves (or sets a nickname, or changes their avatar) — the server emits `user_update`. 3. `dispatcher.ts:700` calls `updateMemberProfile`, which patches `membersStore.members` but leaves `roleRevision` unchanged. 4. `MessageList`'s only members subscription selects `roleRevision`, so `shallowEqual(prev, next)` on the unchanged counter is true and `renderAll()` never fires. A's message list keeps showing B's OLD name and OLD avatar. 5. It does not self-heal on the next message either: `messagesStore.subscribeSelector` at MessageList.ts:859 runs `if (!tryAppendMessages()) renderAll();` — the append path succeeds for a new tail message, so only the new row is built and the already-rendered rows keep the stale identity. The stale rows persist until something forces a full `renderAll()`: a channel switch, a history page load, or an unrelated member join/leave/role change. Meanwhile `MemberList` (which subscribes to the whole store, MemberList.ts:463-467) shows the new name — so the sidebar and the message list disagree about who B is. No test locks the current behavior: `roleRevision` appears nowhere under tests/.",
|
||
"evidence": "members.store.ts:137-154 (updateMemberProfile)\n return { ...prev, members: next }; // <-- no roleRevision bump\nvs. members.store.ts:122 (updateMemberRole)\n return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };\n\nMessageList.ts:896-903 — the ONLY membersStore subscription in the component:\n membersStore.subscribeSelector(\n (s) => s.roleRevision ?? 0,\n () => { renderAll(); },\n )\n\nformatting.ts:143 — read live per rendered row:\n const member = membersStore.getState().members.get(user.id);\n if (member !== undefined) return { username: member.username, displayName: member.displayName ?? null, avatar: member.avatar };\n\ndispatcher.ts:698-705 — the event that lands here:\n ws.on(S.USER_UPDATE, (payload) => { updateMemberProfile(payload.user_id, { username, avatar, displayName, identityPublicKey }); })",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/members.store.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "f8248684"
|
||
},
|
||
"suggestedFix": "One line in the shared mutator: make updateMemberProfile return `{ ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 }` (members.store.ts:152), and update the MembersState doc comment at line 30-34 (and MessageList.ts:894) to say the counter covers membership, role, and profile changes — still excluding presence/typing, which is the exclusion the counter exists for.",
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0109",
|
||
"title": "GIF proxy's log redaction misses the percent-encoded API key, so an upstream failure writes the Klipy credential to stdout and the admin log stream",
|
||
"file": "Server/api/gif_handler.go",
|
||
"line": 176,
|
||
"severity": "medium",
|
||
"why": "The upstream URL is built with `params.Encode()`, which percent-encodes every character outside [A-Za-z0-9-_.~]. `url.Error` embeds that encoded URL in `err.Error()`. `redactKey` does a literal `strings.ReplaceAll(s, apiKey, \"[REDACTED]\")` against the *decoded* key, so it never matches the encoded form and the credential survives into the log line. The sibling in the same package got this right: livekit_proxy.go redacts the raw query blob first \"so an encoded form is caught too\", then the decoded token.",
|
||
"repro": "Set gif.api_key to any key containing a character Encode() escapes (base64-style keys routinely contain '+', '/' or '='), e.g. \"ab+cd/ef=\". Make api.klipy.com unreachable (block DNS or the outbound dial). Call GET /api/v1/gif/trending. gifClient.Do fails with a *url.Error whose message contains `...?key=ab%2Bcd%2Fef%3D&limit=20...`; redactKey compares against the literal \"ab+cd/ef=\" and matches nothing, so the full encoded key lands in stdout and in admin.RingBuffer, from which any admin can read it live over the SSE /admin/api/logs/stream endpoint.",
|
||
"evidence": "gif_handler.go:150 results, err := fetchGIFs(r, gifAPIBase+upstreamPath+\"?\"+params.Encode(), apiKey, limit)\ngif_handler.go:117 \"key\": {apiKey},\ngif_handler.go:176 slog.Warn(\"gif proxy: upstream request failed\", \"error\", redactKey(err.Error(), apiKey))\ngif_handler.go:215 return strings.ReplaceAll(s, apiKey, \"[REDACTED]\")\nlivekit_proxy.go:191-192 safeErr := redactKey(err.Error(), backendURL.RawQuery) // raw query FIRST\n safeErr = redactKey(safeErr, backendURL.Query().Get(\"access_token\"))",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-plugin",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/gif_handler_internal_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "eba1ba02"
|
||
},
|
||
"suggestedFix": "Fix it once in the shared helper rather than at each call site — gif_handler.go:211: `func redactKey(s, apiKey string) string { if apiKey == \"\" { return s }; s = strings.ReplaceAll(s, apiKey, \"[REDACTED]\"); return strings.ReplaceAll(s, url.QueryEscape(apiKey), \"[REDACTED]\") }`. `net/url` is already imported in this file, the second pass is a no-op when the key needs no escaping, and both the three GIF call sites and the livekit proxy inherit the fix.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0110",
|
||
"title": "Every error attribute is blanked to `{}` in the admin live log stream — `err` never reaches the log viewer",
|
||
"file": "Server/admin/logstream.go",
|
||
"line": 298,
|
||
"severity": "medium",
|
||
"why": "`ringHandler.Handle` stores `a.Value.Any()` into a `map[string]any` and then `json.Marshal`s it. Go error values (`*errors.errorString`, `*fmt.wrapError`) have only unexported fields, so they marshal to `{}`. slog's own JSONHandler special-cases `error` and emits `err.Error()`; this handler does not, so the stdout sink and the admin ring buffer disagree on the single most important field of every error line.",
|
||
"repro": "Trigger any `slog.Error(msg, \"err\", err)` — e.g. logstream.go:95 `slog.Error(\"failed to issue log stream ticket\", \"err\", err)`, or the hundreds of `\"err\", err` / `\"error\", err` call sites across ws/, db/, service/. stdout shows `err=\"generating ticket: ...\"`. The admin panel's Logs section (and the SSE /admin/api/logs/stream feed) shows `ERROR [admin] failed to issue log stream ticket {\"err\":{}}`. Searching the log viewer for any substring of the error message never matches, because attrs contains only `{}`.",
|
||
"evidence": "logstream.go:293-307\n\tr.Attrs(func(a slog.Attr) bool {\n\t\tkey := a.Key\n\t\t...\n\t\tattrs[key] = a.Value.Any()\n\t\treturn true\n\t})\n\tvar attrsJSON string\n\tif len(attrs) > 0 {\n\t\tif b, err := json.Marshal(attrs); err == nil {\n\t\t\tattrsJSON = string(b)\n\t\t}\n\t}\n\nVerified: json.Marshal(map[string]any{\"err\": fmt.Errorf(\"wrap: %w\", errors.New(\"boom\"))}) -> {\"err\":{}} (marshal error nil, so the empty object is silently kept).\n\nConsumer: Server/admin/static/index.html:1540 renders `entry.attrs` inline, and :1530 log search matches against `entry.attrs`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/admin/multihandler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a555d9ee",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "One shared conversion in ringHandler.Handle, applied to both the rh.attrs loop (line 290) and the r.Attrs callback (line 298), instead of a.Value.Any(): `func logAttrValue(v slog.Value) any { v = v.Resolve(); if e, ok := v.Any().(error); ok { if _, jm := e.(json.Marshaler); !jm { return e.Error() } }; return v.Any() }`. The Resolve() half matters too: Record.Attrs does not resolve slog.LogValuer, so the ring path currently also bypasses the redaction in Server/db/logvalue.go and Server/config/logvalue.go, storing the raw User/Session/Config value instead of the redacted one. This mirrors what slog's own JSONHandler does for error values.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0111",
|
||
"title": "Auto-idle's return-to-online presence_update is always swallowed by the 1-per-10s presence limiter, so every user shows as Idle to everyone else after returning to the keyboard",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 200,
|
||
"severity": "medium",
|
||
"why": "applyPresence() writes the local presence store unconditionally but only sends the wire frame if limiters.presence.tryConsume() succeeds, and the presence limiter is 1 token per 10 000 ms (rate-limiter.ts:154). autoIdle fires apply(true)->onStatusChange(\"idle\") and then, on the very first mouse event (unthrottled by design, autoIdle.ts:119-124), apply(false)->onStatusChange(\"online\") milliseconds later. The second call always loses the token, so the local store/localStorage say \"online\" while the server and every other client keep the user at \"idle\", with no retry and no user feedback.",
|
||
"repro": "1. Sit idle 10 min. autoIdle.apply(true) -> MainPage.applyPresence(\"idle\") -> updatePresence local + ws.send consumes the single presence token at t=0. 2. Move the mouse at t=+50 ms. autoIdle.onActivity sees idleByTimer===true, calls apply(false) -> MainPage.applyPresence(\"online\"). 3. updatePresence(userId,\"online\") runs; limiters.presence.tryConsume() returns false (sliding window still holds the t=0 stamp), so ws.send({type:\"presence_update\",payload:{status:\"online\"}}) never runs. 4. Own client renders Online; every other member list and the server DB still hold \"idle\". Nothing retries — restoreSavedPresence only runs on a connectionStatus transition to \"connected\".",
|
||
"evidence": "MainPage.ts:195-203\n function applyPresence(status: UserStatus): void {\n const userId = getCurrentUserId();\n if (userId !== 0) { updatePresence(userId, status); }\n if (limiters.presence.tryConsume()) {\n ws.send({ type: \"presence_update\", payload: { status } });\n }\n }\nrate-limiter.ts:153-155 export function createPresenceLimiter(): RateLimiter { return createRateLimiter(1, 10_000); }\nautoIdle.ts:119-124 if (idleByTimer) { apply(false); ... }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "f5d9645b"
|
||
},
|
||
"suggestedFix": "In applyPresence (the shared function all three call sites route through), stop dropping the frame silently — on a failed tryConsume, schedule one retry: keep a module-level `let presenceRetry: ReturnType<typeof setTimeout> | null`, and in the else branch clearTimeout(presenceRetry) then presenceRetry = setTimeout(() => applyPresence(loadUserStatus()), limiters.presence.getRemainingMs()); clear it in the page teardown next to autoIdle.destroy(). Coalescing on the latest status means a burst still costs one frame, and restoreSavedPresence should call applyPresence rather than duplicating the same unguarded shape.",
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0112",
|
||
"title": "handleServeFile's admin bypass covers the DM branch, so an ADMINISTRATOR can download attachments from private DMs they are not a participant in",
|
||
"file": "Server/api/upload_handler.go",
|
||
"line": 302,
|
||
"severity": "medium",
|
||
"why": "`if !isAdmin { ... }` wraps the entire access-control block, including the `aa.ChannelType == \"dm\"` participant check. Every sibling DM read gate in the codebase deliberately has NO admin bypass — service.requireChannelRead (message_query.go:25-30) returns ErrNotFound for a DM the caller is not in even for an Administrator, and PermissionService.RequireChannelAccess (permission.go:73-82) and MessageService.checkSendPermission (message_perms.go:70-80) do the same. The file route is the one path where the admin bit opens a private DM.",
|
||
"repro": "Alice and Bob exchange a 1:1 DM with an attachment; the attachment row gets channel_id = the DM channel, channel_type = \"dm\". An operator with the ADMINISTRATOR bit (not a DM participant) calls GET /api/v1/files/{id}: isAdmin is true at line 300, the whole `if !isAdmin` block at 302-359 is skipped, IsDMParticipant is never consulted, and the file is served 200. The same operator calling GET /api/v1/channels/{dmChannelID}/messages gets 404 \"access denied\" from requireChannelRead. The file id is discoverable without DB access: handleUpload logs it at line 229 (`slog.Info(\"file uploaded\", \"id\", fileID, \"filename\", safeFilename, ...)`), and that line is streamed verbatim to the admin panel's live log viewer. TestServeFile_AdminBypassesAllChecks (upload_handler_test.go:1214) only exercises an unlinked attachment, so the DM case is not test-locked.",
|
||
"evidence": "upload_handler.go:300-302,335-350\n isAdmin := role != nil && permissions.HasAdmin(role.Permissions)\n if !isAdmin {\n ...\n if aa.ChannelType == \"dm\" {\n ok, dmErr := database.IsDMParticipant(r.Context(), user.ID, *aa.ChannelID)\n if dmErr != nil || !ok { ...403... }\nvs Server/service/message_query.go:25-30 (no admin arm)\n if ch.Type == \"dm\" {\n ok, dmErr := s.st.IsDMParticipant(ctx, userID, channelID)\n if dmErr != nil || !ok { return fmt.Errorf(\"%w: access denied\", ErrNotFound) }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8579cb5d",
|
||
"test": "Server/api/upload_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "37227919"
|
||
},
|
||
"suggestedFix": "Move the DM arm out of the bypass so participation is required of everyone, matching requireChannelRead: hoist `if aa.ChannelID != nil && aa.ChannelType == \"dm\"` to just above `if !isAdmin` and 403 there when user == nil or IsDMParticipant is false, leaving the `if !isAdmin` block to cover only the unlinked/avatar and guild-channel arms.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0113",
|
||
"title": "Selecting \"Default\" microphone never changes the capture device — the mute/unmute cycle re-acquires nothing",
|
||
"file": "Client/src/lib/deviceManager.ts",
|
||
"line": 71,
|
||
"severity": "medium",
|
||
"why": "`cycleMicForDeviceSwitch` is the entire implementation of \"switch back to the default input device\", but it only calls `setMicrophoneEnabled(false)` then `(true)`. On an already-published mic that is livekit's `track.mute()` → `track.unmute()`, and with the default `stopMicTrackOnMute: false` neither call touches the device: `mute()` skips the `_mediaStreamTrack.stop()` branch, and `unmute()` only restarts when `stopOnMute || readyState === 'ended' || pendingDeviceChange`. Nothing ever clears `Room.options.audioCaptureDefaults.deviceId` or `LocalAudioTrack._constraints.deviceId`, which a previous `switchActiveDevice('audioinput', id)` left as `{exact: <old device>}`. So the non-empty branch (`room.switchActiveDevice`) really switches, and the empty/\"Default\" branch silently does not.",
|
||
"repro": "Join a voice channel. Open Settings → Voice & Audio and pick a specific microphone (e.g. a USB headset); `VoiceAudioTab.ts:404-407` saves the pref and calls `switchInputDevice(\"usb-mic-id\")`, which reaches `room.switchActiveDevice(\"audioinput\", \"usb-mic-id\")` and pins `audioCaptureDefaults.deviceId = {exact: \"usb-mic-id\"}`. Now select \"Default\" in the same dropdown: `inputSelect.value` is `\"\"`, so `switchInputDevice(\"\")` takes the else branch and only mutes/unmutes the existing publication. The UI and the `audioInputDevice` pref both read \"Default\", but the room keeps capturing from the USB headset for the rest of the session — unplug the headset and the mic dies rather than falling back. The same helper backs `handleDeviceChange`'s device-removed fallback (deviceManager.ts:130), so that \"switched to default\" path cannot reach the default device either. The existing test `tests/unit/device-manager.test.ts:231-237 (\"re-enables microphone for empty deviceId (default fallback)\")` asserts only the `setMicrophoneEnabled(false)`/`(true)` call pair against a mocked Room, so it locks the mechanism, not the outcome the name claims.",
|
||
"evidence": "deviceManager.ts:71-79\n private async cycleMicForDeviceSwitch(room: Room): Promise<void> {\n await room.localParticipant.setMicrophoneEnabled(false);\n if (this.room !== room) return;\n if (isMicPolicyGated()) { ...return; }\n await room.localParticipant.setMicrophoneEnabled(true);\n }\n\ndeviceManager.ts:167-171\n if (deviceId) {\n await room.switchActiveDevice(\"audioinput\", deviceId);\n } else {\n await this.cycleMicForDeviceSwitch(room); // <-- no device reset at all\n }\n\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:19174 stopMicTrackOnMute: false, (OwnCord never overrides it — `grep -rn stopMicTrackOnMute src/` is empty)\nesm.mjs:29446-29448 setTrackEnabled(..., enabled=true) -> `if (track) { yield track.unmute(); }` (no createTracks, no new constraints)\nesm.mjs:20966-20971 mute(): `if (source === Microphone && this.stopOnMute && !isUserProvided) { this._mediaStreamTrack.stop(); }` -> skipped\nesm.mjs:20990-20993 unmute(): `if (source === Microphone && (this.stopOnMute || readyState === 'ended' || this.pendingDeviceChange) ...) { yield this.restart(undefined, true); }` -> all three false\nesm.mjs:32852-32853 switchActiveDevice audioinput: `_this3.options.audioCaptureDefaults.deviceId = deviceConstraint;` (never reset by the cycle)\nlivekitSession.ts:1119-1122 on (re)connect the saved input is only applied when non-empty: `if (savedInput !== \"\") { await localRoom.switchActiveDevice(\"audioinput\", savedInput); }` — so \"\" never resets it there either.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "ca9d12b",
|
||
"test": "Client/tests/unit/device-manager.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "Fix it once in the shared helper rather than in both callers: make cycleMicForDeviceSwitch reset the pinned constraint before the cycle by adding `await room.switchActiveDevice(\"audioinput\", \"default\", false);` as its first statement (exact=false yields a plain 'default' string, i.e. an `ideal` constraint, matching livekit's own audioDefaults `{ideal:'default'}`, so it degrades gracefully where no 'default' id exists). That clears room.options.audioCaptureDefaults.deviceId and drives setDeviceId -> restartTrack on the live publication, so both switchInputDevice('') and the hot-swap fallback actually reach the default device; the existing mute/PTT gate at deviceManager.ts:74 and its two tests stay untouched.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0114",
|
||
"title": "In a group DM, one participant's decline silences every other participant's incoming ring — and never reaches the actual caller",
|
||
"file": "Server/ws/handlers_call.go",
|
||
"line": 84,
|
||
"severity": "medium",
|
||
"why": "handleCallDeclineV2 addresses call_declined to every OTHER participant of the DM (RingTargets excludes only the sender), so fellow callees receive it alongside the ringer. The client's handler `ws.on(\"call_declined\", p => ringCtrl?.cancel(p.channel_id))` (Client/src/pages/MainPage.ts:579-581) cancels purely on channel_id and ignores `from_user`, so it cannot tell \"the caller hung up\" from \"another callee declined\". Meanwhile the caller itself never received call_incoming (RingTargets excludes the sender at handlers_call.go:52 / dm.go:377-380), so `ringCtrl.current()` is null on the caller and cancel() is a no-op there — the decline's stated purpose (\"so a ringing client can stop ringing before the 30s timeout\") is unreachable, and its only observable effect is to kill the other callees' rings.",
|
||
"repro": "Group DM C with A, B, D. A opens C and clicks the call button → voice_join(C) + call_ring(C). Server sends call_incoming{channel_id:C, from_user:A} to B and D; both banners appear and both chimes start. B clicks Decline → call_decline{channel_id:C}. Server RingTargets(B,C)=[A,D] and sends call_declined{channel_id:C, from_user:B} to A and D. D's ring state is {channelId:C, fromUserId:A}, so cancel(C) matches → D's banner is torn down and the chime stops; D can no longer answer a call A is still sitting in. A, having no ring state, ignores the frame entirely — so the one client the decline was meant for is the one it does nothing to.",
|
||
"evidence": "handlers_call.go:79-93:\n targets, err := d.DMSvc.RingTargets(ctx, info.UserID, declineCmd.ChannelID())\n payload := buildCallSignal(MsgTypeCallDeclined, declineCmd.ChannelID(), info.UserID, info.Username)\n for _, pid := range targets { events = append(events, CallSignalEvent{eventType: MsgTypeCallDeclined, targetUserID: pid, payload: payload}) }\n\nMainPage.ts:578-582:\n ws.on(\"call_declined\", (payload) => { ringCtrl?.cancel(payload.channel_id); })\n\ncall-ring.ts:114-117:\n function cancel(channelId: number): void { if (state === null || state.channelId !== channelId) return; stopRinging(); }\n\nThe payload carries `from_user` (messages.go:310-314, types.ts:571-575) but the client never reads it. No test locks this: tests/unit/call-ring.test.ts:158-186 only covers channel-id scoping.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "4eca1171",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Guard the client handler by ringer identity instead of channel alone: in MainPage.ts:579-581, `const r = ringCtrl?.current(); if (r && payload.from_user === r.fromUserId) ringCtrl?.cancel(payload.channel_id);`. That keeps the legitimate glare case (both users ringing each other, the peer declines) working and stops a fellow callee's decline from cancelling an unrelated ring. Server fan-out can stay as-is — it has no call state to target with.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0115",
|
||
"title": "Link-preview fetch buffers the whole response body with no timeout and no size cap — the documented 50 KB bound is applied after the bytes are already in memory",
|
||
"file": "Client/src/components/message-list/embeds.ts",
|
||
"line": 202,
|
||
"severity": "medium",
|
||
"why": "The 5 s AbortController timer is cleared at line 182, the moment response *headers* arrive, so `await res.text()` at line 202 runs with the abort signal already disarmed and reads the body to completion before `html.slice(0, 50_000)` is applied. The slice is therefore not a memory bound at all (the comment at line 49 and line 203 both claim it is), and there is no timeout covering the body phase.",
|
||
"repro": "Any message author posts a plain https URL (no code fence, no masked-link syntax) pointing at a host that answers `Content-Type: text/html` and then streams an endless / multi-GB body. media.ts:549 calls renderGenericLinkPreview() automatically for every such URL when showLinkPreviews is on (default true, media.ts:29), so every client that merely renders the message issues the fetch with no user interaction. tauriFetch resolves as soon as headers arrive, clearTimeout(timer) disarms the only abort, and res.text() accumulates the stream forever: the renderer's memory climbs without limit and the in-flight entry in ogInFlight never settles, so the card stays on its \"loading\" fallback title permanently. Ten such links in one channel = ten simultaneous unbounded reads.",
|
||
"evidence": "const timer = setTimeout(() => controller.abort(), 5000);\n...\nconst res = await tauriFetch(url, fetchOpts);\nclearTimeout(timer); // <-- signal disarmed once headers land\n...\nconst html = await res.text(); // unbounded, untimed\nconst meta = parseOgTags(html.slice(0, 50_000)); // \"memory bound\" applied too late",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "20d7537",
|
||
"test": "Client/tests/unit/embeds.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "Move the disarm past the body read instead of adding a second timer: drop `clearTimeout(timer)` at line 182 and wrap the response handling so it runs after `const html = await res.text();` (or put `clearTimeout(timer)` in a `finally` on the async IIFE). That makes the existing 5 s AbortController cover the body phase, and the stream's abort listener errors res.text() and drops the body on the Rust side.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0116",
|
||
"title": "A failed TOTP verify tears down the TOTP overlay, so the code cannot be re-entered",
|
||
"file": "Client/src/pages/connect-page/LoginForm.ts",
|
||
"line": 667,
|
||
"severity": "medium",
|
||
"why": "handleTotpSubmit's catch calls transitionTo(\"error\", …). transitionTo runs updateTotpOverlay(), which adds `totp-overlay--hidden` for every state that is not \"totp\". A rejected verification therefore closes the code prompt and drops the user back on the login form; there is no showTotp() call on any error path, so the second factor is unreachable without re-submitting the password.",
|
||
"repro": "showTotp() → overlay visible. Enter a code the server rejects; onTotpSubmit rejects; the catch sets formState=\"error\"; updateTotpOverlay hides the overlay and updateFormInputsDisabled re-enables host/username/password. The error banner (LoginForm.ts:191, a child of the form panel behind the overlay) is the only feedback, and the code input is gone. tests/e2e/totp-flow.spec.ts:99 asserts only that the banner becomes visible — nothing locks the overlay staying open or a retry succeeding.",
|
||
"evidence": "LoginForm.ts:663-671:\n try { await onTotpSubmit(code); }\n catch (err) {\n const message = err instanceof Error ? err.message : \"Verification failed.\";\n transitionTo(\"error\", message); // ← state leaves \"totp\"\n } finally { totpSubmitBtn.disabled = false; setText(totpSubmitBtn, \"Verify\"); }\n\nLoginForm.ts:519-527:\n function updateTotpOverlay(): void {\n if (formState === \"totp\") { totpOverlay.classList.remove(\"totp-overlay--hidden\"); ... }\n else { totpOverlay.classList.add(\"totp-overlay--hidden\"); }\n }\n\nThe `finally` re-enables the Verify button on an overlay that is already hidden.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/login-form-totp-retry.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c3a20a95"
|
||
},
|
||
"suggestedFix": "Keep the overlay up while a challenge is outstanding instead of tying it to formState alone: add a `totpPending` flag set in showTotp() and cleared in handleTotpCancel() (and on successful auth), then change updateTotpOverlay's condition to `if (formState === \"totp\" || (formState === \"error\" && totpPending))`, and only clear/focus totpInput on the fresh `formState === \"totp\"` entry so a retry keeps its context. This is the single shared point both the click and Enter paths route through. Pair it with retaining pendingTotpPartialToken in main.ts (move the clear out of `finally` into the success branch plus cancel), otherwise the re-opened prompt hits the empty-token guard at main.ts:520.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0117",
|
||
"title": "Channel create/edit/delete modals lock up permanently on an API failure — the caller swallows the rejection the modal needs to re-enable its button",
|
||
"file": "Client/src/pages/main-page/SidebarArea.ts",
|
||
"line": 280,
|
||
"severity": "medium",
|
||
"why": "CreateChannelModal (and EditChannelModal/DeleteChannelModal) disable their submit button, relabel it \"Creating…\"/\"Saving…\"/\"Deleting…\", and rely on `await onCreate(...)` REJECTING to restore the button and render the inline error. SidebarArea's callbacks catch the API error themselves and only show a toast, so the promise resolves normally: the modal's own catch never runs, the button stays disabled with the in-flight label forever, and the modal's `errorEl` (data-testid=\"channel-create-error\") is dead code on every real code path. The user cannot retry after a recoverable error (duplicate name, 403, transient network) without cancelling and losing everything typed.",
|
||
"repro": "Log in as an admin, click \"+\" on a category, type a channel name that already exists (or disconnect the network), press \"Create Channel\". The server returns an error -> a toast appears, but the button stays greyed out reading \"Creating...\" and no inline error is shown. Fixing the name and clicking again does nothing; only Cancel/Escape/X recovers, discarding the form.",
|
||
"evidence": "SidebarArea.ts:275-284 (create)\n onCreate: async (data) => {\n try { await api.adminCreateChannel(data); modal.destroy?.(); activeModal = null; }\n catch (err) {\n const msg = err instanceof Error ? err.message : \"Failed to create channel\";\n getToast()?.show(msg, \"error\"); // <- swallowed, never rethrown\n }\n },\n\nCreateChannelModal.ts:163-177\n createBtn.setAttribute(\"disabled\", \"true\");\n setText(createBtn, \"Creating...\");\n try { await onCreate({...}); }\n catch (err) { errorEl.style.display = \"block\"; ...; createBtn.removeAttribute(\"disabled\"); setText(createBtn, \"Create Channel\"); }\n\nIdentical pattern at SidebarArea.ts:314 (onSave -> EditChannelModal.ts:377-384) and SidebarArea.ts:337 (onConfirm -> DeleteChannelModal.ts:91-98).\ntests/unit/sidebar-area.test.ts:1761 mocks createCreateChannelModal, so nothing locks the integrated behavior; tests/unit/create-channel-modal.test.ts only exercises the modal with a rejecting onCreate.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "f653578",
|
||
"test": "Client/tests/unit/sidebar-area.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "Preserve the modals' reject-to-recover contract in the shared caller: in SidebarArea.ts's three catch blocks, keep the toast and add `throw err;` (or drop the try/catch entirely and let the modal render the inline error). One line per callback; the six sidebar-area.test.ts cases that `await modalCallArgs.onCreate/onSave/onConfirm(...)` must then be updated to `await expect(...).rejects.toThrow(...)`. Restoring the button in a `finally` inside each modal is the alternative but leaves errorEl dead code and touches three files instead of one.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0118",
|
||
"title": "Identity-keyring account namespace collides between a legacy host-only entry and a scoped host+userId entry, letting one server's voice identity private key be adopted on another server",
|
||
"file": "Client/src/lib/identity.ts",
|
||
"line": 211,
|
||
"severity": "low",
|
||
"why": "The B3-3 per-user scope is folded into the single opaque `host` field as `${host}:${userId}`, and the Rust side turns that into the keyring account `identity:{host}`. A host that carries an explicit port therefore produces exactly the same account string as the pre-B3-3 legacy (host-only) entry of a different server: `identityScopeKey(\"chat.example\", 8443)` -> account `identity:chat.example:8443`, which is byte-identical to `identity_account(\"chat.example:8443\")`. `loadOrGenerateIdentityKeyPair` reads that account directly, so it silently adopts the other server's long-term identity private key instead of minting a fresh one — and `ensureIdentityKeyPublished` then publishes that key's public half to the second server, linking the two identities. Host scoping exists precisely to prevent one host's identity key ever being used on another.",
|
||
"repro": "Same desktop install. (1) Sign in to a self-hosted server reachable as `chat.example:8443` on a build that predates the host+user scoping — the identity private key is stored under keyring account `identity:chat.example:8443`. (2) Sign in to a different server reachable as `chat.example` (port 443) as the user whose id is 8443. `loadOrGenerateIdentityKeyPair(\"chat.example\", 8443)` computes scope `chat.example:8443`, `loadIdentityKey` reads account `identity:chat.example:8443`, `importIdentityKeyPair` succeeds on the first server's blob, and the client signs its voice announces on `chat.example` with `chat.example:8443`'s identity key while `ensureIdentityKeyPublished` PATCHes that public key onto `chat.example`.",
|
||
"evidence": "Client/src/lib/identity.ts:211-213\n function identityScopeKey(host: string, userId: number): string {\n return `${host}:${userId}`;\n }\nClient/src/lib/identity.ts:298-299\n const scope = identityScopeKey(host, userId);\n const stored = await loadIdentityKey(scope); // -> invoke(\"load_identity_key\", { host: scope })\n\nClient/src-tauri/src/credentials.rs:46-48\n fn identity_account(host: &str) -> String {\n format!(\"identity:{host}\")\n }\nClient/src-tauri/src/credentials.rs:227-232 (load_identity_key -> secret_store::get(&app, &identity_account(&host)))",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/identity.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "43c7e5ea"
|
||
},
|
||
"suggestedFix": "One line in identityScopeKey (identity.ts:211-213): use a delimiter that cannot appear in a valid host, e.g. `return `${userId}@${host}`;` (isValidHost forbids '@' and '/'), which makes a scoped key structurally unable to equal any legacy host-only account. Existing scoped accounts re-mint once — the same one-time re-verify the B3-3 comment at 187-189 already accepts; add a scope-to-scope migration only if that churn matters.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0119",
|
||
"title": "Group-DM creation only block-checks the creator against each recipient, so a third party can force two users who blocked each other into a shared room",
|
||
"file": "Server/service/dm.go",
|
||
"line": 250,
|
||
"severity": "low",
|
||
"why": "CreateGroupDM documents the creation-time check as answering \"may these two be in a room together\", and requireDMNotBlocked deliberately exempts group DMs from every downstream sink (send, edit, react, pin, typing, ring) on the strength of that. But the loop only evaluates IsEitherBlocked(creator, recipient) — never recipient↔recipient. Since group DMs are exempt from the send-time block gate, the two mutually-blocked members can then message each other freely, which is exactly what the block is supposed to prevent.",
|
||
"repro": "C blocks B (PUT /api/v1/blocks/{B}). A blocks nobody. A sends POST /api/v1/dms/group {\"recipient_ids\":[B,C]}. Both IsEitherBlocked(A,B) and IsEitherBlocked(A,C) are false, so the group is created with A, B and C. B now sends a message into that channel: checkSendPermission takes the DM branch, IsDMParticipant(B) is true, requireDMNotBlocked short-circuits on IsGroupDM, and the message is delivered to C. C receives messages from a user they blocked. Existing tests only cover creator↔recipient (api/dm_group_handler_test.go:158 TestCreateGroupDM_BlockerCannotAddBlocked, :172 TestCreateGroupDM_BlockedCannotAddBlocker), so nothing locks the recipient↔recipient case.",
|
||
"evidence": "service/dm.go:239-256\n for _, rid := range unique {\n user, err := s.st.GetUserByID(ctx, rid)\n ...\n blocked, err := s.st.IsEitherBlocked(ctx, userID, rid) // creator vs recipient ONLY\n ...\n if blocked { return nil, fmt.Errorf(\"%w: cannot add a blocked user to a group DM\", ErrForbidden) }\n }\n\nservice/message_perms.go:118-122 (requireDMNotBlocked)\n isGroup, gErr := st.IsGroupDM(ctx, channelID)\n if gErr == nil && isGroup { return nil } // groups skip the block gate entirely",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/api/dm_group_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "a353f9ad"
|
||
},
|
||
"suggestedFix": "Check every pair, not just the creator's, in the one place that already owns the gate: build `participantIDs := append([]int64{userID}, unique...)` before the validation loop and run IsEitherBlocked over each i<j pair (n <= db.MaxGroupDMParticipants, so the O(n^2) scan is trivial), returning the same ErrForbidden. That subsumes the existing creator-vs-recipient check rather than adding a second one.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0120",
|
||
"title": "Reaction-users endpoint has no soft-deleted-message guard, unlike its sibling in the same file",
|
||
"file": "Server/service/message_reactions.go",
|
||
"line": 42,
|
||
"severity": "low",
|
||
"why": "GetReactionUsers validates emoji shape, read access and channel ownership of the message, but never checks msg.Deleted. Its siblings do: handleReaction (same file, line 101-103) refuses a deleted message, GetMessagesAround refuses one as ErrNotFound, and handleServeFile was specifically hardened so a deleted message's attachments stop being servable. So a message the client renders as a tombstone still exposes who reacted to it, forever, by direct URL.",
|
||
"repro": "In channel 5, user X posts message 42 and several members react with 👍. A moderator deletes message 42 (soft delete; history and GET /channels/5/messages now omit it and clients render a tombstone). Any member with READ_MESSAGES on channel 5 then calls GET /api/v1/channels/5/messages/42/reactions/%F0%9F%91%8D/users and still receives the full reactor list with 200 OK, because requireChannelRead passes and the deleted flag is never consulted.",
|
||
"evidence": "service/message_reactions.go:41-44 (GetReactionUsers)\n msg, err := s.st.GetMessage(ctx, msgID)\n if err != nil || msg == nil || msg.ChannelID != channelID {\n return nil, fmt.Errorf(\"%w: message not found\", ErrNotFound)\n }\n // no msg.Deleted check\n\nservice/message_reactions.go:97-103 (handleReaction, same file)\n msg, err := s.st.GetMessage(ctx, msgID)\n if err != nil || msg == nil { ... }\n if msg.Deleted {\n return nil, fmt.Errorf(\"%w: cannot react to deleted message\", ErrBadRequest)\n }\n\nservice/message_query.go:151-153 (GetMessagesAround)\n if msg == nil || msg.ChannelID != channelID || msg.Deleted {\n return nil, fmt.Errorf(\"%w: message not found in this channel\", ErrNotFound)",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/service/message_reaction_users_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "8243e60d"
|
||
},
|
||
"suggestedFix": "Add `|| msg.Deleted` to the existing not-found condition at service/message_reactions.go:42, so it reads `if err != nil || msg == nil || msg.ChannelID != channelID || msg.Deleted`. One clause in the service function covers both API and WS callers.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0121",
|
||
"title": "Message jumps and permalinks into a DM fail with \"That channel isn't available\" until the DM has been opened once this session",
|
||
"file": "Client/src/lib/channel-navigation.ts",
|
||
"line": 36,
|
||
"severity": "low",
|
||
"why": "`findChannelById` and `navigateToChannel` gate on `channelsStore.channels`, but a DM's row in that store is *synthesized on open* by `addDmToChannelsStore` — the `ready` payload never carries DM rows, and dmStore is the client's only record of DM membership until the user clicks the conversation. Every jump affordance (global search hit, `owncord://message/...` permalink, pinned/reply jump) routes through `MessageJump.jumpTo`, whose very first check is `findChannelById(channelId) === null`, so a legitimate, visible DM is rejected as invisible.",
|
||
"repro": "Sign in fresh (so `ready` populates dmStore with, say, DM channel 50, and channelsStore holds zero `type: \"dm\"` rows). Without clicking that DM, open the search overlay (it is wired to `jumper.jumpTo` via ChatArea.ts:99-104) and search for a word that appears in DM 50. The server returns the hit (GetAccessibleChannelIDs unions the user's DM ids). Click it: `jumpTo(50, msgId)` calls `findChannelById(50)`, channelsStore has no entry for 50, so it toasts \"That channel isn't available\" and returns false — `getMessagesAround` is never called. Identical failure for an `owncord://message/50/<id>` deep link (main.ts:833 → jumpToMessage) and for a `#`-less permalink chip. Clicking the DM in the sidebar once (selectDmConversation → addDmToChannelsStore) makes the exact same jump work. tests/unit/message-jump.test.ts:221 only locks the case where the id is absent from *both* stores, so this path is not test-covered.",
|
||
"evidence": "channel-navigation.ts:36-39\n export function findChannelById(channelId: number): { id: number; name: string } | null {\n const ch = channelsStore.getState().channels.get(channelId);\n return ch === undefined ? null : { id: ch.id, name: ch.name };\n }\nchannel-navigation.ts:17-18\n export function navigateToChannel(channelId: number): void {\n if (!channelsStore.getState().channels.has(channelId)) return;\nMessageJump.ts:75-78\n if (findChannelById(channelId) === null) {\n showToast(\"That channel isn't available\", \"info\");\n return false;\n }\ndispatcher.ts:444-448 (the codebase's own statement of the invariant)\n // Synthesize the channelsStore mirror row before activating: it is\n // only ever created by addDmToChannelsStore (on open, via\n // selectDmConversation), so a DM present in dmStore from `ready`\n // but never opened this session has none\nServer/service/message_perms.go:42-46 (search covers DMs)\n // Also include DM channels the user participates in.\n dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)\n if err == nil { ids = append(ids, dmIDs...) }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "fa15fe5",
|
||
"test": "Client/tests/unit/channel-navigation.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Make the shared resolver DM-aware instead of patching each affordance: in channel-navigation.ts, when channelsStore has no row for channelId, look the id up in dmStore — findChannelById returns { id, name: dmDisplayName(dm) }, and navigateToChannel calls addDmToChannelsStore(dm) before setActiveChannel so ChannelController can resolve it (dispatcher.ts:449 already crosses the same lib->pages import boundary for exactly this reason).",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0122",
|
||
"title": "dm_channel_close fallback activates a DM without clearing its dmStore unread badge, leaving a permanent phantom badge on the conversation being read",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 450,
|
||
"severity": "low",
|
||
"why": "Every other \"open this DM\" path pairs activation with `clearDmUnread` (`selectDmConversation`, `navigateToChannel`, `markChannelRead`), because a DM's badge lives in dmStore while `setActiveChannel` only clears the channelsStore mirror. The dispatcher's close-fallback calls bare `setActiveChannel`, so dmStore's `unreadCount`/`mentionCount` survive — and because the dispatcher then treats that DM as the active one (`isDmActive` → `updateDmLastMessagePreview`, no increment), the stale count is frozen and never clears for the rest of the session.",
|
||
"repro": "Two open DMs: A (channelId 50, active) and B (channelId 60, unreadCount 3). From another signed-in device, close DM A. The server sends `dm_channel_close{channel_id:50}`; `closeDmLocally(50, fallback)` sees A was active and runs the fallback, which activates B via `setActiveChannel(60)`. dmStore's row for 60 still has `unreadCount: 3`, so `buildDmConversations`/`SidebarDmSection.renderDmListItems` (line 97: `if (dm.unreadCount > 0)`) keep rendering a red \"3\" on the conversation now filling the screen, and it also inflates the DIRECT MESSAGES header total (SidebarDmSection.ts:128). New messages arriving in B take the `isDmActive` branch, so the count never moves; `hasUnread(60)` stays true, so \"Mark All as Read\" fires a redundant `mark_read` for it. tests/unit/dispatcher.test.ts:2931 exercises this fallback with `unreadCount: 0` on both DMs, so the behavior is not locked.",
|
||
"evidence": "dispatcher.ts:449-450\n addDmToChannelsStore(remaining[0]!);\n setActiveChannel(remaining[0]!.channelId);\ncontrast SidebarDmHelpers.ts:53-57 (the sibling path)\n clearDmUnread(dmChannel.channelId);\n addDmToChannelsStore(dmChannel);\n setActiveChannel(dmChannel.channelId);\ncontrast SidebarArea.ts:437-440 (the local close path)\n function fallBackFromDm(): void {\n const remaining = dmStore.getState().channels;\n if (remaining.length > 0) { selectDmConversation(remaining[0]!, dmDeps); return; }\ndispatcher.ts:502-518 (why the count then freezes)\n if (isOwnMessage || isDmActive || ws.isReplaying()) {\n updateDmLastMessagePreview(...) // no unread increment",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "d1c104d",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Replace the two lines with `addDmToChannelsStore(remaining[0]!); navigateToChannel(remaining[0]!.channelId);` — navigateToChannel is already the shared 'open this channel' entry point and does setActiveChannel + clearUnread + clearDmUnread in one call (a bare `clearDmUnread(remaining[0]!.channelId)` before line 450 is the even smaller equivalent).",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0123",
|
||
"title": "markAllRead's paced tail marks messages that arrived after the click as read, destroying a genuinely-new unread badge",
|
||
"file": "Client/src/lib/read-state.ts",
|
||
"line": 118,
|
||
"severity": "low",
|
||
"why": "`markAllRead` snapshots the unread set at click time but defers most of the sends by up to `ceil(n/4) * 1100 ms`. Each deferred timer calls `markChannelRead(id)` unconditionally, which both sends `mark_read` (advancing the server's read state to the channel's current tail) and calls `clearUnread`/`clearDmUnread` locally. A message that arrives in one of those channels during the pacing window is therefore marked read and its badge wiped, even though the user never saw it and it postdates the action they took.",
|
||
"repro": "Have 6 unread channels (ids 1..6) and click \"Mark All as Read\". Channels 1-4 are sent synchronously; 5 and 6 are queued for t+1100 ms. At t+500 ms a new message arrives in channel 6: the dispatcher's chat_message handler calls `incrementUnread(6)` and the sidebar badge shows 1. At t+1100 ms the queued timer fires `markChannelRead(6)`, which sends `mark_read` for channel 6 (advancing the server read state past the new message) and calls `clearUnread(6)`. The message is gone from every unread surface and the server agrees it was read, though it arrived after the click and was never displayed. With ~100 unread conversations the window stretches to ~27 s. tests/unit/read-state.test.ts:156 locks the pacing itself but never delivers a message during the window.",
|
||
"evidence": "read-state.ts:112-120\n const ids = unreadChannelIds();\n for (const [i, id] of ids.entries()) {\n const delay = Math.floor(i / MARK_ALL_READ_BURST_SIZE) * MARK_ALL_READ_BURST_INTERVAL_MS;\n if (delay === 0) { markChannelRead(id); }\n else { pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay)); }\n }\nread-state.ts:42-51 (no re-check of current unread state)\n export function markChannelRead(channelId: number): void {\n const known = ...;\n if (!known) return;\n sender?.(channelId);\n clearUnread(channelId);\n clearDmUnread(channelId);\n }\nread-state.ts:103-105 (the stated intent this breaks)\n // Each channel's local badge is cleared at the moment its own frame actually\n // goes out, not up front, so a channel whose send hasn't fired yet still\n // shows unread rather than lying about it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "95ea82c",
|
||
"test": "Client/tests/unit/read-state.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "In markAllRead, snapshot each id's current lastMessageId alongside the id, and in the deferred callback skip markChannelRead when the channel's lastMessageId has moved (the new message is genuinely unread and keeping its badge is the safer side of the tradeoff). ~4 lines, entirely inside markAllRead; do not change markChannelRead, whose synchronous callers want the unconditional advance.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0124",
|
||
"title": "hidePreview overwrites the pending animation timer without clearing it, orphaning timers that later tear down a freshly reopened preview and strand its media-track listeners",
|
||
"file": "Client/src/lib/streamPreview.ts",
|
||
"line": 273,
|
||
"severity": "low",
|
||
"why": "`hidePreview` clears only `state.debounce` (line 249) and then blindly assigns `state.animation = animTimer` (line 273). The `animation` slot may already hold a live timer — either `stopPreviewDelayed`'s 150 ms grace timer (line 315) or a prior `hidePreview`'s 200 ms removal timer — and that timer is never cancelled, so it keeps running with no handle. When an orphaned removal timer fires it runs `previewTimers.delete(row)` (line 269) against whatever state a *later* hover installed, which both destroys that state's `trackCleanup` handle and makes the abort handler's `clearPreviewState` (lines 79-80) a no-op for the row.",
|
||
"repro": "Voice sidebar, remote participant with a live camera. t=0: hover the row -> mouseenter -> startPreview -> after the 300 ms debounce showPreview inserts `.vu-preview`, adds `ended`/`mute` listeners to the remote MediaStreamTrack and stores `trackCleanup` on the state (lines 148-158). t=400: move the pointer off the row -> mouseleave -> stopPreviewDelayed arms T1 (150 ms) in state.animation. t=450 (inside that window): scroll the voice list, or Tab away (focusout) -> hidePreview runs, does NOT clear T1, and writes T2 (200 ms removal) into state.animation. t=550: T1 fires, the preview is not `:hover`, so it calls hidePreview again -> writes T3 (200 ms) into state.animation, orphaning T2. t=650: T2 fires -> removePreviewDom + previewTimers.delete(row). t=660: hover the row again -> startPreview's clearPreviewState finds no state (already deleted), installs a fresh state with a 300 ms debounce. t=750: the orphaned T3 fires -> previewTimers.delete(row) removes that brand-new state. t=960: the debounce fires and showPreview builds a new <video>, registers `ended`/`mute` on the remote track, then hits `const state = previewTimers.get(row); if (state !== undefined)` (lines 152-158) -> undefined, so trackCleanup is never stored. Those two track listeners are now unreachable by clearPreviewState, by hidePreview, and by the sidebar-teardown abort handler (lines 52-58); they survive every sidebar rebuild, keep firing onTrackDead against a detached row, and pin the video/previewDiv/row closures for the remote track's lifetime. The user-visible symptom on the way there is the preview closing itself ~200 ms after being reopened.",
|
||
"evidence": "hidePreview (246-275):\n const state = previewTimers.get(row);\n if (state !== undefined) {\n clearTimeout(state.debounce); // <- only debounce is cleared\n if (state.trackCleanup !== null) { ... }\n }\n ...\n const animTimer = window.setTimeout(() => {\n removePreviewDom(row);\n previewTimers.delete(row);\n }, 200);\n if (state !== undefined) {\n state.animation = animTimer; // line 273: overwrite, no clearTimeout(state.animation)\n }\n\nstopPreviewDelayed (311-330) writes the same slot:\n clearTimeout(state.animation);\n state.animation = window.setTimeout(() => { ... hidePreview(row); }, 150);\n\nBoth other entry points call hidePreview directly without touching state.animation:\n row.addEventListener(\"focusout\", stopPreview, { signal }); // line 338 -> hidePreview\n attachScrollCollapse -> hidePreview(row as HTMLElement); // line 358",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "f8f3696",
|
||
"test": "Client/tests/unit/stream-preview.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "In hidePreview, cancel the pending animation timer before overwriting it: add `clearTimeout(state.animation);` immediately after `clearTimeout(state.debounce);` at line 249. One line in the shared function covers every entry path (scroll collapse, focusout, delayed stop, preview mouseleave), guarantees at most one live animation timer per state, and keeps that timer cancellable by clearPreviewState/startPreview. clearTimeout(0) on the initial value is a harmless no-op.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0125",
|
||
"title": "Voice & Audio settings tab registers a new permanent abort listener on the overlay-lifetime AbortSignal on every build, pinning every previously built tab",
|
||
"file": "Client/src/components/settings/VoiceAudioTab.ts",
|
||
"line": 490,
|
||
"severity": "low",
|
||
"why": "buildVoiceAudioTabInner adds an \"abort\" listener to the SettingsOverlay-lifetime AbortSignal each time the tab is (re)built, with no {once}, no removeEventListener, and no dedupe. The signal only fires when the whole overlay is destroyed, so the listeners accumulate for the MainPage session and each closure retains that build's entire DOM subtree (selects, sliders, toggles, preview <video>).",
|
||
"repro": "Open Settings, select \"Voice & Audio\", close Settings, reopen it (SettingsOverlay.hide() sets contentLive=false; show() calls renderActiveTab() -> voiceTab.build() -> buildVoiceAudioTabInner). Repeat N times: SettingsOverlay's single AbortController now carries N abort listeners, and the N-1 detached tab subtrees (each with a <video> element and its device <select> lists) are unreachable from the DOM but still strongly held by those closures. Nothing releases them until MainPage is destroyed (logout).",
|
||
"evidence": "490: signal.addEventListener(\"abort\", () => {\n491: stopCameraPreview();\n492: });\n\nThe closure captures stopCameraPreview -> cameraRequestId, registerCamera, previewVideo, and transitively `section`. Contrast the outer factory (line 77, `signal.addEventListener(\"abort\", cleanupMic)`) which is registered exactly once, and lib/modalFactory.ts:128-142 which registers its parent-signal listener with `{ signal: ac.signal }` so it is released when the modal closes.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "a100f4e",
|
||
"test": "Client/tests/unit/voice-audio-tab.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Delete the redundant registration at VoiceAudioTab.ts:490-492. Abort teardown is already covered by the factory's single `signal.addEventListener(\"abort\", cleanupMic)` (line 77): cleanupMic calls `invalidateCameraPreviewRequest?.()` (which bumps cameraRequestId/micRequestId for the current build) and stops `cameraPreviewStream` tracks. The only extra thing line 491 does is null `previewVideo.srcObject` on a subtree that is being discarded anyway.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0126",
|
||
"title": "Plugin enable leaves plugins.enabled = 1 in the DB when the in-memory instance is missing — no rollback on that early return",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 466,
|
||
"severity": "low",
|
||
"why": "`EnablePlugin` writes the DB flag first, then looks up the in-memory instance. The activate-failure path below (line 471–479) carefully rolls the DB flag back with `DisablePlugin`, but the `!ok` path returns `ErrPluginNotFound` with the row already flipped to enabled. The admin sees an error, the persisted state says enabled, and every subsequent startup's `activateAll` just logs \"enabled row has no on-disk manifest, skipping\" — a permanently inconsistent enabled flag with no UI path to clear it (Disable on a plugin the registry does not know still needs the row).",
|
||
"repro": "Install a plugin, stop the server, delete its directory under `plugins/` (or let InstallFromZip's rename fail, see the sibling finding), restart. `scanPluginDirectory` no longer registers it, so `r.plugins[id]` is empty while the `plugins` row survives. Click Enable in the admin panel: the request 404s/errors, but `SELECT enabled FROM plugins WHERE id=?` is now 1 forever, and every restart logs \"enabled row has no on-disk manifest, skipping\".",
|
||
"evidence": "if err := r.cfg.Store.EnablePlugin(ctx, id); err != nil {\n return err\n}\nr.mu.RLock()\ninst, ok := r.plugins[id]\nr.mu.RUnlock()\nif !ok {\n return ErrPluginNotFound // <-- DB row already enabled, never rolled back\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/plugin/registry_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9075f248",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "In EnablePlugin, roll back on the not-found path the same way the activate path does: `if !ok { _ = r.cfg.Store.DisablePlugin(ctx, id); return ErrPluginNotFound }`.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0127",
|
||
"title": "Plugin uninstall returns success when the on-disk directory cannot be removed, so the plugin is resurrected on the next startup",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 538,
|
||
"severity": "low",
|
||
"why": "`UninstallPlugin` deletes the DB row and the in-memory records, then removes the plugin directory best-effort — an `os.RemoveAll` failure is only warned and the function still returns `nil`. The comment two lines above states the removal is what stops `scanPluginDirectory` resurrecting the plugin on the next startup, so the error path silently produces exactly the outcome it was written to prevent while telling the admin the uninstall succeeded.",
|
||
"repro": "On Windows, uninstall a plugin whose .wasm file is still open/mapped (AV scanner, or a wazero module handle not yet released) — `os.RemoveAll` fails with a sharing violation. The admin panel shows the uninstall succeeded and the plugin disappears from the list. Restart the server: `scanPluginDirectory` finds the leftover `plugins/<name>/plugin.json` and `installFromDisk` re-creates the row, so the \"uninstalled\" plugin is back.",
|
||
"evidence": "// Remove on-disk files so the plugin isn't resurrected on the next\n// startup by scanPluginDirectory.\nif pluginDir != \"\" {\n if err := os.RemoveAll(pluginDir); err != nil {\n slog.Warn(\"plugin: failed to remove plugin directory after uninstall\", \"dir\", pluginDir, \"err\", err)\n }\n}\nreturn nil",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/plugin/registry_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9075f248",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Return the error instead of swallowing it: `if err := os.RemoveAll(pluginDir); err != nil { return fmt.Errorf(\"remove plugin dir: %w\", err) }` — the DB row and in-memory records are already gone, so the admin at least learns the files must be cleaned up before the next restart.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0128",
|
||
"title": "An attachment-metadata read failure broadcasts the message with no attachments — an attachment-only send renders as a blank bubble for everyone",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 135,
|
||
"severity": "low",
|
||
"why": "After the attachments are successfully linked in the DB, `GetAttachmentsByMessageIDs` is read purely to populate the broadcast payload. On error the failure is logged and `attachments` stays nil, but the function returns success — so `handleChatSendV2` builds `chat_message` with an empty attachment list. Because `sanitizeContent` allows empty content when attachments were requested, an image-only message is fanned out to every subscriber as a message with no content and no attachments. Nothing corrects it live; only a history refetch does.",
|
||
"repro": "Post an image with no text. Make `GetAttachmentsByMessageIDs` fail for that one call (transient SQLITE_BUSY on the reader pool, or the request ctx being cancelled right after `LinkAttachmentsToMessage` commits). The row and its attachment link are committed, the sender gets `chat_send_ok`, and every client in the channel receives a `chat_message` with `content: \"\"` and `attachments: []` — an empty message bubble that stays empty until the channel history is refetched.",
|
||
"evidence": "if linked > 0 {\n attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID})\n if attErr != nil {\n slog.Error(\"MessageService.SendMessage GetAttachments\", \"err\", attErr)\n } else {\n attachments = attMap[msgID]\n }\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Server/service/message_crud_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "86acf049"
|
||
},
|
||
"suggestedFix": "Do the post-commit read on a detached ctx like the compensating deletes in the same function: `s.st.GetAttachmentsByMessageIDs(context.WithoutCancel(ctx), []int64{msgID})`.",
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0129",
|
||
"title": "\"Yesterday\" boundary is computed by subtracting 24h from local midnight, so DST days mislabel message timestamps by a full day",
|
||
"file": "Client/src/components/message-list/formatting.ts",
|
||
"line": 81,
|
||
"severity": "low",
|
||
"why": "`yesterdayStart` is `todayStart.getTime() - 86_400_000` rather than calendar-yesterday's local midnight (`new Date(y, m, d-1)`). On a DST transition the local day is 23 or 25 hours, so the fence post lands inside the wrong calendar day and formatMessageTimestamp — which renders every message's hover/expanded timestamp — labels messages with the wrong relative day.",
|
||
"repro": "Spring forward (US, 23-hour local day): with `now` = Mon Mar 9 10:00 local, todayStart = Mar 9 00:00, todayStart-24h = Sat Mar 7 23:00 local (not Sun Mar 8 00:00). A message posted Sat Mar 7 at 23:30 satisfies `date >= yesterdayStart` and renders as \"Yesterday at 11:30 PM\" although it is two days old. Fall back (25-hour local day): with `now` = Mon Nov 3 10:00, todayStart-24h = Sun Nov 2 01:00, so a message posted Sun Nov 2 at 00:30 fails `date >= yesterdayStart` and renders as \"11/02/2026 12:30 AM\" instead of \"Yesterday at 12:30 AM\".",
|
||
"evidence": "Client/src/components/message-list/formatting.ts:80-88\n `const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());`\n `const yesterdayStart = new Date(todayStart.getTime() - 86_400_000);`\n `if (date >= todayStart) { return \\`Today at ${timeStr}\\`; }`\n `if (date >= yesterdayStart) { return \\`Yesterday at ${timeStr}\\`; }`\nUsed at Client/src/components/message-list/renderers.ts:238 `formatMessageTimestamp(msg.timestamp)`.\nThe existing test only covers the non-DST case: tests/unit/renderers.test.ts:1186-1190 asserts `now - 24h` matches /^Yesterday at /, so the DST edge is not locked.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "19315e5",
|
||
"test": "Client/tests/unit/renderers.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "Replace line 81 with the calendar day before, mirroring line 80: `const yesterdayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);` (Date normalizes day 0 / negative days across month and year boundaries, so no extra guard is needed).",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0130",
|
||
"title": "GIF freeze compares a raw URL string against the DOM-normalized img.src, so any URL the parser rewrites is never actually frozen while the UI claims it is",
|
||
"file": "Client/src/lib/media-visibility.ts",
|
||
"line": 70,
|
||
"severity": "low",
|
||
"why": "freezeImage only captures and swaps in the still frame when `img.src === entry.originalSrc`. `entry.originalSrc` is the raw string the caller passed (media.ts:318 passes the message-content URL verbatim; embeds.ts:331 passes the embed's image URL), while the `img.src` getter returns the URL after WHATWG normalization — host lowercased, default port stripped, path percent-encoded, dot segments resolved. When those differ the guard is false: no canvas capture, no src swap, so the GIF keeps animating at full frame rate while entry.isPlaying is set false, the button flips to ▶ and the wrapper gets `gif-paused`. Off-screen freeze (observer callback, line 153) and window-blur freeze (pauseAllMedia, line 289) become no-ops too, defeating the module's entire stated purpose.",
|
||
"repro": "Post a message containing `https://EXAMPLE.com/anim.gif` (or any GIF URL with a space/non-ASCII char in the filename, or an explicit `:443`). isDirectImageUrl passes, renderInlineImage sets attrs.src = url and calls observeMedia(img, url, wrap) on load (media.ts:314-321). `img.src` now reads `https://example.com/anim.gif`, which !== `https://EXAMPLE.com/anim.gif`. Click the pause button: updateButton paints ▶ and adds `gif-paused`, but the branch at line 70 is skipped so img.src is untouched and the GIF keeps animating. Scroll it out of view or blur the window — still animating. The existing test only ever uses an already-normalized `https://example.com/cat.gif` (tests/unit/media-visibility.test.ts:106), so nothing locks this.",
|
||
"evidence": "media-visibility.ts:70-77\n `if (img.src === entry.originalSrc) {`\n ` if (entry.frozenSrc === null) { entry.frozenSrc = captureStaticFrame(img); }`\n ` if (entry.frozenSrc !== null) { img.src = entry.frozenSrc; }`\n `}`\nmedia.ts:268-278 `const attrs: Record<string, string> = { src: url, ... }; const img = createElement(\"img\", attrs);`\nmedia.ts:318 `observeMedia(img, url, wrap, !animateGifsPref);`",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "87ab12c",
|
||
"test": "Client/tests/unit/media-visibility.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "In freezeImage (media-visibility.ts:70-77) drop the originalSrc identity guard and compare against the frozen frame instead, so the swap no longer depends on string-matching an un-normalized URL:\n\n if (entry.frozenSrc === null) {\n entry.frozenSrc = captureStaticFrame(img);\n }\n if (entry.frozenSrc !== null && img.src !== entry.frozenSrc) {\n img.src = entry.frozenSrc;\n }\n\nOne hunk in the shared function; fixes all three callers (media.ts:318, embeds.ts:331, attachments.ts:573/594) and all freeze triggers (button, observer, pauseAllMedia, startFrozen) at once. Re-entry is still safe: frozenSrc is non-null once captured, so a second freeze never re-captures from the already-frozen frame.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0131",
|
||
"title": "A username containing an uppercase non-ASCII letter can never be @mentioned",
|
||
"file": "Server/db/mention_queries.go",
|
||
"line": 262,
|
||
"severity": "low",
|
||
"why": "resolveMentions lowercases every @token with Go's Unicode-aware strings.ToLower, then GetUserIDsByUsernames matches with `username IN (...)`, relying on the column's `COLLATE NOCASE`. SQLite's NOCASE folds ASCII A-Z only, so a Unicode-lowercased token can never equal a stored name whose letters are non-ASCII uppercase — the lookup silently returns nothing and the mention degrades to plain text.",
|
||
"repro": "auth.ValidateUsername (Server/auth/helpers.go:19-47) only rejects control/format runes, so \"Émile\" is a legal username. Alice posts \"hey @Émile\". parseMentionTokens matches it (the token class is `[\\p{L}\\p{N}_.-]`, Server/service/mentions.go:30) and lowercases it to \"émile\" (mentions.go:69). GetUserIDsByUsernames binds 'émile' and runs `SELECT id, username FROM users WHERE <notBanned> AND username IN ('émile')`; NOCASE compares 'é' (U+00E9) with 'É' (U+00C9) bytewise, so no row comes back. resolveMentions returns an empty set: no message_mentions row is written, applyMentionCounts never bumps Émile's read_states.mention_count, and the broadcast carries an empty `mentions` array so the client renders no highlight. Every spelling fails identically — typing the exact stored \"@Émile\" still gets lowercased to \"émile\" before the query — so the user is permanently unmentionable, while a purely-ASCII \"@bob\" resolves fine.",
|
||
"evidence": "db/mention_queries.go:244-246 — \"// GetUserIDsByUsernames resolves usernames to ids, keyed by the lowercased\\n// username. Matching is case-insensitive because users.username is UNIQUE\\n// COLLATE NOCASE, which makes the column's comparisons case-insensitive too.\"\ndb/mention_queries.go:260-263 — `rows, err := d.reader.QueryContext(ctx, fmt.Sprintf(`SELECT id, username FROM users WHERE %s AND username IN (%s)`, notBannedClause, strings.Join(placeholders, \",\")), args...)`\nservice/mentions.go:69 — `raw := strings.ToLower(m[2])`\nmigrations/001_initial_schema.sql:24 — `username TEXT NOT NULL UNIQUE COLLATE NOCASE,`",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-service",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/db/mention_queries_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "c7e87f19"
|
||
},
|
||
"suggestedFix": "Make the mention fold match the column's fold instead of exceeding it: replace the Unicode `strings.ToLower` with an ASCII-only lower in the two places that must agree — service/mentions.go:69 (`raw := strings.ToLower(m[2])`) and db/mention_queries.go:277 (`result[strings.ToLower(name)] = id`). A four-line helper (`func lowerASCII(s string) string` mapping only 'A'-'Z') used in both spots makes \"@Émile\" bind the literal 'Émile' and match the stored row, and keeps the map key and the lookup key identical. This also aligns mentions with every other username comparison in the codebase (dbgen/users.sql.go:99 `WHERE username = ? COLLATE NOCASE`), which already folds ASCII-only. Do not instead add `LOWER()`/`COLLATE` to the query — SQLite's built-in LOWER() is ASCII-only too, so it would not help.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0132",
|
||
"title": "Connection-quality degradation is never reported: the 3 s debounce timer is cleared and rescheduled by every 2 s poll, so onQualityChanged only fires when stats collection fails",
|
||
"file": "Client/src/lib/connectionStats.ts",
|
||
"line": 168,
|
||
"severity": "low",
|
||
"why": "POLL_INTERVAL_MS (2000) is shorter than QUALITY_DEBOUNCE_MS (3000), and `lastQuality` is only advanced inside the timer callback. So while the measured quality differs from `lastQuality`, every single poll re-enters the `newQuality !== lastQuality` branch, clears the pending timer and schedules a fresh 3 s one — the timer can never reach its deadline. The only way it fires is if a poll returns early (`!room` or `reports.length === 0`), i.e. when the stats path is broken, and by then `current.quality` is whatever the last successful poll left. The net effect is that the quality-change notification is structurally unreachable during exactly the condition it exists to detect.",
|
||
"repro": "1. Join voice. `startStatsPoller()` creates the poller; lastQuality = \"excellent\".\n2. t=2000 ms: first poll, candidate-pair currentRoundTripTime = 0.01 -> quality \"excellent\". No branch taken.\n3. Network degrades to RTT 500 ms. t=4000 ms poll: quality \"bad\" != lastQuality \"excellent\" -> timer scheduled for t=7000.\n4. t=6000 ms poll: quality still \"bad\" != \"excellent\" -> clearTimeout, reschedule for t=9000.\n5. t=8000 ms poll: reschedule for t=11000. ... and so on for the entire duration of the bad connection.\n Result: qualityChangeListeners are never invoked, the VoiceWidget stats pane never auto-expands, and lastQuality stays \"excellent\" forever.\n6. Conversely, unplug the network so `collectAllStats` returns [] (line 142 early-return): the timer is no longer reset and fires ~3 s later, expanding the stats pane at the one moment the numbers on it are stale — the inverse of the intended behaviour.",
|
||
"evidence": "connectionStats.ts:137-177\n const POLL_INTERVAL_MS = 2000; // line 7\n const QUALITY_DEBOUNCE_MS = 3000; // line 135\n async function poll() {\n const room = getRoom();\n if (!room) return; // line 139 <- only escape hatch\n const reports = await collectAllStats(room);\n if (reports.length === 0) return; // line 142 <- only other escape hatch\n ...\n const newQuality = current.quality;\n if (newQuality !== lastQuality) { // line 168\n if (qualityDebounceTimer !== null) clearTimeout(qualityDebounceTimer); // line 169\n qualityDebounceTimer = setTimeout(() => { // line 170\n if (current.quality !== lastQuality) {\n const prevQuality = lastQuality;\n lastQuality = current.quality; // line 172-173: ONLY writer of lastQuality\n qualityChangeListeners.forEach((cb) => cb(current.quality, prevQuality));\n }\n }, QUALITY_DEBOUNCE_MS);\n }\n }\n\nSole consumer — VoiceWidget.ts:149-154:\n qualityUnlisten = statsPoller.onQualityChanged((quality, _prevQuality) => {\n // Auto-expand stats pane when quality degrades\n if ((quality === \"poor\" || quality === \"bad\") && statsPane !== null) {\n statsPane.classList.add(\"visible\");\n }\n });\n\nNote on the existing test: tests/unit/connection-stats.test.ts:319 is named \"fires quality change callback with debounce\" — i.e. it asserts the intended contract — but it only passes because line 357 sets `roomActive = false` so polls stop resetting the timer. Its comment (lines 320-323) documents the symptom and rationalises it as \"fires after the connection stabilizes\", which is not what the code does: when quality stabilizes it equals `lastQuality`, so the guard at line 171 is false and nothing fires. The test locks the callback's existence, not the perpetual reset.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "bac1cda",
|
||
"test": "Client/tests/unit/connection-stats.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "In poll(), only arm the timer when none is pending, clear it when quality returns to `lastQuality`, and null the handle when it fires:\n\n```ts\nif (newQuality !== lastQuality) {\n if (qualityDebounceTimer === null) {\n qualityDebounceTimer = setTimeout(() => {\n qualityDebounceTimer = null;\n if (current.quality !== lastQuality) {\n const prevQuality = lastQuality;\n lastQuality = current.quality;\n qualityChangeListeners.forEach((cb) => cb(current.quality, prevQuality));\n }\n }, QUALITY_DEBOUNCE_MS);\n }\n} else if (qualityDebounceTimer !== null) {\n clearTimeout(qualityDebounceTimer);\n qualityDebounceTimer = null;\n}\n```\n\nNulling the handle inside the callback is required, otherwise the new `=== null` check would never re-arm after the first fire. stop() (lines 195-198) already nulls it.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0133",
|
||
"title": "UninstallPlugin deletes a directory derived from the manifest name, not the plugin's real on-disk directory, so any plugin whose folder name differs is resurrected on next start",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 520,
|
||
"severity": "low",
|
||
"why": "`scanPluginDirectory` takes the on-disk directory from the filesystem entry name (`e.Name()`) and never compares it to `manifest.Name`; the real directory is recorded as `foundPlugin.Dir` / `Instance.WASMPath`. `UninstallPlugin` instead rebuilds the path as `filepath.Join(r.cfg.Directory, inst.Manifest.Name)`. When the two differ, `os.RemoveAll` targets a path that does not exist — and `os.RemoveAll` returns nil for a missing path, so even the warning on the next line never fires and the handler answers 204 as if the uninstall succeeded. `AssetHandler` (host_ui.go:59) derives the same plugin root correctly as `filepath.Dir(inst.WASMPath)`, so the two sites disagree about where the plugin lives.",
|
||
"repro": "Install a plugin by hand as `plugins/hello-v2/` containing `plugin.json` = {\"name\":\"hello\",\"version\":\"1\",\"entrypoint\":\"hello.wasm\"} plus `hello.wasm`. Start the server (LoadAll registers it with WASMPath=plugins/hello-v2/hello.wasm). DELETE /api/v1/admin/plugins/{id} -> 204, DB row gone, in-memory record gone; `os.RemoveAll(\"plugins/hello\")` no-ops with a nil error so nothing is logged. Restart the server: LoadAll rescans `plugins/hello-v2`, InstallPlugin re-inserts the row, and the uninstalled plugin is back in the admin list. (The registry test only ever writes the directory with the same name as the manifest, so it never exercises the mismatch.)",
|
||
"evidence": "registry.go:520 pluginDir = filepath.Join(r.cfg.Directory, inst.Manifest.Name)\nregistry.go:538 if err := os.RemoveAll(pluginDir); err != nil { slog.Warn(\"plugin: failed to remove plugin directory after uninstall\", ...) }\nloader.go:52 pluginDir := filepath.Join(dir, e.Name()) // real dir = filesystem entry name\nloader.go:85 wasmPath := filepath.Join(pluginDir, manifest.Entrypoint)\nhost_ui.go:59 pluginDir, dirErr := filepath.Abs(filepath.Dir(inst.WASMPath)) // sibling derives it from WASMPath",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-plugin",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/plugin/registry_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "9075f248",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Stop deriving the path from the manifest: add a `Dir string` field to `Instance`, set it from `found.Dir` in installFromDisk (InstallFromZip already passes `Dir: finalDir` in its foundPlugin), and use `inst.Dir` at registry.go:520. One field, one assignment, one call-site change — `filepath.Dir(inst.WASMPath)` is not equivalent because a manifest Entrypoint may be a nested relative path (manifest.go:147 validateRelativePath).",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0134",
|
||
"title": "DM sidebar avatar image load destroys the presence status dot",
|
||
"file": "Client/src/components/DmSidebar.ts",
|
||
"line": 95,
|
||
"severity": "low",
|
||
"why": "paintAvatar() clears the avatar circle with `el.textContent = \"\"` before inserting the fetched <img>. For a 1:1 DM, buildAvatar() appends the `.dm-status` presence dot to that same element synchronously, after paintAvatar() has already started its async fetch. When the bytes arrive the wipe removes the dot along with the letter, so every DM row whose peer has an uploaded avatar permanently loses its online/idle/dnd/offline indicator. The sibling helper `lib/avatar.ts:120` solves exactly this by calling `letter.remove()` instead of clearing the parent.",
|
||
"repro": "Open the DM sidebar with a 1:1 conversation whose peer has an avatar (`avatar: \"/api/v1/files/42\"`, `status: \"online\"`). buildAvatar (DmSidebar.ts:129-137) runs paintAvatar -> setText(el,\"A\") -> starts fetchImageAsDataUrl, then appends the `.dm-status` span. One microtask later the fetch resolves, `el.textContent = \"\"` drops the status span, and `el.appendChild(img)` leaves the circle with the image only. `container.querySelector('.dm-avatar .dm-status')` is null. Peers with `avatar: null` keep their dot, which is why every existing status test (tests/unit/dm-sidebar.test.ts:350-366, 481) passes — makeConvo defaults to `avatar: null`.",
|
||
"evidence": "function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {\n setText(el, label.charAt(0).toUpperCase());\n ...\n void fetchImageAsDataUrl(resolved).then((dataUrl) => {\n if (dataUrl === null || !el.isConnected) return;\n const img = createElement(\"img\", { src: dataUrl, alt: label });\n ...\n el.textContent = \"\"; // <-- wipes the .dm-status dot appended at line 136\n el.appendChild(img);\n });\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "8b2652d",
|
||
"test": "Client/tests/unit/dm-sidebar.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "In paintAvatar, stop clearing the whole element — remove only the letter. Smallest edit at line 95: replace `el.textContent = \"\";` with `for (const n of [...el.childNodes]) if (n.nodeType === Node.TEXT_NODE) n.remove();` (or mirror lib/avatar.ts: append the initial as a span and call `letter.remove()`). One change in the shared helper covers both the 1:1 and the group-face call sites.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0135",
|
||
"title": "Re-subscribed screenshare-audio tracks leak detached <audio> elements into screenshareAudioElements forever",
|
||
"file": "Client/src/lib/audioElements.ts",
|
||
"line": 132,
|
||
"severity": "low",
|
||
"why": "The screenshare branch of handleTrackSubscribedAudio detaches and DOM-removes any previously attached elements for the track, but never deletes them from `this.screenshareAudioElements.get(userId)` — unlike handleTrackUnsubscribedAudio (line 186), which does exactly that. The set therefore grows with dead elements, and the `audioEls.size === 0` cleanup at line 187 can never fire, so the userId key is never released either.",
|
||
"repro": "LiveKit re-fires TrackSubscribed for an already-attached screenshare-audio track before the old TrackUnsubscribed lands — the exact fast-reconnect ordering the sibling mic branch's comment at line 153-154 cites. Subscribe #1 adds element A to the set. Subscribe #2 does `track.detach()` (returns [A]), `A.remove()`, then `audioEls.add(B)`; A is still in the set. The eventual unsubscribe detaches [B], deletes B, sees `size === 1` (A) and leaves the userId entry alive. A is now an orphaned HTMLAudioElement pinned by the map for the rest of the voice session, and setScreenshareAudioVolume/muteScreenshareAudio keep writing to it; only cleanupAllAudioElements() ever frees it.",
|
||
"evidence": "if (publication.source === Track.Source.ScreenShareAudio) {\n for (const el of track.detach()) el.remove(); // <-- no audioEls.delete(el)\n const audioEl = track.attach();\n ...\n audioEls.add(audioEl);\n// vs. handleTrackUnsubscribedAudio, which does it right:\n const detachedEls = track.detach() as HTMLAudioElement[];\n for (const el of detachedEls) el.remove();\n const audioEls = this.screenshareAudioElements.get(userId);\n if (audioEls !== undefined) {\n for (const el of detachedEls) audioEls.delete(el);\n if (audioEls.size === 0) this.screenshareAudioElements.delete(userId);",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/audio-elements.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "6d123b8e"
|
||
},
|
||
"suggestedFix": "Hoist the Set lookup above the detach loop and delete what you detach: `const prev = this.screenshareAudioElements.get(userId); for (const el of track.detach()) { el.remove(); prev?.delete(el); }` — then reuse `prev` for the existing get-or-create at lines 138-142.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0136",
|
||
"title": "api.setConfig's host validator rejects every IPv6 literal, so login to an IPv6 server is impossible while the rest of the client explicitly supports it",
|
||
"file": "Client/src/lib/api.ts",
|
||
"line": 82,
|
||
"severity": "low",
|
||
"why": "`isValidHost` is `/^[\\w.-]+(:\\d+)?$/` — `:` and `[`/`]` are outside the character class, so `[::1]:8443`, `[fd00::1]`, and bare `2001:db8::1` all fail and `setConfig` throws `Invalid host format`. Every other layer of the same client implements IPv6 host handling deliberately and has tests asserting it: `livekitSession.ensureLiveKitProxy` (src/lib/livekitSession.ts:693-703) branches on bracketed vs. bare IPv6, `livekit_proxy.rs::validate_remote_host` + `parse_server_name` accept and strip brackets (test `accepts_plain_hostnames_and_ports` asserts `[::1]:8443` is OK), and `http_proxy.rs::validate_remote_host` allows `[` and `]` with the same assertion. The one gate on the login path is the only place that refuses.",
|
||
"repro": "Enter `[::1]:8443` (or any IPv6 literal) in the connect page's Server Address field and press Log In. ServerPanel's health check succeeds — `profiles.pingHost` → `ensureHttpProxy(host)` → `start_http_proxy` accepts the bracketed host and the tunnel connects, so the profile shows \"online\". main.ts:495 then calls `api.setConfig({ host })`, `isValidHost(\"[::1]:8443\")` returns false, and api.ts:212 throws `new Error(\"Invalid host format\")` before any request is issued. The user sees a generic failure on a server the app just reported reachable. Same for register (main.ts:512) and auto-login (main.ts:686). No test locks this behaviour (tests/unit/api.test.ts only exercises DNS-name hosts).",
|
||
"evidence": "function isValidHost(host: string): boolean {\n return /^[\\w.-]+(:\\d+)?$/.test(host) && host.length <= 253;\n}\n...\nif (newConfig.host !== undefined && !isValidHost(newConfig.host)) {\n log.error(\"setConfig rejected invalid host\", { host: newConfig.host });\n throw new Error(\"Invalid host format\");\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "aefcd27",
|
||
"test": "Client/tests/unit/api.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Widen the one regex in api.ts:82 to allow a bracketed IPv6 literal: return /^(\\[[0-9A-Fa-f:.]+\\]|[\\w.-]+)(:\\d+)?$/.test(host) && host.length <= 253; (single shared gate, all three callers route through it).",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0137",
|
||
"title": "Upload rejection echoes the raw storage error, disclosing absolute server filesystem paths to any authenticated user",
|
||
"file": "Server/api/upload_handler.go",
|
||
"line": 193,
|
||
"severity": "low",
|
||
"why": "The 400 body is built with fmt.Sprintf(\"upload rejected: %s\", saveErr) and storage.Save's error strings embed the resolved absolute destination path (\"creating file %s\", \"syncing file %s\", \"resolved path %q escapes storage directory\"). Every other error path in this file returns a fixed message; this one forwards internal detail to the caller.",
|
||
"repro": "Any authenticated user POSTs a multipart upload while the storage directory is unwritable (disk full, permissions changed, read-only mount). storage.Save returns `creating file /srv/owncord/data/uploads/9f1c...-uuid: permission denied` from storage.go:127, and handleUpload returns HTTP 400 with body {\"error\":\"BAD_REQUEST\",\"message\":\"upload rejected: creating file /srv/owncord/data/uploads/9f1c...: permission denied\"}, handing the client the server's absolute storage path and the generated file UUID.",
|
||
"evidence": "Server/api/upload_handler.go:189-195\n if saveErr != nil {\n slog.Warn(\"file upload rejected\", \"error\", saveErr)\n writeJSON(w, http.StatusBadRequest, errorResponse{\n Error: \"BAD_REQUEST\",\n Message: fmt.Sprintf(\"upload rejected: %s\", saveErr),\n })\nServer/storage/storage.go:127 return 0, fmt.Errorf(\"creating file %s: %w\", dst, err)\nServer/storage/storage.go:152 return 0, fmt.Errorf(\"syncing file %s: %w\", dst, syncErr)",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/api/upload_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "08e5ebaa"
|
||
},
|
||
"suggestedFix": "Keep the detail in the existing slog.Warn and stop echoing it: return a fixed `Message: \"upload rejected\"`. If the user-facing reasons matter (file type, size), match on the known sentinel/validation errors from storage and map those to their own fixed messages, defaulting everything else to 500 with the generic body.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0138",
|
||
"title": "The status picker is never actually disabled while the socket is down — a custom status set then is silently dropped and never re-sent",
|
||
"file": "Client/src/components/UserBar.ts",
|
||
"line": 187,
|
||
"severity": "low",
|
||
"why": "updatePickerDisabled's only effect is toggling the class `ub-status-picker--disabled`, which no stylesheet defines — the class appears nowhere in src/styles/*.css, only in UserBar.ts and its unit test. StatusPicker has no internal disabled state either, so the dropdown and its custom-status input stay fully interactive while connectionStatus !== \"connected\". onCustomStatusChange then persists the text locally but skips ws.send, and nothing ever re-sends it: restoreSavedPresence (MainPage.ts:180) re-sends only `status`, never `custom_status`, and StatusPicker's commit() sets lastCommittedCustom before calling onChange, so a later blur/Enter with the same text short-circuits and never retries.",
|
||
"repro": "The WS drops (uiStore.connectionStatus becomes \"reconnecting\"). The status dot is still clickable — no CSS makes it otherwise. Open the picker, type \"in a meeting\" in the custom-status input and press Enter. commit() sets lastCommittedCustom = \"in a meeting\" and calls onCustomStatusChange, which saveCustomStatus()s it to localStorage but skips ws.send because canSetStatus() is false. The socket reconnects; restoreSavedPresence sends only { status }. The UserBar input and the local prefs show \"in a meeting\" indefinitely, no other user ever sees it, and re-opening the picker and pressing Enter again is a no-op because text === lastCommittedCustom. The user must type a different string first to get it to the server.",
|
||
"evidence": "UserBar.ts:185-193 (the entire 'disable' implementation):\n const updatePickerDisabled = (): void => {\n const enabled = canSetStatus();\n statusPickerWrap.classList.toggle(\"ub-status-picker--disabled\", !enabled);\n if (!enabled) { statusPickerWrap.title = \"Offline\"; } else { statusPickerWrap.title = \"\"; }\n };\n\nrg 'ub-status-picker--disabled' over the client tree returns only UserBar.ts:187 and tests/unit/status-picker-userbar.test.ts — no CSS rule.\n\nUserBar.ts:160-169:\n onCustomStatusChange: (text: string) => {\n saveCustomStatus(text);\n const ws = options?.ws;\n if (ws !== null && ws !== undefined && canSetStatus()) { ws.send({ ... custom_status: text }) }\n }\n\nStatusPicker.ts:183-189:\n const commit = (): void => {\n const text = input.value.trim()...;\n if (text === lastCommittedCustom) return;\n lastCommittedCustom = text; // advanced even though onChange may drop the send\n input.value = text; onChange(text);\n };\n\nMainPage.ts:180-191 (reconnect repair covers status only):\n function restoreSavedPresence(): void {\n const status = loadUserStatus(); ... ws.send({ type: \"presence_update\", payload: { status } });\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "6c379c4",
|
||
"test": "Client/tests/unit/status-picker-userbar.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Add the missing rule to src/styles/app.css so the documented disable is real: `.user-bar .ub-status-picker--disabled { pointer-events: none; opacity: 0.5; }`. One rule at the wrap element covers the dot, the options and the custom-status input, so no send site needs a second guard.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0139",
|
||
"title": "Emoji create/delete fan-out is tied to the request context, so an aborted request commits the mutation and silently skips the emoji_update broadcast",
|
||
"file": "Server/api/emoji_handler.go",
|
||
"line": 250,
|
||
"severity": "low",
|
||
"why": "broadcastEmojiSet is called with r.Context() after the row has already been committed. If the client aborts (or the request deadline fires) between the commit and the re-read, svc.Emoji.List(ctx) fails with context.Canceled, the error is logged and swallowed, and no emoji_update is sent — every connected client keeps the stale set. The same file's service layer already uses context.WithoutCancel for its post-commit work (service/emoji.go: db.WriteAudit(context.WithoutCancel(ctx), ...)), so the correct pattern is established and not applied to the fan-out.",
|
||
"repro": "An admin issues DELETE /api/v1/emoji/7 and the connection is dropped (client navigates away, proxy times out, user hits Escape in the admin UI) right after svc.Emoji.Delete commits. r.Context() is cancelled, so svc.Emoji.List(ctx) inside broadcastEmojiSet returns context.Canceled, the branch logs and returns, and BroadcastEmojiUpdate is never called. Emoji 7 is gone from the DB and its image route now 404s, but every already-connected client still lists :shortcode: in the picker, still renders it in the autocomplete, and gets a broken image for every message that uses it, until each client reconnects. Same failure on the create path at line 217: the emoji exists but nobody sees it appear.",
|
||
"evidence": "emoji_handler.go:239-251:\n removed, err := svc.Emoji.Delete(r.Context(), user.ID, id) // row is gone here\n ...\n broadcastEmojiSet(r.Context(), svc, broadcaster) // request ctx\n w.WriteHeader(http.StatusNoContent)\n\nemoji_handler.go:258-268:\n func broadcastEmojiSet(ctx context.Context, svc *service.Services, broadcaster EmojiBroadcaster) {\n if broadcaster == nil { return }\n list, err := svc.Emoji.List(ctx)\n if err != nil { slog.Error(\"failed to load emoji for broadcast\", \"error\", err); return }\n broadcaster.BroadcastEmojiUpdate(list)\n }\n\nservice/emoji.go (the pattern that was applied to the audit write but not the fan-out):\n db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, \"emoji_delete\", ...)\n\nemoji_handler.go:217 is the identical call on the create path.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/api/emoji_handler_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "14c8faf0",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Detach cancellation at the single shared helper rather than at both call sites: inside broadcastEmojiSet, `list, err := svc.Emoji.List(context.WithoutCancel(ctx))`.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0140",
|
||
"title": "Group-DM creation ties its dm_channel_open fan-out to the request context, so an aborted request creates a group DM nobody can see",
|
||
"file": "Server/api/dm_handler.go",
|
||
"line": 312,
|
||
"severity": "low",
|
||
"why": "This is the same defect already confirmed at emoji_handler.go:250, at a sibling that a fix there would not touch. CreateGroupDM has already committed when broadcastDMOpen is called with r.Context(); if the caller disconnects, that context is cancelled mid-handler, every svc.DMs.DMSummaryFor(ctx, …) inside the loop fails with context.Canceled, and the `continue` at dm_handler.go:268 swallows each one at Debug level. The recovery path the comment relies on — markDMVisibilityChanged forcing a full-ready resync — is a documented no-op today (*ws.Hub does not implement dmVisibilityMarker, dm_handler.go:29-32), so nothing redelivers the event.",
|
||
"repro": "Client A POSTs /api/v1/dms/group with recipients B and C, then aborts the request (closes the window, kills the app, or the client-side AbortSignal fires) after the service commits but before the loop finishes. The channel row and its participant rows exist. Inside broadcastDMOpen the first DMSummaryFor call returns context.Canceled, logs at Debug, and `continue`s for B and C alike — neither ever receives dm_channel_open, and dm_channel_open is unsequenced so the seq-replay resume path can never deliver it. B and C have a group DM they are members of that is absent from their sidebar until they fully re-login. The identical pattern is at line 223 (group-leave refresh — survivors keep rendering the member who left) and line 351 (rename refresh — participants keep the old group name). context.WithoutCancel is used two lines below at 236 for the voice eviction, so the file already knows the distinction.",
|
||
"evidence": "api/dm_handler.go:312 broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, result.ParticipantIDs)\napi/dm_handler.go:265-269 summary, pErr := svc.DMs.DMSummaryFor(ctx, pid, channelID)\n if pErr != nil { slog.Debug(...); continue }\napi/dm_handler.go:236 ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), ...) // the correct pattern, 76 lines up\napi/dm_handler.go:29-32 // NOTE: *ws.Hub does not export this yet — ... the assertion below misses and this is a no-op.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Server/api/dm_handler_visibility_ctx_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "108bbe42",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "One line at the top of the shared helper: in broadcastDMOpen (dm_handler.go:252), after the nil/empty guard, add `ctx = context.WithoutCancel(ctx)`. That covers all three call sites (create :312, rename :351, group-leave :223) without touching any caller.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0141",
|
||
"title": "Autostart toggle's read-back races its own write, leaving the switch showing OFF while the OS setting is ON",
|
||
"file": "Client/src/components/settings/AdvancedTab.ts",
|
||
"line": 264,
|
||
"severity": "low",
|
||
"why": "buildAutostartRow renders the toggle immediately in the OFF position and corrects it from an unawaited IIFE that resolves a dynamic import and then `isEnabled()`. The toggle is clickable during that window, and createToggle flips the class optimistically before invoking onChange (helpers.ts:66-71). If the user clicks before the init's `isEnabled()` resolves, the two async chains race: the init's stale `false` result unconditionally overwrites both the `enabled` variable and the visual state that the click path just set, so the OS ends up with autostart enabled while the UI and the local `enabled` shadow both say OFF.",
|
||
"repro": "OS autostart is currently OFF. Open Settings → Advanced; the Launch on Login row paints immediately with the toggle OFF while `await import(\"@tauri-apps/plugin-autostart\")` is still fetching the chunk. Click the toggle during that window: onChange(true) runs enable(), the OS registers autostart. The init IIFE's `isEnabled()` call, issued before enable() completed, resolves false → line 264 sets enabled=false and line 265 removes the \"on\" class. The panel now shows OFF, `enabled` is false, and the app really does launch on login. The user's next click is read as \"turn on\" and calls enable() again, so the state cannot be corrected from that panel without closing and reopening it.",
|
||
"evidence": "AdvancedTab.ts:239-270:\n let enabled = false;\n const toggle = createToggle(false, { signal, onChange: (nowOn) => { void (async () => {\n const { enable, disable } = await import(\"@tauri-apps/plugin-autostart\");\n if (nowOn) await enable(); else await disable();\n enabled = nowOn;\n } catch { toggle.classList.toggle(\"on\", enabled); ... } )(); }});\n ...\n void (async () => {\n const { isEnabled } = await import(\"@tauri-apps/plugin-autostart\");\n enabled = await isEnabled(); // line 264 — no guard against a click already applied\n toggle.classList.toggle(\"on\", enabled);\n toggle.setAttribute(\"aria-checked\", String(enabled));\n })();\n\nhelpers.ts:66-71 (optimistic flip before onChange):\n const nowOn = !toggle.classList.contains(\"on\");\n toggle.classList.toggle(\"on\", nowOn);\n opts.onChange(nowOn);",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "1270085",
|
||
"test": "Client/tests/unit/advanced-tab.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "One boolean in buildAutostartRow: set `let touched = false;` and `touched = true;` as the first line of onChange, then guard the init write — `if (!touched) { enabled = await isEnabled(); toggle.classList.toggle(\"on\", enabled); toggle.setAttribute(\"aria-checked\", String(enabled)); }` (keep the isEnabled() call inside the try so the catch still removes the row outside Tauri). Alternative one-liner: render the toggle with `aria-disabled`/pointer-events off until the init IIFE resolves.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0142",
|
||
"title": "ChannelSidebar never repaints on a role change, so channel-management and voice-moderation affordances stay stale after a promotion or demotion",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 859,
|
||
"severity": "low",
|
||
"why": "canManageChannels() (line 627, and attachDragHandlers/attachChannelContextMenu) and canModerateVoice() (line 311) are evaluated at render time from authStore.user.role and the roles store, but the sidebar's only authStore subscription selects `s.serverName`. Nothing subscribes to authStore.user or to the roles store, so MEMBER_UPDATE (dispatcher.ts:672 updateUser({role})) and ROLES_UPDATE (dispatcher.ts:682 setRoles) change the answer without triggering renderChannels().",
|
||
"repro": "User A is an admin with MANAGE_CHANNELS. An owner demotes A to a plain member; the server sends member_update, dispatcher.ts:672 writes the new role into authStore.user. The sidebar does not re-render, so A still sees the category \"+\" button, Edit/Delete Channel in the right-click menu, the voice-moderation submenu, and live drag handles. A then drags a channel: drag-reorder.ts:162 calls updateChannelPosition() optimistically on the local store and only afterwards issues the PATCHes, which the server now 403s — the sidebar is left showing an order the server rejected and never rolls back. The mirror case is equally real: a freshly promoted moderator gets none of these affordances until an unrelated channel/voice event happens to fire renderChannels() (or the socket reconnects).",
|
||
"evidence": "const unsubAuth = authStore.subscribeSelector(\n (s) => s.serverName,\n (serverName) => { if (serverNameEl !== null) setText(serverNameEl, serverName ?? \"Server Name\"); },\n);\n// subscriptions registered in mount(): dmStore.channels, channelsStore.channels,\n// channelsStore.activeChannelId, authStore.serverName, uiStore.collapsedCategories,\n// uiStore.connectionStatus, voiceStore struct-sig, voiceStore speaking — no role source.\n// vs dispatcher.ts:664-672 \"...without this a promotion or demotion of the current user\n// would leave every affordance stale until the socket reconnects\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "b0f9576e"
|
||
},
|
||
"suggestedFix": "One extra subscription in mount(), next to the existing authStore one: `unsubscribers.push(authStore.subscribeSelector((s) => s.user?.role ?? \"\", () => renderChannels()));` and, for permission-mask edits, `unsubscribers.push(channelsStore.subscribeSelector((s) => s.roles, () => renderChannels()));`",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0143",
|
||
"title": "DM profile notes are saved under the legacy unscoped key on every real code path — MainPage never passes `host`, so notes bleed between servers",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 253,
|
||
"severity": "low",
|
||
"why": "DmProfileSidebarOptions.host exists specifically to scope the note's localStorage key per server (DmProfileSidebar.ts:35-44), and loadNote/saveNote fall back to `owncord:dm-note:{userId}` when it is \"\". The only production caller omits it, even though MainPage already has apiConfig.host in scope and uses it to scope channel mutes, the NSFW gate and audio volume (MainPage.ts:105-107). Only the unit tests ever exercise the scoped path.",
|
||
"repro": "Connect to server A (multi-profile client, profiles.ts), open the DM with user id 5, type a private note (\"owes me money\"). It is written to localStorage key `owncord:dm-note:5`. Disconnect, connect to unrelated server B, open the DM with that server's user id 5 — a different person — and the note about server A's user is displayed in their profile panel. Editing it there overwrites the original, which is unrecoverable. Every per-server local state the same page owns (channel mutes, NSFW gate, per-user volume) is host-scoped; DM notes are the one that was left unscoped.",
|
||
"evidence": "// MainPage.ts:253\ndmProfileSidebar = createDmProfileSidebar({\n user: { id: recipient.id, username: ..., avatar: ..., status, about: null, joinDate: null },\n onClose: () => { dmProfileSidebar?.destroy?.(); dmProfileSidebar = null; },\n}); // <-- no `host`\n\n// DmProfileSidebar.ts:107\nconst key = host !== \"\" ? scopedNoteKey(userId, host) : legacyNoteKey(userId);",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7be9ccd2",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "f5d9645b"
|
||
},
|
||
"suggestedFix": "Add one property at MainPage.ts:253: `host: apiConfig.host ?? \"\",` in the createDmProfileSidebar options (apiConfig is already in scope from line 95). loadNote already falls back to the legacy key, so existing notes migrate on first save.",
|
||
"fixed": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0144",
|
||
"title": "Bare URLs ending in a closing paren are linkified to a truncated address",
|
||
"file": "Client/src/components/message-list/content-parser.ts",
|
||
"line": 171,
|
||
"severity": "low",
|
||
"why": "renderMentions strips every trailing `)` from an autolinked URL as \"sentence-level punctuation\", with no bracket balancing. The anchor's href becomes a different URL than the one the author typed and than the visible link text's source, so the link points at a wrong (usually 404) address. The markdown layer deliberately keeps the paren in the token (markdown.ts urlEnd only trims `*_~|`), and the masked-link path preserves it — this is the one path that mangles it.",
|
||
"repro": "Send the message `https://en.wikipedia.org/wiki/Rust_(programming_language)` as plain text (no brackets). parseInline emits it as one text node; renderMentions strips the final `)`; the rendered anchor's href and text are `https://en.wikipedia.org/wiki/Rust_(programming_language` and a bare `)` text node follows. Clicking it opens a broken URL.",
|
||
"evidence": "content-parser.ts:170-181:\n const rawUrl = match[0];\n const stripped = rawUrl.replace(/[.,;:!?)]+$/, \"\");\n const trailing = rawUrl.slice(stripped.length);\n const url = stripped || rawUrl;\n if (isSafeUrl(url)) {\n const link = createElement(\"a\", { class: \"msg-link\", href: url, ... });\n setText(link, url);\n\nmarkdown.ts:87-106 urlEnd keeps `)` in the token (it only trims runs of `*_~|`), so the full URL reaches renderMentions.\n\ntests/unit/content-markdown.test.ts:400 only locks the *masked*-link form `[wiki](https://en.example.org/wiki/Foo_(bar))`, which goes through buildMaskedLink and is unaffected.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/renderers.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "826b8aa7"
|
||
},
|
||
"suggestedFix": "In content-parser.ts:171, only strip a trailing `)` when it is unbalanced — after the existing strip, if the removed trailing text starts with `)` and the URL contains more `(` than `)`, give one `)` back. Smallest form: strip `[.,;:!?]+$` first, then drop a single trailing `)` only when `(stripped.match(/\\(/g)||[]).length < (stripped.match(/\\)/g)||[]).length`. One change in the shared renderMentions covers every caller.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0145",
|
||
"title": "Admin API-token creation accepts a negative expires_hours and mints a token that never expires",
|
||
"file": "Server/admin/handlers_tokens.go",
|
||
"line": 89,
|
||
"severity": "low",
|
||
"why": "ExpiresHours has no validation. The `> 0` test sends any negative value down the nil-expiresAt branch, which db.CreateAPIToken stores as \"never expires\" — the caller asked for a bounded credential and silently received a permanent one. Symmetrically, a value above ~2.56e6 overflows time.Duration(int64 ns) and produces a past timestamp, so the response hands the admin a raw token that GetActiveAPIToken will never resolve.",
|
||
"repro": "POST /admin/api/tokens as Owner with {\"label\":\"ci\",\"expires_hours\":-1}. Expected: 400, or a token already expired. Actual: 201 with a raw bearer token whose api_tokens.expires_at is NULL — a permanent credential that survives password change and bulk logout. Conversely {\"expires_hours\":3000000}: 3e6 * 3.6e12 ns overflows int64, time.Now().Add(negative) yields a past instant, and the returned token 401s on first use with no error reported at creation.",
|
||
"evidence": "handlers_tokens.go:88-103:\n var expiresAt *time.Time\n if req.ExpiresHours > 0 {\n t := time.Now().Add(time.Duration(req.ExpiresHours) * time.Hour)\n expiresAt = &t\n }\n id, err := database.CreateAPIToken(r.Context(), user.ID, auth.HashToken(raw), req.Label, expiresAt)\n ...\n writeJSON(w, http.StatusCreated, createTokenResponse{ID: id, Token: raw, ...})\n\ncreateTokenRequest.ExpiresHours is a plain `int` (line 42) decoded straight from JSON with no bounds check. The doc comment at line 41 states \"ExpiresHours 0 means never expires\" — negatives are undocumented and fall into the same branch.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/admin/api_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "d9bf6003"
|
||
},
|
||
"suggestedFix": "One guard in handleCreateAPIToken, next to the label check: `if req.ExpiresHours < 0 || req.ExpiresHours > 24*365*10 { writeErr(w, http.StatusBadRequest, \"BAD_REQUEST\", \"expires_hours must be between 0 and 87600\"); return }`. That kills both the never-expires-on-negative case and the Duration overflow with a single bounds check before line 88.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0146",
|
||
"title": "CheckForUpdate has no in-flight dedupe, so cache expiry stampedes GitHub and burns the server's API quota for everyone",
|
||
"file": "Server/updater/updater.go",
|
||
"line": 170,
|
||
"severity": "low",
|
||
"why": "The release-metadata cache is a plain check-then-act: every caller that finds the cache expired drops the lock and issues its own outbound GitHub API call. The same file already imports and uses `golang.org/x/sync/singleflight` (`textAssetSF`) to dedupe the sibling text-asset fetch — `FetchTextAssetCached` — and `api/client_update.go:73-75` states the intent explicitly (\"Cached with the same TTL as the release info so this unauthenticated endpoint does not perform an outbound fetch on every request (DoS hardening)\"). CheckForUpdate is the one fetch in the package that never got that treatment, and it is reachable unauthenticated.",
|
||
"repro": "Route `/api/v1/client-update/{target}/{current_version}` is mounted unauthenticated (api/router.go:292) behind only a 30-req/min per-IP limiter (clientUpdateRateLimitPerMinute = 30, api/constants.go:35). With the 1h cache expired, fire 30 concurrent GETs from one IP: all 30 pass the expiry check before any of them repopulates `u.cache`, so 30 concurrent `GET api.github.com/repos/.../releases/latest` calls go out (each with a 30s fetchTimeout). Two such minutes exceed GitHub's 60/hr unauthenticated quota for the server's egress IP; GitHub then answers 403, `fetchLatestRelease` returns `github API returned status 403`, and that error is written to the process-wide `cachedErr` for errorCacheTTL. For the next 5 minutes every caller — including the owner-only admin update handlers at admin/update_handlers.go:25 and :56, which share the same Updater — gets the cached 403. Repeating the burst every 5 minutes keeps update checks dead indefinitely. The same stampede occurs benignly in normal operation: every client auto-polling at the moment the hourly cache expires triggers its own outbound call.",
|
||
"evidence": "u.mu.Lock()\nif u.cache != nil && now.Before(u.cacheExpiry) { cached := *u.cache; u.mu.Unlock(); return cached, nil }\nif u.cachedErr != nil && now.Before(u.errCacheExpiry) { ... }\nu.mu.Unlock()\n\nfetchCtx, cancel := detachFetch(ctx)\ndefer cancel()\n\ninfo, err := u.fetchLatestRelease(fetchCtx) // <- no singleflight, unlike textAssetSF\nif err != nil {\n\tu.mu.Lock()\n\tu.cachedErr = err\n\tu.errCacheExpiry = now.Add(errorCacheTTL)\n\tu.mu.Unlock()\n\treturn UpdateInfo{}, err\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Server/updater/release_singleflight_test.go",
|
||
"revertProof": "pass",
|
||
"branchCommit": "2d7643ad"
|
||
},
|
||
"suggestedFix": "Add `releaseSF singleflight.Group` to Updater and route the fetch through it, mirroring assets.go:100 — replace line 173 with `v, err, _ := u.releaseSF.Do(\"latest-release\", func() (any, error) { return u.fetchLatestRelease(fetchCtx) })` and keep the existing cache/errCache writes inside that func so a single winner fills the cache for the whole herd. One change in the shared method covers both the public route and the admin handlers.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0147",
|
||
"title": "SearchOverlay re-registers per-result click listeners on the component-lifetime AbortSignal on every render, pinning every discarded result row",
|
||
"file": "Client/src/components/SearchOverlay.ts",
|
||
"line": 96,
|
||
"severity": "low",
|
||
"why": "`renderResults()` detaches the old rows with `clearChildren` and then calls `item.addEventListener(\"click\", ..., { signal })` for each new row, where `signal` is the single overlay-lifetime AbortController created at factory time (line 40-41). Detaching a node does not remove its listeners, and an abort algorithm registered on a signal holds a reference to its target until the signal aborts — which only happens in `destroy()`. Every re-render therefore adds N more permanently-retained abort algorithms, each keeping a detached `<div>` subtree alive. Same defect class as the already-confirmed context-menu.ts:88 and VoiceAudioTab.ts:490 findings.",
|
||
"repro": "Open the search overlay and type a 10-character query. Debounced search fires and `renderResults()` runs once per completed search; each keystroke that lands past the 300 ms debounce plus the 500 ms rate-limit window triggers another. Then press ArrowDown/ArrowUp to walk the list — lines 171-186 call `renderResults()` on *every* arrow keypress. With 20 results and 100 arrow presses, 2000 detached `<div class=\"search-result-item\">` subtrees (each with a header, three spans and a content div) plus their closures are retained by `ac.signal` and are unreachable for GC until the overlay is destroyed. Verified in a heap snapshot pattern identical to the confirmed context-menu.ts:88 case.",
|
||
"evidence": "const ac = new AbortController();\nconst signal = ac.signal; // overlay lifetime, aborted only in destroy()\n\nfunction renderResults(): void {\n clearChildren(resultsDiv); // detaches rows; listeners are NOT removed\n for (let i = 0; i < results.length; i++) {\n const item = createElement(\"div\", {...});\n ...\n item.addEventListener(\"click\", () => { options.onSelectResult(r); options.onClose(); }, { signal });\n resultsDiv.appendChild(item);\n }\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c3837fa",
|
||
"branchCommit": "7b1779a",
|
||
"test": "Client/tests/unit/search-overlay.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1367
|
||
},
|
||
"suggestedFix": "Register one delegated click listener on resultsDiv at mount time instead of one per row: `resultsDiv.addEventListener(\"click\", (e) => { const row = (e.target as Element).closest(\".search-result-item\"); if (!row) return; const r = results[Number(row.getAttribute(\"data-testid\")!.slice(\"search-result-\".length))]; if (r) { options.onSelectResult(r); options.onClose(); } }, { signal });` and delete the per-row addEventListener at line 96-103. One listener for the overlay's whole lifetime, no per-render accumulation.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0148",
|
||
"title": "A failed channel delete leaves the modal stuck on \"Deleting...\" forever — its error path is unreachable",
|
||
"file": "Client/src/components/DeleteChannelModal.ts",
|
||
"line": 93,
|
||
"severity": "low",
|
||
"why": "The confirm handler disables the button, writes \"Deleting...\", and relies on `onConfirm()` REJECTING to restore the button and show the inline error. Its only caller catches the rejection itself and returns a resolved promise, so the catch block (and the whole `delete-channel-error` element) is dead code and the button is never re-enabled.",
|
||
"repro": "As a user whose delete is refused (403 from adminDeleteChannel, or with the server unreachable), open a channel's context menu → Delete Channel → click \"Delete Channel\". SidebarArea.ts:333-340 awaits `api.adminDeleteChannel`, catches the error, shows a toast, and returns normally; DeleteChannelModal's `await onConfirm()` therefore resolves. The button stays `disabled` reading \"Deleting...\", `errorEl` stays `display:none`, and retrying is impossible without closing (Cancel/X/Escape) and reopening the dialog.",
|
||
"evidence": "// DeleteChannelModal.ts:91-98 — only re-enables on rejection\ntry { await onConfirm(); } catch (err) { errorEl.style.display = \"block\"; ... deleteBtn.removeAttribute(\"disabled\"); setText(deleteBtn, \"Delete Channel\"); }\n\n// SidebarArea.ts:332-341 — never rethrows, so the catch above can never run\nonConfirm: async () => {\n try { await api.adminDeleteChannel(channel.id); modal.destroy?.(); activeModal = null; }\n catch (err) { getToast()?.show(msg, \"error\"); }\n},",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b1fb565",
|
||
"branchCommit": "58fd21b",
|
||
"test": "Client/tests/unit/delete-channel-modal.test.ts",
|
||
"revertProof": "self-reported",
|
||
"pr": 1366
|
||
},
|
||
"suggestedFix": "Restore the button in the shared function rather than in the caller: in DeleteChannelModal.ts wrap the call as `try { await onConfirm(); } catch (err) { …existing inline error… } finally { if (overlay?.isConnected === true) { deleteBtn.removeAttribute(\"disabled\"); setText(deleteBtn, \"Delete Channel\"); } }`. On success the caller already destroy()s the modal so `overlay` is null and the finally is a no-op; on failure the button is re-armed regardless of whether the caller rethrows or swallows. One guard, covers every present and future caller, and keeps both existing test suites green.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0149",
|
||
"title": "The reaction emoji picker drops the server's custom emoji, so custom-emoji reactions are unreachable except by accident",
|
||
"file": "Client/src/pages/main-page/ReactionController.ts",
|
||
"line": 84,
|
||
"severity": "low",
|
||
"why": "createEmojiPicker builds the \"Server\" category only when `customEmoji` is supplied (EmojiPicker.ts:580). The composer passes `listCustomEmoji()`; the reaction picker passes nothing. Custom-emoji reactions are a fully supported, rendered feature — reactions.ts:30 resolves a `:shortcode:` reaction through buildCustomEmojiNode — so the picker for the one surface that needs them is the one that hides them.",
|
||
"repro": "Upload a custom emoji, then click the \"+\" add-reaction chip on any message. The picker shows Smileys/People/… but no Server category, so no custom emoji can be chosen. Inconsistently, if the user previously inserted that emoji from the composer picker, the shared `owncord:recent-emoji` localStorage key puts the `:shortcode:` in the \"Recent\" row of the reaction picker, where it works — so the same emoji is selectable or not depending on unrelated composer history.",
|
||
"evidence": "// ReactionController.ts:84 — no customEmoji passed\nconst picker = createEmojiPicker({ onSelect: ..., onClose: ... });\n\n// EmojiPicker.ts:580 — the Server category is conditional on it\nif (options.customEmoji && options.customEmoji.length > 0) {\n cats.push({ name: SERVER_CATEGORY, emoji: options.customEmoji.map((e) => `:${e.shortcode}:`) });\n}\n\n// MessageInput.ts — the sibling call site does pass it (listCustomEmoji())",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "db0275a2",
|
||
"test": "Client/tests/unit/reaction-controller.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "6d1a785b",
|
||
"pr": 1369
|
||
},
|
||
"suggestedFix": "Pass the set at open time, exactly as the composer does: import { listCustomEmoji } from \"@stores/emoji.store\" in ReactionController.ts and add `customEmoji: listCustomEmoji(),` to the createEmojiPicker({ … }) call at line 84. Reading it inside openPicker (not at controller construction) keeps an emoji_update reflected in the next picker opened, matching the MessageInput.ts:886-888 comment.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0150",
|
||
"title": "Ctrl+K quick switcher is dead with CapsLock on, fires on AltGr+K, and ignores the settings-overlay suspension every other global shortcut honours",
|
||
"file": "Client/src/pages/main-page/OverlayManagers.ts",
|
||
"line": 119,
|
||
"severity": "low",
|
||
"why": "The quick-switcher shortcut compares `e.key === \"k\"` case-sensitively and checks only `ctrlKey || metaKey`. `KeyboardEvent.key` reflects CapsLock and Shift, so with CapsLock on (or Ctrl+Shift+K) the key is \"K\" and the shortcut silently does nothing. It also does not exclude `altKey`, so AltGr (which Chromium/WebView2 reports as ctrlKey+altKey on Windows) opening the overlay and calling `e.preventDefault()` swallows AltGr-produced characters on non-US layouts. And unlike every other app-wide shortcut it has no `isSuspended` gate, so Ctrl+K while the Settings overlay is open stacks the switcher on top and can switch channels behind it. GlobalKeybinds.ts:43-45 documents exactly this case-insensitivity requirement and guards altKey at line 40 and suspension at MainPage.ts:518; the quick switcher, explicitly carved out of that file, never got any of the three.",
|
||
"repro": "(a) Turn CapsLock on, press Ctrl+K -> nothing happens; Ctrl+Shift+K likewise. (b) On a German/Polish layout press AltGr+K -> the quick switcher opens and the intended character is never inserted into the composer. (c) Open Settings, press Ctrl+K -> the quick switcher mounts over the settings overlay and selecting a row switches the channel behind it, while Ctrl+F/Ctrl+M correctly no-op.",
|
||
"evidence": "OverlayManagers.ts:118-127\n const handler = (e: KeyboardEvent): void => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"k\") { // case-sensitive, no !e.altKey, no isSuspended\n e.preventDefault();\n if (instance !== null) { close(); } else { open(); }\n }\n };\n\nSame condition duplicated in QuickSwitcher.ts:155 (handleGlobalKeydown), so the in-overlay close path is dead under CapsLock too.\n\nContrast GlobalKeybinds.ts:40-45\n if (!(e.ctrlKey || e.metaKey) || e.altKey) return;\n if (handlers.isSuspended?.() === true) return;\n // `e.key` is layout-dependent and uppercases with Shift held — compare\n // case-insensitively so Ctrl+Shift+V arrives as \"V\", not a missed \"v\".\n const key = e.key.toLowerCase();\n\nMainPage.ts:505-506 attaches the quick-switcher manager with no isSuspended; MainPage.ts:518 passes `isSuspended: () => uiStore.getState().settingsOpen` to attachGlobalKeybinds only.",
|
||
"status": "fixed",
|
||
"found": "2026-08-13",
|
||
"hunt": "2026-08-13-postopt",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "8787b906",
|
||
"test": "Client/tests/unit/overlay-managers.test.ts",
|
||
"revertProof": "pass",
|
||
"branchCommit": "185a420a"
|
||
},
|
||
"suggestedFix": "Mirror the GlobalKeybinds guard in the two handlers: at OverlayManagers.ts:119 and QuickSwitcher.ts:155 use `if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== \"k\") return;`. For the suspension gap, add an optional `isSuspended?: () => boolean` to createQuickSwitcherManager and early-return on it, wiring `isSuspended: () => uiStore.getState().settingsOpen` at MainPage.ts:505 so both attach sites read the same source of truth.",
|
||
"fixedDate": "2026-08-14"
|
||
},
|
||
{
|
||
"id": "OC-0151",
|
||
"title": "Registration sanitizes the username before bounding it, so a 1 MiB body pins a CPU core for minutes (unauthenticated)",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 285,
|
||
"severity": "high",
|
||
"why": "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))\nServer/api/auth_handler.go:296 if err := auth.ValidateUsername(req.Username); err != nil { // 32-rune cap, runs AFTER\nServer/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 } ... }\nServer/service/message.go:166 func sanitizePass(s string) string { return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) }\ncontrast — Server/api/auth_handler.go:430 if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen { ... } // login bounds first\ncontrast — Server/service/message.go:220 if len(raw) > maxMessageLen*4 { return \"\", ... } // message content bounds first",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "9de9a6b5",
|
||
"test": "Server/api/auth_handler_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0152",
|
||
"title": "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",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 748,
|
||
"severity": "high",
|
||
"why": "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\nserve.go:70 ctx := r.Context()\nserve.go:748 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, \"none\")); err != nil {\nserve.go:756 if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {\nserve.go:513 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {\nserve.go:520 for _, evt := range events { if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {\n-- vs --\nserve_pumps.go:16 wCtx, cancel := context.WithTimeout(ctx, writeTimeout)",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "e2fe7cdc",
|
||
"test": "Server/ws/serve_handshake_write_deadline_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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, ...):\n\nfunc handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {\n\twCtx, cancel := context.WithTimeout(ctx, writeTimeout)\n\tdefer cancel()\n\treturn conn.Write(wCtx, websocket.MessageText, msg)\n}\n\nThe 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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0153",
|
||
"title": "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",
|
||
"file": "Server/admin/setup_handler.go",
|
||
"line": 175,
|
||
"severity": "high",
|
||
"why": "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\":\"<strong pw>\"}. 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\":\"<same pw>\"} -> 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()\nServer/admin/setup_handler.go:175 req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username))\n -> CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID) (setup_handler.go:220)\n\nvs. the already-fixed sibling:\nServer/api/auth_handler.go:285 req.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n // \"a plain call here would store a different string than what handleLogin looks up\n // (which only trims), permanently locking out any username containing one of those characters\"\n\nand the lookup side:\nServer/api/auth_handler.go:407 req.Username = strings.TrimSpace(req.Username) // no sanitizer\nServer/api/auth_handler.go:470 user, err := database.GetUserByUsername(r.Context(), req.Username)\n\nbluemonday v1.0.27 sanitize.go:417-443 — case html.TextToken: default: buff.WriteString(token.String()) // x/net/html TextToken.String() == EscapeString(Data)",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "29619536",
|
||
"test": "Server/admin/setup_handler_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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/J3vb/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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0154",
|
||
"title": "Admin \"Can access\" toggle is silently reverted by the override matrix in the same Save — channel stays public",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 1175,
|
||
"severity": "high",
|
||
"why": "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});\n1172: const path=permTargetPath();\n1173: if(path){\n1174: const masks=collectOverrideMasks();\n1175: if(masks.allow===0&&masks.deny===0)await api('DELETE',path);",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-server-admin",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "69258a51",
|
||
"test": "Client/tests/contract/server-admin-static-channel-perms.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0155",
|
||
"title": "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",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 976,
|
||
"severity": "medium",
|
||
"why": "`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/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\n let sentInWindow = 0;\n for (const [peerId, peerKey] of peers) {\n ...\n if (sentInWindow >= E2EEManager.OFFER_RATE_LIMIT_PER_SEC) { // 60\n await new Promise<void>((resolve) => setTimeout(resolve, E2EEManager.OFFER_RATE_WINDOW_MS));\n sentInWindow = 0;\n ...\n this.deps.getWs()?.send({ type: \"voice_e2ee_offer\", payload: {...} });\n sentInWindow++;\n\nlivekitE2EE.ts:1256-1263\n private async drainPendingRotationOrArmTimer(): Promise<void> {\n if (this._rotationPending) {\n this._rotationPending = false;\n await this.rotateKeyPeriodically(); // second full N-offer burst, sentInWindow back to 0\n return;\n }\n\nServer/ws/voice_e2ee.go:203-206\n ratKey := auth.Key(auth.Key(\"voice_e2ee_offer\", info.UserID), voiceChID)\n if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EEOfferRateLimit, voiceE2EEWindow) {\n return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: \"too many e2ee offers\"}}\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "5db10850",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0156",
|
||
"title": "A queued custom-status presence_update loses its custom_status when a later plain status change supersedes it",
|
||
"file": "Client/src/lib/presence.ts",
|
||
"line": 71,
|
||
"severity": "medium",
|
||
"why": "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.\n\nt=0s Click the UserBar avatar dot -> click \"Idle\".\n UserBar.ts:174 -> presenceSender.send(\"idle\"). limiter.tryConsume() succeeds, {status:\"idle\"} goes out. The window is now closed until t=10s.\n\nt=2s In the same open dropdown, type \"Working on OwnCord\" into the custom-status input and press Enter.\n UserBar.ts:185 -> saveCustomStatus(text); presenceSender.send(\"idle\", \"Working on OwnCord\").\n updatePresence writes the text into membersStore. tryConsume() fails, so\n retry = setTimeout(() => send(loadUserStatus(), \"Working on OwnCord\"), ~8000).\n\nt=4s Click \"Do Not Disturb\" in the same dropdown.\n UserBar.ts:174 -> presenceSender.send(\"dnd\"), customStatus === undefined.\n presence.ts:54-57 clears the t=2s timer; presence.ts:71 re-arms it as\n send(loadUserStatus(), undefined).\n\nt=10s The retry fires and emits ws.send({type:\"presence_update\", payload:{status:\"dnd\"}}) — no custom_status key.\n\nObserved: 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.\n\nExpected: 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).\n\nNot 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\n function send(status: UserStatus, customStatus?: string): void {\n const userId = authStore.getState().user?.id ?? 0;\n if (userId !== 0) {\n updatePresence(userId, status, customStatus); // local optimistic write happens regardless\n }\n if (retry !== null) {\n clearTimeout(retry); // <-- discards the queued frame's custom_status\n retry = null;\n }\n if (limiter.tryConsume()) {\n if (customStatus === undefined) {\n ws.send({ type: \"presence_update\", payload: { status } }); // no custom_status field\n } else {\n ws.send({ type: \"presence_update\", payload: { status, custom_status: customStatus } });\n }\n } else {\n retry = setTimeout(() => {\n retry = null;\n send(loadUserStatus(), customStatus); // status is re-read live; customStatus is the *stale* argument\n }, limiter.getRemainingMs());\n }\n }\n\nBoth producers share one PresenceSender (MainPage.ts:133 `const presenceSender = createPresenceSender(ws, limiters.presence);`) and live in the same dropdown:\n components/UserBar.ts:174 onStatusChange: sender.send(status); // customStatus === undefined\n components/UserBar.ts:185 onCustomStatusChange: sender.send(loadUserStatus(), text);",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7243d1cb",
|
||
"test": "Client/tests/unit/presence-sender.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"coordNote": "Coordinates corrected by the session: the finder cited a stale numbering (file has 84 lines); the verifier read the working tree (send() at 49-73) and its coordinates were used.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0157",
|
||
"title": "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",
|
||
"file": "Client/src/main.ts",
|
||
"line": 763,
|
||
"severity": "medium",
|
||
"why": "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`).\n2) 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.)\n3) `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.\n4) `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.\n5) 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.\nVariant 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\"); } });\nmain.ts:427 appEl!.appendChild(connectedOverlay.element);\nmain.ts:428 connectedOverlay.show();\nmain.ts:430 const unsubReady = ws.on(\"ready\", () => { unsubReady(); connectedOverlay?.markReady(); });\n\nmain.ts:763 if (!isAuthenticated && router.getCurrentPage() === \"main\") {\nmain.ts:776 dispatcherCleanup?.();\nmain.ts:778 sessionCleanup?.();\nmain.ts:780 ws.disconnect();\n // ...no connectedOverlay?.destroy() anywhere in this block\nmain.ts:795 router.navigate(\"connect\");\nmain.ts:796 }\n\nstyles/login.css:1427 .connected-overlay { position: fixed; inset: 0; background: var(--bg-primary); display: none; ... z-index: 200; }\nstyles/login.css:1438 .connected-overlay.visible { display: flex; }\n\nlib/dispatcher.ts:1046 if (payload.code === \"BANNED\") {\nlib/dispatcher.ts:1056 setTransientError(payload.message || \"You have been banned\");\nlib/dispatcher.ts:1057 ws.disconnect();\nlib/dispatcher.ts:1058 clearAuth();",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b9b86067",
|
||
"test": "Client/tests/unit/main.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0158",
|
||
"title": "Archived channel PATCH commits, then skips voice eviction and visibility fan-out when the post-commit re-read fails",
|
||
"file": "Server/admin/handlers_channels.go",
|
||
"line": 249,
|
||
"severity": "medium",
|
||
"why": "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)\nif err != nil || updated == nil {\n\twriteErr(w, http.StatusInternalServerError, \"INTERNAL_ERROR\", \"failed to fetch updated channel\")\n\treturn\n}\nif hub != nil {\n\thub.BroadcastChannelUpdate(updated)\n\tif existing.Archived != updated.Archived {\n\t\tif !existing.Archived && updated.Archived {\n\t\t\thub.CleanupVoiceForChannel(id)\n\t\t}\n\t\thub.RefreshChannelVisibility(updated)\n\t}\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c9b72055",
|
||
"test": "Server/admin/channels_archive_voice_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0159",
|
||
"title": "Handshake/replay writes have no write deadline, so a client that stops reading pins a server goroutine + socket forever",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 520,
|
||
"severity": "medium",
|
||
"why": "reconnectWriteReplay and handleFreshConnect write auth_ok, up to maxColdReplay (5000) replay frames, and the whole ready payload with `conn.Write(ctx, ...)` where ctx is the raw `r.Context()` captured at serve.go:69. websocket.Accept hijacks the connection and net/http's hijackLocked clears the deadlines srv.WriteTimeout (main.go:195) had set, so there is no OS-level deadline either, and the request context is never cancelled while ServeHTTP is still blocked. writePump — the only other writer — wraps every write in context.WithTimeout(ctx, writeTimeout) (serve_pumps.go:16) and authenticateConn wraps its handshake I/O in authDeadline (serve_auth.go:27), so this path is the one outlier. Nothing can unblock it: writePump has not started yet (startPumps runs only after the handshake returns, serve.go:69-98), and the only conn.Close calls are on this same goroutine's own error branches. sweepStaleClients fires at 90s and calls kickClient, which deletes the hub entry and closes the send channels but never touches conn (hub_sweep.go:57-69) — so the entry leaves h.clients (and stops counting against server.max_ws_connections) while the goroutine, TLS session and fd leak permanently.",
|
||
"repro": "Authenticated client opens the WS, sends `auth` with last_seq ~5000 below the hub's current seq, then never reads from the socket (or is simply on a stalled/congested link). handleReconnect takes the buffer/db tier and enters the loop at serve.go:519-525. Once the peer's receive window plus the server's socket send buffer are full (a 5000-frame chat_message replay is multiple MB), conn.Write blocks with no deadline. At T+90s sweepStaleClients kicks the client out of h.clients — freeing the max_ws_connections slot — but the goroutine stays parked in conn.Write for the life of the process. Repeat to accumulate unbounded goroutines/fds. Same shape at serve.go:756 for a large `ready`.",
|
||
"evidence": "serve.go:69 ctx := r.Context()\nserve.go:513 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(...)); err != nil {\nserve.go:519 for _, evt := range events {\nserve.go:520 if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {\nserve.go:756 if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {\n-- contrast, serve_pumps.go:15-18 --\nfunc writePumpWrite(ctx context.Context, conn *websocket.Conn, c *Client, msg []byte) bool {\n\twCtx, cancel := context.WithTimeout(ctx, writeTimeout)\n\terr := conn.Write(wCtx, websocket.MessageText, msg)\n-- hub_sweep.go:56-69: kickClient deletes the map entry + closeSend + UnsubscribeAll, never conn.Close --",
|
||
"status": "duplicate",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": null,
|
||
"suggestedFix": "Add one helper in Server/ws and use it for all four handshake writes instead of raw conn.Write: 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) } — apply at serve.go:513, 520, 748, 756 (and the error-frame write at 765). This mirrors writePumpWrite and needs no caller changes; the existing failure branches already run unregisterFailedHandshake + conn.Close on a write error.",
|
||
"duplicateOf": "OC-0152",
|
||
"rationale": "Same defect as OC-0152: unbounded conn.Write on the handshake/replay path in Server/ws/serve.go, found independently by the flow-reconnect lens. Both records propose the identical handshakeWrite(ctx,...) helper over the same call sites (513, 520, 748, 756, 764). Kept OC-0152 (higher severity, cites all five sites)."
|
||
},
|
||
{
|
||
"id": "OC-0160",
|
||
"title": "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",
|
||
"file": "Client/src/lib/ws.ts",
|
||
"line": 241,
|
||
"severity": "medium",
|
||
"why": "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\nws.ts:239 const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE;\nws.ts:241 if (raw.length > maxSize) {\nws.ts:242 log.warn(\"Message exceeds size limit, dropping\", { size: raw.length });\nws.ts:243 return;\n-- Server/ws/serve_ready.go:326 --\nmembers, err := database.ListMembers(ctx) // no LIMIT, no pagination\nserve_ready.go:363-367 \"channels\": channelPayloads, \"members\": members, \"roles\": roles, \"dm_channels\": dmChannels\n-- main.ts:357 -- ws.connect({ host, token }); // no maxMessageSizeBytes",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "f5aee939",
|
||
"test": "Client/tests/unit/ws-messaging.test.ts, Client/tests/unit/ws-lifecycle.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.)",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0161",
|
||
"title": "A mistyped 2FA enrollment code signs the user out and permanently deletes their saved credential",
|
||
"file": "Client/src/lib/api.ts",
|
||
"line": 155,
|
||
"severity": "medium",
|
||
"why": "`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).\n2. Settings -> Account -> Enable two-factor authentication; enter the correct password, scan the QR.\n3. Type any wrong 6-digit code and submit.\n4. Server: handleConfirmTOTP -> VerifyTOTPCodeOnce fails -> 401 UNAUTHORIZED.\n5. 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.\nExpected: 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/src/lib/api.ts:154-158\n if (res.status === 401) {\n onUnauthorized?.();\n const err = await parseError(res);\n throw new ApiClientError(401, err.error, err.message);\n }\n\nServer/api/totp_handler.go:315-321 (handleConfirmTOTP)\n if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {\n writeJSON(w, http.StatusUnauthorized, errorResponse{\n Error: \"UNAUTHORIZED\",\n Message: \"invalid two-factor code\",\n })\n return\n }\n\nClient/src/main.ts:120-129\n const api = createApiClient({ host: \"\" }, () => {\n if (authStore.getState().isAuthenticated) {\n setTransientError(\"Your session expired — sign in again.\");\n }\n clearAuth();\n });\n\nClient/src/main.ts:786-794 (authStore isAuthenticated subscriber)\n const host = api.getConfig().host;\n if (host && authStore.getState().logoutReason !== \"server_shutdown\") {\n void deleteCredential(host);\n sessionStorage.setItem(\"owncord:skip-auto-login\", \"1\");\n }\n router.navigate(\"connect\");\n\nClient/src/pages/MainPage.ts:486-495 (onConfirmTotp catches and toasts — after clearAuth already ran)\n\nNo 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.",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "01dca528",
|
||
"test": "Client/tests/unit/api.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0162",
|
||
"title": "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",
|
||
"file": "Client/src/lib/ptt.ts",
|
||
"line": 269,
|
||
"severity": "medium",
|
||
"why": "`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<void> {\n savePref(\"pttVk\", vk);\n ...\n await invoke(\"ptt_set_key\", { vkCode: vk });\n if (!listening && vk !== 0) {\n await initPtt(); // starts the poller; never setPttGated(true) / setMuted(true)\n }\n if (vk === 0) {\n await stopPtt(); // the reverse direction DOES rearm, via ungateMic()\n }\n\n// Rust side (src-tauri/src/ptt.rs:311):\nfn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {\n let pressed = vk != 0 && key_down;\n (pressed != was_pressed).then_some(pressed) // idle key after start => None, forever\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "e391caeb",
|
||
"test": "Client/tests/unit/ptt.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0163",
|
||
"title": "A bare (unbracketed) IPv6 host is accepted everywhere except the WebSocket URL, which is built by raw interpolation — login succeeds, the socket never connects",
|
||
"file": "Client/src/lib/ws.ts",
|
||
"line": 540,
|
||
"severity": "medium",
|
||
"why": "`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`;\n\napi.ts:87-91 // Bare (unbracketed) IPv6 literal, e.g. \"2001:db8::1\" or \"::1\".\n if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;\n\nlivekitSession.ts:759-761 } else if ((this.serverHost.match(/:/g) ?? []).length > 1) {\n // Bare IPv6 (multiple colons) — wrap in brackets and add default port\n hostWithPort = `[${this.serverHost}]:443`;\n\nhttp_proxy.rs:311-315 let dial_target = if hostname.contains(':') { format!(\"[{hostname}]:{port}\") } else { ... };",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "f5aee939",
|
||
"test": "Client/tests/unit/ws-messaging.test.ts, Client/tests/unit/ws-lifecycle.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0164",
|
||
"title": "The server's \"update_aborted\" restart-cancel broadcast puts the client into a permanent \"Reconnecting...\" banner — the exact failure it was added to prevent",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 363,
|
||
"severity": "medium",
|
||
"why": "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:\n if (banner !== null && payload.reason !== \"shutdown\") {\n banner.showRestart(payload.delay_seconds);\n }\n\nServerBanner.ts:35-43:\n intervalId = setInterval(() => {\n remaining -= 1;\n if (remaining <= 0) { clearCountdown(); showReconnecting(); return; }\n setText(root, `Server restarting in ${remaining} seconds...`);\n }, 1000);\n\nServer/admin/update_handlers.go:163-181 (the intent this violates):\n // The caller has already broadcast \"restarting in 5s\" ... every failure path\n // must correct that promise -- otherwise the client's restart banner counts\n // down to a permanent \"Reconnecting...\" over a connection that never\n // actually dropped (OC-0226).\n defer func() { if !committed && hub != nil { hub.BroadcastServerRestart(\"update_aborted\", 0) } }()",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c7d8e13c",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0165",
|
||
"title": "scanPluginDirectory aborts the entire scan on the first bad plugin directory, so one malformed plugin disables every other plugin",
|
||
"file": "Server/plugin/loader.go",
|
||
"line": 87,
|
||
"severity": "medium",
|
||
"why": "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)\nloader.go:87 return nil, fmt.Errorf(\"plugin %q: missing entrypoint %s: %w\", e.Name(), manifest.Entrypoint, statErr)\nregistry.go:171-173 manifests, err := scanPluginDirectory(r.cfg.Directory); if err != nil { return fmt.Errorf(...) }\nregistry.go:176 if err := r.installFromDisk(ctx, found); err != nil { slog.Warn(...); continue } // per-plugin policy, never reached\nregistry.go:180 return r.activateAll(ctx) // never reached",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "4bdb24e3",
|
||
"test": "Server/plugin/loader_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0166",
|
||
"title": "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",
|
||
"file": "Server/ws/emit.go",
|
||
"line": 77,
|
||
"severity": "low",
|
||
"why": "`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())`\nevent.go:270-277 `type PresenceSelfEvent struct{ targetUserID int64; payload []byte }` with `TargetUserID()`+`Payload()` (satisfies UserTargetedEvent)\nevent.go:286-311 `presenceEvents` returns `[]Event{PresenceOthersEvent{...}, PresenceSelfEvent{...}}` only when `db.BroadcastStatus(status) != status`, i.e. status == invisible\nhub_broadcast.go:736-737 `h.BroadcastToAllExcept(userID, ...)` / `h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))` ← same logical frame, NORMAL queue\nserve_pumps.go:70-101 writePump: \"Priority 1: drain all pending high-priority messages first\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "21a1a73d",
|
||
"test": "Server/ws/emit_presence_self_priority_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0167",
|
||
"title": "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",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 245,
|
||
"severity": "low",
|
||
"why": "`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\n const queued = this._pendingAnnounces.splice(0);\n for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {\n await this.handleAnnounce(qId, qKey, qSig);\n log.info(\"E2EE: drained queued announce\", { userId: qId });\n }\n\nlivekitE2EE.ts:786-811 (handleAnnounceInner)\n if (this._isKeyHolder && currentRoomKey && keypair) {\n ...\n this.deps.getWs()?.send({\n type: \"voice_e2ee_offer\",\n payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },\n });\n\nServer/ws/voice_join.go:490-495 — voiceJoinComplete replays every existing participant's announce to the joiner:\n if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != \"\" {\n c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "5db10850",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0168",
|
||
"title": "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",
|
||
"file": "Server/db/plugin_queries.go",
|
||
"line": 116,
|
||
"severity": "low",
|
||
"why": "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.\n\nCase 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 —\nfunc (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {\n\trows, err := d.reader.QueryContext(ctx,\n\t\t`SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`,\n\t\tpluginID, prefix+\"%\", limit,\n\t)\n\nSiblings on the same table use exact match:\nServer/db/plugin_queries.go:87 `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`\nServer/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`\nServer/db/plugin_queries.go:108 `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`\n\nNo `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.\n\nplugin_kv's key collation is the default BINARY: Server/migrations/015_plugins.sql declares `key TEXT NOT NULL` with `PRIMARY KEY (plugin_id, key)`.\n\nCaller contract: Server/plugin/host_storage.go:59-67 — \"StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit.\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "a4d5be62",
|
||
"test": "Server/db/plugin_queries_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0169",
|
||
"title": "A failed keyring write deletes the existing secret before the fallback write that may also fail, permanently destroying a good stored credential/identity key",
|
||
"file": "Client/src-tauri/src/secret_store.rs",
|
||
"line": 164,
|
||
"severity": "low",
|
||
"why": "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) => {\n log::error!(\"{SERVICE}: credential store write failed for '{account}': {e}\");\n // An older secret may already sit in the keyring from a prior\n // successful write. get() reads the keyring first, so leaving\n // that stale entry in place would shadow the fresh secret parked\n // in the fallback below — mirrors the read-back-mismatch arm\n // above, which purges for the same reason.\n if let Err(de) = keyring_delete(account) { // <-- line 164, destructive, runs first\n log::warn!(...);\n }\n }\n }\n\n fallback_set(account, secret)?; // <-- line 173, fallible, runs second",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "tauri-rust",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "d1068247",
|
||
"test": "Client/src-tauri/src/secret_store.rs (mod tests: set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail)",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0170",
|
||
"title": "roles_update fan-out is tied to the request context, so a role create/update/delete commits with no client ever told",
|
||
"file": "Server/admin/handlers_roles.go",
|
||
"line": 262,
|
||
"severity": "low",
|
||
"why": "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) {\n\tif hub == nil || database == nil {\n\t\treturn\n\t}\n\tlist, err := database.ListRoles(r.Context())\n\tif err != nil {\n\t\tslog.Warn(\"admin: roles_update broadcast skipped, role list unreadable\", \"err\", err)\n\t\treturn\n\t}\n\thub.BroadcastRolesUpdate(list)\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "6cd7c01a",
|
||
"test": "Server/admin/handlers_roles_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In broadcastRoles, read with context.WithoutCancel(r.Context()) instead of r.Context() — one change in the shared helper covers all three call sites.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0171",
|
||
"title": "Optimistic send reconciles in place, so a message that arrives while the send is in flight is rendered permanently out of id/time order",
|
||
"file": "Client/src/stores/messages.store.ts",
|
||
"line": 257,
|
||
"severity": "low",
|
||
"why": "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)); ... }\nmessages.store.ts:257 let updatedMsgs = [...existing, message];\nmessages.store.ts:759-763 const updatedList = existing.map((m) => m.correlationId === correlationId ? { ...m, id: messageId, timestamp, status: \"sent\" as const, errorCode: null } : m,);",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "5980a38b",
|
||
"test": "Client/tests/unit/messages.store.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0172",
|
||
"title": "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",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 480,
|
||
"severity": "low",
|
||
"why": "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\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\treturn\n\t}\n\n…the announce relay it skips (voice_join.go:493):\n\t\tif pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != \"\" {\n\t\t\tc.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))\n\t\t}\n\n…the receiving guard that then drops the holder's offer (Client/src/lib/livekitE2EE.ts:859):\n const peerKey = this._peerPublicKeys.get(fromUserId);\n if (!peerKey) {\n log.warn(\"E2EE: received offer from unknown peer\", { fromUserId });\n return;\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "dddd0560",
|
||
"test": "Server/ws/oc_0172_voice_join_getchannelvoicestates_error_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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):\n\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\t// The joiner's own voice_state was already broadcast above, so this\n\t\t// rollback must broadcast the compensating voice_leave.\n\t\th.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true)\n\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to join voice channel\"))\n\t\treturn\n\t}\n\nOne guard in the shared function; rollbackVoiceJoin already re-runs updateKeyHolder and scopes the delete to state.JoinedAt.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0173",
|
||
"title": "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",
|
||
"file": "Server/admin/setup_wizard.go",
|
||
"line": 104,
|
||
"severity": "low",
|
||
"why": "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))\nServer/admin/setup_wizard.go:114 motd := strings.TrimSpace(setupSanitizer.Sanitize(*wr.Motd))\nServer/admin/setup_wizard.go:203-208 updates[\"server_name\"] = *wr.ServerName ; updates[\"motd\"] = *wr.Motd\n\nvs. the other writer of the exact same keys, with no sanitizer anywhere in the file:\nServer/admin/handlers_settings.go:93-110 normalizeSettingUpdates — only require_2fa/registration_open are normalized\nServer/admin/handlers_settings.go:66-72 INSERT INTO settings (key, value) ... ON CONFLICT DO UPDATE SET value = excluded.value\n\nconsumer side:\nServer/ws/hub.go:172-176 refreshSettingsLocked reads settings server_name/motd into the hub cache\nServer/ws/serve_ready.go:19 buildAuthOK ships them to every client\nClient/src/components/ConnectedOverlay.ts:62 setText(srvIcon, serverName.charAt(0).toUpperCase())",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "5b2211c5",
|
||
"test": "Server/admin/setup_wizard_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.)",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0174",
|
||
"title": "Every automatic channel selection filters to type \"text\", stranding users whose only visible channels are announcement channels on a blank pane",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 338,
|
||
"severity": "low",
|
||
"why": "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\");\ndispatcher.ts:543 .filter((ch) => ch.type === \"text\")\ndispatcher.ts:716 .filter((ch) => ch.type === \"text\")\nSidebarArea.ts:474 if (ch.type === \"text\") {\nSidebarArea.ts:565 if (ch.type === \"text\") {\n(vs Server/ws/serve_ready.go:226 if visibleChannels[i].Type == \"text\" || visibleChannels[i].Type == \"announcement\" {)",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "d066591a",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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/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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0175",
|
||
"title": "applySetChannelID's post-Subscribe revalidation re-checks READ but not `archived`, so a channel archived mid-focus leaves the socket permanently subscribed to it",
|
||
"file": "Server/ws/handlers.go",
|
||
"line": 265,
|
||
"severity": "low",
|
||
"why": "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)\nServer/ws/handlers.go:261 if ch != nil && ch.Type == \"dm\" { ... }\nServer/ws/handlers.go:265 } else if ch != nil && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {\nServer/ws/handlers.go:266 return // no ch.Archived check — HandleChannelFocus has one, this mirror does not",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "eb70032d",
|
||
"test": "Server/ws/handler_focus_revoke_race_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0176",
|
||
"title": "Tray Status menu sends presence_update directly, bypassing the shared presence rate limiter and its retry",
|
||
"file": "Client/src/main.ts",
|
||
"line": 263,
|
||
"severity": "low",
|
||
"why": "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).\n2. 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).\n3. 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.\n4. 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.\n\nThe 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.\n\nSecondary 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.\n\nThe 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\n void listen<string>(\"status-change\", (e) => {\n const status = e.payload;\n if (status === \"online\" || status === \"idle\" || status === \"dnd\" || status === \"offline\") {\n const mapped = status === \"offline\" ? \"invisible\" : status;\n saveUserStatus(mapped);\n ws.send({ type: \"presence_update\", payload: { status: mapped } }); // <-- raw send, no limiter, no retry, no updatePresence()\n }\n });\n\nContrast every other producer:\n pages/MainPage.ts:133 const presenceSender = createPresenceSender(ws, limiters.presence);\n pages/MainPage.ts:209 function applyPresence(status) { presenceSender.send(status); } // settings Account tab + auto-idle\n components/UserBar.ts:177 sender.send(status); // UserBar status picker\n\nlib/presence.ts:56-73 is the budget + retry the tray skips:\n if (limiter.tryConsume()) { ws.send({ type: \"presence_update\", payload: { status } }); }\n else { retry = setTimeout(() => { retry = null; send(loadUserStatus(), customStatus); }, limiter.getRemainingMs()); }\n\nServer/service/channel.go:171-174 (the budget being blown):\n ratKey := auth.Key(\"presence\", userID)\n if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) { return nil, ErrRateLimited }\n\nThe 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.",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b9b86067",
|
||
"test": "Client/tests/unit/main.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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/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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0177",
|
||
"title": "The voice roster is the only identity surface that ignores nicknames — VoiceUser carries no display name at all",
|
||
"file": "Client/src/stores/voice.store.ts",
|
||
"line": 253,
|
||
"severity": "low",
|
||
"why": "`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\nvoice.store.ts:165 username: member?.username ?? \"\",\nChannelSidebar.ts:410 const nameEl = createElement(\"span\", { class: \"vu-name\" }, user.username || \"Unknown\");\nmembers.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.\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "81b22f56",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0178",
|
||
"title": "The typing indicator prints raw usernames instead of nicknames, despite holding full Member objects",
|
||
"file": "Client/src/components/TypingIndicator.ts",
|
||
"line": 20,
|
||
"severity": "low",
|
||
"why": "`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 {\nTypingIndicator.ts:20 return `${users[0]?.username ?? \"Someone\"} is typing...`;\nmembers.store.ts:182 export function memberDisplayName(member: Pick<Member, \"username\" | \"displayName\">): string",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "e2ae20c0",
|
||
"test": "Client/tests/unit/components/TypingIndicator.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0179",
|
||
"title": "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",
|
||
"file": "Client/src/pages/main-page/VoiceCallbacks.ts",
|
||
"line": 96,
|
||
"severity": "low",
|
||
"why": "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.\n2. 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.\n3. U un-deafens. localServerDeafened is false so the guard at line 92 passes; the voice_deafen frame is accepted, then line 96/97 fire.\n4. voiceSelfToggleV2 -> refuseIfServerSilenced(deafen=false) sees ServerMuted -> returns ClientError{SERVER_MUTED, \"you were muted by a moderator\"} with no voice_state broadcast.\n5. 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: () => {\n if (!limiters.voice.tryConsume()) return;\n const state = voiceStore.getState();\n if (state.localServerDeafened === true) return; // guards deafen only\n if (state.localDeafened) {\n voiceSessionSetDeafened(false);\n ws.send({ type: \"voice_deafen\", payload: { deafened: false } });\n voiceSessionSetMuted(false); // no localServerMuted check\n ws.send({ type: \"voice_mute\", payload: { muted: false } });\n\n// contrast, onMuteToggle (same file, line 76):\n// if (state.localServerMuted === true) return;",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "5c0606bf",
|
||
"test": "Client/tests/unit/voice-callbacks.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Mirror onMuteToggle's guard on the unmute half of the undeafen branch in 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).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0180",
|
||
"title": "Avatar URL is HTML-escaped by the bare bluemonday sanitizer, corrupting every avatar URL with a query string",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 202,
|
||
"severity": "low",
|
||
"why": "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\n// sanitizer.Sanitize below — Sanitize's output is always HTML-escaped\nreq.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n...\nif req.Avatar != nil {\n\ttrimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar))\n\tif err := validateAvatarURL(trimmed); err != nil {",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "hotspot-server-admin",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "78b81b1b",
|
||
"test": "Server/api/profile_handler_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0181",
|
||
"title": "Settings overlay opened for the first time from the Connect page never moves focus into the dialog, and its focus trap is inert",
|
||
"file": "Client/src/components/SettingsOverlay.ts",
|
||
"line": 393,
|
||
"severity": "low",
|
||
"why": "`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\n if (uiStore.getState().settingsOpen) {\n show();\n }\n\n container.appendChild(root); // <- root is only attached AFTER show()/focusDialog(panel)",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "9e8719dc",
|
||
"test": "Client/tests/unit/settings-overlay.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0182",
|
||
"title": "Member profile popup shows a stale presence status after an in-place presence patch",
|
||
"file": "Client/src/components/MemberList.ts",
|
||
"line": 248,
|
||
"severity": "low",
|
||
"why": "`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(...) }`\nMemberList.ts:243-251 — `activePopup = createUserProfilePopup({ user: { … status: member.status, … } })` where `member` is the argument captured by `createMemberItem(member, …)` at line 190/396.\nmembers.store.ts:169-174 — `const next = new Map(prev.members); next.set(userId, { ...existing, status, … });` (new object each time).\nUserProfilePopup.ts:169/238/239 render `STATUS_COLORS[user.status]` / `STATUS_LABELS[user.status]` from that captured snapshot.",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "9fedef5b",
|
||
"test": "Client/tests/unit/member-list.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0183",
|
||
"title": "platformInit's page-count conversion overflows uint32, so plugins.max_memory_mb >= 4096 silently yields a 0-page (or far smaller) memory limit",
|
||
"file": "Server/plugin/sandbox_wazero.go",
|
||
"line": 87,
|
||
"severity": "low",
|
||
"why": "`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": "\tmemMB := cfg.MaxMemoryMB\n\tif memMB <= 0 {\n\t\tmemMB = 64 // default 64 MiB per plugin runtime\n\t}\n\tmemPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes\n\n\trt := wazero.NewRuntimeWithConfig(ctx,\n\t\twazero.NewRuntimeConfig().\n\t\t\tWithMemoryLimitPages(memPages).",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "549088d5",
|
||
"test": "Server/plugin/sandbox_wazero_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0184",
|
||
"title": "urlEnd gives back a trailing single '*' but not a trailing single '_', so `_https://…_` never italicizes",
|
||
"file": "Client/src/components/message-list/markdown.ts",
|
||
"line": 99,
|
||
"severity": "low",
|
||
"why": "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;\n for (;;) {\n const tail = src.slice(min, end);\n const run = /([*_~|])\\1+$/.exec(tail);\n if (run !== null) {\n end -= run[0].length;\n continue;\n }\n if (end > min && src[end - 1] === \"*\") {\n end--;\n continue;\n }\n break;\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "fd09f4e6",
|
||
"test": "Client/tests/unit/content-markdown.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "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.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0185",
|
||
"title": "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",
|
||
"file": "Client/src/styles/app.css",
|
||
"line": 2160,
|
||
"severity": "low",
|
||
"why": "`.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 `<button>` elements with no `tabindex=\"-1\"`, so Tab focuses them; they render at `opacity: 0`, so the focus ring is invisible and the user has no idea where focus is or what will fire on Enter.",
|
||
"repro": "Open a channel with 20 messages, click into the message list, and press Tab repeatedly without moving the mouse. Focus walks into `.msg-actions-bar` and steps through 4-6 buttons per message (`msg-react-*`, `msg-reply-*`, `msg-pin-*`, `msg-edit-*`, `msg-delete-*`, `msg-copy-link-*`, created at renderers.ts:301-360) with nothing visible on screen. Pressing Enter on the third invisible stop deletes a message. Adding `.message:focus-within .msg-actions-bar { opacity: 1; pointer-events: auto; }` makes them appear.",
|
||
"evidence": "app.css:2150-2170:\n.msg-actions-bar {\n position: absolute; ...\n opacity: 0; /* line 2160 */\n pointer-events: none; /* line 2161 */\n ...\n}\n.message:hover .msg-actions-bar { /* line 2166 - the ONLY reveal rule */\n opacity: 1;\n pointer-events: auto;\n}\n\nrenderers.ts:301-311 creates them as focusable <button>s:\n const reactBtn = createElement(\"button\", { \"data-testid\": `msg-react-${msg.id}`, \"aria-label\": \"React\" });\n reactBtn.addEventListener(\"click\", () => opts.onReactionClick(msg.id, \"\"), { signal });",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "2dd2446a",
|
||
"test": "Client/tests/unit/msg-actions-bar-focus-css.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Add one rule next to the hover rule at app.css:2166: `.message:focus-within .msg-actions-bar { opacity: 1; pointer-events: auto; }` (keeping the existing hover selector), so a focused action button is visible wherever it can be activated.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0186",
|
||
"title": "Reaction pills are put in the tab order but have no keyboard activation, so a reaction cannot be toggled without a pointer",
|
||
"file": "Client/src/components/message-list/reactions.ts",
|
||
"line": 23,
|
||
"severity": "low",
|
||
"why": "The chip is given `tabindex=\"0\"` and a `click` listener, but no `role` and no `keydown` handler. A `<span>` gets no native key activation, so Enter/Space on a focused chip does nothing. The sibling `+` add-reaction chip (line 48) has no `tabindex` at all, so it is not even reachable. QuickSwitchOverlay.ts:110-119 shows the codebase's own pattern for exactly this case (role=\"button\" + an Enter/Space keydown mirroring the click).",
|
||
"repro": "Focus a reaction pill with Tab (focusin fires `attachReactionTooltip`'s `start`, reaction-tooltip.ts:310, so the who-reacted tooltip does appear — proving the chip is reachable). Press Enter, then Space: `opts.onReactionClick(msg.id, reaction.emoji)` is never invoked and the reaction is not toggled. Continuing to Tab never reaches the `+` chip, which carries no tabindex, so the emoji picker cannot be opened from the pill row either.",
|
||
"evidence": "reactions.ts:20-35:\n const chip = createElement(\"span\", {\n class: reaction.me ? \"reaction-chip me\" : \"reaction-chip\",\n // Focusable so the who-reacted tooltip is reachable without a pointer.\n tabindex: \"0\",\n \"data-emoji\": reaction.emoji,\n });\n ...\n chip.addEventListener(\"click\", () => opts.onReactionClick(msg.id, reaction.emoji), { signal });\n // no keydown listener, no role=\"button\"\n\nreactions.ts:48-49 (add button, not focusable at all):\n const addBtn = createElement(\"span\", { class: \"reaction-chip add-reaction\" }, \"+\");\n addBtn.addEventListener(\"click\", () => opts.onReactionClick(msg.id, \"\"), { signal });",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "f037dfdd",
|
||
"test": "Client/tests/unit/reactions-keyboard.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In reactions.ts, give the chip role=\"button\" in the createElement attrs and add, next to the existing click listener, `chip.addEventListener(\"keydown\", (e) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); opts.onReactionClick(msg.id, reaction.emoji); } }, { signal });` — and apply the same three attributes/listener to the add-reaction chip at line 48 so the picker is reachable too.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0187",
|
||
"title": "\"Add Server\" host validator rejects every IPv6 literal that the rest of the client accepts",
|
||
"file": "Client/src/pages/connect-page/ServerPanel.ts",
|
||
"line": 310,
|
||
"severity": "low",
|
||
"why": "ServerPanel's Add Server modal validates the address with `/^[\\w.-]+(:\\d+)?$/`, which matches no IPv6 form (no brackets, no colons beyond one host:port separator). `api.ts`'s `isValidHost` — the validator every real connection goes through — was extended with two explicit IPv6 branches (bracketed `[::1]:8443` and bare `2001:db8::1`), and the Rust proxies (`http_proxy.rs`/`livekit_proxy.rs` `validate_remote_host`/`parse_server_name`) accept them too. ServerPanel is the only other host regex in the client and never got the sibling branches, so an IPv6 server can be logged into by typing the address into the login form (`LoginForm.ts` `hostInput` is free text with no pattern check) but can never be saved as a server profile.",
|
||
"repro": "Open the connect page → Servers panel → \"+ Add Server\". Enter name \"v6\" and Host Address \"[::1]:8443\" (or \"2001:db8::1\"). Click \"Add Server\": `handleSave` fails the regex, calls `hostAddrInput.setCustomValidity(\"Invalid server address (expected host or host:port)\")` + `reportValidity()` and returns — the profile is never created and `onAddProfile` is never called. Typing that exact same address into the login form's Server Address field and pressing Connect succeeds, because `api.setConfig`'s `isValidHost` (api.ts:85-91) accepts both bracketed and bare IPv6. Net effect: IPv6 servers are reachable but unsaveable, so auto-login/health-check/profile list never work for them. No test locks the regex (tests/unit/server-panel.test.ts contains no address-validation case).",
|
||
"evidence": "ServerPanel.ts:309-315\n // Validate address: must be a valid hostname:port — no paths, no special chars\n if (!/^[\\w.-]+(:\\d+)?$/.test(addr)) {\n hostAddrInput.setCustomValidity(\"Invalid server address (expected host or host:port)\");\n hostAddrInput.reportValidity();\n return;\n }\n\nvs api.ts:81-94\n function isValidHost(host: string): boolean {\n if (host.length > 253) return false;\n if (/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/.test(host)) return true;\n if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;\n return /^[\\w.-]+(:\\d+)?$/.test(host);\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "3f2d1add",
|
||
"test": "Client/tests/unit/server-panel.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Stop duplicating the regex: export the existing isValidHost (currently a closure inside createApiClient in src/lib/api.ts:81-94) into a shared module and call it from ServerPanel.handleSave, so the modal and the connection path share one gate. If a lift is unwanted, at minimum add the same two branches ahead of the DNS/IPv4 test at ServerPanel.ts:310: `/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/.test(addr)` and `(addr.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(addr)`.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0188",
|
||
"title": "Renaming your username overwrites the profile card's display-name header with the raw username",
|
||
"file": "Client/src/components/settings/AccountTab.ts",
|
||
"line": 1151,
|
||
"severity": "low",
|
||
"why": "`headerName` is the `.account-header-name` slot, built from `resolveDisplayName({username, displayName})` — i.e. it shows the nickname when one is set. The username-rename success path writes the new *username* into both `headerName` and `usernameValue`, ignoring the display name entirely. Its sibling save path in the same file (`buildProfileFields`) gets this right: `onSaved(displayName.length > 0 ? displayName : username)`. So the two writers of the same DOM node disagree, and the rename path desyncs the card from the actual profile state until the tab is rebuilt.",
|
||
"repro": "Settings → Account. Set Display Name to \"Alice Smith\" and press \"Save Profile\" — the card header correctly reads \"Alice Smith\". Now press \"Edit\" next to Username, type \"alice2\", press Save. `onUpdateProfile({username:\"alice2\"})` resolves (the server merges, leaving display_name = \"Alice Smith\" untouched) and line 1151 runs `setText(headerName, \"alice2\")`. The card header now reads \"alice2\" even though the stored display name is still \"Alice Smith\", and every other surface (UserBar, message list, member list) keeps showing \"Alice Smith\". Switching to another settings tab and back re-runs `buildAccountTab`, which restores \"Alice Smith\" — confirming the header was wrong, not the data.",
|
||
"evidence": "AccountTab.ts:1147-1154\n void options\n .onUpdateProfile({ username: newName })\n .then(() => {\n setText(headerName, newName);\n setText(usernameValue, newName);\n editForm.style.display = \"none\";\n })\n\ncompare AccountTab.ts:70 (header source) and :275-277 (the correct sibling)\n const headerName = createElement(\"div\", { class: \"account-header-name\" }, displayName);\n onSaved(\n displayName.length > 0 ? displayName : (authStore.getState().user?.username ?? \"\"),\n );",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "2ce5879c",
|
||
"test": "Client/tests/unit/settings-overlay.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "At AccountTab.ts:1151 resolve the same way the initial build does instead of writing the raw username: `setText(headerName, resolveDisplayName({ username: newName, displayName: authStore.getState().user?.display_name ?? null }));` (resolveDisplayName is already imported at :11, and updateUser has run before this .then, so the store is fresh). Leave :1152 (usernameValue) as is.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0189",
|
||
"title": "Embedded DM section drops mentionCount entirely, so an @mention in a muted DM is invisible in the default sidebar",
|
||
"file": "Client/src/pages/main-page/SidebarDmSection.ts",
|
||
"line": 128,
|
||
"severity": "low",
|
||
"why": "`renderDmListItems` renders only `dm.unreadCount` and computes the header aggregate as `sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount)`. `DmChannel.mentionCount` is never read. Both sibling surfaces do the opposite: `ChannelSidebar.ts:280` and `DmSidebar.ts:201` render a separate mention badge that a mute deliberately does NOT dim or suppress, and `lib/channel-mutes.ts` states the policy explicitly (\"a message that mentions you STILL notifies and still shows the red mention badge... a mute that swallowed a direct mention would be a mute nobody could safely use\"). In channels mode — the default sidebar — a mute therefore silences exactly the thing the policy says it must never silence.",
|
||
"repro": "Mute a 1:1 DM (DM sidebar row context menu -> Mute). Switch the sidebar back to \"channels\" mode. Have the other user send a message that @-mentions you. The DM's mentionCount and unreadCount both increment in dmStore, but: (a) the row badge is the plain dimmed unread badge, never the red mention badge; (b) the DIRECT MESSAGES header badge adds 0 for that channel because the reduce at line 129 zeroes every muted conversation regardless of mentions. Result: no mention indicator anywhere in the default sidebar. Do the same with the same DM open in DM-sidebar mode and DmSidebar.ts:201 correctly shows the red `dm-mention-badge`.",
|
||
"evidence": "line 97: if (dm.unreadCount > 0) { ... class: muted ? \"dm-unread-badge muted\" : \"dm-unread-badge\" ... }\nline 128: const totalUnread = dmChannels.reduce(\nline 129: (sum, c) => sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount),\n// vs DmSidebar.ts:196 \"A muted conversation dims the unread badge but NOT the mention badge\"\n// vs ChannelSidebar.ts:271 \"mention badge is deliberately left alone: a mute silences chatter, never...\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "bc58ebc8",
|
||
"test": "Client/tests/unit/sidebar-dm-section.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In SidebarDmSection.ts mirror DmSidebar's precedence: render a `dm-mention-badge` when `dm.mentionCount > 0` (else the existing unread badge), and change the header reduce to `sum + (isChannelMuted(c.channelId) ? c.mentionCount : c.unreadCount)` so a mute drops chatter but not mentions.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0190",
|
||
"title": "adminPanelUrl does not bracket a bare IPv6 host, producing an unopenable URL for the Audit Log button",
|
||
"file": "Client/src/lib/admin-panel.ts",
|
||
"line": 20,
|
||
"severity": "low",
|
||
"why": "`https://${host}/admin` interpolates the stored host verbatim. The client explicitly supports unbracketed IPv6 server hosts: `api.ts:87-91` accepts `::1` / `2001:db8::1`, `livekitSession.ts:763` wraps bare IPv6 in brackets before building a URL, and `http_proxy.rs::resolve_remote_target` normalizes `2001:db8::1` to `[2001:db8::1]:443`. admin-panel.ts is the one URL-building site that skips that normalization, so it emits `https://2001:db8::1/admin#audit`, which is not a valid absolute URL (RFC 3986 requires brackets around an IPv6 literal authority).",
|
||
"repro": "Connect to a server by typing a bare IPv6 address at the connect screen (e.g. `::1` or `2001:db8::1` — accepted by api.ts isValidHost line 91, and REST/WS/LiveKit all work because the Rust proxies bracket it themselves). As a moderator with VIEW_AUDIT_LOG, click the sidebar's \"Audit Log\" button (SidebarArea.ts:214). openAdminPanel builds `https://::1/admin#audit` and hands it to `openUrl`; the browser cannot resolve that authority. The affordance is broken on exactly the hosts the rest of the client supports; with a bracketed host (`[::1]:8443`) the same click works.",
|
||
"evidence": "admin-panel.ts:20 const base = `https://${host}/admin`;\n// vs livekitSession.ts:763-765\n// } else if ((this.serverHost.match(/:/g) ?? []).length > 1) {\n// // Bare IPv6 (multiple colons) — wrap in brackets and add default port\n// hostWithPort = `[${this.serverHost}]:443`;\n// and api.ts:91 if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "204b333a",
|
||
"test": "Client/tests/unit/admin-panel.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Normalize inside adminPanelUrl before interpolation, reusing the existing convention: `const authority = !host.startsWith(\"[\") && (host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host) ? `[${host}]` : host;` then build `https://${authority}/admin`.",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0191",
|
||
"title": "Focus trap treats disabled controls as focusable, so Tab escapes the dialog while a modal's submit button is in flight",
|
||
"file": "Client/src/lib/a11y.ts",
|
||
"line": 12,
|
||
"severity": "low",
|
||
"why": "FOCUSABLE_SELECTOR matches `button`/`input`/`select` regardless of the `disabled` attribute, and `isFocusable` filters only on inline display/visibility. A disabled control can never be `document.activeElement`, so when it is the computed `last` (or `first`) the wrap comparison in `trapFocus` never matches and no preventDefault happens. Worse, disabling a control that currently holds focus blurs it to `document.body`, which is outside `container` — the keydown listener is bound to `container`, so Tab is not even seen by the trap and falls through to native document order.",
|
||
"repro": "Open the Create Channel modal (CreateChannelModal.ts). Document-order focusables inside `.modal` are: closeBtn, categoryInput, nameInput, typeSelect, cancelBtn, createBtn — so `last` === createBtn. Type a name and click \"Create Channel\" against a slow/unreachable server: line 163 sets `disabled`, the browser blurs the button, activeElement becomes body. Press Tab — the trapFocus keydown handler on `modal` never fires (target is body) and focus lands in the sidebar/message list behind the still-open modal. Even if focus is manually put back on cancelBtn, Tab does not wrap, because `active === last` compares against the disabled createBtn. Same path in EditChannelModal.ts:299 and DeleteChannelModal.ts:88.",
|
||
"evidence": "a11y.ts:12-13 const FOCUSABLE_SELECTOR =\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\na11y.ts:26 return el.style.display !== \"none\" && el.style.visibility !== \"hidden\";\na11y.ts:81 } else if (!e.shiftKey && (active === last || active === container)) {\nCreateChannelModal.ts:163 createBtn.setAttribute(\"disabled\", \"true\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-19",
|
||
"hunt": "2026-08-19-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "a84e2f5a",
|
||
"test": "Client/tests/unit/a11y.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Exclude disabled controls in the one shared place: `const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';` (or add `!el.hasAttribute(\"disabled\")` to isFocusable, a11y.ts:26).",
|
||
"fixedDate": "2026-08-19"
|
||
},
|
||
{
|
||
"id": "OC-0192",
|
||
"title": "PATCH /users/me runs the quadratic fixpoint sanitizer on avatar/display_name/about with no raw-length bound — one 1 MiB request burns ~11 minutes of CPU",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 220,
|
||
"severity": "high",
|
||
"why": "The handler explicitly bounds `req.Username` at `maxLoginUsernameLen*4` before handing it to `service.SanitizeText` (OC-0151: \"sanitizeToFixpoint's cost is quadratic in input length, and nothing bounds this field before it runs\"), but its three sibling fields in the same handler reach the same sanitizer with no bound at all: `req.Avatar` is sanitized at line 220 and only length-checked afterwards by `validateAvatarURL` (maxAvatarURLLen=512), and `display_name`/`about` are passed through to `UserService.UpdateProfile`, which calls `cleanText` (= `sanitizeToFixpoint`) at user.go:140/143 *before* the MaxDisplayNameLen / MaxAboutLen checks. `sanitizeToFixpoint` loops `len(raw)+1` times, and each pass is `html.UnescapeString(bluemonday.Sanitize(html.UnescapeString(s)))`; bluemonday re-escapes `&` on every pass, so a nested-entity payload peels exactly one level per pass and the whole thing is O(n^2) over the request body.",
|
||
"repro": "Measured on this repo (temporary test through service.SanitizeText, since removed) with payload `\"&\" + strings.Repeat(\"amp;\", k)`: 2 KB -> 4.5 ms, 4 KB -> 12 ms, 8 KB -> 43 ms, 16 KB -> 168 ms — clean quadratic. PATCH /api/v1/users/me is NOT in `bodyCapExemptPrefixes`, so the body cap is defaultMaxBodySize = 1 MiB. Extrapolating the measured curve, `PATCH /api/v1/users/me` with `{\"username\":\"bob\",\"avatar\":\"&ampamp…;\"}` where the avatar string is ~1 MiB of nested `amp;` levels costs ~690 s (~11.5 min) of one core before `validateAvatarURL` ever sees the string and rejects it as >512 chars. `display_name` and `about` take the same path via `cleanText`. The route's only limiter is per-IP `\"profile:\"` at 10/min, so one authenticated user can keep ~10 cores saturated indefinitely; the request is answered with a 400 either way, so nothing in the logs attributes the load.",
|
||
"evidence": "// profile_handler.go\n185:\tif len(req.Username) > maxLoginUsernameLen*4 { // <- the guard, username only\n...\n219:\tif req.Avatar != nil {\n220:\t\ttrimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar)) // <- unbounded\n221:\t\tif err := validateAvatarURL(trimmed); err != nil { // len check AFTER\n...\n234:\tif req.DisplayName != nil {\n235:\t\tif err := validateDisplayName(*req.DisplayName); err != nil { // char check only, no length\n...\n256:\tupdated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{\n257:\t\tUsername: req.Username, Avatar: req.Avatar,\n259:\t\tDisplayName: req.DisplayName, About: req.About,\n\n// service/user.go\n140:\tif patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen {\n143:\tif patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen {\n\n// service/message.go\n195:func sanitizeToFixpoint(raw string) string {\n197:\tfor i := 0; i <= len(raw); i++ {\n198:\t\tnext := sanitizePass(s)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c688fc20eec6379545be2c1bc8ce1ecd946d8bb0",
|
||
"test": "Server/api/profile_handler_test.go (TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing); Server/service/profile_fields_test.go (TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing)",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Bound the raw bytes before they reach the fixpoint sanitizer, at the two chokepoints rather than per field: (1) in handleUpdateProfile, before line 220, `if req.Avatar != nil && len(*req.Avatar) > maxAvatarURLLen*4 { 400 }`; (2) in UserService.UpdateProfile, before the cleanText calls at user.go:140/143, reject on raw byte length — `if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 { ErrBadRequest }` and the same for About with MaxAboutLen*4 — which also covers any non-REST caller. Same shape as the existing guard at profile_handler.go:185 and service/message.go:220.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0193",
|
||
"title": "A refused voice-channel *switch* nulls currentChannelId while the old LiveKit session is still live — widget disappears, mic stays published, and the recovery click re-triggers the same clear",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 1117,
|
||
"severity": "high",
|
||
"why": "The catch-all error handler rolls back an optimistic join with `if (voiceStore.getState().voiceStatus === \"joining\") leaveVoiceChannel();`. Its stated invariant — \"An already-established session is never in 'joining', so this never touches a live voice call\" — is false during a channel switch: `VoiceCallbacks.onVoiceJoin` calls `joinVoiceChannel(newCh)`, which sets `currentChannelId=newCh, voiceStatus=\"joining\"` while `LiveKitSession._state` is still `connected(oldCh)` with the mic track published. `leaveVoiceChannel()` is store-only — it never calls `LiveKitSession.leaveVoice()` — so the store and the live media session desynchronise.",
|
||
"repro": "User is connected to voice channel A (voiceStatus \"connected\", mic published). They click voice channel B in the sidebar, where B is refused at precheck — e.g. B has a channel_overrides deny on CONNECT_VOICE (FORBIDDEN), B was just archived (BAD_REQUEST), or the user tripped voiceJoinRateLimit=5/s by clicking several voice channels quickly (RATE_LIMITED).\n1. onVoiceJoin(B): store -> currentChannelId=B, voiceStatus=\"joining\"; LiveKitSession still connected(A).\n2. Server refuses in voiceJoinPrecheck. No voice_leave is broadcast; the user remains in A in voice_states and on the SFU.\n3. dispatcher.ts:1117 sees voiceStatus===\"joining\" -> leaveVoiceChannel() -> currentChannelId=null.\n4. VoiceWidget.render hides the whole widget (no leave/mute button). LiveKitSession._state is still connected(A): the mic track is still published and audio still flows to every peer in A, and every other client still shows the user in A.\n5. Recovery attempt: the user clicks channel A again -> joinVoiceChannel(A) (prev is null, so it sets voiceStatus=\"joining\") -> voice_join A -> server answers ALREADY_JOINED (voice_join.go voiceJoinLeaveCurrent, currentChID==channelID) -> the same guard fires and hides the widget again. The user cannot leave the call from the UI at all; only an app restart or WS drop ends it.\nThe existing test that supposedly covers this (tests/unit/dispatcher.test.ts:3770 \"does not touch an already-established voice session on CHANNEL_FULL\") seeds voiceStatus=\"connected\", a state a switch never passes through, so nothing locks the real behaviour.",
|
||
"evidence": "dispatcher.ts:1117-1119\n if (voiceStore.getState().voiceStatus === \"joining\") {\n leaveVoiceChannel();\n }\n\nVoiceCallbacks.ts:179-184 onVoiceJoin: joinVoiceChannel(channelId); ws.send({type:\"voice_join\",...})\nvoice.store.ts:300-315 joinVoiceChannel: currentChannelId=channelId, voiceStatus=\"joining\" (only short-circuits when prev.currentChannelId === channelId)\nvoice.store.ts:319-330 leaveVoiceChannel: currentChannelId=null, voiceStatus=\"idle\" (no LiveKit teardown)\nVoiceWidget.ts:236-242 if (channelId === null) { root.classList.remove(\"visible\"); return; } -> no disconnect/mute buttons at all\nServer/ws/voice_join.go:57-65 voiceJoinPrecheck (RATE_LIMITED / FORBIDDEN / NOT_FOUND / BAD_REQUEST-archived / VOICE_ERROR) runs BEFORE voiceJoinLeaveCurrent, so a precheck refusal emits no voice_leave at all and the user stays in the old channel server-side.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Make the rollback tear down the media session, not just the store, in the one shared guard at dispatcher.ts:1117: `if (voiceStore.getState().voiceStatus === \"joining\") { void livekitSession().then(({ isVoiceConnected, leaveVoice }) => { if (isVoiceConnected()) leaveVoice(true); }); leaveVoiceChannel(); }` — isVoiceConnected is already exported (livekitSession.ts:1803) and leaveVoice(true) both disconnects room A and sends voice_leave so the server/SFU state matches the cleared store. A first-time-join refusal has no live session, so isVoiceConnected() is false and behavior there is unchanged (the existing tests at dispatcher.test.ts:3757/3789/3798 stay green).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0194",
|
||
"title": "Group-DM create/rename feed the quadratic fixpoint sanitizer an unbounded body field, on the only REST route group with no rate limiter",
|
||
"file": "Server/service/dm.go",
|
||
"line": 234,
|
||
"severity": "high",
|
||
"why": "`cleanText` -> `sanitizeToFixpoint` costs roughly O(entity-nesting-depth x length); every other caller of that pipeline bounds its raw input first (`sanitizeContent` at `maxMessageLen*4`, the register path and PATCH /users/me at `maxLoginUsernameLen*4`). `CreateGroupDM` and `RenameGroupDM` do not: they run it on the raw `name` from the JSON body, bounded only by the global 1 MiB cap, and `MountDMRoutes` attaches no `RateLimitMiddleware` at all — so unlike the already-confirmed profile-handler instance (which at least sits behind `profile:` at `profileUpdateRateLimitPerMinute`), these two can be issued back-to-back. In `CreateGroupDM` the sanitizer also runs *before* the recipient-existence, ban and block checks, so the CPU is spent even when the request is going to 404.",
|
||
"repro": "As any authenticated user: POST /api/v1/dms/group with body {\"recipient_ids\":[999999,999998],\"name\":\"&\"+\"amp;\"*200000+\"lt;\"} (~1 MiB, well under the 1 MiB MaxBodySizeUnless cap). handleCreateGroupDM decodes it and calls CreateGroupDM: the dedup loop passes (two distinct positive ids), len(unique)==2 satisfies both the >=2 and <=MaxGroupDMParticipants checks, and control reaches `cleanName := cleanText(name)` at dm.go:234. sanitizeToFixpoint peels roughly one entity layer per pass over a ~1 MiB string, ~2x10^5 passes, ~10^11 bytes of work — minutes of pinned CPU — before `GetUserByID(999999)` is ever called and the request 404s. /api/v1/dms has no rate limiter, so N concurrent requests pin N cores; the server's SQLite writer and every other request path starve. PATCH /api/v1/dms/{channelId} with the same body reaches the identical call at dm.go:323 (after only an IsDMParticipant + IsGroupDM check).",
|
||
"evidence": "dm.go:234 `cleanName := cleanText(name)` and dm.go:323 `cleanName := cleanText(name)`; user.go:113 `func cleanText(v string) string { return strings.TrimSpace(sanitizeToFixpoint(v)) }`; message.go:220 shows the bound the sibling path has (`if len(raw) > maxMessageLen*4 { return \"\", ... }`) before `sanitizeToFixpoint(raw)`; api/dm_handler.go:72 `r.Route(\"/api/v1/dms\", func(r chi.Router) { r.Use(AuthMiddleware(database)); r.Post(\"/\", ...); r.Post(\"/group\", ...); r.Patch(\"/{channelId}\", ...) })` — no RateLimitMiddleware.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "fba75319a419477890944bd0218573cfb5d6333c",
|
||
"test": "Server/service/dm_test.go",
|
||
"revertProof": "pass (hand-proved)"
|
||
},
|
||
"suggestedFix": "Bound the raw bytes before sanitizing, exactly as OC-0151 did. Smallest shared form: add a bounded helper next to cleanText in Server/service/user.go, e.g. `func cleanTextBounded(v string, maxRunes int) (string, bool) { if len(v) > maxRunes*4 { return \"\", false }; return cleanText(v), true }`, then use it at dm.go:234 and dm.go:323 with MaxGroupDMNameLen (returning the existing `%w: name must be at most %d characters` ErrBadRequest on the false branch), and at the custom-status sites (service/channel.go:182, service/user.go:202) with MaxCustomStatusLen. *4 still admits any legitimate 100-rune UTF-8 name, so no valid input changes behavior. Separately, MountDMRoutes should carry a RateLimitMiddleware like its sibling route groups, but that is defense in depth, not the fix.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0195",
|
||
"title": "presence_update's custom_status runs the unbounded fixpoint sanitizer on a full 1 MiB WebSocket frame",
|
||
"file": "Server/service/channel.go",
|
||
"line": 182,
|
||
"severity": "high",
|
||
"why": "`HandlePresenceUpdate` applies the 128-rune `MaxCustomStatusLen` cap to the *output* of `cleanText`, so the quadratic `sanitizeToFixpoint` runs on the raw client string first. The WS read limit is `config.MaxMessageBytes` (1 MiB), so a single `presence_update` frame can carry a 1 MiB nested-entity payload straight into it. This is the same defect already confirmed for PATCH /users/me, but on a different transport that the REST-side bound would not cover, and it executes on the connection's own readPump goroutine, so nothing bounds how many of these run at once.",
|
||
"repro": "Authenticate a WebSocket, then send {\"type\":\"presence_update\",\"payload\":{\"status\":\"online\",\"custom_status\":\"&\"+\"amp;\"*200000+\"lt;\"}} — ~1 MiB, accepted because conn.SetReadLimit(wsReadLimitBytes) is 1<<20 (serve.go:27/80). readPump (serve_pumps.go:210) calls hub.handleMessage on the per-client goroutine; handlePresenceV2 calls HandlePresenceUpdate, whose limiter check (1 per 10s) passes, `db.ValidStatuses[\"online\"]` passes, and channel.go:182 `text := cleanText(*customStatus)` then spins for minutes before the 128-rune check at channel.go:183 rejects it. Because each call outlives the 10 s limiter window, one account can start a new 1 MiB frame every 10 s and accumulate dozens of concurrently spinning CPU-bound goroutines from a single connection stream.",
|
||
"evidence": "channel.go:180-186 `if customStatus != nil { text := cleanText(*customStatus); if utf8.RuneCountInString(text) > MaxCustomStatusLen { return ... } }` — the bound is on the sanitized output, not the raw input; ws/serve.go:27 `wsReadLimitBytes = config.MaxMessageBytes` (config/constants.go:7 `MaxMessageBytes = 1 << 20`); ws/serve_pumps.go:210 `hub.handleMessage(c, msg)` inside readPump. Same unguarded call also at service/user.go:202 (`SetCustomStatus`).",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "bdbd5ac472ee73cdb566ac6348b21f9569b61598",
|
||
"test": "Server/service/profile_fields_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Same shared guard as the DM finding: bound the raw bytes before cleanText. In Server/service/channel.go:180, `if len(*customStatus) > MaxCustomStatusLen*4 { return nil, fmt.Errorf(\"%w: custom_status must be at most %d characters\", ErrBadRequest, MaxCustomStatusLen) }` before `text := cleanText(*customStatus)`, and the identical pre-check at Server/service/user.go:202. Best done as the one `cleanTextBounded(v string, maxRunes int)` helper in service/user.go used by all four unguarded sites (channel.go:182, user.go:202, dm.go:234, dm.go:323) rather than four hand-rolled checks.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0196",
|
||
"title": "A transient DB read error during the WS auth handshake is reported as a terminal auth_error, logging the user out",
|
||
"file": "Server/ws/serve_auth.go",
|
||
"line": 62,
|
||
"severity": "medium",
|
||
"why": "authenticateConn collapses \"session lookup failed\" (DB error) into the same client-visible frame as \"no such session\" — buildAuthError, which the protocol defines as non-recoverable. The code comment two lines below explicitly recognises the distinction (\"DB outage, not a bad token\") but applies it only to the server log; the wire frame is unchanged, so a momentarily unreadable database signs valid users out.",
|
||
"repro": "1. Server is under SQLite reader contention (WAL checkpoint, admin backup/restore, a long write tx, or busy_timeout exceeded) so `database.GetSessionByTokenHash` returns an error rather than (nil, nil).\n2. Any client whose socket drops in that window reconnects and sends its auth frame.\n3. Server takes the `err != nil` branch at serve_auth.go:61 and writes `buildAuthError(\"invalid token\")` (line 62). The identical hazard exists at line 78 for a `GetUserByID` error → `buildAuthError(\"user not found\")`.\n4. Client/src/lib/ws.ts:301 sets `intentionalClose = true`, calls `disconnectProxy()` and `setState(\"disconnected\")` — the auto-reconnect loop stops permanently.\n5. Client/src/lib/dispatcher.ts:259 runs `clearAuth()` on the same frame, which resets authStore (INITIAL_STATE), tears down voice, and drops messages/channels/blocks stores — a full logout back to the connect page.\nNet effect: the session row in the DB is perfectly valid and unexpired, yet every client that happened to reconnect during a sub-second DB hiccup must sign in again. Contrast Server/ws/hub_sweep.go:137-143, where the same package refuses to treat a failed session lookup as evidence about any individual session (\"kicking everyone on a transient DB error would be a mass disconnect\"), and Server/ws/messages.go's `buildErrorMsg(ErrCodeInternal, ...)`, which the client does NOT treat as terminal. No test pins the DB-error case — Server/ws/ws_integration_test.go only covers malformed/absent/nonexistent tokens.",
|
||
"evidence": "sess, err := database.GetSessionByTokenHash(ctx, hash)\nif err != nil || sess == nil {\n\t_ = conn.Write(ctx, websocket.MessageText, buildAuthError(\"invalid token\"))\n\tif err != nil {\n\t\t// DB outage, not a bad token — carry the cause so the caller's log\n\t\t// distinguishes it from an ordinary invalid-token rejection.\n\t\treturn nil, \"\", resumeHint{}, fmt.Errorf(\"auth: session lookup failed: %w\", err)\n\t}",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "ed0bfe03e014aa441c2dc118e09e97f54ce0e90f",
|
||
"test": "Server/ws/ws_integration_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Split the error branch from the not-found branch in authenticateConn so only a genuine miss produces the terminal frame. At Server/ws/serve_auth.go:60: `if err != nil { _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, \"temporary failure, please retry\")); return nil, \"\", resumeHint{}, fmt.Errorf(\"auth: session lookup failed: %w\", err) }` then `if sess == nil { ...buildAuthError(\"invalid token\")... }`. Apply the same split at line 76-83 for GetUserByID. ErrCodeInternal is not terminal on the client (dispatcher.ts treats only auth_error/BANNED as credential-clearing), so the socket simply closes and the normal backoff reconnect retries.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0197",
|
||
"title": "validateDisplayName runs on the raw body, before the sanitizer's HTML-entity unescape — `‮` becomes a real bidi override in the stored display name",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 235,
|
||
"severity": "medium",
|
||
"why": "The username field in the same handler is sanitized first and validated second (line 199 then 206), which is the correct order; `display_name` is validated first (line 235, against the raw JSON string) and sanitized second (inside `UserService.UpdateProfile` -> `cleanText` -> `sanitizeToFixpoint`). `sanitizePass` ends with `html.UnescapeString(...)`, so an entity-encoded control or Cf character passes `validateDisplayName` as harmless ASCII and is then turned into the real character before storage — defeating the guard whose stated purpose is \"it is rendered wherever a username is, so control characters and bidi overrides are exactly as unwelcome here\".",
|
||
"repro": "Verified against the real sanitizer in this repo (temporary test in Server/service, since removed):\n SanitizeText(\"ada‮gnp.exe\") == \"adagnp.exe\" (runes: [... 8238 ...])\n SanitizeText(\"ada‮gnp.exe\") == \"adagnp.exe\"\nSo `PATCH /api/v1/users/me` with `{\"username\":\"dn_user\",\"display_name\":\"ada‮gnp.exe\"}` returns 200 and stores a display name containing U+202E RIGHT-TO-LEFT OVERRIDE, while the existing test Server/api/avatar_handler_test.go:283 shows the literal form `\"adagnp.exe\"` is (correctly) rejected with 400. The same bypass admits control characters: `\"a b\"` stores a real newline. `MaxDisplayNameLen` still holds, so the value is persisted and broadcast via user_update to every client, where it renders in place of the username in the member list, message rows and voice roster.",
|
||
"evidence": "// profile_handler.go — username: sanitize THEN validate\n199:\treq.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n206:\tif err := auth.ValidateUsername(req.Username); err != nil {\n\n// profile_handler.go — display_name: validate raw, sanitize later in the service\n234:\tif req.DisplayName != nil {\n235:\t\tif err := validateDisplayName(*req.DisplayName); err != nil {\n\n137:func validateDisplayName(name string) error {\n138:\tfor _, r := range name {\n139:\t\tif unicode.IsControl(r) || unicode.In(r, unicode.Cf) {\n\n// service/message.go — the outer unescape that re-creates the character\n166:func sanitizePass(s string) string {\n167:\treturn html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s)))",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c688fc20eec6379545be2c1bc8ce1ecd946d8bb0",
|
||
"test": "Server/api/profile_handler_test.go (TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName)",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Validate the sanitized value, matching the username path in the same handler: at profile_handler.go:234, first bound and sanitize — `trimmed := strings.TrimSpace(service.SanitizeText(*req.DisplayName))` (after the byte bound from the previous finding) — then `validateDisplayName(trimmed)`, and set `req.DisplayName = &trimmed` before the UpdateProfile call. cleanText's fixpoint output is stable, so the service's re-sanitize is a no-op.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0198",
|
||
"title": "Block-then-evict: the shared-DM lookup that gates voice eviction uses the request context, so a client disconnect right after the block commits leaves the blocked user in the blocker's live 1:1 DM call forever",
|
||
"file": "Server/api/dm_handler.go",
|
||
"line": 410,
|
||
"severity": "medium",
|
||
"why": "`svc.Blocks.BlockUser` has already committed by line 409. The eviction call itself is correctly detached with `context.WithoutCancel(r.Context())` (line 414), but the `SharedOneToOneDM` lookup that decides whether to evict is not — it runs on `r.Context()`. A canceled request context makes that lookup return a wrapped error, which is logged and skipped, so the eviction never happens. As the handler's own comment states, no later gate compensates: the block is otherwise only enforced at `voice_join` and voluntary `voice_token_refresh`, both driven by the blocked client, so the blocked user keeps speaking and listening in the blocker's DM call indefinitely.",
|
||
"repro": "A and B are in the voice room of their 1:1 DM channel. A sends `PUT /api/v1/blocks/{B}` and the TCP connection is torn down (client navigates away / app quits / reverse proxy read timeout) between `BlockUser` returning at line 397 and `SharedOneToOneDM` returning at line 410. `r.Context()` is done, `FindDMChannelIDBetween` fails with context.Canceled, the handler logs \"shared-DM lookup for voice eviction failed\" and returns. The block row is durable — A's client shows B as blocked and the DM composer is gated — but B is still connected to the SFU room for that channel with a live mic, and nothing re-runs the gate for the life of B's session. Contrast handleCloseDM (line 227-241), which detaches every post-commit step.",
|
||
"evidence": "397:\t\tif err := svc.Blocks.BlockUser(r.Context(), user.ID, targetID); err != nil { // commits here\n...\n409:\t\tif ve, evictable := broadcaster.(dmVoiceEvictor); evictable {\n410:\t\t\tif chID, exists, err := svc.DMs.SharedOneToOneDM(r.Context(), user.ID, targetID); err != nil {\n411:\t\t\t\tslog.Warn(\"block: shared-DM lookup for voice eviction failed\", ...)\n413:\t\t\t} else if exists {\n414:\t\t\t\tve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), targetID, chID)\n\n// service/dm.go:369 — any ctx error becomes ErrInternal, i.e. the skip branch\n371:\tid, ok, err := s.st.FindDMChannelIDBetween(ctx, userA, userB)\n372:\tif err != nil {\n373:\t\treturn 0, false, fmt.Errorf(\"%w: failed to look up shared DM: %v\", ErrInternal, err)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "d652b237a7c5b6f77b51b48ef96b5005a11f0b22",
|
||
"test": "Server/api/dm_handler_block_context_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Take one detached context immediately after the block commits and use it for the whole post-commit tail: at dm_handler.go:398 add `bgCtx := context.WithoutCancel(r.Context())`, then use bgCtx for both svc.DMs.SharedOneToOneDM (line 410) and ve.DisconnectFromVoiceInChannel (line 414) — same shape as bgCtx in handleRenameGroupDM.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0199",
|
||
"title": "REST-created 1:1 DM pre-opens dm_open_state for the recipient, so the first message never emits dm_channel_open and the DM never appears in their sidebar",
|
||
"file": "Server/db/dm_queries.go",
|
||
"line": 171,
|
||
"severity": "medium",
|
||
"why": "GetOrCreateDMChannel inserts dm_open_state rows for BOTH users at creation time, but SendMessage only reports a recipient in OpenedDMFor when its own INSERT OR IGNORE actually inserted a row (OpenDM is :execrows). Because the row already exists, `opened` is false, ws/handlers_chat.go emits no DMChannelOpenEvent for the recipient, and no visibility-watermark bump happens either — so neither a live event nor a warm reconnect ever tells the recipient the DM exists. handleCreateDM (api/dm_handler.go:75, 120) is wired with no DMBroadcaster at all, unlike handleCreateGroupDM/handleRenameGroupDM/handleCloseDM, so nothing else covers the gap.",
|
||
"repro": "Alice POSTs /api/v1/dms {recipient_id: bob} (the client's api.createDm). GetOrCreateDMChannel creates channel 50 and inserts dm_open_state for BOTH alice(1) and bob(2). Alice then sends the first message. sendMessageDMSideEffects calls OpenDM(bob, 50) -> INSERT OR IGNORE affects 0 rows -> opened=false -> result.OpenedDMFor is empty -> handleChatSendV2 emits only the sequenced chat_message, no dm_channel_open for bob. On bob's client, dispatcher.ts CHAT_MESSAGE finds `isDm === false` (channel 50 is not in dmStore) and `incrementUnread` no-ops (DM ids are absent from channelsStore), so bob gets a desktop notification for a message with no sidebar entry, no unread badge, and no way to open the conversation — until he fully restarts and receives a `ready` payload. Group DMs do not have this bug: handleCreateGroupDM explicitly calls broadcastDMOpen for every participant. The existing regression test (service/message_crud_test.go:232 TestSendMessage_DoesNotReopenAlreadyOpenDM) misses it because newDMFixture seeds dm_participants only, never dm_open_state, so it never reproduces the REST-created state.",
|
||
"evidence": "Server/db/dm_queries.go:170-174\n\t// Open the DM for both users.\n\t_, err = tx.Exec(\n\t\t`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?)`,\n\t\tuser1ID, channelID, user2ID, channelID,\n\t)\n\nServer/service/message_crud.go:274-281\n\t\topened, openErr := s.st.OpenDM(bgCtx, pid, p.ChannelID)\n\t\t...\n\t\tif opened {\n\t\t\tresult.OpenedDMFor = append(result.OpenedDMFor, pid)\n\t\t}\n\nServer/api/dm_handler.go:75 r.Post(\"/\", handleCreateDM(svc)) // no broadcaster, unlike every sibling DM route",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "c6873380625cd99a4f7544de0047739c47d07af1",
|
||
"test": "Server/api/dm_handler_create_notify_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Wire the broadcaster into the create route the same way its siblings are: MountDMRoutes -> r.Post(\"/\", handleCreateDM(svc, broadcaster)), and in handleCreateDM after a successful CreateDM add `if result.Created { broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID}) }`. broadcastDMOpen already does the context.WithoutCancel detach and the markDMVisibilityChanged watermark bump, so this one call covers both the live event and the warm-reconnect path. (The alternative — dropping user2 from the create-time dm_open_state insert so the first message's OpenDM reports opened=true — also works but changes GetUserDMChannelIDs visibility for the recipient before the first message and breaks db/dm_queries_test.go:113-121.)",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0200",
|
||
"title": "Cert-mismatch \"Reject\" is a no-op for any bracketed-IPv6 server: normalizeHostForCertCompare never mirrors cert_store_key's bracket unwrap",
|
||
"file": "Client/src/lib/ws.ts",
|
||
"line": 127,
|
||
"severity": "medium",
|
||
"why": "`tofu::cert_store_key` (src-tauri/src/tofu.rs:302-309) unwraps the brackets off a portless/`:443` bracketed IPv6 literal, so every `cert-tofu` event carries the BARE address. Its documented JS mirror `normalizeHostForCertCompare` only strips `:443` and lowercases — it never unwraps brackets — so for a profile saved as `[2001:db8::1]` the two strings can never be equal. Every guard that gates a security action on that equality silently takes the \"unrelated host\" branch.",
|
||
"repro": "Save/log into a server whose host is a bracketed IPv6 literal with no port or with `:443` (accepted by `isValidHost`, src/lib/hostValidation.ts:27 — `/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/`), e.g. `[2001:db8::1]`. `lastConnectHost` (main.ts:363) and `config.host` are stored verbatim as `\"[2001:db8::1]\"`. ws.ts:538 builds `wss://[2001:db8::1]/api/v1/ws`; Rust `extract_host` → `cert_store_key(\"[2001:db8::1]\")` → strip_suffix(\":443\") misses → strip_prefix('[')+strip_suffix(']') → emits `host: \"2001:db8::1\"`. JS computes `normalizeHostForCertCompare(\"[2001:db8::1]\")` = `\"[2001:db8::1]\"`. Now rotate/replace the server certificate. (1) ws.ts:381 `raw.host === normalizeHostForCertCompare(config.host)` is false → `certMismatchBlock` stays false and `cancelReconnect()`/`setState(\"disconnected\")` never run, so the reconnect loop keeps re-arming and re-firing a mismatch modal instead of latching once. (2) main.ts:233 `if (evt.host === normalizeHostForCertCompare(lastConnectHost))` is false → clicking **Reject** on the \"certificate changed — possible MITM\" modal does NOT call `ws.disconnect()`, `clearAuth()` or `router.navigate(\"connect\")`; the user stays authenticated and connected to the server whose certificate they just rejected. (3) main.ts:218 → after **Accept**, `reconnectAfterCertAccept` never runs. (4) main.ts:185 → after confirming a FIRST-USE certificate the pending `ws.connect` is never resumed, so first login to such a server hangs on the connect page. tests/unit/ws-cert.test.ts:484-497 pins only the lowercase half of this parity contract and its own comment names consequence (2) as the worst case.",
|
||
"evidence": "ws.ts:126-128 export function normalizeHostForCertCompare(host: string): string { return host.replace(/:443$/, \"\").toLowerCase(); }\n\ntofu.rs:302-309 pub(crate) fn cert_store_key(host: &str) -> String {\n let stripped = host.strip_suffix(\":443\").unwrap_or(host);\n let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped);\n unbracketed.to_ascii_lowercase()\n}\n\nmain.ts:233 if (evt.host === normalizeHostForCertCompare(lastConnectHost)) { ws.disconnect(); clearAuth(); router.navigate(\"connect\"); }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "tauri-rust",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "80f96f83403d9518a0c8d5f0e0b9654254449d4b",
|
||
"test": "Client/tests/unit/ws-cert.test.ts",
|
||
"revertProof": "pass (hand-proved)"
|
||
},
|
||
"suggestedFix": "Make the JS mirror the Rust key exactly, in the one shared helper (ws.ts:126): `export function normalizeHostForCertCompare(host: string): string { const stripped = host.replace(/:443$/, \"\"); const unbracketed = stripped.startsWith(\"[\") && stripped.endsWith(\"]\") ? stripped.slice(1, -1) : stripped; return unbracketed.toLowerCase(); }` - same order as cert_store_key (strip :443, then unwrap brackets, then lowercase), so \"[2001:db8::1]\" and \"[2001:db8::1]:443\" both normalize to \"2001:db8::1\" while \"[2001:db8::1]:8443\" keeps its brackets. All four call sites are fixed by that single change.",
|
||
"fixedDate": "2026-08-20",
|
||
"note": "Independently fixed on main by #1397 (labelled there as OC-0163's TS half) while this branch was in flight. The merge of origin/main kept main's equivalent implementation of normalizeHostForCertCompare, so this branch's own edit to that function is superseded; the behaviour is pinned by main's tests plus this branch's. Distinct from ledger OC-0163, which is the bare-IPv6 WebSocket-URL defect at ws.ts:540."
|
||
},
|
||
{
|
||
"id": "OC-0201",
|
||
"title": "A full-resync `ready` that preserves a live voice session does no E2EE reconciliation: departed peers keep a working room key and a client elected key holder during the outage never learns it",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 273,
|
||
"severity": "medium",
|
||
"why": "On the full-ready reconnect tier the client rebuilds `voiceUsers` wholesale from `payload.voice_states` and never notifies `E2EEManager` about anyone who left while the socket was down — `handleParticipantLeft` is only ever driven by live `voice_leave` frames, which this tier does not replay. `ready` also carries no ECDH keys, so nothing re-derives `_isKeyHolder` or prunes `_peerPublicKeys`. The same handler already patches the sibling symptom for moderator mute/deafen (lines 295-306, \"this full resync is the only place a moderator mute/deafen issued while we were disconnected ever reaches us\"), so the gap is a known-shape hole left open for E2EE state.",
|
||
"repro": "Voice channel 5 holds A(uid 1, key holder), B(uid 2), C(uid 3). B's TCP connection goes silent without FIN/RST (wifi handoff / laptop sleep / NAT rebind), so B's server-side readPump never errors and B stays in h.clients (sweepStaleClients only kicks after staleClientTimeout = 90s). B's client detects its own ping timeout and reconnects on a new socket with last_seq>0; registerNow sees the old entry, reports replaced=true, transfers B's voice state, and re-runs updateKeyHolder. B's LiveKit/SFU connection was never dropped, so the media session and B's room key are still live.\nThe resume takes the full-ready tier (replay buffer no longer covers last_seq, or mustFullResync was tripped by a visibility bump), so handleFreshConnect sends `ready` instead of replaying events.\nCase 1 — forward secrecy: C left during the 40s outage. B never receives C's voice_leave, so handleParticipantLeft(3) never runs, C stays in B's _peerPublicKeys and, if B is the holder, B never rotates. C's captured room key keeps decrypting the room's SFrames until B's 5-minute periodic timer happens to fire.\nCase 2 — holder stall: A (the holder, lowest uid) left during the outage. Server-side updateKeyHolder on B's re-registration elects B (uid 2, now lowest). B's E2EEManager._isKeyHolder is still false and no voice_leave for A ever arrives, so B never self-elects and never rotates. Every subsequent joiner announces, is offered nothing (B won't offer; C's offers are refused with NOT_KEY_HOLDER by voice_e2ee.go:198), times out after 10s+5s in setupKeyExchange and is ejected from voice with \"e2ee_timeout\". The channel stays in that state until the next voice_leave happens to run an election on B.",
|
||
"evidence": "dispatcher.ts:269-306\n ws.on(S.READY, (payload) => {\n ...\n setVoiceStates(payload.voice_states); // wholesale roster replace\n ...\n } else if (selfVoiceState !== undefined) { // live voice session survived the WS drop\n enforceModeratorAudioState(...); // <- only mute/deafen is reconciled\n }\n(no livekitSession() call anywhere in the READY handler — grep of dispatcher.ts shows livekitSession() only at 857/894/917/950/966/974/1150/1187)\n\nlivekitE2EE.ts:1197 handleParticipantLeft() is the ONLY path that deletes _peerPublicKeys entries, rotates for membership forward secrecy, and self-elects a new key holder.\nServer/ws/hub.go:486-499 registerNow transfers the old connection's voice state on lastSeq>0, and\nServer/ws/hub.go:593-595 then runs updateKeyHolder(replacedVoiceChID) — so the server can elect the reconnecting client key holder.\nServer/ws/serve.go:882-886 freshConnectCleanStaleVoice deliberately KEEPS the voice_states row on the replay-failure fallback (lastSeq>0, old client still registered), which is what makes the full-ready-with-live-voice case reachable.\nServer/ws/serve_ready.go:356-365 the ready payload carries voice_states only — no e2ee public keys, no key-holder field.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Reconcile voice membership in the same branch that already reconciles moderator audio state (dispatcher.ts:296-306). Snapshot the roster BEFORE the wholesale replace at line 273 — `const prevPeers = voiceStore.getState().currentChannelId !== null ? new Set(voiceStore.getState().voiceUsers.get(voiceStore.getState().currentChannelId)?.keys() ?? []) : new Set<number>()` — then, in the `else if (selfVoiceState !== undefined)` branch, for every uid in prevPeers that is absent from payload.voice_states for selfVoiceState.channel_id (and is not our own id), call `void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid))`. handleParticipantLeft already prunes/retires the peer key, re-runs the lowest-uid election (self-electing and rotating when appropriate), so one call site covers both the departed-peer rotation and the missed key-holder promotion.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0202",
|
||
"title": "A transient DB read error in AuthMiddleware is reported as 401, which makes the client log the user out and permanently delete their saved credential",
|
||
"file": "Server/api/middleware.go",
|
||
"line": 117,
|
||
"severity": "medium",
|
||
"why": "ResolveTokenHash deliberately returns DB errors *wrapped* (never a sentinel) so callers can tell an outage from a bad token — the middleware even logs it as \"auth: token resolution failed\" — but then falls into the same `default` arm as ErrTokenNotFound and answers 401 UNAUTHORIZED. The desktop client treats every 401 as \"session expired\": it calls the global onUnauthorized sink, which runs clearAuth(), and the authStore subscriber then tears down the WS and calls deleteCredential(host) plus sets `owncord:skip-auto-login`. So one transient SQLite read failure on any authenticated REST call signs a live user out and destroys their stored credential, even though the session token is still perfectly valid and the WebSocket was healthy.",
|
||
"repro": "1. User is signed in on MainPage with a remembered host (credential in the OS keyring) and a healthy WS.\n2. The SQLite reader momentarily fails one query — e.g. `database is locked` / `disk I/O error` during the scheduled backup or a restore, or the DB file is briefly replaced (admin/handlers_backup.go's restore path swaps the file under the running server).\n3. Any authenticated REST call in flight (GET /api/v1/channels/{id}/messages, /dms, /blocks, an avatar fetch, search…) hits AuthMiddleware; GetSessionByTokenHash/GetUserByID/GetRoleByID returns the wrapped DB error.\n4. Middleware writes 401 UNAUTHORIZED instead of 500/503.\n5. api.ts fires onUnauthorized -> clearAuth() -> main.ts subscriber runs ws.disconnect(), deleteCredential(host), sets skip-auto-login, navigates to the connect page with \"Your session expired — sign in again.\"\n6. The session row was never revoked and the token is still valid, but the user must retype their password and auto-login is disabled for that host. No test pins this (Server/api/middleware_test.go covers missing/invalid/expired/revoked tokens and a dangling role, never a DB error).",
|
||
"evidence": "Server/api/middleware.go:117-128\n\t\t\tcase err != nil:\n\t\t\t\t// ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad\n\t\t\t\t// token — log it so it's distinguishable from ordinary 401s.\n\t\t\t\tif !errors.Is(err, auth.ErrTokenNotFound) {\n\t\t\t\t\tslog.ErrorContext(r.Context(), \"auth: token resolution failed\", \"error\", err)\n\t\t\t\t}\n\t\t\t\twriteJSON(w, http.StatusUnauthorized, errorResponse{\n\t\t\t\t\tError: \"UNAUTHORIZED\",\n\t\t\t\t\tMessage: \"invalid or expired session\",\n\t\t\t\t})\n\t\t\t\treturn\n\nServer/auth/resolve.go:41-77 — every store error is returned raw/wrapped, matching none of the sentinels:\n\tsess, err := store.GetSessionByTokenHash(ctx, hash); if err != nil { return nil,nil,nil, err }\n\tuser, err := store.GetUserByID(ctx, userID); if err != nil { return nil,nil,nil, err }\n\trole, err := store.GetRoleByID(ctx, user.RoleID); if err != nil { return nil,nil,nil, err }\n\nClient/src/lib/api.ts:141-155\n if (res.status === 401) {\n if (!opts?.skipUnauthorized) {\n onUnauthorized?.();\n }\n\nClient/src/main.ts:121-130\nconst api = createApiClient({ host: \"\" }, () => {\n ...\n clearAuth();\n});\n\nClient/src/main.ts:807-815\n const host = api.getConfig().host;\n if (host && authStore.getState().logoutReason !== \"server_shutdown\") {\n void deleteCredential(host);\n sessionStorage.setItem(\"owncord:skip-auto-login\", \"1\");\n }\n\nContrast: the ws revoked-session sweep refuses to do this on the identical signal — Server/ws/hub_sweep.go:137-143 \"A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep.\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "394ea9ccc7f8222ef5b4d019c3fd846dc62a7c52",
|
||
"test": "Server/api/middleware_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Split the default arm in AuthMiddleware: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other (wrapped) error write 503 SERVICE_UNAVAILABLE (or 500) alongside the existing slog.ErrorContext, so the client's 401 sink never fires on a server-side fault.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0203",
|
||
"title": "The channel-override escalation guard is missing on every clear path, so a MANAGE_CHANNELS holder can hand a lower-ranked role a permission their own role lacks",
|
||
"file": "Server/admin/handlers_channel_perms.go",
|
||
"line": 222,
|
||
"severity": "medium",
|
||
"why": "requireGrantableOverride exists to stop a non-ADMINISTRATOR MANAGE_CHANNELS holder granting a bit their own role does not hold, and handleDeleteChannelPermission's own comment says clearing an override \"restores exactly the access the PUT path refuses to grant, so gate it identically to handlePutChannelPermission\" — but it only adds the hierarchy guard and never calls requireGrantableOverride. handleDeleteChannelUserPermission has the same omission, and the guard is also defeated on the PUT paths themselves because it only inspects the *new* allow|deny mask: PUT {allow:0, deny:0} passes trivially while wiping an existing deny row. Clearing a deny is a grant (EffectivePerms = (rolePerm &^ deny) | allow), so the documented invariant does not hold on any of the four endpoints.",
|
||
"repro": "Setup: role \"Helper\" (position 5) whose base permissions include MANAGE_MESSAGES; channel #general carries channel_overrides(channel=#general, role=Helper, allow=0, deny=MANAGE_MESSAGES). Actor \"Mod\" holds MANAGE_CHANNELS but NOT MANAGE_MESSAGES and NOT ADMINISTRATOR, at position 10.\n\n1. Mod sends PUT /admin/api/channels/{general}/permissions/{helper} with {\"allow\": MANAGE_MESSAGES, \"deny\": 0} -> 403 \"cannot grant a permission your own role lacks (MANAGE_MESSAGES)\" (handlers_channel_perms.go:141).\n2. Mod instead sends DELETE /admin/api/channels/{general}/permissions/{helper} (or PUT with {\"allow\":0,\"deny\":0}) -> 204/200. Only the position check runs, and 5 < 10 passes.\n3. The deny row is gone, so EffectivePerms(Helper.base, 0, 0) now yields MANAGE_MESSAGES in #general: every Helper can delete other members' messages there — a power Mod does not have and was explicitly refused in step 1. permInvalidator + RefreshChannelVisibility even push the widened grant out immediately.\n\nSame two steps work against a single member through PUT/DELETE /channels/{id}/user-permissions/{userId} (handlers_channel_perms.go:342 vs 397).",
|
||
"evidence": "Server/admin/handlers_channel_perms.go:88-102 (the invariant)\n// requireGrantableOverride refuses to write a channel override whose allow or\n// deny mask contains a bit the actor's own role does not hold. Without this,\n// any MANAGE_CHANNELS holder could grant themselves or another user a\n// permission (e.g. MANAGE_SERVER) they were never assigned ...\nfunc requireGrantableOverride(actorRole *db.Role, allow, deny int64) error {\n\tif permissions.HasAdmin(actorRole.Permissions) { return nil }\n\tif escalated := (allow | deny) &^ actorRole.Permissions; escalated != 0 { ... }\n\nServer/admin/handlers_channel_perms.go:141-150 (PUT role — both guards)\n\t\tif err := requireGrantableOverride(actorRole, allow, deny); err != nil { ...403... }\n\t\tif role.Position >= actorRole.Position { ...403... }\n\nServer/admin/handlers_channel_perms.go:218-230 (DELETE role — hierarchy ONLY)\n\t\t// Hierarchy guard: deleting an override is a permission mutation with the\n\t\t// same authority as writing one (removing a deny row restores exactly the\n\t\t// access the PUT path refuses to grant), so gate it identically to\n\t\t// handlePutChannelPermission.\n\t\tif role.Position >= actorRole.Position { ...403... }\n\t\tif err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { ... }\n\nServer/admin/handlers_channel_perms.go:395-404 (DELETE per-user — hierarchy ONLY, no requireGrantableOverride)\n\nServer/permissions/permissions.go:138-140\nfunc EffectivePerms(rolePerm, allow, deny int64) int64 { return (rolePerm &^ deny) | allow }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "c978a348bbdd58b7c26e848f52f65a4f8c11c487",
|
||
"test": "Server/admin/handlers_channel_perms_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Do the escalation check against the masks being REMOVED, not the ones being written: in both DELETE handlers load the current row (database.GetChannelPermissions / GetUserChannelPermissions) and call requireGrantableOverride(actorRole, curAllow, curDeny) before deleting; in both PUT handlers pass (curAllow|allow, curDeny|deny) so a clear-by-zero-mask is covered by the same guard. Note a bare requireGrantableOverride(actorRole, 0, 0) on the DELETE paths would be a no-op.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0204",
|
||
"title": "A message or @mention arriving while the user reads back-history in the same channel is silently dropped: no row, no badge, no notification",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 581,
|
||
"severity": "medium",
|
||
"why": "Three independently-correct guards compose into a hole. messages.store's addMessage refuses to append to a detached around-window, dispatcher skips incrementUnread/incrementMention because the channel is activeChannelId, and notifyIncomingMessage bails because the window is focused and the channel is active. Nothing else records that the message arrived, so an @mention of the user vanishes with zero indication.",
|
||
"repro": "1. Open #general (a channel with more than ~100 messages of history). 2. Click a reply-reference, a search result, or a permalink pointing at an old message IN #general. MessageJump.jumpTo (pages/main-page/MessageJump.ts:95-102) calls api.getMessagesAround and passes has_more_after=true to setAroundMessages, which adds #general to detachedChannels (messages.store.ts:517-524). #general is still activeChannelId. 3. Leave the app window focused. Another user posts \"@you ping\" in #general. 4. dispatcher CHAT_MESSAGE: addMessage(payload) hits messages.store.ts:255 and returns prev unchanged -> the row is never stored or rendered. payload.channel_id !== activeId is FALSE, so incrementUnread (dispatcher.ts:582) and incrementMention (dispatcher.ts:585) are both skipped. notifyIncomingMessage hits notifications.ts:56 (isWindowFocused() && channel_id === activeChannelId) and returns -> no desktop popup, no chime, no taskbar flash. RESULT: nothing at all reaches the user. The server did increment read_states.mention_count, but dispatcher's ready handler calls markChannelRead(currentActive) (dispatcher.ts:397) on the next full resync, erasing it permanently. Recovery requires the user to guess and click \"Jump to Present\".",
|
||
"evidence": "messages.store.ts:252-255 // 3. Append as a new message — unless the channel is showing a detached\n// around-window ...\nif (prev.detachedChannels.has(channelId)) return prev;\n\ndispatcher.ts:581-586\nif (payload.channel_id !== activeId && !isOwnMessage) {\n incrementUnread(payload.channel_id);\n if (isMention) {\n incrementMention(payload.channel_id);\n }\n}\n\nnotifications.ts:54-56\n// Don't notify if the window is focused AND the message is in the active channel\nconst activeChannelId = channelsStore.getState().activeChannelId;\nif (isWindowFocused() && payload.channel_id === activeChannelId) return;",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Stop using \"channel is active\" as a proxy for \"the user is looking at the live tail\". Smallest change: in notifications.ts:56 use the already-exported detached selector — `if (isWindowFocused() && payload.channel_id === activeChannelId && !isWindowDetached(payload.channel_id)) return;` — so a detached active channel still notifies. Mirror it at dispatcher.ts:581 (`if ((payload.channel_id !== activeId || isWindowDetached(payload.channel_id)) && !isOwnMessage)`) if the badge is wanted too; that path additionally needs the badge cleared when the channel reattaches (reattachToPresent / setMessages) or the count will linger.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0205",
|
||
"title": "Embed pipeline never strips the trailing punctuation the linkifier strips, so a URL at the end of a sentence gets no embed (and YouTube links render as a duplicate broken bare link)",
|
||
"file": "Client/src/components/message-list/media.ts",
|
||
"line": 515,
|
||
"severity": "medium",
|
||
"why": "`extractUrls` returns raw `URL_REGEX` matches. `URL_REGEX = /https?:\\/\\/[^\\s<>\"']+/g` swallows any trailing `.`, `,`, `)`, `!`, `?`. `renderMentions` in content-parser.ts:171 explicitly strips exactly those characters (with a paren-balance give-back) before building the anchor, so the linkified href and the URL handed to the embed pipeline disagree on every URL that is followed by sentence punctuation. The embed path then either mis-classifies the URL or fetches an address that does not exist.",
|
||
"repro": "Post `Nice pic https://cdn.example.com/a.png.` (sentence-ending period). extractUrls yields `https://cdn.example.com/a.png.`; `isDirectImageUrl` tests `new URL(...).pathname` = `/a.png.` against `/\\.(gif|png|jpg|jpeg|webp)$/`, which fails, so the inline image is never rendered and a generic link-preview card is fetched for a 404 address instead. Same for `(https://cdn.example.com/a.png)`. For YouTube: post `Check https://youtu.be/dQw4w9WgXcQ.` — extractYouTubeId returns `dQw4w9WgXcQ.` (non-null), so renderYouTubeEmbed is entered, YOUTUBE_ID_RE `^[\\w-]{1,20}$` rejects the `.`, and the message gets a second, plain `<a>` \"embed\" pointing at the trailing-dot URL underneath the correctly-linkified one — no player. No test covers punctuation: tests/unit/media.test.ts:1211-1246 only exercises clean URLs.",
|
||
"evidence": "media.ts:511-516\n const withoutCodeBlocks = content\n .replace(CODE_BLOCK_REGEX, \"\")\n .replace(INLINE_CODE_REGEX, \"\")\n .replace(MASKED_LINK_REGEX, \"\");\n const matches = withoutCodeBlocks.match(URL_REGEX);\n return matches ?? [];\n\nvs content-parser.ts:170-179\n const rawUrl = match[0];\n let stripped = rawUrl.replace(/[.,;:!?)]+$/, \"\");\n ... if (opens > closes) stripped = stripped + \")\";",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "0b665f3c7da336b95beb7c63f3c3eb23b634e173",
|
||
"test": "Client/tests/unit/media.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Export the trailing-punctuation strip from content-parser.ts (factor renderMentions:171-179 into e.g. `stripUrlTrailingPunctuation(raw)`) and apply it once in extractUrls (media.ts:515) before returning, so the embed pipeline and the anchor agree on the same URL. One change in the shared extractor covers YouTube, direct-image and generic-preview branches.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0206",
|
||
"title": "VAD AudioWorklet counts 128-sample render quanta as if they were ~16 ms frames, so the mic gate closes after 32 ms of quiet instead of the intended ~200 ms",
|
||
"file": "Client/public/vad-worklet.js",
|
||
"line": 19,
|
||
"severity": "medium",
|
||
"why": "AudioWorkletProcessor.process() is invoked once per render quantum — 128 sample frames — and AudioPipeline creates its context with `new AudioContext({ sampleRate: 48000 })` (audioPipeline.ts:120), so one process() call is exactly 128/48000 = 2.667 ms. The worklet's frame counters were sized for ~16 ms frames (the setTimeout fallback's poll interval): _gateOnFrames = 12 is annotated \"~200ms of silence before gating\" but is 32 ms; _startupGrace = 30 is annotated \"~500ms grace period\" but is 80 ms; the RMS throttle `_frameCounter >= 6` is annotated \"~50ms\" but is 16 ms. audioPipeline.ts:415 calls the fallback's logic \"identical to the setTimeout version\", where the same 12/30 constants at 16 ms per poll do give ~192 ms and ~480 ms. The two VAD implementations therefore disagree by ~6x on the only tuning parameter that matters, and the primary (worklet) path is the wrong one.",
|
||
"repro": "Join voice with the default voiceSensitivity of 50 (threshold RMS 0.05) on a build where /vad-worklet.js loads successfully (the logged \"VAD AudioWorklet started\" path). Speak a normal sentence. Inter-word pauses of 40-150 ms drop RMS below threshold for longer than 12 render quanta (32 ms), so the worklet posts {type:\"gate\",gated:true}, AudioPipeline sets the GainNode target to 0 with setTargetAtTime(tau=0.015) and the outgoing mic level collapses mid-sentence; the ungate needs only 2 quanta (5.3 ms) so the gain immediately ramps back. The result is continuous level pumping / clipped word onsets heard by every other participant. Force the fallback instead (make addModule reject, e.g. remove /vad-worklet.js) and the identical constants gate only after ~192 ms, so the same speech passes through cleanly — the two paths produce audibly different behaviour from the same tuning values.",
|
||
"evidence": "vad-worklet.js:19,26,71\n\tthis._gateOnFrames = 12; // ~200ms of silence before gating\n\tthis._startupGrace = 30; // ~500ms grace period\n\tif (this._frameCounter >= 6) { // \"~50ms at 128 samples/frame @ 48kHz\"\n\naudioPipeline.ts:120 const ctx = new AudioContext({ sampleRate: 48000 });\naudioPipeline.ts:364-367 (fallback, polled at 16 ms)\n\tconst GATE_ON_FRAMES = 12;\n\tconst GATE_OFF_FRAMES = 2;\n\tconst STARTUP_GRACE = 30;\naudioPipeline.ts:390,410 this.vadTimer = setTimeout(poll, 16);",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "e0ed40e4d96c1757522e8d69b47863c2c65f92eb",
|
||
"test": "Client/tests/unit/vad-worklet-timing.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Fix the constants in Client/public/vad-worklet.js to be counts of 128-sample render quanta rather than 16 ms polls: _gateOnFrames = 75 (~200 ms), _gateOffFrames = 12 (~32 ms), _startupGrace = 188 (~500 ms), and the RMS-throttle test at line 71 to _frameCounter >= 19 (~50 ms). That is the single-place fix, since _startupGrace and the RMS throttle are not overridable through the config message. If you would rather keep the timing on the main thread, derive them there instead — in startVadWorklet (audioPipeline.ts:326) post gateOnFrames: Math.round(0.2 * ctx.sampleRate / 128) and gateOffFrames: Math.round(0.033 * ctx.sampleRate / 128) — but the grace period and RMS throttle still have to be corrected in the worklet.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0207",
|
||
"title": "The video-mode wake-up signature omits currentChannelId, so VideoModeController's lastChannelId goes stale and clearStreams() deletes the remote tile onRemoteVideo just added",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 710,
|
||
"severity": "medium",
|
||
"why": "MainPage's voiceStore subscriber only calls videoModeCtrl.checkVideoMode() when the camera/screenshare signature changes (MainPage.ts:710-724), and that signature is built from localCamera/localScreenshare plus per-user camera/screenshare flags — currentChannelId is read (line 711) only to pick which roster to scan, and never contributes to `sig`. checkVideoMode() is the ONLY writer of VideoModeController's `lastChannelId` (VideoModeController.ts:121-126), so a voice-channel switch that leaves the signature unchanged never advances it. The next call to checkVideoMode() is then the one made by setOnRemoteVideo (MainPage.ts:695) immediately after it added a tile — and it fires `videoGrid.clearStreams()` for the now-stale channel change, destroying that tile one line after it was created.",
|
||
"repro": "1. Alice is in voice channel A. Nobody in A or B has a camera or screenshare on, so MainPage's prevVideoSignature is \"\" and VideoModeController's lastChannelId is A.\n2. Alice clicks voice channel B in the sidebar. VoiceCallbacks.onVoiceJoin -> joinVoiceChannel(B) sets currentChannelId = B (voice.store.ts:300-315) and touches nothing else. MainPage's subscriber recomputes sig: still \"\" (no local flags, B's roster has no camera/screenshare flags), so line 721's guard fails and checkVideoMode() is NOT called. lastChannelId is still A.\n3. Bob, already in B, starts a screenshare. LiveKit's TrackSubscribed reaches Alice before the server's voice_state broadcast does — the exact race VideoModeController.ts:142-143 documents.\n4. setOnRemoteVideo runs: videoGrid.addStream(bobId + 1_000_000, \"Bob (Screen)\", stream, ...) at MainPage.ts:690, then videoModeCtrl.checkVideoMode() at MainPage.ts:695.\n5. checkVideoMode sees channelId (B) !== lastChannelId (A) and calls videoGrid.clearStreams() (VideoModeController.ts:123), deleting the tile added in step 4. It then reads channelUsers.get(bobId).screenshare === false (WS still lagging) and videoGrid.hasStreams() === false (just cleared), so anyVideoOn is false and it calls closeVideoGrid().\n6. The voice_state broadcast finally arrives; sig changes to \":s<bobId>\" and checkVideoMode runs again — but channelId now equals lastChannelId, and remote tiles are only ever added by onRemoteVideo, which has already fired for this track and will not fire again.\nResult: Bob's screenshare is permanently invisible to Alice. Clicking \"Watch stream\" on Bob (ChannelSidebar.ts:539-541 -> MainPage.ts:398-401) opens the grid and calls setFocus(bobId + 1_000_000) for a tile that no longer exists, so VideoGrid.rebuildFocusLayout renders an empty .video-focus-main with no thumbnails. Only Bob stopping and restarting the share recovers it.",
|
||
"evidence": "MainPage.ts:710-724 — `let sig = (state.localCamera ? \"c\" : \"\") + (state.localScreenshare ? \"s\" : \"\"); const channelId = state.currentChannelId; if (channelId !== null) { const users = state.voiceUsers.get(channelId); if (users) { for (const [uid, u] of users) { if (u.camera) sig += `:c${uid}`; if (u.screenshare) sig += `:s${uid}`; } } } if (sig !== prevVideoSignature) { prevVideoSignature = sig; videoModeCtrl?.checkVideoMode(); }` — no channelId term in `sig`.\n\nVideoModeController.ts:112-126 — `function checkVideoMode(): void { const voice = voiceStore.getState(); const channelId = voice.currentChannelId; if (channelId !== lastChannelId) { if (lastChannelId !== null) { videoGrid.clearStreams(); } lastChannelId = channelId; }` — `lastChannelId` is assigned nowhere else except `destroy()`.\n\nMainPage.ts:690-695 — `videoGrid.addStream(tileId, username, stream, { isSelf: false, audioUserId: userId, isScreenshare }); videoModeCtrl?.checkVideoMode();` — add, then clear.\n\nVideoModeController.ts:141-143 states the premise of the race: \"Check both voice store state AND whether the grid has tiles, because LiveKit track delivery can race ahead of the WS voice_state update.\"\n\nVideoModeController.ts:213-217 confirms nothing else re-adds the tile: \"Remote video tiles are managed exclusively by the onRemoteVideo / onRemoteVideoRemoved callbacks (driven by LiveKit TrackSubscribed / TrackUnsubscribed).\"\n\nGrep confirms only three checkVideoMode call sites exist: MainPage.ts:695, :700, :723.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Include the voice channel id in the signature so any channel switch immediately advances lastChannelId. In MainPage.ts:710-711, seed the signature with the channel id, e.g. `const channelId = state.currentChannelId; let sig = `${String(channelId)}|` + (state.localCamera ? \"c\" : \"\") + (state.localScreenshare ? \"s\" : \"\");` (moving the existing line 711 above line 710). One line at the single wake-up site; the controller's tested clear-on-change behavior is untouched.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0208",
|
||
"title": "Voice sidebar's re-render signature omits sessionFingerprint, so an unverified peer's session fingerprint goes permanently stale after their LiveKit reconnect",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 943,
|
||
"severity": "medium",
|
||
"why": "`unsubVoiceStructure` is the only voiceStore subscription that calls `renderChannels()` (the sibling `unsubSpeaking` only toggles CSS classes), and its structural signature folds in `verif.status` but never `verif.sessionFingerprint`. The unverified badge's tooltip is built from `v.sessionFingerprint` (ChannelSidebar.ts:81-83), so a peer whose ephemeral key changes mid-call at an unchanged status produces a new fingerprint in the store that the DOM never picks up — the badge keeps advertising the superseded value, which for a peer with no identity key is the only out-of-band comparison value the feature (OC-0003) exists to provide.",
|
||
"repro": "Local user A and legacy peer B (B has no published identity key) are in the same voice channel. A's sidebar shows B's muted shield with tooltip \"Session fingerprint …: FP1\". B's LiveKit room connection drops and auto-reconnects: `E2EEManager.reannounceForReconnect()` (livekitE2EE.ts:337-375) generates a fresh ECDH keypair and re-announces it. A's `handleAnnounceInner` -> `verifyPeerAnnounce` writes `setPeerVerification({userId: B, status: \"unverified\", safetyNumber: null, sessionFingerprint: FP2})` (livekitE2EE.ts:553, 557-565). B never left `voiceUsers` and B's status is still \"unverified\", so the structural signature at line 943 is byte-identical to before, `subscribeSelector` fires no callback, `renderChannels()` never runs, and A's tooltip keeps showing FP1 while B's live session key hashes to FP2. It stays wrong until some unrelated structural change (someone toggles mute/camera, or a join/leave) happens to force a re-render. Reading FP1 out of band against B's screen (which shows FP2 via `localSessionFingerprint`, correctly refreshed because line 936 includes it) reports a false mismatch; the reverse ordering hides a real one.",
|
||
"evidence": "ChannelSidebar.ts:943 — `structSig += `:${uid}${u.muted ? \"m\" : \"\"}...${verif ? `@${verif.status}` : \"\"}`;` (only `verif.status`, no `verif.sessionFingerprint`)\nChannelSidebar.ts:81-83 — `(v.sessionFingerprint !== null ? ` Session fingerprint (changes every call — not an identity): ${v.sessionFingerprint}` : \"\")`\nChannelSidebar.ts:936 — the local half IS covered: `let structSig = `${state.currentChannelId ?? \"\"}#${state.localSessionFingerprint ?? \"\"}`;`",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "05287f67725f44eb4933e29ff604481a1ad7baab",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Include the fingerprint (and safety number) in the structural signature at ChannelSidebar.ts:943 — replace `${verif ? `@${verif.status}` : \"\"}` with `${verif ? `@${verif.status}/${verif.safetyNumber ?? \"\"}/${verif.sessionFingerprint ?? \"\"}` : \"\"}`. One change in the shared selector covers every badge field the tooltip reads.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0209",
|
||
"title": "A replayed retired-key announce overwrites the peer's displayed verification and session fingerprint before the replay guard rejects it",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 748,
|
||
"severity": "medium",
|
||
"why": "`handleAnnounceInner` runs `verifyPeerAnnounce` — which calls `setPeerVerificationIfCurrent` on every branch, including the success branches that publish `sessionFingerprint` computed from the announced key — before the `isRetiredPeerKey` replay guard at lines 779-783 / 790-793. An announce that is then rejected as a retired-key replay has already rewritten the voice store's verification entry for that peer, so the UI advertises the fingerprint of a key that is provably no longer the peer's live ECDH key (`_peerPublicKeys` is left untouched), and a red \"mismatch\" badge raised by a preceding forged announce is reset to green by the replay.",
|
||
"repro": "Peer P announces ephemeral key K1; A stores it and publishes verification {status, sessionFingerprint: FP(K1)}. P reconnects and announces K2; A retires K1 (`retirePeerKey`, line 785), stores K2, and publishes {status, sessionFingerprint: FP(K2)}. The relay now re-emits P's original, still-validly-signed K1 announce (the exact replay OC-0011's retired-key guard was added for — the announce message carries no channel/epoch/nonce). `handleAnnounceInner` calls `verifyPeerAnnounce(P, K1, sig1)`: the signature verifies against P's pinned identity key, so it returns true after writing `setPeerVerification({userId: P, status: \"verified\"|\"unverified\", sessionFingerprint: FP(K1)})`. Only then does line 780 reject the announce and return, leaving `_peerPublicKeys[P] === K2`. The badge now reports FP(K1) — a key A itself already retired — as P's current session fingerprint. If the replay is preceded by a forged/unsigned announce that set status \"mismatch\", the replay also clears that red badge back to verified.",
|
||
"evidence": "livekitE2EE.ts:747-751 — `if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) { return; }` runs first\nlivekitE2EE.ts:553 — `const sessionFingerprint = await computeRawKeyFingerprint(this.rawFromBase64(publicKeyBase64));` then `setPeerVerificationIfCurrent(..., sessionFingerprint)` at 557-565 / 613-622\nlivekitE2EE.ts:779-783 — `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error(\"E2EE: rejecting replayed peer key announce (previously retired)\", { userId }); return; }` (same guard again at 790-793) — reached only *after* the store write",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Hoist the replay check ahead of verification: in handleAnnounceInner, immediately after the `_ecdhKeyPair` queue check / generation capture (~line 741), add `if (this.isRetiredPeerKey(userId, publicKeyBase64)) { log.error(...); return; }` and delete the two later duplicates at 779-783 and 790-793. Safe because a retired key is never the live key — retirePeerKey is only called for a key being replaced (785) or for a departing peer whose entry is deleted (1200/1214) — so the dedupe branch at 764-769 cannot be starved.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0210",
|
||
"title": "Ring-buffer-only mode has no cross-restart seq-epoch guard: a partial replay from a fresh seq epoch is presented as a clean resume",
|
||
"file": "Server/main.go",
|
||
"line": 431,
|
||
"severity": "medium",
|
||
"why": "`seedHubReplayState` is the only thing that re-seeds `h.seq` from persisted events AND calls `hub.MarkVisibilityChanged()` at boot, and it lives *behind* `runStartEventPersistence`'s early return. With `event_persistence.enabled: false` (an explicitly supported mode — config.go:81-83 \"falls back to ring-buffer-only behaviour (Phase A semantics)\") the hub restarts with `h.seq == 0`, `visibilityChangeSeq == 0`, and an empty ring, so a reconnecting client carrying a stale pre-restart `last_seq` is matched against seq numbers belonging to a completely different epoch. `EventRingBuffer.EventsSinceFiltered` (ringbuffer.go:101/107) only refuses when `afterSeq <= oldestSeq` or `afterSeq > newestSeq`; a stale watermark that happens to land inside the new epoch's live window passes both checks and yields a partial replay. `mustFullResync` (hub_events.go:84-87) is inert because the watermark is 0. The client-side mitigation (ws.ts:326-329, OC-0032) only resets `lastSeq` when `replay_source === \"none\"`, so this path — `replay_source: \"buffer\"` — bypasses it, and the client tracks only `max(seq)` so the skipped events can never be requested again.",
|
||
"repro": "Config `event_persistence.enabled: false`. Server runs briefly; hub seq reaches 40; client A's in-memory `lastSeq` = 40. Restart the server (admin Restart / update / supervisor). On boot `runStartEventPersistence` returns at main.go:431, so `h.seq = 0`, `visibilityChangeSeq = 0`, ring empty. Other clients reconnect first; each connect fans out a sequenced global presence/member frame, pushing the NEW epoch's seq to 60 (ring holds new-epoch seq 1..60). Client A now reconnects with `last_seq: 40`: `mustFullResync(40)` is false (w==0); `EventsSinceFiltered(40, allowed)` sees oldest=1, newest=60, so 40 > 1 and 40 <= 60 → it returns the 20 frames with seq 41..60. handleReconnect writes `auth_ok` with `replay_source: \"buffer\"` plus those 20 frames. Client A never receives new-epoch events 1..40 (the other users' presence/member/channel frames), its `lastSeq` is never reset because `replay_source != \"none\"`, and since it only reports `max(seq)` the hole is unrecoverable for the life of the connection — its member list and presence state stay silently wrong while the UI reports a successful resume.",
|
||
"evidence": "Server/main.go:431 `if !cfg.EventPersistence.Enabled || hub == nil { return nil, nil }` — line 435 `seedHubReplayState(bgCtx, hub, database, log)` and its `hub.MarkVisibilityChanged()` (main.go:846) are unreachable in ring-only mode.\nServer/ws/ringbuffer.go:101-108 `if afterSeq <= oldestSeq { return nil }` / `if afterSeq > rb.newestSeqLocked() { return nil }` — nothing rejects an in-window afterSeq from a previous epoch.\nServer/ws/hub_events.go:84-87 `func (h *Hub) mustFullResync(lastSeq uint64) bool { w := h.visibilityChangeSeq.Load(); return w > 0 && lastSeq <= w }` — `w == 0` on this boot, so always false.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "3bd29b5d9fea466025209d837c1036952c28f55b",
|
||
"test": "Server/main_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Add a per-process epoch nonce to the resume handshake and reject a mismatched one in the single shared guard. Concretely: generate a random `bootEpoch uint64` in ws.NewHub, emit it in buildAuthOK/buildReady, have serve_auth.go's authPayload accept an `epoch` field alongside `last_seq`, and in reconnectPrecheck (Server/ws/serve.go:298-310, next to the existing mustFullResync check) force the full-ready path whenever the echoed epoch is absent or != h.bootEpoch. That is one guard covering both tiers and both the disabled-persistence and empty-events-table cases. A server-only stopgap, if the protocol change is too big: set a `h.freshEpoch` flag whenever the boot did not seed from persistence (main.go:431 disabled branch and main.go:843 maxSeq<=0 branch) and make mustFullResync return true for any lastSeq > 0 while it is set — correct, at the cost of degrading every ring-only reconnect to a full ready.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0211",
|
||
"title": "Periodic session recheck disconnects the client on a transient DB error, unlike its sibling sweep which explicitly refuses to",
|
||
"file": "Server/ws/handlers.go",
|
||
"line": 122,
|
||
"severity": "low",
|
||
"why": "handleMessageSessionRecheck treats `dbErr != nil` identically to \"session row is gone\" and \"session expired\", kicking the connection and logging the misleading reason \"ws session expired\". The sibling backstop in the same package (sweepRevokedSessions) documents and implements the opposite rule for exactly this case, so the two authorization paths disagree about what a failed read means.",
|
||
"repro": "1. A client sends its 10th message since the last check (SessionCheckInterval, client.go:21), so `shouldCheck` is true.\n2. At that instant `h.db.GetSessionWithBanStatus` fails transiently — SQLITE_BUSY past busy_timeout, an I/O error, a maintenance window. It returns (nil, err).\n3. handlers.go:122 takes the branch and calls `h.kickClient(c)` at line 124: the client is deleted from h.clients, its send channels are closed and it is unsubscribed from every topic — with no error frame explaining why.\n4. Because the trigger is a server-wide DB condition, every connected client that crosses its 10-message boundary in that window is dropped simultaneously, and they all reconnect at once — adding load to the already-contended DB and feeding straight into the handshake path above (serve_auth.go:62), where the same failing query now produces a terminal auth_error and a logout.\nCompare Server/ws/hub_sweep.go:136-143, which on the identical failure logs and skips: \"A failed batch lookup says nothing about any individual session — kicking everyone on a transient DB error would be a mass disconnect. Skip this sweep; the next tick retries.\" No test covers the dbErr path; Server/ws/handlers_test.go only exercises a genuinely deleted session.",
|
||
"evidence": "result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash)\nif dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {\n\tslog.Info(\"ws session expired, closing connection\", \"user_id\", c.userID)\n\th.kickClient(c)\n\treturn true\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "59ac14a78b39b192950cc4fa770225c8cd7fb923",
|
||
"test": "Server/ws/oc_0211_session_recheck_dberr_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Treat a failed read as no evidence, matching the sibling sweep. In Server/ws/handlers.go:121, before the combined check: `if dbErr != nil { slog.Warn(\"ws session recheck: lookup failed, skipping\", \"user_id\", c.userID, \"err\", dbErr); return false }` — the next recheck and sweepRevokedSessions remain the enforcement backstops.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0212",
|
||
"title": "TOFU re-pin recovery is a no-op for the live call, and it deletes the only badge that showed the peer was blocked",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 674,
|
||
"severity": "low",
|
||
"why": "`rePinPeerIdentity` writes the new pin and then calls `clearPeerVerification(userId)`, but nothing re-runs the announce that was rejected. `handleAnnounceInner` returns before `this._peerPublicKeys.set(...)` on a failed `verifyPeerAnnounce` (line 750), and a mid-call peer never re-announces (announce is only sent from `setupKeyExchange` and `reannounceForReconnect`), so the peer stays out of `_peerPublicKeys` — and therefore out of every offer and every rotation — for the rest of the call. Meanwhile `clearPeerVerification` removes the map entry entirely, so `ChannelSidebar.ts:480` (`if (verification !== null)`) renders no shield at all: the user sees the red shield-alert vanish and reads that as \"fixed\". The method's own doc comment (\"the next announce re-verifies against the new pin\") assumes an announce that a live call never produces.",
|
||
"repro": "Users A, B, C in one voice call; C is the key holder, A has B pinned to identity key K_old. B reinstalls (new identity key K_new) and rejoins the channel. B's `voice_e2ee_announce` reaches A: `verifyPeerAnnounce` sees `pin(K_old) !== publishedIdentity(K_new)` → status \"mismatch\", returns false, so A never stores B's ECDH key. C has no pin for B, accepts, and offers B the room key, so B is a normal participant for everyone but A. A clicks the red shield on B's row, confirms the fingerprint out of band, clicks \"Trust New Key\": `rePinPeerIdentity` succeeds, `clearPeerVerification(B)` runs, B's badge disappears. B is still absent from A's `_peerPublicKeys`. When C leaves and A is elected key holder, A's `distributeRoomKey` iterates `_peerPublicKeys` — B gets no offer, is stranded on the retired key, and goes permanently silent/undecryptable for the rest of the call, with no badge or error anywhere in A's UI.",
|
||
"evidence": "livekitE2EE.ts:663-676\n const result = await storeIdentityPin(host, String(userId), verifiedKey);\n if (result === \"failed\") { ...; return false; }\n clearPeerVerification(userId);\n log.info(\"E2EE: re-pinned peer identity key (TOFU recovery)\", { userId });\n return true;\n\nlivekitE2EE.ts:747-751 (the rejection that is never retried)\n if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))) {\n return; // rejected/blocked — do not store or wrap\n }\n\nChannelSidebar.ts:480-503\n const verification = getPeerVerification(user.userId);\n if (verification !== null) { ...render badge... }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Buffer the rejected announce instead of discarding it: in verifyPeerAnnounce's mismatch branch (livekitE2EE.ts:536-547) record `{userId -> {publicKeyBase64, signatureBase64}}` in a new `_blockedAnnounces` map (cleared in clearState alongside _pendingAnnounces), and in rePinPeerIdentity, after the successful storeIdentityPin and before clearPeerVerification, replay it: `const pending = this._blockedAnnounces.get(userId); if (pending) { this._blockedAnnounces.delete(userId); await this.handleAnnounce(userId, pending.publicKeyBase64, pending.signatureBase64); }`. That re-runs the normal verifying path against the new pin, re-populates _peerPublicKeys, sends the offer if we are the holder, and lets setPeerVerification write the real \"verified\" badge rather than leaving the row blank.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0213",
|
||
"title": "A stale voice_leave retires a rejoined peer's LIVE ephemeral key, permanently locking them out of the room key",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 1214,
|
||
"severity": "low",
|
||
"why": "`voice_leave` carries only `{channel_id, user_id}` (messages.go:704) — no join token — and it travels through the buffered `h.broadcast` queue plus the hub dispatch goroutine, while `voice_e2ee_announce` is published straight into the recipient's send queue from the sender's read-pump (`sendToVoiceChannelExcept` → `pubsub.Publish`). Hub_broadcast.go:64-72 explicitly names this asymmetry as a reordering hazard. So a peer's rejoin announce can be delivered ahead of the voice_leave for the join instance it superseded. `handleParticipantLeft` then reads the peer's CURRENT key as `departingKey`, deletes it from `_peerPublicKeys`, and feeds it to `retirePeerKey` — which `handleAnnounceInner` (lines 779-784 / 790-793) uses to reject any later announce carrying that key as a replay. The peer is both un-keyed and un-re-announceable.",
|
||
"repro": "A (key holder) and P are in voice channel X; A holds P's ephemeral key K2. P leaves X — `finishVoiceLeave` enqueues voice_leave(X,P) onto `h.broadcast` (hub.go:150, capacity 1024). Under a broadcast burst the hub dispatch goroutine lags. P rejoins X ~100 ms later; `setupKeyExchange` mints K4 and sends `voice_e2ee_announce`, which `sendToVoiceChannelExcept` publishes directly into A's send queue, overtaking the still-queued voice_leave. At A: the announce applies first — K2 retired, K4 stored, offer sent. Then the stale voice_leave(X,P) arrives: `handleParticipantLeft(P)` reads `departingKey = K4`, deletes P from `_peerPublicKeys`, retires K4, and (since `wasKeyHolder && hadPeerKey`) rotates the room key excluding P. P now decrypts nothing and is decrypted by nobody; P's reconnect-confirm re-announce of K4 (line 405) is rejected by `isRetiredPeerKey`, and P is never offered a key again for the remainder of the call unless P's SFU connection drops and mints a fresh keypair. A channel filter does not fix this — the stale leave names the same channel P rejoined.",
|
||
"evidence": "livekitE2EE.ts:1198-1215\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n this._peerOfferEpochs.delete(userId);\n clearPeerVerification(userId);\n if (departingKey) {\n this.retirePeerKey(userId, await exportPublicKey(departingKey));\n }\n\nlivekitE2EE.ts:790-793 (the resulting permanent rejection)\n if (this.isRetiredPeerKey(userId, publicKeyBase64)) {\n log.error(\"E2EE: rejecting replayed peer key announce (previously retired)\", { userId });\n return;\n }\n\nServer/ws/voice_e2ee.go:270-272 (direct publish, bypasses h.broadcast)\n func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) {\n h.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID)\n\nServer/ws/hub_broadcast.go:64-72 documents that publishing straight to pub/sub \"would reintroduce exactly that kind of reordering\".",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "ccd9f39b69b202dc2858c8b02e97104e67f81aec",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since protocol/schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0214",
|
||
"title": "DeleteAccount's last-admin guard counts admins with `banned = 0`, so a lapsed temporary ban on any admin permanently blocks another admin's self-deletion",
|
||
"file": "Server/db/account.go",
|
||
"line": 202,
|
||
"severity": "low",
|
||
"why": "Same lapsed-ban split as above, in the opposite direction: the guard's \"is there another usable admin left\" count excludes an admin whose temporary ban has expired, even though that admin can log in and administer normally (auth.IsEffectivelyBanned returns false for them). The guard then reports ErrLastAdmin for a server that in fact still has a working administrator, and there is no way for the caller to clear it short of an explicit unban.",
|
||
"repro": "Server has exactly two admin-class accounts, alice and bob. Alice is temp-banned for 1h at some point; the hour lapses (users.banned stays 1, ban_expires in the past) and alice keeps logging in and administering fine. Bob now calls DELETE /api/v1/users/me. deleteAccountAdminGuard resolves the admin role ids, sees bob is admin-class, and runs `SELECT COUNT(*) FROM users WHERE role_id IN (...) AND id != bob AND banned = 0` -> alice is excluded -> adminCount == 0 -> ErrLastAdmin. Bob can never delete his account while alice's stale banned flag stands, even though alice is a fully functional administrator.",
|
||
"evidence": "Server/db/account.go:200-210\n\t\t\tvar adminCount int\n\t\t\tif err := tx.QueryRowContext(ctx,\n\t\t\t\tfmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`,\n\t\t\t\t\tstrings.Join(placeholders, \",\")),\n\t\t\t\targs...,\n\t\t\t).Scan(&adminCount); err != nil { ... }\n\t\t\tif adminCount == 0 {\n\t\t\t\treturn ErrLastAdmin\n\t\t\t}\n\n(the same file's anonymiseUser comment, account.go:110-113, explicitly documents that a stale lapsed ban_expires means banned=1 does NOT mean banned)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "d793d3e1f3b932e48b0165756bc7db5ce99f9f01",
|
||
"test": "Server/db/account_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Reuse the package's canonical predicate instead of the raw column: in deleteAccountAdminGuard replace `AND banned = 0` with `AND ` + notBannedClause (db/mention_queries.go:40, same package). A permanently banned admin (ban_expires NULL) and a deleted/anonymised account still fail that clause, so the guard keeps excluding them; only the lapsed-temp-ban admin is counted again.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0215",
|
||
"title": "cert_store_key strips \":443\" off a bare IPv6 literal ending in hextet 443, pinning one server under two different keys",
|
||
"file": "Client/src-tauri/src/tofu.rs",
|
||
"line": 303,
|
||
"severity": "low",
|
||
"why": "`strip_suffix(\":443\")` runs before any bracket/IPv6 awareness, so a bare (unbracketed) IPv6 address whose final hextet is `443` has its last group eaten as if it were a port. `http_proxy` passes the bare host verbatim while `ws_proxy` passes the bracketed authority from the wss:// URL, so the same server resolves to two different cert-store keys. The sibling parser `http_proxy::split_host_port` (http_proxy.rs:285-298) explicitly guards this case (`Some((host, port)) if !host.contains(':')`); `cert_store_key` has no such guard.",
|
||
"repro": "Configure a server at a bare IPv6 address ending in :443, e.g. host = `fd00::443` (accepted by isValidHost's bare-IPv6 branch, and dialled correctly by http_proxy::resolve_remote_target as `[fd00::443]:443`).\n\n- REST path: `ensureHttpProxy(\"fd00::443\")` → http_proxy.rs:386 `tofu::cert_store_key(\"fd00::443\")` → strip_suffix(\":443\") matches → `\"fd00:\"`. The first-use prompt is emitted with `host: \"fd00:\"` and `accept_cert_fingerprint` pins under `\"fd00:\"`.\n- WS path: ws.ts:538 builds `wss://[fd00::443]/api/v1/ws` (bracketBareIPv6Host); Rust `extract_host` → `cert_store_key(\"[fd00::443]\")` → the string ends in `443]`, so strip_suffix(\":443\") misses → brackets stripped → `\"fd00::443\"`. `evaluate` finds no pin → a SECOND first-use prompt for the same certificate.\n- LiveKit path: `ensureLiveKitProxy` sends `[fd00::443]:443` → key `\"fd00::443\"`, agreeing with WS and disagreeing with HTTP.\n\nNet effect: the user is asked to confirm one server's fingerprint twice, one prompt shows the meaningless host string `fd00:`, and the REST tunnel's pin lives under a key no other surface ever reads or re-validates. On a later certificate rotation the same split produces two independent mismatch prompts. No existing test covers a bare IPv6 whose last hextet is 443 (tofu.rs:421-432 only covers `2001:db8::1`).",
|
||
"evidence": "tofu.rs:302-309\n pub(crate) fn cert_store_key(host: &str) -> String {\n let stripped = host.strip_suffix(\":443\").unwrap_or(host);\n let unbracketed = stripped.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')).unwrap_or(stripped);\n unbracketed.to_ascii_lowercase()\n }\n\ncontrast http_proxy.rs:293-296 (the guard cert_store_key lacks):\n match remote_host.rsplit_once(':') {\n Some((host, port)) if !host.contains(':') => Ok((host, port)),\n _ => Ok((remote_host, \"443\")),\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "tauri-rust",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "0fe64f2f18cfad3ffc90ff4252923569a9c37904",
|
||
"test": "Client/src-tauri/src/tofu.rs",
|
||
"revertProof": "pass (hand-proved)"
|
||
},
|
||
"suggestedFix": "Give cert_store_key the same bracket/IPv6 guard split_host_port already has, in the one shared function (tofu.rs:303): `let stripped = match host.strip_suffix(\":443\") { Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest, _ => host };` then keep the existing bracket-unwrap and lowercase. That leaves \"example.com:443\" -> \"example.com\" and \"[2001:db8::1]:443\" -> \"2001:db8::1\" unchanged, while \"fd00::443\" stays whole and matches the ws/livekit key.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0216",
|
||
"title": "Unmuting while server-deafened fires a voice_deafen the server always refuses — an error toast on every unmute",
|
||
"file": "Client/src/pages/main-page/VoiceCallbacks.ts",
|
||
"line": 80,
|
||
"severity": "low",
|
||
"why": "`onMuteToggle`'s unmute branch auto-undeafens without checking `localServerDeafened`, while its sibling `onDeafenToggle` does carry the mirror-image `localServerMuted` guard (line 99). The server refuses a self-undeafen while `server_deafened` is set (Server/ws/voice_controls.go `refuseIfServerSilenced`, ErrCodeServerDeafened), and livekitSession.setDeafened(false) already refuses locally — so the frame is pure waste that lands in the dispatcher's generic error branch as a user-facing toast.",
|
||
"repro": "1) User A joins a voice channel, not muted, not deafened. 2) A moderator server-deafens A (`voice_mod_deafen`) → dispatcher's enforceModeratorAudioState sets localDeafened=true, localServerDeafened=true; VoiceWidget disables only the deafen button (VoiceWidget.ts:281-287), the mic button stays enabled. 3) A clicks the mic button (or presses Ctrl+M) to self-mute → localMuted=true. 4) A clicks the mic button again to unmute → `state.localMuted` is true, `state.localServerMuted` is false, so the branch runs: `voiceSessionSetMuted(false)` + `voice_mute{false}` (fine), then because `state.localDeafened` is true it calls `voiceSessionSetDeafened(false)` (silently refused by livekitSession.ts:1607) and sends `voice_deafen{deafened:false}`. The server answers SERVER_DEAFENED, and dispatcher.ts's catch-all error branch (line 1172) pops a red \"you were deafened by a moderator\" toast. Every subsequent mute/unmute cycle repeats it.",
|
||
"evidence": " if (state.localMuted) {\n voiceSessionSetMuted(false);\n ws.send({ type: \"voice_mute\", payload: { muted: false } });\n if (state.localDeafened) {\n voiceSessionSetDeafened(false);\n ws.send({ type: \"voice_deafen\", payload: { deafened: false } });\n }\n } else {\n\n// vs. the guarded sibling at line 99:\n// if (state.localServerMuted !== true) {\n// voiceSessionSetMuted(false);\n// ws.send({ type: \"voice_mute\", payload: { muted: false } });\n// }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "cfb8168eadbf82563ff9c7a90a521783f20cb7d1",
|
||
"test": "Client/tests/unit/voice-callbacks.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Mirror the sibling guard in Client/src/pages/main-page/VoiceCallbacks.ts:80 — change `if (state.localDeafened) {` to `if (state.localDeafened && state.localServerDeafened !== true) {` so no voice_deafen{deafened:false} frame is sent while the moderator deafen stands (the unmute half at 78-79 still goes through).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0217",
|
||
"title": "scrollToMessage registers a new permanent abort listener (pinning a message row) on the component-lifetime AbortSignal on every jump",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 1021,
|
||
"severity": "low",
|
||
"why": "The highlight-flash cleanup is attached to `ac.signal` — the MessageList's whole-lifetime controller — once per `scrollToMessage` call, and `{ once: true }` only removes it when abort actually fires (i.e. at destroy). Each closure captures the target row's `HTMLElement`, so every jump adds one listener and pins one (usually already re-rendered away) DOM subtree until the channel is unmounted.",
|
||
"repro": "Open a channel and jump repeatedly within it — click a reply bar's jump arrow, a search hit, or a pinned entry — N times. Each successful `scrollToMessage` reaches line 1017-1021 and calls `ac.signal.addEventListener(\"abort\", …)`. After 200 jumps the single AbortSignal carries 200 listeners and 200 detached message-row elements are still strongly reachable through their closures; none are released until `destroy()` aborts the controller. `renderWindow()`/`renderAll()` rebuild `contentContainer`'s children on every store update, so the pinned nodes are dead DOM. Same defect shape as the already-fixed SearchOverlay.ts:96, context-menu.ts:88 and VoiceAudioTab.ts:490 findings.",
|
||
"evidence": " el.classList.add(\"highlight-flash\");\n const timer = window.setTimeout(() => {\n el.classList.remove(\"highlight-flash\");\n }, 1500);\n // Unmounting mid-flash must not leave a timer pointing at a dead node.\n ac.signal.addEventListener(\"abort\", () => clearTimeout(timer), { once: true });",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "8f6b22708d2a17f349b117f74817174c1a349faf",
|
||
"test": "Client/tests/unit/message-list.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Drop the per-call registration: hoist one factory-scoped `let flashTimer = 0; let flashEl: HTMLElement | null = null;`, and in scrollToMessage do `if (flashTimer !== 0) { clearTimeout(flashTimer); flashEl?.classList.remove(\"highlight-flash\"); } flashEl = el; flashTimer = window.setTimeout(() => { el.classList.remove(\"highlight-flash\"); flashTimer = 0; flashEl = null; }, 1500);`, then clear the same pair inside the existing destroy() (next to `ac.abort()`), so no abort listener is registered at all.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0218",
|
||
"title": "ready-time GET /blocks has no staleness guard, so it silently reverts a block/unblock the user performs while it is in flight",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 467,
|
||
"severity": "low",
|
||
"why": "The `ready` handler fires `api.listBlocks()` and unconditionally applies the response with `setBlockedByMe(...)`, a whole-set replace. `onToggleBlock` writes the same store with a per-user delta (`setUserBlockedByMe`) only after its own `await api.blockUser/unblockUser`. Neither writer has a generation/epoch guard, so whichever network reply lands second wins — and the stale full-set reply can land after the fresh delta.",
|
||
"repro": "1. Client is in a session with user 42 in `blockedByMe`. 2. The socket reconnects onto the full-resync tier, so dispatcher's `ready` handler runs and issues GET /blocks at T0 (response body will contain 42). 3. At T0+50ms the user opens the member list and clicks \"Unblock\" on user 42; DELETE /blocks returns 204, `setUserBlockedByMe(42, false)` removes 42 from `blockedByMe`, and the toast says \"Unblocked <name>\". 4. At T0+150ms the GET issued in step 2 resolves and `setBlockedByMe([...,42,...])` replaces the whole set, re-adding 42. Result: the server has the user unblocked, but `dmComposerBlockReason` still returns \"You've blocked this user. Unblock to send messages.\" and the DM composer stays disabled. Nothing else ever writes `blockedByMe`, so the contradiction persists until the next `ready`. The mirror case (user clicks Block inside the window) drops the block locally, un-gating a composer whose sends the server will refuse.",
|
||
"evidence": "dispatcher.ts:464-470\n clearBlockedByThem();\n if (api !== undefined) {\n api\n .listBlocks()\n .then((r) => setBlockedByMe(r.blocked_user_ids))\n .catch((err) => log.warn(\"Failed to load block list\", { error: String(err) }));\n }\n\nSidebarMemberSection.ts:176-185\n onToggleBlock: async (userId, username, block) => {\n try {\n if (block) {\n await api.blockUser(userId);\n } else {\n await api.unblockUser(userId);\n }\n setUserBlockedByMe(userId, block);\n getToast()?.show(block ? `Blocked ${username}` : `Unblocked ${username}`, \"success\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "4bab1b4b4b8b74baac7a3eb153dd313bb1d02932",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Put the guard in the store, not the caller, so both writers share it: give blocksStore a monotonically increasing `blockedByMeRev` that setUserBlockedByMe bumps on every accepted per-user delta, and change setBlockedByMe to take the revision observed before the fetch — `setBlockedByMe(userIds, rev)` returns without writing when `rev !== state.blockedByMeRev`. In dispatcher.ts:465-469 snapshot it before the call: `const rev = blocksStore.getState().blockedByMeRev; api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, rev))`. Existing callers of setBlockedByMe in tests pass the current revision (or make the parameter optional = force).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0219",
|
||
"title": "rollbackVoiceJoin clears the client's voice state but never drops its VoiceTopic subscription, so the socket keeps receiving another room's E2EE announces for the rest of the connection",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 643,
|
||
"severity": "low",
|
||
"why": "Every other path that takes a client out of voice while its WS stays up goes through `clearVoiceAndUnsubscribe` (voice_leave.go:15) or an explicit `h.pubsub.Unsubscribe(c, VoiceTopic(...))` (voice_leave.go:47, hub_sweep.go:405, livekit_webhook.go:300). `rollbackVoiceJoin` does only `c.clearVoiceChID()` — and it is reached from `voiceJoinComplete` *after* `h.pubsub.Subscribe(c, VoiceTopic(channelID))` has already run (voice_join.go:471 → 494). The client's in-memory voice state and the pubsub subscription registry therefore disagree for the lifetime of the socket, which is exactly the hazard `clearVoiceAndUnsubscribe`'s own doc comment says every leave path must avoid.",
|
||
"repro": "1. Alice joins voice channel 5. `voiceJoinComplete` runs: line 471 subscribes her socket to VoiceTopic(5), line 474 elects a key holder, line 476 broadcasts her voice_state.\n2. The very next statement, `h.db.GetChannelVoiceStates(ctx, 5)` (line 491), fails (SQLITE_BUSY / I/O error / the ctx-free reader pool hiccup this branch was written for — see the OC-0172 test which fault-injects exactly this).\n3. Line 494 calls `rollbackVoiceJoin(ctx, c, 5, state.JoinedAt, true)`: `c.clearVoiceChID()` zeroes her voiceChID, the row is deleted, a compensating voice_leave is broadcast, and her client tears the session down. Line 495 sends her an INTERNAL error.\n4. But `ps.topics[\"voice:5\"]` still maps her userID → her *Client. `sendToVoiceChannelExcept` (voice_e2ee.go:271) publishes every `voice_e2ee_announce` for channel 5 onto that topic, and `buildVoiceE2EEAnnounce(userID, pubKey, sig)` carries no channel_id.\n5. Alice retries and joins voice channel 9. Bob (still in channel 5) reconnects to the SFU and re-announces. The relay reaches Alice's socket; `dispatcher.ts:965` calls `handleE2EEAnnounce(...)` with no channel filter, and `handleAnnounceInner` (livekitE2EE.ts:807) writes Bob into `_peerPublicKeys` for the channel-9 session. If Alice is channel 9's key holder, line 818 immediately wraps channel 9's room key for Bob and spends a `sendOfferPaced` slot plus one of the server's 64-offers/sec budget on an offer the server then drops. Every subsequent rotation re-includes Bob in `distributeRoomKey`'s peer snapshot, permanently taxing the rotation budget that OC-0167 was fixed to keep inside the server's cap.",
|
||
"evidence": "voice_join.go:471 h.pubsub.Subscribe(c, VoiceTopic(channelID))\nvoice_join.go:491-496\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\th.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true)\n\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to join voice channel\"))\n\t\treturn\n\t}\nvoice_join.go:642-643\n\tfunc (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) {\n\t\tc.clearVoiceChID() // <- no h.pubsub.Unsubscribe(c, VoiceTopic(channelID))\n\ncontrast, voice_leave.go:14-20\n\tfunc (h *Hub) clearVoiceAndUnsubscribe(c *Client) (int64, string) {\n\t\toldChID, oldJoinToken := c.clearVoiceState()\n\t\tif oldChID != 0 { h.pubsub.Unsubscribe(c, VoiceTopic(oldChID)) }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "state-desync",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "ee8c8214803b4d75833684c1a4701d0f31f8a4d9",
|
||
"test": "Server/ws/oc_0219_voice_join_rollback_unsubscribe_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In Server/ws/voice_join.go:643 replace `c.clearVoiceChID()` with `h.clearVoiceAndUnsubscribe(c)`. It performs the same clear (clearVoiceChID already delegates to clearVoiceState, client.go:143-144) and additionally drops VoiceTopic(oldChID). This is safe for the other two call sites: at line 286 the client's voice state is still 0 (setVoiceState has not run), so no unsubscribe fires, and at line 396 oldChID == channelID, which is the subscription that should not exist yet and is a no-op.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0220",
|
||
"title": "A group DM that loses all its other members renders with a completely blank name everywhere",
|
||
"file": "Client/src/stores/dm.store.ts",
|
||
"line": 197,
|
||
"severity": "low",
|
||
"why": "`dmDisplayName` falls back to `dm.recipient.username` when `participants` is empty, but the server only populates `Recipient` when the \"others\" list is non-empty (`db.NewDMChannelInfo` / `GetUserDMChannels` both leave it as the zero-valued `DMUser`). A group DM legitimately reaches zero *other* participants — `LeaveGroupDM` only deletes the channel row when `remaining == 0`, so the last member keeps a live, is_group=1 channel — and `dm_participants` also CASCADEs when other members delete their accounts. The DmChannel doc comment (\"Never empty for a live DM\") and the DmSidebar avatar builder (which explicitly handles \"An empty group (every other member has left)\") both anticipate this state; `dmDisplayName` is the one place that does not, so it returns \"\".",
|
||
"repro": "1. A, B and C create an unnamed group DM (name is \"\", the default when the optional name is omitted).\n2. B closes the DM (DELETE /dms/{id} -> DMService.CloseDM -> LeaveGroupDM); then C does the same. `remaining` is 1, not 0, so the channel row survives for A.\n3. A reconnects (or just receives the dm_channel_open refresh). GetUserDMChannels returns the channel with Recipients=[] and Recipient={ID:0, Username:\"\"}.\n4. dmDisplayName returns \"\". A's DM sidebar row, the chat header (ChannelController.ts:593), the quick switcher, MainPage.ts:230 and DM desktop notifications (notifications.ts:35 -> NotificationsTab renders `@` + \"\") all render an empty label, and the avatar circle renders no letter. The conversation is unidentifiable and, in a list of several such groups, indistinguishable.\nNote: the existing test `dm-groups.test.ts:86` (\"falls back to the recipient when the participant list is empty\") only covers the legacy pre-group case where `recipient` IS populated, so it does not lock this behavior.",
|
||
"evidence": "dm.store.ts:194-201\n export function dmDisplayName(dm: DmChannel): string {\n if (dm.name !== \"\") return dm.name;\n const names = dm.participants.map((p) => (p.displayName ?? \"\") || p.username);\n if (names.length === 0) return dm.recipient.username; // <-- zero-value DMUser -> \"\"\n\nServer/db/dm_queries.go:80-82 (NewDMChannelInfo)\n if len(others) > 0 {\n info.Recipient = others[0]\n } // else Recipient stays the zero value (Username \"\")\n\nServer/db/dm_queries.go:246-249 (GetUserDMChannels) — same guard.\nServer/ws/serve_ready.go:88 already codes around it: `if dmChannels[i].Recipient.ID != 0 && ...`.\n\nDmSidebar.ts:120-122 acknowledges the same state for avatars:\n // An empty group (every other member has left) still needs a mark, so fall\n // back to the row's own label rather than rendering an empty circle.\n const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }];\n(convo.username is itself dmDisplayName's \"\", so the avatar letter is empty too.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b7229b94fec6cd686cbd423ce2f431a590e1152a",
|
||
"test": "Client/tests/unit/dm-groups.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Give the shared function a terminal fallback instead of returning a possibly-empty recipient username — one guard covers all seven call sites: replace dm.store.ts:197 with `if (names.length === 0) return dm.recipient.username !== \"\" ? dm.recipient.username : (dm.isGroup ? \"Empty group\" : \"Unknown user\");`",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0221",
|
||
"title": "Composer has no attachment-count cap while the server hard-rejects >10, so an 11-attachment message can never be sent",
|
||
"file": "Client/src/components/MessageInput.ts",
|
||
"line": 612,
|
||
"severity": "low",
|
||
"why": "`handlePasteFile` uploads and queues an attachment with no bound on `pendingAttachments.length`, and `handleSend` forwards every finished upload id. The server's chat_send constructor refuses the whole frame at exactly 11 (`if len(p.Attachments) > 10`), and that refusal is a *parse* error, so it comes back as the generic `BAD_REQUEST` / \"invalid payload\" with no mention of attachments. The composer has already cleared the preview bar by then, so the user is left with a permanently-failing message row and no way to learn what is wrong.",
|
||
"repro": "1. Open any channel with ATTACH_FILES. Paste (or pick, 11 times) 11 small images into the composer; all 11 upload successfully and show previews.\n2. Type any text and press Send.\n3. The server's chat_send parser returns BAD_REQUEST \"invalid payload\" carrying the send's correlation id; the optimistic row flips to \"failed\" with a generic error, the preview bar is already cleared, and the 11 uploads are orphaned server-side.\n4. Retry fails identically every time. Nothing in the UI indicates that the attachment count (11 > 10) is the cause, and the composer never prevented queueing the 11th.",
|
||
"evidence": "Client MessageInput.ts:536-612 (handlePasteFile) validates only `state.editing`, `file.size` and `file.type`, then:\n pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item });\n(no length check anywhere in the file — `pendingAttachments` is only ever read for `.length > 0` / `.length === 0`)\n\nClient MessageInput.ts:489-496 (handleSend)\n const attachmentIds = pendingAttachments\n .filter((a) => !a.id.startsWith(\"pending-\"))\n .map((a) => a.id);\n options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds);\n clearReply();\n clearPendingAttachments(); // previews discarded regardless of outcome\n\nServer/ws/command.go:416\n if len(p.Attachments) > 10 {\n return nil, fmt.Errorf(\"too many attachments (max 10)\")\n }\n\nServer/ws/handlers.go:51-54 — a constructor error yields:\n c.sendMsg(buildErrorMsgWithID(ErrCodeBadRequest, \"invalid payload\", env.ID))\n\nClient dispatcher.ts:1067-1086 routes that id to `markSendFailed(id, \"BAD_REQUEST\")`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "dbf2867514ee50cf15b390246470ffaacb17c344",
|
||
"test": "Client/tests/unit/message-input.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Add the bound at the single entry point rather than at each call site — in handlePasteFile, alongside the existing size/type guards (MessageInput.ts, just before the `const tempId = ...` line): `if (pendingAttachments.length >= 10) { showUploadError(\"You can attach at most 10 files to a message\"); return; }`. Placing it before the upload also stops the 11th file from being uploaded and orphaned.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0222",
|
||
"title": "Resume path builds auth_ok before applyConnectStatus, so every reconnect ships the disconnect-time status and makes the client fire a redundant presence_update",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 289,
|
||
"severity": "low",
|
||
"why": "handleFreshConnect settles the session status (applyConnectStatus, serve.go:836) BEFORE it writes auth_ok (serve.go:840), so a fresh connect's auth_ok carries db.ConnectStatus(saved). handleReconnect inverts that order: reconnectWriteReplay writes buildAuthOK(ctx, c.user, ...) at serve.go:574 and applyConnectStatus only runs at serve.go:289, after the replay burst. On a resume, c.user.Status is whatever the previous connection's teardown left in the row — and MarkUserDisconnected (Server/db/queries/sqlite/users.sql:31, 'SET status = CASE WHEN status = ''online'' THEN ''offline'' ELSE status END') rewrites a plain online user to 'offline'. So every resumed auth_ok tells the client its own status is 'offline' while the server is simultaneously about to set and broadcast 'online'. The client's MainPage.restoreSavedPresence() (Client/src/pages/MainPage.ts:196-201) compares loadUserStatus() against exactly that auth_ok value and, on mismatch, sends a presence_update — the call its own doc comment says 'is a no-op in the normal case', and which tests/unit/main-page.test.ts:624 explicitly forces to be a no-op by pinning authStore.user.status to 'online'. Ordering is deterministic: ws.ts's setState('connected') schedules the uiStore notification in a microtask (lib/store.ts:134) while setAuth runs synchronously in the same dispatch, so restoreSavedPresence always reads the freshly-received (stale) auth_ok status.",
|
||
"repro": "1. User U (no chosen status; local pref userStatus = \"online\") is connected; users.status = 'online'. 2. Kill the socket (proxy blip). readPump's defer runs MarkUserDisconnected -> users.status = 'offline'. 3. ws.ts backs off ~1s and reconnects with last_seq > 0; the ring buffer still covers it, so handleReconnect takes the buffer tier. 4. reconnectPrecheck's refreshUserSnapshot reads status 'offline'; reconnectWriteReplay writes auth_ok with payload.user.status = \"offline\". 5. Client: setAuth stores status 'offline'; uiStore.connectionStatus flips back to \"connected\"; MainPage's subscriber calls restoreSavedPresence(), sees \"online\" != \"offline\", and sends presence_update{status:\"online\"} — consuming the session's single 1-per-10s presence token and triggering a second server-wide sequenced presence fan-out on top of the one applyConnectStatus/announceConnectPresence already produced. 6. Consequence: any genuine status change (manual pick, or auto-idle's idle/return-to-online transition) in the next 10 s is deferred to presenceSender's retry instead of being sent immediately; and in a reconnect storm every reconnecting client adds an extra O(connected-clients) global broadcast, defeating the QueuePresence coalescer. Contrast a fresh connect (F5), where auth_ok carries 'online' and restoreSavedPresence is correctly a no-op.",
|
||
"evidence": "serve.go handleReconnect:\n\tif !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) { ... } // line 280 -> writes buildAuthOK(ctx, c.user, ...) at line 574\n\t// Update presence but skip member_join — user was already known.\n\tapplyConnectStatus(ctx, database, c) // line 289 <-- runs AFTER auth_ok\n\th.announceConnectPresence(c) // line 290\n\nvs. handleFreshConnect:\n\tapplyConnectStatus(ctx, database, c) // line 836 <-- runs BEFORE auth_ok\n\tif err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, \"none\")); ... // line 840\n\nClient/src/pages/MainPage.ts:196\n\tfunction restoreSavedPresence(): void {\n\t const status = loadUserStatus(); // \"online\" by default\n\t const serverStatus = authStore.getState().user?.status; // \"offline\" from the resumed auth_ok\n\t if (serverStatus === status) return;\n\t applyPresence(status); // spends the shared 1-per-10s token\n\t}",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "7c05232817c4c87e31206a5c2b8a49a0380fa5d0",
|
||
"test": "Server/ws/oc_0222_reconnect_status_order_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Move the applyConnectStatus(ctx, database, c) call in handleReconnect from serve.go:289 to just before the reconnectWriteReplay call at serve.go:280 (i.e. immediately after reconnectRegister returns), leaving h.announceConnectPresence(c) where it is. That makes the resumed auth_ok carry db.ConnectStatus(saved), matching handleFreshConnect, with no change to the replay contents or the post-replay broadcast.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0223",
|
||
"title": "@here raises a mention badge for users who are offline but whose last chosen status was idle/dnd",
|
||
"file": "Server/service/mentions.go",
|
||
"line": 179,
|
||
"severity": "low",
|
||
"why": "The @here narrowing reads `users.status`, but that column keeps a *chosen* idle/dnd across a disconnect by design — MarkUserDisconnected rewrites only 'online' to 'offline'. Every other read path compensates with the \"no live connection is offline, whatever the row says\" rule (ws/serve_ready.go presentableMembers/presentableDMChannels); this one does not, so @here reaches signed-out users whose last status was idle or dnd while correctly skipping signed-out users whose last status was online.",
|
||
"repro": "User B sets status to Do Not Disturb (or Idle) and closes the client; readPump's teardown calls MarkUserDisconnected, which leaves users.status = 'dnd'. User C simply closes the client while online; their row becomes 'offline'. User A (holding MENTION_EVERYONE) posts \"@here standup\" in #general. applyMentionCounts -> mentionReaders returns both B and C; BroadcastStatus('dnd') != 'offline' so B is added to `recipients` and IncrementMentionCounts bumps B's read_states.mention_count, while C is correctly skipped. B — equally offline — comes back to a red @here mention badge, which is exactly what the @here/offline narrowing exists to prevent. No test in Server/service/mentions_test.go covers a disconnected idle/dnd reader (only the invisible-but-connected case, TestSendMessage_HereSkipsInvisibleUsers).",
|
||
"evidence": "// Server/service/mentions.go:179\nif set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline {\n continue\n}\n\n// Server/db/queries/sqlite/users.sql:25-34 (MarkUserDisconnected)\n// \"It clears only 'online' ... A stale choice never renders as 'present'\n// because the read path treats a member with no live connection as offline\n// regardless.\"\nUPDATE users\nSET status = CASE WHEN status = 'online' THEN 'offline' ELSE status END,\n last_seen = datetime('now')\nWHERE id = ?;\n\n// r.Status comes straight from the column: db/mention_queries.go:351\n// SELECT id, status, role_id FROM users WHERE <notBanned> AND role_id IN (...)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "0ad96147da2e50377b23e985d8f07dafc2953602",
|
||
"test": "Server/service/mentions_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Give applyMentionCounts the same live-connection rule the read path uses, in the one shared place rather than at each call site. Add an optional predicate to MessageService (e.g. `online func(int64) bool`, nil-safe) that the ws layer wires to the hub's connected-id lookup (Hub.GetClient / connectedUserIDs), then at Server/service/mentions.go:179 make the @here skip `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID)))`. Leaving s.online nil preserves today's behavior for tests and any caller that has no hub.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0224",
|
||
"title": "A DM message that lands between registerNow and buildReady is counted twice in the DM unread badge — updateDmLastMessage has no message-id monotonicity guard",
|
||
"file": "Client/src/stores/dm.store.ts",
|
||
"line": 142,
|
||
"severity": "low",
|
||
"why": "updateDmLastMessage increments unreadCount unconditionally, with no check that `messageId` is newer than the row's `lastMessageId`. On a fresh connect the server registers the client (ws/serve.go handleFreshConnect, registerNow) BEFORE it snapshots unread counts in buildReady, so a DM delivered in that window is both included in ready's authoritative `unread_count` and queued for delivery after ready — the client applies both.",
|
||
"repro": "1) User A opens the client (fresh connect, last_seq = 0). 2) handleFreshConnect calls registerNow, which subscribes A to UserTopic; a DM sent by B at this instant is fanned out via EmitEvents -> sendSequencedToUsers -> SendToUser and lands in A's send buffer. 3) buildReady then runs GetUserDMChannels, which already counts that message: dm_channels[i].unread_count = 1. 4) A's client applies `ready` (setDmChannels sets unreadCount = 1), then the transport delivers the queued chat_message; the dispatcher's CHAT_MESSAGE handler (dispatcher.ts:601) calls updateDmLastMessage, which bumps unreadCount to 2. The DM sidebar shows a badge of 2 for one unread message, and it persists until the DM is opened or the next full `ready`. buildReady runs ~8 DB queries after registration, so the window is milliseconds-to-tens-of-milliseconds wide on a loaded server.",
|
||
"evidence": "// Client/src/stores/dm.store.ts:136-143\nchannels: [\n {\n ...updated,\n lastMessageId: messageId,\n lastMessage: content,\n lastMessageAt: timestamp,\n unreadCount: updated.unreadCount + 1, // no `messageId > updated.lastMessageId` guard\n },\n ...rest,\n]\n\n// Server/ws/serve.go handleFreshConnect: registerNow(...) at ~line 805,\n// buildReady(...) at ~line 846 — registration precedes the snapshot, and\n// writePump (which drains the queued frame) only starts after the handshake\n// writes, so the queued chat_message is delivered strictly after `ready`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "b7229b94fec6cd686cbd423ce2f431a590e1152a",
|
||
"test": "Client/tests/unit/dm-store.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Guard the increment on message-id monotonicity inside the shared store function, not at the dispatcher call sites (message ids are globally monotonic rowids, so id <= lastMessageId can only mean a duplicate or an out-of-order re-delivery). In Client/src/stores/dm.store.ts:142 replace `unreadCount: updated.unreadCount + 1` with `unreadCount: updated.lastMessageId !== null && messageId <= updated.lastMessageId ? updated.unreadCount : updated.unreadCount + 1`, keeping the preview/reorder update unconditional.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0225",
|
||
"title": "A transient DB read error in the admin perimeter is reported as 401, ejecting an admin from the panel mid-session",
|
||
"file": "Server/admin/middleware.go",
|
||
"line": 56,
|
||
"severity": "low",
|
||
"why": "adminAuthMiddleware's error switch has the same shape as api.AuthMiddleware's: ResolveTokenHash's wrapped DB errors are non-sentinel, so they fall into the `default` arm and are answered as 401 \"invalid or expired session\" — indistinguishable from an unknown token. Unlike the api middleware this one does not even log the distinction, so a DB outage on the admin perimeter is silently reported to the operator as a dead session. The desktop client routes adminRequest() through the same doFetch 401 sink as ordinary API calls, so an admin acting from the app (kick/ban/role change/channel edit all go to /admin/api) is signed out and has their credential deleted by the same path as finding 1.",
|
||
"repro": "1. An admin has the panel open (or is using the desktop client's admin actions) with a valid session.\n2. One SQLite read fails transiently — the scheduled backup's VACUUM INTO, a restore swapping the file, or plain lock contention — while a /admin/api/* request is in flight.\n3. ResolveTokenHash returns the wrapped DB error; the default arm writes 401 \"invalid or expired session\".\n4. The web panel shows the admin as logged out; from the desktop client, doFetch's 401 sink fires onUnauthorized -> clearAuth -> deleteCredential(host), ending the whole chat session and erasing the stored credential — all for a session that was never revoked.",
|
||
"evidence": "Server/admin/middleware.go:47-61\n\t\t\tuser, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash)\n\t\t\tif err != nil {\n\t\t\t\tswitch {\n\t\t\t\tcase errors.Is(err, auth.ErrTokenExpired): ...\n\t\t\t\tcase errors.Is(err, auth.ErrUserNotFound): ...\n\t\t\t\tcase errors.Is(err, auth.ErrRoleNotFound): ...\n\t\t\t\tdefault:\n\t\t\t\t\t// ErrTokenNotFound or a wrapped DB error.\n\t\t\t\t\twriteErr(w, http.StatusUnauthorized, \"UNAUTHORIZED\", \"invalid or expired session\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\nClient/src/lib/api.ts:190-197 — adminRequest() uses the same doFetch, so it hits the 401 sink at api.ts:150 with no skipUnauthorized opt-out.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "b0ef1313f087bd19870ee237da5e7aa85bc3c59f",
|
||
"test": "Server/admin/middleware_db_error_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Mirror the same split here: keep 401 only for errors.Is(err, auth.ErrTokenNotFound); for any other error log it and writeErr 503 SERVICE_UNAVAILABLE. Best done once — have both middlewares share a helper that maps a ResolveTokenHash error to (status, code, message).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0226",
|
||
"title": "uiStore is the only domain store clearAuth does not reset, so sidebarMode (and activeDmUserId) leak across a logout into the next server",
|
||
"file": "Client/src/stores/ui.store.ts",
|
||
"line": 36,
|
||
"severity": "low",
|
||
"why": "ui.store.ts exports no reset function and clearAuth (auth.store.ts:96-99) resets voice, messages, channels and blocks but never the UI store. sidebarMode is not restated by any `ready` payload, so it survives sign-out as module-global state and SidebarArea mounts the next server's sidebar in whatever mode the previous session left it in.",
|
||
"repro": "1. Sign into server X. 2. Click a DM in the sidebar -> SidebarDmHelpers.ts:51-52 calls setActiveDmUser(<X's peer id>) then setSidebarMode(\"dms\"). 3. Log out (lib/logout.ts:17 -> clearAuth). clearAuth resets voiceStore, messagesStore, channelsStore, blocksStore and authStore, but uiStore is untouched: sidebarMode is still \"dms\" and activeDmUserId still holds server X's user id. 4. Sign into a different server Y. MainPage mounts, createSidebarArea reads `const initialMode = uiStore.getState().sidebarMode;` (pages/main-page/SidebarArea.ts:705) and mounts the DM sidebar instead of Y's channel list, even though the chat pane shows a text channel the dispatcher auto-selected. The user must hit \"Back\" to reach the channel list. Same class as the already-fixed blocksStore-survives-clearAuth and settingsOpen-survives-ConnectPage.destroy leaks.",
|
||
"evidence": "ui.store.ts:36 export const uiStore = createStore<UiState>(INITIAL_STATE); // no resetUiStore export anywhere in the file\nui.store.ts:180-186 export function setSidebarMode(mode) { uiStore.setState((prev) => ({ ...prev, sidebarMode: mode, activeDmUserId: mode === \"channels\" ? null : prev.activeDmUserId })); }\n\nauth.store.ts:96-99\n resetVoiceStore();\n resetMessagesStore();\n resetChannelsStore();\n resetBlocksStore();\n\nSidebarArea.ts:704-706\n // Initial mount based on current store state\n const initialMode = uiStore.getState().sidebarMode;\n mountSidebarContent(initialMode);",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "78c75ada02ea05601721336032a47b027924277a",
|
||
"test": "Client/tests/unit/auth.store.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "One line in clearAuth (auth.store.ts, alongside resetBlocksStore()): `setSidebarMode(\"channels\")` imported from @stores/ui.store — it also nulls activeDmUserId (ui.store.ts:180-186). ui.store imports nothing from auth.store, so there is no import cycle. Prefer this over a blanket resetUiStore(), which would also clobber `theme` (a user preference, not session state).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0227",
|
||
"title": "Video-call tiles label participants with the raw username and never refresh it, so a nickname is ignored and a mid-call rename leaves the tile stale",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 685,
|
||
"severity": "low",
|
||
"why": "The video grid's tile label is built from voiceStore's frozen `user.username` rather than the member's display name, and addStream is the only thing that writes the label (VideoGrid.ts:274-277). addStream is called exactly twice — from setOnRemoteVideo on LiveKit TrackSubscribed (MainPage.ts:690) and from VideoModeController's local-tile block, which is latched behind `if (!localTileAdded)` (VideoModeController.ts:174-185). Neither is re-driven by a profile change, so the label is fixed for the life of the tile. Every other identity surface was moved to memberDisplayName — ChannelSidebar's voice roster (ChannelSidebar.ts:421-422, \"Render the same identity a rename shows everywhere else\"), the member list, message rows, the DM sidebar and the typing indicator — with only the deliberately security-sensitive surfaces (E2EE mismatch modal, moderation menu) keeping the raw username. The video tile is neither, and it was missed.",
|
||
"repro": "Two users in a voice channel; user B has a nickname/display_name set (\"Bee\") that differs from their username (\"bob_1994\"). B turns on their camera. A's sidebar voice roster shows \"Bee\" (memberDisplayName), while the video tile that opens shows \"bob_1994\" — the same person under two names in one screen. Then, with the camera still on, B renames themselves via Settings → Account: the user_update fan-out patches membersStore and voiceStore, the sidebar row and every message row repaint with the new name, but the video tile keeps the old string because nothing calls addStream again for that tile.",
|
||
"evidence": "MainPage.ts:685-694\n\tconst username = isScreenshare\n\t ? user?.username ? `${user.username} (Screen)` : `User ${userId} (Screen)`\n\t : (user?.username ?? `User ${userId}`);\n\tvideoGrid.addStream(tileId, username, stream, {...});\n\nVideoModeController.ts:174-185\n\tif (!localTileAdded) { ... videoGrid.addStream(currentUserId, me?.username ? `${me.username} (You)` : \"You\", ...); localTileAdded = true; }\n\nVideoGrid.ts:274-277 (label is only rewritten by another addStream call)\n\nChannelSidebar.ts:421-422\n\tconst member = membersStore.getState().members.get(user.userId);\n\tconst label = (member !== undefined ? memberDisplayName(member) : user.username) || \"Unknown\";",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Route both call sites through one shared label helper that prefers memberDisplayName: in MainPage.ts:685-689 and VideoModeController.ts:178/199, look up membersStore.getState().members.get(userId) and use memberDisplayName(member) with user.username as fallback (the exact idiom at ChannelSidebar.ts:421-422). For the staleness half, add a VideoGrid.setLabel(tileId, text) and call it from the existing USER_UPDATE handling — cheapest hook is right after updateVoiceUserProfile in dispatcher.ts:815, relabelling the plain and SCREENSHARE_TILE_ID_OFFSET tiles for that user id — rather than un-latching localTileAdded, which would re-run addStream and churn srcObject.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0228",
|
||
"title": "Mention pills bypass the server's authoritative `mentions` list, so a token the server refused still renders as a live (yellow \"you\") mention",
|
||
"file": "Client/src/lib/mentions.ts",
|
||
"line": 77,
|
||
"severity": "low",
|
||
"why": "`resolveMentionUserId` runs its member-list-by-username fallback unconditionally, not only when the server omitted `mentions`. The header comment promises \"a token the server would not resolve must not be highlighted here either\" and the row-level gate (`mentionsCurrentUser`, line 108) does honour the server, but the inline pill built by `buildMentionNode` (content-parser.ts:301) resolves locally and stamps `.mention-self` (the yellow \"you were mentioned\" styling, app.css:1298). Result: one and the same message can be un-highlighted at the row level and self-highlighted at the token level, and the sender sees a live pill for a ping that was never delivered.",
|
||
"repro": "Three independent ways to make the server's `mentions` disagree with the local parse, all of which produce a false pill:\n(1) Cap: post one message containing 21+ distinct @mentions of real members. Server resolves only the first 20 (maxMentionsPerMessage, mentions.go:17/142) and ships `mentions` with 20 ids; every client still renders all 21 as `.mention` pills, and the 21st user's own client renders theirs as `.mention-self` (yellow) while `highlightsCurrentUser` returns false — no row highlight, no unread mention badge, no notification.\n(2) Case folding: user `élodie` exists. Post \"hey @Élodie\". Server: LowerASCII(\"Élodie\") = \"Élodie\" != map key LowerASCII(\"élodie\") = \"élodie\" -> `mentions: []`, no badge. Client: \"Élodie\".toLowerCase() === \"élodie\".toLowerCase() -> resolves, renders `.mention .mention-self` for élodie.\n(3) The existing unit test tests/unit/mentions-render.test.ts:309 (\"trusts the server list over the local name parse\") sends content \"hey @me\" with mentions:[10] where the signed-in user is id 12, and asserts the row lacks `.mentioned`. Render the same message and query `.mention` — the span carries `mention-self` and data-user-id=\"12\", i.e. the exact case the test declares the server wins is still self-highlighted inline.",
|
||
"evidence": "mentions.ts:67-85\n for (const id of info?.mentions ?? []) {\n const member = members.get(id);\n if (member !== undefined && matches(member.username)) return id;\n }\n for (const member of members.values()) { // <- line 77, runs even when info.mentions was supplied\n if (matches(member.username)) return member.id;\n }\n\nvs the server, which is the sole authority for the wire field `mentions`:\nServer/service/mentions.go:142 if len(set.UserIDs) >= maxMentionsPerMessage { break } // cap = 20\nServer/service/mentions.go:71 raw := db.LowerASCII(m[2]) // ASCII-only fold, deliberately (OC-0131)\n\nvs the client, which folds with full Unicode:\nmentions.ts:51 const lower = token.toLowerCase();\nmentions.ts:71 const matches = (username) => spellings.includes(username.toLowerCase());",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "754ce6b5e70a3143bfd11729cee07168da7ca83e",
|
||
"test": "Client/tests/unit/mentions-render.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Gate the two local fallbacks in the shared function rather than at each call site. In resolveMentionUserId, after the info.mentions loop, return null when the server supplied a list: `if (info?.mentions !== undefined) return null;` placed between line 76 and line 77. Callers that legitimately have no server list — resolveMentionsFromContent (line 94), renderers.ts:149's renderMentions(msg.content), and optimistic echoes whose msg.mentions is undefined (messages.store.ts:299-311) — pass info?.mentions === undefined and keep the fallback. This makes the token-level gate agree with mentionsCurrentUser (line 108) and with the already-documented rule at lines 63-65 that a server-listed id the member map cannot name stays unhighlighted. Verified against the suite: no existing assertion in tests/unit/mentions-render.test.ts changes.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0229",
|
||
"title": "Channel rows register their context-menu listener on the sidebar-lifetime AbortSignal on every render, so every incoming message permanently retains a full set of detached rows",
|
||
"file": "Client/src/components/channel-sidebar/context-menu.ts",
|
||
"line": 53,
|
||
"severity": "low",
|
||
"why": "`attachChannelContextMenu` does `el.addEventListener(\"contextmenu\", …, { signal })` where `signal` is `ChannelSidebar`'s single factory-lifetime `ac.signal` (ChannelSidebar.ts:747, passed down at :806 → :610). Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal that holds a strong reference to the event target; it is only released when the signal aborts, which happens once, in `ChannelSidebar.destroy()`. `renderChannels()` (ChannelSidebar.ts:776) does `clearChildren(channelList)` and rebuilds every row from scratch, so each render leaks one retained detached row per channel. This is the same defect class the ledger already confirmed for SearchOverlay.ts:96 and MessageList.ts:1021 — but at far higher frequency, because `renderChannels()` is wired to `channelsStore.subscribeSelector((s) => s.channels, …)` (ChannelSidebar.ts:872) and `incrementUnread` builds a brand-new `channels` Map for every message delivered to a non-active channel (channels.store.ts:350). The same file also gets it right elsewhere: lines 185-188 explicitly tie the menu's bridge listener to `menuAc` \"so this bridge listener is torn down with the menu itself\", while the five menu-item listeners beside it (lines 82, 104, 135, 155, and the `signal` handed to `appendPurgeSection` at 172) stay on the long-lived `signal` and retain one detached `.channel-ctx-menu` subtree per right-click.",
|
||
"repro": "Sign in to a server with 20 channels and leave the client open on one channel while traffic flows in the others. Every message posted to a non-active channel calls `incrementUnread`, which returns `{...prev, channels: new Map(...)}`, which fires the `s.channels` selector, which runs `renderChannels()`: `clearChildren(channelList)` detaches all 20 rows and 20 fresh rows are built, each calling `attachChannelContextMenu(el, channel, ac.signal, …)` at ChannelSidebar.ts:610. After 1,000 messages, `ac.signal` holds 20,000 abort algorithms, each pinning a detached `.channel-item` subtree (plus, for a MANAGE_CHANNELS holder, three more per row from drag-reorder.ts:256/268/304). None are collectable until MainPage is destroyed. Take a heap snapshot after 10 minutes on a busy server and filter for detached `.channel-item` — the count grows monotonically with message volume and never drops. Secondary repro for the per-open variant: right-click the same channel row 200 times, dismissing each menu with a left-click; 200 detached `.channel-ctx-menu` subtrees (each with up to five item divs and their closures) stay reachable from `ac.signal`.",
|
||
"evidence": "context-menu.ts:53-56, 195\n el.addEventListener(\n \"contextmenu\",\n (e) => { … },\n { signal }, // ChannelSidebar's factory-lifetime ac.signal\n );\n\ncontext-menu.ts:82-89 (and 104, 135, 155) — per-open menu items on the same long-lived signal\n markItem.addEventListener(\"click\", () => { closeMenu(); markChannelRead(channel.id); }, { signal });\n\ncontext-menu.ts:185-188 — the file's own statement of the hazard, applied to one listener only\n // Tie this bridge listener's own lifetime to menuAc so it does not\n // outlive the menu it belongs to …\n signal.addEventListener(\"abort\", closeMenu, { signal: menuAc.signal });\n\nChannelSidebar.ts:747 const ac = new AbortController();\nChannelSidebar.ts:781 clearChildren(channelList);\nChannelSidebar.ts:806 ac.signal,\nChannelSidebar.ts:610 attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);\nChannelSidebar.ts:872-875\n const unsubChannelsMap = channelsStore.subscribeSelector((s) => s.channels, () => renderChannels());\n\nchannels.store.ts:346-352 (incrementUnread) — new Channel object AND new Map per message",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "aa963efa464fef4e2a5cece8706afd6d3072ee82",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Do not hand rows the factory-lifetime signal. In createChannelSidebar add `let renderAc: AbortController | null = null;` and at the top of renderChannels() (before clearChildren) do `renderAc?.abort(); renderAc = new AbortController();`, then pass `renderAc.signal` instead of `ac.signal` into renderCategoryGroup at ChannelSidebar.ts:806, and add `renderAc?.abort(); renderAc = null;` beside `ac.abort()` in destroy(). One change in the shared render function covers the context menu, drag handlers, and every other per-row listener; the sidebar-lifetime `ac.signal` stays for header/root listeners (mount at :826-855) that are created once.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0230",
|
||
"title": "\"Clear Logs\" empties the list but leaves the entry counter showing the pre-clear total",
|
||
"file": "Client/src/components/settings/LogsTab.ts",
|
||
"line": 241,
|
||
"severity": "low",
|
||
"why": "`countEl` is built once at LogsTab.ts:317 from `getLogBuffer().length` and is only ever rewritten inside the `addLogListener` callback at line 341. The Clear button's handler calls `clearLogBuffer()` + `renderLogEntries()` — neither of which touches `countEl` — so the counter and the list it labels disagree. The Refresh button (line 249) has the same gap. The counter only self-corrects when the next log entry at or above the current minimum level happens to be emitted *while the Logs tab is the active tab*; at the production default level of `info`, no UI interaction in the settings panel emits one, so the wrong number can sit there indefinitely.",
|
||
"repro": "Open Settings → Logs on a client that has been running a while (say 412 buffered entries). The panel shows the log rows and \"412 entries\". Click \"Clear Logs\". The list goes empty (renderLogEntries() re-runs against the now-empty buffer) but the line above it still reads \"412 entries\". Click \"Refresh\" — still \"412 entries\". It stays wrong until some component emits an info/warn/error log line while the Logs tab is still on screen.",
|
||
"evidence": "LogsTab.ts:237-245\n const clearBtn = createElement(\"button\", { class: \"ac-btn\" }, \"Clear Logs\");\n clearBtn.addEventListener(\"click\", () => {\n clearLogBuffer();\n renderLogEntries(); // no countEl update\n }, { signal });\n\nLogsTab.ts:317-323\n const countEl = createElement(\"div\", {...}, `${getLogBuffer().length} entries`);\n\nLogsTab.ts:338-343 — the only place countEl is ever updated\n unsubLogListener = addLogListener(() => {\n if (getActiveTab() === \"Logs\") {\n renderLogEntries();\n countEl.textContent = `${getLogBuffer().length} entries`;\n }\n });\n\nLogsTab.ts:88-100 — renderLogEntries touches only logListEl",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "bd007f675b3710b953cfbac14189abffee299c54",
|
||
"test": "Client/tests/unit/logs-tab.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Extract the count refresh into a local `function updateCount(): void { countEl.textContent = `${getLogBuffer().length} entries`; }` declared after countEl and call it from renderLogEntries (or from both the Clear and Refresh handlers plus the log listener). Cleanest single-point version: move countEl's creation above renderLogEntries' use and have renderLogEntries itself update the count, so every render path — clear, refresh, filter change, live entry — stays consistent.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0231",
|
||
"title": "stopVadPolling never detaches the VAD worklet's MessagePort handler, so a gate message posted before the worklet sees `stop` can re-gate the mic to zero after VAD has been turned off — with sensitivity 100 nothing ever un-gates it",
|
||
"file": "Client/src/lib/audioPipeline.ts",
|
||
"line": 418,
|
||
"severity": "low",
|
||
"why": "`stopVadPolling()` posts `{type:\"stop\"}`, calls `workletNode.disconnect()` and nulls `this.vadWorkletNode`, but never sets `workletNode.port.onmessage = null` nor `port.close()`. The `onmessage` closure installed at line 332 stays live (the closure itself keeps the node alive, and `disconnect()` only drops the node's outgoing edges — the incoming `analyser.connect(workletNode)` from line 324 is never removed either). The `stop` message has to cross to the audio thread, so the processor keeps running for several render quanta and can post `{type:\"gate\", gated:true}` in that window. That message is still dispatched on the main thread and sets `this.vadGated = true` + `updatePipelineGain()`, which drives the still-live GainNode to 0. On the `setVoiceSensitivity(>=100)` path there is no VAD left to ever post `gated:false`, and `updatePipelineGain()` keeps returning 0 for every later call because it reads `this.vadGated`.",
|
||
"repro": "In a voice call with VAD active (sensitivity < 100, the default 50), stop speaking so the worklet is about to gate, then drag the Input Sensitivity handle in Settings → Voice & Audio all the way to the far left (sensitivity 100, 'gate nothing'). VoiceAudioTab.ts:163-177 calls setVoiceSensitivity on every pointermove, so the last live worklet is stopped with no replacement. If the worklet emitted its silence→gated transition in the few quanta between the `stop` postMessage and the audio thread processing it, the late `{gate:true}` lands after stopVadPolling ungated, setting vadGated=true and driving the pipeline GainNode to 0. The mic is now permanently silent to every peer while the UI shows VAD disabled and unmuted; even moving the Input Volume slider does not help (setInputVolume → updatePipelineGain still multiplies by the stuck gate). Only a full pipeline rebuild — mute/unmute, a device change, or leaving and rejoining voice — clears it.",
|
||
"evidence": "audioPipeline.ts:418-439 `stopVadPolling()` — `this.vadWorkletNode.port.postMessage({ type: \"stop\" }); this.vadWorkletNode.disconnect(); this.vadWorkletNode = null;` (no `port.onmessage = null`, no `port.close()`), then `if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }`. audioPipeline.ts:332-342 `workletNode.port.onmessage = (event) => { if (event.data.type === \"gate\") { … this.vadGated = gated; this.updatePipelineGain(); } }`. audioPipeline.ts:253-258 — the `clamped >= 100` branch of `setVoiceSensitivity` does NOT restart VAD. audioPipeline.ts:223-231 `updatePipelineGain` → `const effectiveGain = this.vadGated ? 0 : this.currentInputGain;`. public/vad-worklet.js:42-44 `else if (event.data.type === \"stop\") { this._active = false; }` — only observed at the next `process()` call, and :77-83 posts `{gate:true}` from that same still-running `process()`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "9b0863967d3475e15d6600b2b9809b56921dbbef",
|
||
"test": "Client/tests/unit/audio-pipeline-vad-worklet-teardown.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In stopVadPolling(), detach the handler before stopping the node: `this.vadWorkletNode.port.onmessage = null;` immediately before the existing `postMessage({type:\"stop\"})` / `disconnect()` at audioPipeline.ts:429-434. One guard in the shared teardown covers every caller (setVoiceSensitivity, startVadPolling's self-stop, teardownAudioPipeline). Equivalent alternative: capture `const vadGen = this._vadGeneration` in startVadWorklet and early-return from the onmessage closure when `vadGen !== this._vadGeneration`.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0232",
|
||
"title": "\"Reduce Motion\" and \"Sync with OS\" are two writers of one CSS class with no arbitration, so a manual toggle silently overrides the OS accessibility setting",
|
||
"file": "Client/src/components/settings/AccessibilityTab.ts",
|
||
"line": 24,
|
||
"severity": "low",
|
||
"why": "The `reducedMotion` toggle's sideEffect writes `documentElement.classList.toggle(\"reduced-motion\", nowOn)` directly, and `syncOsMotionListener` (os-motion.ts:34/38) writes the same class from the media query. Neither consults the other. `.reduced-motion` (app.css:5228) is the app-wide animation kill switch — the three `@media (prefers-reduced-motion: reduce)` blocks in the stylesheets only cover `.highlight-flash`, `.jump-to-present-pill` and `.upp-popup`, so removing the class genuinely re-enables every other animation and transition. The toggle's rendered state is also always `loadPref(\"reducedMotion\", false)` (line 72), never the effective state, so with OS sync on the switch reads OFF while motion is in fact reduced.",
|
||
"repro": "OS has \"reduce motion\" enabled. Settings → Accessibility → turn ON \"Sync with OS\": `syncOsMotionListener(true)` adds `reduced-motion`; animations stop. The \"Reduce Motion\" switch still renders OFF (its pref is false). Now click \"Reduce Motion\" ON, then OFF: the second click runs `classList.toggle(\"reduced-motion\", false)`, removing the class. Animations are back app-wide even though \"Sync with OS\" is still ON and the OS still asks for reduced motion; nothing restores it until the OS setting itself changes or the app restarts. The mirror case also loses data: with OS sync ON and OS = no-reduce, a manual \"Reduce Motion\" ON survives to localStorage but is wiped on next launch, because applyStoredAppearance (appearance.ts:45-55) applies the manual pref first and then calls syncOsMotionListener, which re-derives the class from the OS.",
|
||
"evidence": "// AccessibilityTab.ts:19-26\n{ key: \"reducedMotion\", ..., sideEffect: (nowOn) => {\n document.documentElement.classList.toggle(\"reduced-motion\", nowOn);\n } },\n// os-motion.ts:32-41\nac = new AbortController();\nconst mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\ndocument.documentElement.classList.toggle(\"reduced-motion\", mq.matches);\nmq.addEventListener(\"change\", (e) => {\n document.documentElement.classList.toggle(\"reduced-motion\", e.matches);\n}, { signal: ac.signal });\n// app.css:5228\n.reduced-motion, .reduced-motion * { animation-duration: 0s !important; transition-duration: 0s !important; }",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "08e188135283b0883cd4aba261f3e30cb3769677",
|
||
"test": "Client/tests/unit/AccessibilityTab.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Make os-motion the single writer: change the reducedMotion sideEffect in AccessibilityTab.ts:23-25 to `sideEffect: () => syncOsMotionListener(loadPref<boolean>(\"syncOsMotion\", false))`. savePref has already stored the new manual value, and syncOsMotionListener(false) re-reads it while syncOsMotionListener(true) re-derives the class from the media query, so whichever source owns the class wins consistently — matching applyStoredAppearance's startup ordering (appearance.ts:45-55).",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0233",
|
||
"title": "Desktop notification titles print the raw username, ignoring the nickname the message row beside them renders",
|
||
"file": "Client/src/lib/notifications.ts",
|
||
"line": 99,
|
||
"severity": "low",
|
||
"why": "`notifyIncomingMessage` builds the title from `payload.user.username`, but `MessageUser` carries `display_name` (types.ts:97) and the message list renders it via `resolveAuthor` (message-list/formatting.ts:145-152). The popup that tells you who wrote to you names them differently from the row you click through to.",
|
||
"repro": "User id 42 has username `a_martinez`, display_name `Alice`. She posts in #general while the window is unfocused. The desktop notification reads \"a_martinez in #general\"; opening the app shows the same message authored by \"Alice\". A user who only ever sees nicknames cannot tell who the notification is from.",
|
||
"evidence": "const title = sanitizeNotif(\n mentioned\n ? `${payload.user.username} mentioned you in ${channelLabel}`\n : `${payload.user.username} in ${channelLabel}`,\n 80,\n);",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-2",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "f3eeaad2edff76a208b4a3d0bee4fb00458f29cc",
|
||
"test": "Client/tests/unit/notifications.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Resolve the author name once at notifications.ts:97 the way every other surface does — `const authorName = memberDisplayName(membersStore.getState().members.get(payload.user.id) ?? { username: payload.user.username, displayName: payload.user.display_name ?? null });` (or reuse resolveAuthor) — and interpolate authorName into both title branches, leaving the 80-char sanitizeNotif cap unchanged.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0234",
|
||
"title": "resolveLanguage resolves fence tags against Object.prototype, so ```constructor / ```toString return a function instead of null",
|
||
"file": "Client/src/components/message-list/syntax-highlight.ts",
|
||
"line": 196,
|
||
"severity": "low",
|
||
"why": "ALIASES is a plain object literal, so the lookup walks the prototype chain. `ALIASES[\"constructor\"]` is the Object constructor, `ALIASES[\"toString\"]` / `[\"valueOf\"]` / `[\"hasOwnProperty\"]` / `[\"isPrototypeOf\"]` / `[\"propertyIsEnumerable\"]` / `[\"toLocaleString\"]` are Function objects — none are null or undefined, so `?? null` never fires and the function returns a non-string in violation of its declared `string | null` type and its documented \"null when unknown\" contract. LANG_TAG_REGEX (`/^[A-Za-z][\\w+#-]{0,19}$/`, content-parser.ts:435) accepts every one of those tags, so a message can reach it. The caller then writes the value straight into a DOM attribute.",
|
||
"repro": "Post a message whose body is a fence tagged `constructor`:\n```constructor\nx = 1\n```\ncontent-parser.ts:477 calls resolveLanguage(\"constructor\"), which returns the `Object` function rather than null; line 478's `canonical !== null` passes and block.setAttribute(\"data-lang\", canonical) stringifies it, producing data-lang=\"function Object() { [native code] }\" on the rendered <div class=\"msg-codeblock\">. The same input with the fix (an own-property guard, e.g. Object.hasOwn(ALIASES, tag) or a null-prototype map) yields no data-lang at all, which is what every other unknown tag does. Unit test content-markdown.test.ts:661 only pins `resolveLanguage(\"nope\")`, so the prototype keys are uncovered.",
|
||
"evidence": "syntax-highlight.ts:160-197:\n const ALIASES: Readonly<Record<string, string>> = { js: \"javascript\", ... };\n export function resolveLanguage(tag: string | null): string | null {\n if (tag === null) return null;\n return ALIASES[tag.toLowerCase()] ?? null; // prototype chain, no own-property guard\n }\n\ncontent-parser.ts:477-479:\n const canonical = resolveLanguage(lang);\n if (canonical !== null) block.setAttribute(\"data-lang\", canonical);\n for (const token of highlightCode(code, canonical)) {",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "74d1ea6d3fa2897a46443158faf7820d29bacb32",
|
||
"test": "Client/tests/unit/content-markdown.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Guard the lookup for own properties in the one shared function: `const hit = Object.hasOwn(ALIASES, key) ? ALIASES[key] : undefined; return hit ?? null;` (or declare ALIASES via Object.assign(Object.create(null), {...}) / a Map). No caller-side change needed.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0235",
|
||
"title": "In a group DM, the ringer leaving voice cancels every other callee's ring even though the call is still live",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 629,
|
||
"severity": "low",
|
||
"why": "The `voice_leave` handler treats \"the user who rang me left the DM's voice channel\" as \"the call is over\", but in a group DM the room can still hold other participants who already accepted. The condition tests only `payload.user_id === ringing.fromUserId`; it never asks whether the channel's voice roster is now empty, even though `voiceStore.voiceUsers` is a `Map<channelId, Map<userId, VoiceUser>>` that answers exactly that.",
|
||
"repro": "Group DM with A, B, C (channels.type='dm', is_group=1 — Server/db/dm_queries.go:292, and service/dm.go RingTargets fans out to every other participant). (1) A clicks call: MainPage.startCall() (MainPage.ts:292-304) joins the DM voice channel and sends call_ring. (2) B and C both get call_incoming and both ring. (3) B clicks Accept and joins the voice channel. C is still ringing. (4) A leaves voice (hangs up or switches channel). The server broadcasts voice_leave for A to the DM's READ audience, which includes C (hub_broadcast.go channelReadAudience for a DM = its participants). (5) C's handler matches A's user_id and calls ringCtrl.cancel(channelId) -> stopRinging(): C's banner disappears and the chime stops, even though B is sitting in the call waiting. C loses the one-click Accept and gets no indication the call is still open.",
|
||
"evidence": "MainPage.ts:626-631\n ws.on(\"voice_leave\", (payload) => {\n const ringing = ringCtrl?.current();\n if (ringing === null || ringing === undefined) return;\n if (payload.user_id === ringing.fromUserId) {\n ringCtrl?.cancel(payload.channel_id);\n }\n }),\n\ncall-ring.ts:114-117 — cancel() only re-checks the channel id, not occupancy:\n function cancel(channelId: number): void {\n if (state === null || state.channelId !== channelId) return;\n stopRinging();\n }\n\nThe roster that would answer the real question exists: voice.store.ts:73\n readonly voiceUsers: ReadonlyMap<number, ReadonlyMap<number, VoiceUser>>; // channelId -> userId -> VoiceUser",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "0b4b7d5a95de9c5ee5ecde093ab7f54371c17c85",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Guard the cancel on the channel's voice roster being empty of anyone but the leaver, in the single MainPage voice_leave handler (voiceStore is already imported at MainPage.ts:27):\n\nws.on(\"voice_leave\", (payload) => {\n const ringing = ringCtrl?.current();\n if (ringing === null || ringing === undefined) return;\n if (payload.user_id !== ringing.fromUserId) return;\n const roster = voiceStore.getState().voiceUsers.get(payload.channel_id);\n const othersStillIn =\n roster !== undefined && [...roster.keys()].some((id) => id !== payload.user_id);\n if (!othersStillIn) ringCtrl?.cancel(payload.channel_id);\n});\n\n(The dispatcher's removeVoiceUser may or may not have run first; excluding payload.user_id makes the check order-independent, and keeps main-page.test.ts:503-528 green since that test's roster is empty.)",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0236",
|
||
"title": "Auto-idle's inactivity timer is never re-armed except by a DOM input event, so a tray status change leaves the watcher permanently disarmed",
|
||
"file": "Client/src/lib/autoIdle.ts",
|
||
"line": 107,
|
||
"severity": "low",
|
||
"why": "`arm()` is called only at construction and from `onActivity`. The timeout callback sets `timer = null` and calls `apply(true)` without re-arming. Any status change made through a surface that produces no window input event (the OS tray menu, which calls `saveUserStatus` directly) therefore cannot restart the ten-minute clock, and the user stays broadcast as Online while away — the exact outcome auto-idle exists to prevent.",
|
||
"repro": "(1) User is Online and walks away. At T+10 min the timer fires: apply(true) flips the pref to idle/auto, `timer` is left null. (2) User comes back and sets Online from the OS tray Status submenu. main.ts:270 calls saveUserStatus(\"online\") — a native tray menu delivers no mousemove/keydown/mousedown into the webview, so onActivity never runs and arm() is never called. Status is now online/manual with no armed timer. (3) User walks away again without clicking inside the app window. The ten-minute watcher is gone: they show Online to every other member indefinitely. The same dead-timer state is also reached whenever the timer fires while the status is ineligible (dnd/invisible/manual-idle), where apply(true) returns null and nothing re-arms. tests/unit/auto-idle.test.ts:144-155 exercises the dnd case but never asserts re-arming, so nothing locks the current behaviour in.",
|
||
"evidence": "autoIdle.ts:105-130\n function arm(): void {\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null; // <- fired; never re-armed here\n if (destroyed) return;\n apply(true);\n }, delayMs);\n }\n\n function onActivity(): void { // the ONLY other arm() caller\n ...\n if (now - lastActivityRun < ACTIVITY_THROTTLE_MS) return;\n lastActivityRun = now;\n arm();\n }\n\nmain.ts:264-272 — the tray writes the status with no notifyActivity()/arm():\n void listen<string>(\"status-change\", (e) => {\n ...\n saveUserStatus(mapped);\n getActivePresenceSender()?.send(mapped);\n });\n\n(The controller exposes notifyActivity() at autoIdle.ts:138 but nothing in src/ calls it.)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"fix": {
|
||
"commit": "e9686461682f8719591981d4fa9dcc61b1f02564",
|
||
"test": "Client/tests/unit/auto-idle.test.ts",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "Re-arm inside the timeout callback so the watcher survives a firing that changed nothing:\n\n timer = setTimeout(() => {\n timer = null;\n if (destroyed) return;\n apply(true);\n arm(); // keep watching: the status may become eligible again\n }, delayMs);\n\nOne change in the shared arm(), rather than a notifyActivity() call bolted onto every external status-writing surface. Re-arming is harmless when already idle — nextAutoStatus(idle, *, true) returns null.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0237",
|
||
"title": "WebSocket internal errors ship the raw wrapped error to the client and are never logged, unlike every sibling path",
|
||
"file": "Server/ws/handlers_chat.go",
|
||
"line": 185,
|
||
"severity": "low",
|
||
"why": "serviceErrorToResult's default branch puts err.Error() into the ClientError message for ErrCodeInternal. Service-layer ErrInternal wrappers embed the underlying driver error via %v, so the raw DB error text is sent to the requesting client. Its REST twin (writeServiceError, Server/api/channel_handler.go:418-422) deliberately does the opposite — it logs the error and replies with the fixed string \"an internal error occurred\" — and every other ErrCodeInternal site inside package ws uses a fixed message (deps.go:132/139, registry.go:59, voice_controls.go:58/161/249/262, serve.go:856). Worse, ws/handlers.go:85-92 only logs when result.Error is NOT a ClientError, so this path also produces zero server-side log output: the operator sees nothing while the client sees everything.",
|
||
"repro": "An authenticated user sends a `call_ring` frame for a DM they participate in while the SQLite file is under write contention or otherwise erroring. handlers_call.go:45 calls DMSvc.RingTargets, which fails at Server/service/dm.go:404-406 and returns fmt.Errorf(\"%w: failed to read DM participants: %v\", ErrInternal, err). handlers_call.go:47 hands that to serviceErrorToResult, which falls to the default branch at handlers_chat.go:184-185 and builds ClientError{Code:\"INTERNAL\", Message: err.Error()}. handlers.go:86-87 writes that message verbatim onto the socket, so the client receives e.g. `{\"type\":\"error\",\"code\":\"INTERNAL\",\"message\":\"internal error: failed to read DM participants: GetDMParticipantIDs: database is locked\"}` — internal query names and driver state disclosed to an ordinary member — while nothing is written to the server log, so the operator has no record the failure happened. The identical REST call would have logged it and returned only \"an internal error occurred\".",
|
||
"evidence": "Server/ws/handlers_chat.go:184-186\n\tdefault:\n\t\treturn Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}}\n\t}\n\ncontrast Server/api/channel_handler.go:418-422\n\tcase errors.Is(err, service.ErrInternal):\n\t\tslog.ErrorContext(ctx, \"service error\", \"error\", err)\n\t\twriteJSON(w, http.StatusInternalServerError, errorResponse{Error: \"INTERNAL_ERROR\", Message: \"an internal error occurred\"})\n\ncontrast Server/ws/handlers.go:85-92 (ClientError branch does not log)\n\tif ce, ok := result.Error.(ClientError); ok {\n\t\tc.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID))\n\t} else {\n\t\tslog.Error(\"ws handler internal error\", ...)",
|
||
"status": "fixed",
|
||
"found": "2026-08-20",
|
||
"hunt": "2026-08-20-general",
|
||
"lens": "explore-3",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"fix": {
|
||
"commit": "5dcf18f3466e76cc613c3a8a7a2d14c9c7fe1bb9",
|
||
"test": "Server/ws/oc_0237_service_error_internal_test.go",
|
||
"revertProof": "pass"
|
||
},
|
||
"suggestedFix": "In the default branch of serviceErrorToResult (Server/ws/handlers_chat.go:184-186) log and return a fixed string, matching writeServiceError: `default: slog.Error(\"ws service internal error\", \"err\", err); return Result{Error: ClientError{Code: ErrCodeInternal, Message: \"internal error\"}}`. One change in the shared helper covers all seven call sites; no caller change needed.",
|
||
"fixedDate": "2026-08-20"
|
||
},
|
||
{
|
||
"id": "OC-0238",
|
||
"title": "Managed LiveKit process is never configured to send webhooks, so both webhook handlers are dead in the default deployment",
|
||
"file": "Server/ws/livekit_process.go",
|
||
"line": 123,
|
||
"severity": "medium",
|
||
"why": "generateConfig writes the companion livekit-server's livekit.yaml with only port / rtc / keys / logging — there is no `webhook:` block (no `api_key`, no `urls`). LiveKit only POSTs webhook events when `webhook.urls` is set, so a server running the auto-generated config never emits `participant_joined` or `participant_left`, and Server/ws/livekit_webhook.go (mounted at POST /api/v1/livekit/webhook in Server/api/router.go:389) never receives anything. Two documented recovery/enforcement mechanisms are therefore inert: the `participant_left` ghost-voice-state cleanup (docs/livekit-setup.md §6 explicitly claims it works) and `webhookJoinedEnforceVoiceState`'s BUG-127 rogue/replayed-token eviction. sweepStaleVoiceStates cannot substitute for the first one: it classifies a row stale only when `!ok || c.getVoiceChID() != vs.ChannelID` (Server/ws/hub_sweep.go:243-244), and in the case the webhook exists to catch the client's in-memory voiceChID still agrees with the row, so the sweep skips it forever.",
|
||
"repro": "Start a server with voice.livekit_binary set (or voice.auto_download_livekit: true) — the default managed-LiveKit shape. `cat data/livekit.yaml`: the generated file contains no `webhook:` key. Join a voice channel from a client, then sever only the client's LiveKit/SFU transport (kill the media connection, or block UDP 50000-60000) while leaving its WebSocket up. LiveKit drops the participant but sends no participant_left webhook (the server never logs \"livekit webhook received\"). The voice_states row and the client's voiceChID both still name the channel, so sweepStaleVoiceStates never reaps it: the user shows as present in the voice roster to every other client — and keeps consuming a voice_max_users capacity slot — until they explicitly send voice_leave or their WebSocket disconnects.",
|
||
"evidence": "content := fmt.Sprintf(`# Auto-generated by OwnCord — regenerated on every server start.\n...\nport: 7880\n\nrtc:\n port_range_start: 50000\n port_range_end: 60000\n use_external_ip: true%s%s\n pli_throttle:\n low_quality: 500ms\n mid_quality: 1s\n high_quality: 1s\n\nkeys:\n \"%s\": \"%s\"\n\nlogging:\n level: info\n`, nodeIPLine, advertiseInternalLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) // ← no `webhook:` section is ever written",
|
||
"status": "declined",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Emit a webhook block from generateConfig alongside the existing keys block — one change in the one function that owns the managed config:\n\n webhook:\n api_key: \"<p.cfg.LiveKitAPIKey>\"\n urls:\n - \"http://127.0.0.1:<owncord listen port>/api/v1/livekit/webhook\"\n\nThe companion process is always co-located with OwnCord, so the loopback address is derivable from the server config (pass the listen addr/scheme into NewLiveKitProcess the way tlsCfg already is). Prefer plain HTTP on loopback: LiveKit's webhook sender verifies TLS, so pointing it at a self-signed HTTPS listener would fail silently — and if the server is HTTPS-only, add an explicit loopback plaintext hook or a config knob rather than leaving the section out. Whatever address is chosen must be inside cfg.Server.LiveKitWebhookCIDRs() or AdminIPRestrict drops the POST. Also add the same section to Server/livekit.yaml.example so the Docker path is not silently unprotected.",
|
||
"rationale": "Root cause confirmed (generateConfig never emits a webhook: block), but a correct fix requires the companion livekit-server to reach OwnCord's own webhook endpoint, and the two are only ever joined via OwnCord's own listener. The default deployment this finding's repro targets (auto_download_livekit=true) pairs with the default TLS mode self_signed (Server/config/config.go defaults()), and Server/main.go serves either TLS or plain HTTP, never both -- there is no plaintext path in the default config. OwnCord's self-signed cert (Server/auth/tls.go GenerateSelfSigned) has no SAN at all (no IPAddresses/DNSNames), so a standard Go http.Client -- which is what LiveKit's webhook sender uses -- cannot successfully verify a TLS connection to it under any hostname, including 127.0.0.1. I confirmed LiveKit's webhook config (api_key + urls) exposes no per-URL TLS trust/skip option; the only lever is the global LIVEKIT_INSECURE_SKIP_VERIFY_TLS env var, which disables TLS verification for ALL outbound connections from the LiveKit process and is documented upstream as lab/testing-only. So a fully correct fix requires choosing one of: (a) add a new loopback-only plaintext HTTP listener dedicated to the webhook (a new network surface on the server process), (b) globally disable outbound TLS verification on the companion LiveKit process (a real security weakening), or (c) leave the managed-process path unfixed and require an externally-reachable LiveKit for webhooks. Each is a deliberate product/security tradeoff, not a mechanical patch to generateConfig -- emitting a webhook: block pointed at https://127.0.0.1:<port> without solving this would look fixed while remaining silently broken in exactly the default-deployment scenario the finding describes, which is worse than the status quo (false confidence that ghost-voice-state cleanup and BUG-127 enforcement are live). Per rule 7, deferring this to a human product decision rather than inventing one of the above unilaterally. No files modified."
|
||
},
|
||
{
|
||
"id": "OC-0239",
|
||
"title": "The OC-0213 stale-voice_leave guard is inert: the dispatcher empties the roster before handleParticipantLeft reads it, so a rejoined peer's LIVE key is retired and they are rotated out of the call permanently",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 1261,
|
||
"severity": "medium",
|
||
"why": "`handleParticipantLeft` decides whether a `voice_leave` is a stale, lagging leave (peer already rejoined) by asking whether the local voice roster still lists the peer: `if (departingKey && !channelUsers?.has(userId))`. But the only production caller — the VOICE_LEAVE handler in `dispatcher.ts` — calls `removeVoiceUser(payload)` (which unconditionally deletes that user from `voiceUsers`) *before* it calls `handleParticipantLeft`, so `channelUsers?.has(userId)` is always false and the guard never fires. The other caller (the `ready` resync at dispatcher.ts:344) runs after `setVoiceStates` has already replaced the roster, so it too can never satisfy the guard. The harm the guard was written to prevent is therefore fully live: `_peerPublicKeys.delete(userId)` (line 1229) plus `retirePeerKey(userId, <the peer's CURRENT key>)` (line 1262) evict a peer who never left, `isRetiredPeerKey` then rejects any later re-announce of that key as a replay (line 782), and the `wasKeyHolder && hadPeerKey` branch (line 1363) rotates the room key excluding them. Mid-call peers never re-announce on their own, so the peer is deaf and mute for the rest of the call with no recovery.",
|
||
"repro": "Channel V, key holder A (uid 10), peer B (uid 20). B switches voice channels (or F5-reloads and rejoins V). Server `handleVoiceJoin` → `voiceJoinLeaveCurrent` → `finishVoiceLeave` enqueues `voice_leave` on the buffered `h.broadcast` queue (hub_broadcast.go:40), then sends B its `voice_token` directly. B's client runs `setupKeyExchange` and sends `voice_e2ee_announce`, which the server relays straight into A's send queue from B's read pump (`sendToVoiceChannelExcept` → `pubsub.Publish`, voice_e2ee.go:270) — bypassing the broadcast queue entirely, the reordering hazard hub_broadcast.go:64-72 documents. With the hub dispatch loop even one RTT behind, A receives the announce first: `handleAnnounceInner` retires B's old key, stores B's new key, and offers B the current room key. A then processes the queued stale `voice_leave`: `removeVoiceUser` deletes B from `voiceUsers` (dispatcher.ts:967), so `channelUsers?.has(20)` is false at livekitE2EE.ts:1261; A retires B's *live* key, drops it from `_peerPublicKeys`, and (as key holder with `hadPeerKey === true`) rotates the room key to the remaining peer set, which no longer contains B. B stays listed in everyone's roster but can no longer decrypt A's frames, A can no longer decrypt B's, and B's key can never be re-accepted because it is now in `_retiredPeerKeys`.",
|
||
"evidence": "livekitE2EE.ts:1227-1263 —\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n ...\n const channelUsers = channelId ? state.voiceUsers.get(channelId) : undefined;\n ...\n if (departingKey && !channelUsers?.has(userId)) {\n this.retirePeerKey(userId, await exportPublicKey(departingKey));\n }\n\ndispatcher.ts:966-985 —\n ws.on(S.VOICE_LEAVE, (payload) => {\n removeVoiceUser(payload); // <-- deletes payload.user_id from voiceUsers FIRST\n ...\n void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {\n void handleParticipantLeft(payload.user_id); // <-- guard now reads an emptied roster\n\nvoice.store.ts:279-296 — removeVoiceUser deletes the user unconditionally (no join-token / staleness check; `voice_leave` carries only {channel_id, user_id}).\n\nThe unit test that locks the guard (tests/unit/livekit-e2ee.test.ts:1717) hand-populates `mockVoiceState.voiceUsers.set(1, new Map([[PEER_ID, {}]]))` and calls `mgr.handleParticipantLeft` directly, so it never exercises the dispatcher ordering that makes the guard unreachable in production.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "voice-e2ee",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Snapshot roster presence before the store mutation and pass it in, rather than re-deriving it after. In dispatcher.ts, before line 967: const stillPresent = voiceStore.getState().voiceUsers.get(payload.channel_id)?.has(payload.user_id) ?? false; then removeVoiceUser(payload); and call handleParticipantLeft(payload.user_id, stillPresent). In livekitE2EE.ts:1226 add a `stillInRoster = false` parameter and change line 1261 to `if (departingKey && !stillInRoster)`; the ready-resync caller at dispatcher.ts:344 passes false (correct there by construction, since it only fires for uids absent from the new roster). Keeping the default false preserves current behavior for every other caller and leaves the decision in one place.",
|
||
"fix": {
|
||
"commit": "c518032",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0240",
|
||
"title": "clientIPWithProxies prefers X-Real-IP, which the project's own documented nginx recipe never sets — client-chosen IP drives the admin allowlist and every lockout key",
|
||
"file": "Server/api/middleware.go",
|
||
"line": 291,
|
||
"severity": "medium",
|
||
"why": "When the peer is a trusted proxy, `clientIPWithProxies` returns the raw `X-Real-IP` header ahead of `X-Forwarded-For`, and it wins the tiebreak. X-Forwarded-For gets careful anti-spoof handling (right-to-left walk, skip trusted hops, comment at :297-302 explaining that a client can prepend entries) precisely because clients can inject into it; X-Real-IP gets none of that — the only check is `net.ParseIP != nil`. That is only safe if the reverse proxy overwrites X-Real-IP, and the project's own \"Working nginx snippet\" in `docs/deployment.md:283-301` sets `Host`, `Upgrade`, `Connection` and `X-Forwarded-For` but never `X-Real-IP` (nginx forwards unlisted client headers verbatim; Caddy's `reverse_proxy` likewise sets only the X-Forwarded-* family). `docs/api.md:31` separately asserts the contract is \"client IPs are resolved from `X-Forwarded-For` only\", which the code contradicts. The resulting value is the identity used by `AdminIPRestrict` (middleware.go:389, gating `/admin`, `/api/v1/metrics`, and `/api/v1/livekit/webhook` — router.go:172, 388, 417) and by every per-IP rate-limit / lockout key (`rateLimitMiddlewareWithPrefix` at :235-236; `handleLogin`'s `ip` at auth_handler.go:338 feeding `login_lock:`/`login_fail:` at auth_handler.go:458/500).",
|
||
"repro": "Deploy exactly as docs/deployment.md prescribes: nginx at 10.0.0.2 with the documented snippet, `server.trusted_proxies: [\"10.0.0.2/32\"]`, `server.admin_allowed_cidrs: [\"192.168.1.0/24\"]`. (1) Allowlist bypass: `curl -H 'X-Real-IP: 192.168.1.50' https://chat.example.com/admin/` from any internet host — nginx does not set X-Real-IP so the client's value passes through; `clientIPWithProxies` sees RemoteAddr 10.0.0.2 (trusted), takes `X-Real-IP`, `ipInNets(\"192.168.1.50\", allowedNets)` is true, and the admin panel (and the unauthenticated `/api/v1/metrics`) is served to the public internet. (2) Lockout/rate-limit evasion: `for i in $(seq 1 500); do curl -H \"X-Real-IP: 203.0.113.$((i%254+1))\" -d '{\"username\":\"victim\",\"password\":\"guess$i\"}' https://chat.example.com/api/v1/auth/login; done` — each request lands in a fresh `login:`/`login_fail:`/`login_lock:` bucket, so the 5/min per-IP throttle and the 9-failure IP lockout never engage (same for `register:`, `totp_verify:`, `search:`, `gif:`). Note `TestClientIP_TrustedProxy_UsesXRealIP` (clientip_test.go:105) pins that X-Real-IP wins, so the fix belongs either in the shipped proxy recipe (`proxy_set_header X-Real-IP $remote_addr;`) or in dropping the X-Real-IP preference — today neither side closes it.",
|
||
"evidence": "// Prefer X-Real-IP when coming from a trusted proxy.\n// BUG-112: Validate extracted IP to prevent spoofed rate-limit keys.\nif xri := strings.TrimSpace(r.Header.Get(\"X-Real-IP\")); xri != \"\" {\n\tif net.ParseIP(xri) != nil {\n\t\treturn xri\n\t}\n}\n// ...then, only if X-Real-IP was absent, the hardened XFF walk runs.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "api-authz",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "One change in the shared helper: in clientIPWithProxies, run the X-Forwarded-For block (middleware.go:296-323) first and fall through to the X-Real-IP block only when XFF yields nothing. Every proxy that fronts this (nginx per the shipped snippet, Caddy's reverse_proxy) always sets X-Forwarded-For with the true peer appended, so the hardened right-to-left walk becomes the authority and a client-injected X-Real-IP can no longer override it; the no-XFF fallback that TestClientIP_TrustedProxy_UsesXRealIP pins is preserved. Pair it with adding `proxy_set_header X-Real-IP $remote_addr;` to the docs/deployment.md:288-300 snippet so the documented deployment sanitizes the header too.",
|
||
"fix": {
|
||
"commit": "8aa760e",
|
||
"test": "Server/api/clientip_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0241",
|
||
"title": "setServerHost/resolveServerUrl never bracket a bare IPv6 host, so on such a server every avatar falls back to a letter and every image attachment fails to load (and loses its bearer token)",
|
||
"file": "Client/src/components/message-list/attachments.ts",
|
||
"line": 43,
|
||
"severity": "medium",
|
||
"why": "A bare (unbracketed) IPv6 literal is a first-class supported host everywhere else — `isValidHost` accepts it, `ws.ts::bracketBareIPv6Host` brackets it for the WS URL, `ensureLiveKitProxy` brackets it for the LiveKit proxy — but `setServerHost` stores it raw and `resolveServerUrl` interpolates it straight into `https://${_serverHost}${url}`. An unbracketed IPv6 authority is not a parseable URL, so both `isSafeUrl` and `isServerUrl` throw-and-return-false for every server-relative media URL. The same line also repeats the unguarded `:443` strip that disagrees with `cert_store_key` for `fd00::443`.",
|
||
"repro": "1. Run the server on an IPv6 address and enter the host as the bare literal `2001:db8::1` (accepted by hostValidation.ts:31; `bracketBareIPv6Host` exists specifically because this form is supported). Log in — WS and REST both work, because ws.ts and http_proxy each normalize it themselves.\n2. MainPage.ts:98 calls `setServerHost(apiConfig.host)` with the verbatim host, so `_serverHost === \"2001:db8::1\"`.\n3. The server hands out server-relative media URLs (`/api/v1/files/{id}`, Server/api/upload_handler.go:274; avatars point at the same, profile_handler.go:525). `resolveServerUrl(\"/api/v1/files/7\")` returns `\"https://2001:db8::1/api/v1/files/7\"`, which `new URL()` rejects (WHATWG requires brackets around an IPv6 authority).\n4. Avatars: `isRenderableAvatar` (avatar.ts:75-79) calls `isSafeUrl(resolveServerUrl(url))` -> `new URL` throws -> false, so `createAvatarElement` returns the letter-initial fallback for every user, forever.\n5. Image attachments: `fetchImageAsDataUrl(resolveServerUrl(att.url))` -> `fetchServerFile` -> `isServerUrl` false -> falls through to `tauriFetch(<malformed url>)` with no `Authorization` header and outside the cert-pinned loopback tunnel; the fetch rejects and the image never renders.\nSwitching the same server to the bracketed form `[2001:db8::1]` makes all of it work, which isolates the cause.",
|
||
"evidence": "attachments.ts:42-54\n export function setServerHost(host: string): void {\n _serverHost = host.replace(/:443$/, \"\").toLowerCase(); // no bracketing of a bare IPv6 literal\n }\n export function resolveServerUrl(url: string): string {\n if (url.startsWith(\"http://\") || url.startsWith(\"https://\")) return url;\n if (_serverHost !== null) return `https://${_serverHost}${url}`; // -> \"https://2001:db8::1/api/v1/files/7\"\n return url;\n }\n\nattachments.ts:109-115 (isSafeUrl) and 164-171 (isServerUrl) both `new URL(url)` inside try/catch and return false on throw.\nattachments.ts:188 if (!isServerUrl(url)) return tauriFetch(url); // token + cert-pinned proxy dropped",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "tauri-rust",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Normalize once inside setServerHost so both resolveServerUrl and isServerUrl agree with WHATWG's `URL.host` (which is bracketed for IPv6): guard the :443 strip the way tofu.rs::cert_store_key does, then bracket a bare IPv6 literal using the existing exported helper. e.g. in attachments.ts:42-44:\n\n import { bracketBareIPv6Host } from \"@lib/ws\";\n export function setServerHost(host: string): void {\n const stripped = /:443$/.test(host) && (!host.slice(0, -4).includes(\":\") || host.slice(0, -4).endsWith(\"]\"))\n ? host.slice(0, -4)\n : host;\n _serverHost = bracketBareIPv6Host(stripped).toLowerCase();\n }\n\nThat single guard fixes resolveServerUrl (produces a parseable https://[..]/path), isServerUrl (parsed.host is the bracketed form, so the equality holds), and the ensureHttpProxy(parsed.host) hand-off (validate_remote_host and split_host_port both accept the bracketed form). No caller-side changes needed.",
|
||
"fix": {
|
||
"commit": "ee0c1a4",
|
||
"test": "Client/tests/unit/attachments-ipv6-host.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0242",
|
||
"title": "A DM @mention delivered in the registerNow→buildReady window is counted twice — incrementDmMention has no message-id guard, unlike its unread sibling",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 672,
|
||
"severity": "medium",
|
||
"why": "`updateDmLastMessage` was given a message-id monotonicity guard (dm.store.ts:142-145) precisely because a DM message that lands between the server's `registerNow` and `buildReady` is both counted in `ready`'s snapshot AND redelivered as a queued `chat_message`. The dispatcher calls `incrementDmMention` immediately afterwards for the same frame, and that function increments unconditionally — so the mention badge double-counts in exactly the case the unread badge was fixed for, leaving the DM showing 2 mentions against 1 unread, permanently until the next `ready`.",
|
||
"repro": "Server ordering (Server/ws/serve.go:822 registerNow → :853 buildReady, and handshakeWrite at :47 writes `ready` straight to the socket while the queued broadcast waits in c.send): 1) User A is signed in on device D and is a participant in DM channel 7. 2) D's socket connects; the server runs registerNow (which subscribes UserTopic(A)) at serve.go:822. 3) Before buildReady runs at serve.go:853, user B posts \"@A hello\" in DM 7. The row commits; sendSequencedToUsers → SendToUser(A) queues the chat_message in A's send buffer. 4) buildReady's live GetChannelUnreadCounts (serve_ready.go:338) now sees the message, so `ready`'s dm_channels entry for 7 carries unread_count: 1, mention_count: 1. 5) `ready` is written directly to the wire and applied first → dmStore channel 7 = {unreadCount: 1, mentionCount: 1, lastMessageId: <that id>}. 6) writePump then drains the queued chat_message. The dispatcher runs: `updateDmLastMessage(7, id, ...)` sees `id <= lastMessageId` and correctly leaves unreadCount at 1, then `incrementDmMention(7)` bumps mentionCount to 2. 7) DmSidebar (dm.store mentionCount is what it renders) shows a red \"2\" mention badge on a DM with exactly one unread message. The count only corrects on the next full `ready`.",
|
||
"evidence": "dispatcher.ts:667-673\n updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);\n // The DM badge reads dmStore's mentionCount ...\n if (isMention) {\n incrementDmMention(payload.channel_id);\n }\n\ndm.store.ts:245-251 — incrementDmMention takes no message id at all:\nexport function incrementDmMention(channelId: number): void {\n dmStore.setState((prev) => ({\n channels: prev.channels.map((c) =>\n c.channelId === channelId ? { ...c, mentionCount: c.mentionCount + 1 } : c,\n ),\n }));\n}\n\ndm.store.ts:142-145 (the guard that exists only for unread):\n unreadCount:\n updated.lastMessageId !== null && messageId <= updated.lastMessageId\n ? updated.unreadCount\n : updated.unreadCount + 1,",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "client-state",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Fold the mention bump into updateDmLastMessage so both counters sit behind the one id guard it already computes — e.g. add an `isMention` boolean parameter and set `mentionCount: isMention && !(updated.lastMessageId !== null && messageId <= updated.lastMessageId) ? updated.mentionCount + 1 : updated.mentionCount` in the same setState (dm.store.ts:136-148), then drop the separate incrementDmMention call at dispatcher.ts:671-673. Do NOT simply add a `messageId <= lastMessageId` check inside incrementDmMention: updateDmLastMessage runs first and has already advanced lastMessageId to that same id, so such a guard would skip every mention.",
|
||
"fix": {
|
||
"commit": "82a57f4",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0243",
|
||
"title": "EnablePlugin activates outside the registry lock, so a concurrent DisablePlugin/UninstallPlugin leaves a live WASM module with registered slash commands",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 567,
|
||
"severity": "medium",
|
||
"why": "EnablePlugin releases r.mu before calling r.activate (which compiles + instantiates the guest module and then calls RegisterCommand), while DisablePlugin does its whole teardown — clearing inst.Enabled, deleting r.commands entries owned by inst, and platformDeactivate — under one r.mu hold. A disable that runs entirely inside activate's unlocked compile window finds nothing to tear down (inst.module is still nil, no command bindings exist yet), and activate then installs the module and re-registers the commands afterwards. DispatchCommand (Server/plugin/host_commands.go:60) routes purely off r.commands and never consults inst.Enabled, so the \"disabled\" plugin keeps executing guest code for every user.",
|
||
"repro": "Server built with -tags wazero, plugins enabled. Two concurrent admin requests for the same plugin id: POST /api/v1/admin/plugins/{id}/enable and POST /api/v1/admin/plugins/{id}/disable (nothing serializes the two handlers — Server/api/plugins_handler.go:139 and :156). Interleaving: (1) enable writes plugins.enabled=1, takes r.mu, sets inst.Enabled=true, releases r.mu, enters activate → activateWithRuntime → rt.CompileModule (CPU-bound, hundreds of ms, no lock held, Server/plugin/sandbox_wazero.go:135); (2) disable writes plugins.enabled=0, takes r.mu, sets inst.Enabled=false, scans r.commands (empty for inst), calls platformDeactivate which early-returns because inst.module is nil, releases r.mu; (3) enable resumes, takes r.mu, sees inst.module==nil, stores the instantiated module and compiled module, releases r.mu, then calls RegisterCommand for every name in list_commands (sandbox_wazero.go:178-187). Final state: DB plugins.enabled=0 and inst.Enabled==false (admin panel shows the plugin off), but r.commands holds live bindings to inst and inst.module is a running module. Any user typing that slash command still gets it dispatched, until process restart. The same interleaving against UninstallPlugin (registry.go:611, which calls DisablePlugin first and then deletes the row, the r.plugins/r.byName entries and the on-disk directory) leaves an uninstalled plugin's commands bound to an *Instance whose files are gone.",
|
||
"evidence": "EnablePlugin (registry.go:550-577):\n\tr.mu.Lock()\n\tinst.Enabled = true\n\tr.mu.Unlock()\n\tif err := r.activate(ctx, inst); err != nil { // <-- line 567, no lock held across activation\n\nDisablePlugin (registry.go:582-601):\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tif inst, ok := r.plugins[id]; ok {\n\t\tinst.Enabled = false\n\t\tfor cmd, owner := range r.commands { if owner == inst { delete(r.commands, cmd) } }\n\t\tr.platformDeactivate(ctx, inst) // no-op while inst.module is still nil\n\t}\n\nDispatchCommand (host_commands.go:60-83) — no Enabled check:\n\tr.mu.RLock()\n\tinst, ok := r.commands[cmd]\n\tplatform := r.runtimePlatform\n\tr.mu.RUnlock()\n\t...\n\treturn r.invokeCommand(ctx, inst, userID, channelID, cmd, args)",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Re-check the flag after activation in the one shared place: in EnablePlugin, after `r.activate(ctx, inst)` returns nil, take r.mu and if `!inst.Enabled` (a concurrent disable won), run the same teardown DisablePlugin performs — delete every r.commands entry whose owner==inst and call r.platformDeactivate(ctx, inst) — before returning. (Equivalently, add a registry-level lifecycleMu held across the whole body of EnablePlugin/DisablePlugin, with UninstallPlugin calling an unexported disableLocked so it does not re-enter.)",
|
||
"fix": {
|
||
"commit": "2f996a5",
|
||
"test": "Server/plugin/registry_enable_race_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0244",
|
||
"title": "A jump to a recent message silently deletes any live message that arrived during the around-window fetch, with no badge, no pill and no recovery path",
|
||
"file": "Client/src/stores/messages.store.ts",
|
||
"line": 504,
|
||
"severity": "medium",
|
||
"why": "setAroundMessages replaces the loaded window and carries over only rows whose status !== \"sent\", so every real (server-confirmed) message that landed while the /messages/around request was in flight is discarded. When the server reported has_more_after === false the channel is then also removed from detachedChannels (line 517-522), i.e. the client asserts the window IS the live tail — so there is no \"Jump to Present\" pill, the channel stays marked loaded, and nothing ever refetches. Its sibling setMessages handles the identical race explicitly (line 431: `if (m.status === \"sent\") return m.id > maxSnapshotId;`), which is what makes this an omission rather than a design choice.",
|
||
"repro": "Alice has #general open. She clicks a search hit / permalink / DM-profile \"view messages\" entry pointing at message 980 in #announcements. MessageJump.ts:70-102 runs: navigateToChannel(#announcements) mounts an empty window, scrollIfMounted fails, and `await opts.api.getMessagesAround(#announcements, 980, {limit:50})` is issued. The server (Server/service/message_query.go:159-183, beforeCount=25/afterCount=24) snapshots messages 955..1000 and, because 1000 is the newest, returns has_more_after=false. While that response is in flight Bob posts message 1001; the socket is already subscribed (channel_focus was sent by mountChannel), so dispatcher.ts:600 addMessage appends it, and incrementUnread is skipped because #announcements is now activeChannelId and not detached (dispatcher.ts:641-648). The response lands, setAroundMessages replaces the window with 955..1000 and deletes #announcements from detachedChannels. Message 1001 is gone from the list; no NEW badge, no \"Jump to Present\" pill, loadedChannels still contains the channel so MessageController.loadMessages short-circuits (MessageController.ts:75). Alice only sees message 1001 after switching channels away and back (which calls invalidateChannelMessageWindow). Existing test messages-store-detached.test.ts:87 covers only the has_more_after=true case, so nothing locks this behaviour.",
|
||
"evidence": "messages.store.ts:503-506\n const previous = prev.messagesByChannel.get(channelId) ?? [];\n const carried = previous.filter((m) => m.status !== \"sent\");\n const updatedMessages = new Map(prev.messagesByChannel);\n updatedMessages.set(channelId, carried.length > 0 ? [...trimmed, ...carried] : trimmed);\n\nmessages.store.ts:517-522 (same call)\n const updatedDetached = new Set(prev.detachedChannels);\n if (hasMoreAfter || trimmed.length < converted.length) {\n updatedDetached.add(channelId);\n } else {\n updatedDetached.delete(channelId); // <-- claims \"this is the live tail\"\n }\n\nContrast, messages.store.ts:425-435 (setMessages):\n const carried = previous.filter((m) => {\n if (snapshotIds.has(m.id)) return false;\n if (m.status === \"sent\") return m.id > maxSnapshotId; // keeps live rows newer than the snapshot\n ...",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "In setAroundMessages, when the resulting window is NOT detached (i.e. `!hasMoreAfter && trimmed.length === converted.length`), also carry \"sent\" rows newer than the snapshot, exactly as setMessages does: compute `const maxWindowId = trimmed.reduce((m, x) => Math.max(m, x.id), 0)` and change line 504 to `const carried = previous.filter((m) => m.status !== \"sent\" || (attached && m.id > maxWindowId))`, where `attached = !hasMoreAfter && trimmed.length === converted.length`. Keep dropping newer sent rows in the detached branch — there the \"Jump to Present\" pill plus the OC-0204 unread increment already represent them.",
|
||
"fix": {
|
||
"commit": "6293792",
|
||
"test": "Client/tests/unit/messages-store-detached.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0245",
|
||
"title": "voice_mod_move silently clears the target's moderator-imposed server mute/deafen",
|
||
"file": "Server/ws/voice_moderation.go",
|
||
"line": 408,
|
||
"severity": "medium",
|
||
"why": "A moderator move is implemented as \"server deletes the voice_states row, client re-joins\". The flag-preservation snapshot in voiceJoinLeaveCurrent is gated on `currentChID > 0`, which is already 0 by the time the moved client's voice_join runs, so the fresh INSERT restores the schema defaults (server_muted/server_deafened = 0). The self-switch path preserves these flags and is locked by a test (TestVoice_Join_SwitchChannel_PreservesServerMute); the moderator-initiated switch — the one the moderator explicitly asked for — silently lifts the mute with no indication to anyone.",
|
||
"repro": "1. Moderator M (MUTE_MEMBERS, outranks U) server-mutes U in voice channel A -> voice_states row for U has server_muted=1, muted=1 (ApplyVoiceServerMute), and LiveKit MuteParticipantAudio is applied to identity `user-U:<joinedAt-A>`.\n2. M issues voice_mod_move(user_id=U, to_channel_id=B). handleVoiceModMoveV2 calls disconnectFromVoiceIn(..., A), which DELETEs U's voice_states row, then sends voice_moved to U.\n3. U's client (dispatcher.ts VOICE_MOVED) replies with voice_join{channel_id: B}.\n4. handleVoiceJoin -> voiceJoinLeaveCurrent: c.getVoiceChID() is 0 (the moderator cleared it), so the `if currentChID > 0` snapshot is skipped and wasServerMuted/wasServerDeafened stay false.\n5. voiceJoinPersist INSERTs a brand-new row -> server_muted=0, server_deafened=0, muted=0. voiceJoinRestoreModFlags is a no-op.\nResult: the voice_state broadcast shows U unmuted, refuseIfServerSilenced no longer blocks U's own unmute, and the SFU mute is gone too (channel B's participant identity `user-U:<joinedAt-B>` was never muted). The moderator's silencing action is undone by the moderator's own move, with no error or log.",
|
||
"evidence": "voice_moderation.go:408 `if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) {` -> Hub.DisconnectFromVoiceInChannel -> handleVoiceLeaveIfStillIn -> finishVoiceLeave -> LeaveVoiceChannelIfMatch (DELETE FROM voice_states ...).\nvoice_moderation.go:415 `d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))`.\nvoice_join.go:207-213:\n var wasServerMuted, wasServerDeafened bool\n if currentChID > 0 {\n if prevState, prevErr := h.db.GetVoiceState(ctx, c.userID); prevErr == nil && prevState != nil {\n wasServerMuted = prevState.ServerMuted\n wasServerDeafened = prevState.ServerDeafened\n }\n }\n(the comment above it admits: \"This covers the self-switch only. voice_mod_move deletes the row ... currentChID is already 0\")\ndb/queries/sqlite/voice.sql JoinVoiceChannel inserts only (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at); migrations/021_voice_server_moderation.sql: `ADD COLUMN server_muted INTEGER NOT NULL DEFAULT 0`.\nClient/src/lib/dispatcher.ts VOICE_MOVED handler: `ws.send({ type: \"voice_join\", payload: { channel_id: payload.to_channel_id } })`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Carry the flags across the server-driven leave in memory rather than through the DB row that the move deletes. In handleVoiceModMoveV2, before disconnectFromVoiceIn at voice_moderation.go:408, capture state.ServerMuted/state.ServerDeafened (already loaded by voiceModTarget) and stash them on the target's *Client under the same voiceMu that clearVoiceStateIfMatch takes, e.g. c.setPendingModFlags(serverMuted, serverDeafened). Then in the single shared consumer, voiceJoinLeaveCurrent (voice_join.go:207-213), fall through to those pending flags when currentChID == 0: `wasServerMuted, wasServerDeafened = c.takePendingModFlags()` (take-and-clear, so an ordinary later rejoin is unaffected). voiceJoinRestoreModFlags then re-applies and re-reads exactly as it already does for the self-switch, and the voice_state broadcast carries the restored flags. One guard in the shared join helper covers both the move and any future server-driven relocation; no schema change is needed.",
|
||
"fix": {
|
||
"commit": "0fe07a2",
|
||
"test": "Server/ws/voice_moderation_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0246",
|
||
"title": "A moderator lifting a server-mute/deafen leaves the user permanently self-muted, and the server/peer roster says the opposite",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 135,
|
||
"severity": "medium",
|
||
"why": "enforceModeratorAudioState only ever *applies* a moderator restriction — it writes the moderator's mute into the same voiceStore.localMuted/localDeafened the user's own toggle uses, and nothing ever un-applies it when server_muted/server_deafened go back to false. Because livekitSession.setMuted()/setDeafened() deliberately send no voice_mute/voice_deafen frame (unlike VoiceCallbacks.onMuteToggle/onDeafenToggle, which always pair the local write with a ws.send), voice_states.muted/deafened on the server were never changed either — so once the moderator flag clears, the client is silent/deaf while every other participant's roster renders the user as unmuted and undeafened with no moderator badge.",
|
||
"repro": "1. User X joins a voice channel and is NOT self-muted: voiceStore.localMuted=false, and the server row voice_states.muted=0 (peers render X unmuted).\n2. A moderator issues voice_mod_mute on X. The server sets server_muted=1 and broadcasts voice_state. X's dispatcher VOICE_STATE handler calls enforceModeratorAudioState(true,false) -> applyMute=true -> livekitSession.setMuted(true) -> setLocalMuted(true) + applyMicMuteState(true), which calls room.localParticipant.setMicrophoneEnabled(false) (mic track fully unpublished). No voice_mute frame is sent, so voice_states.muted is still 0.\n3. The moderator un-mutes X. The server sets server_muted=0 and broadcasts voice_state. updateVoiceState clears localServerMuted; enforceModeratorAudioState(false,false) computes applyMute = `false && ...` = false and does nothing.\n4. Result: X's mic is still unpublished (nobody can hear X) while voice_states.muted=0 and server_muted=0, so every other client's voice roster shows X as a normal, unmuted participant. The same sequence with voice_mod_deafen leaves X with remote audio permanently unsubscribed (applyRemoteAudioSubscriptionState(true) never reversed) while peers see deafened=false. Only a manual click on X's own mute/deafen button (which finally sends voice_mute/voice_deafen) resolves it.",
|
||
"evidence": "dispatcher.ts:132-142\n function enforceModeratorAudioState(serverMuted: boolean, serverDeafened: boolean): void {\n const voice = voiceStore.getState();\n const applyDeafen = serverDeafened && !voice.localDeafened;\n const applyMute = serverMuted && !voice.localMuted;\n if (applyDeafen || applyMute) {\n void livekitSession().then(({ setDeafened, setMuted }) => {\n if (applyDeafen) setDeafened(true);\n if (applyMute) setMuted(true);\n });\n }\n }\n(no branch for serverMuted===false / serverDeafened===false)\n\nlivekitSession.ts:1590-1603 setMuted(): `setLocalMuted(muted); this.applyMicMuteState(muted)` — no ws.send.\nlivekitSession.ts:1605-1620 setDeafened(): `setLocalDeafened(...)`, `applyRemoteAudioSubscriptionState(...)`, `applyMicMuteState(...)` — no ws.send.\nVoiceCallbacks.ts:70-113 by contrast always pairs voiceSessionSetMuted/SetDeafened with `ws.send({type:\"voice_mute\"|\"voice_deafen\", ...})`.\nServer/ws/voice_moderation.go writes only server_muted/server_deafened (SetVoiceServerMute/SetVoiceServerDeafen); voice_states.muted/deafened are untouched by moderation.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Reconcile on the falling edge in the one shared function. Capture the pre-update flags before updateVoiceState overwrites them (dispatcher.ts:906-910 calls updateVoiceState first), then extend enforceModeratorAudioState to also take the payload's own muted/deafened and, when a moderator flag goes true->false while the corresponding local flag is still set and the server's self flag is false, call setMuted(false)/setDeafened(false). Scoping it to the true->false transition (rather than to `!serverMuted` generally) keeps an in-flight optimistic self-mute from being flipped back by an unrelated restated voice_state, e.g. one produced by a camera toggle.",
|
||
"fix": {
|
||
"commit": "82a57f4",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0247",
|
||
"title": "ChannelController.destroyChannel() never drops the torn-down channel's loaded flag, so re-entering it after the active channel was cleared renders a permanently stale message window",
|
||
"file": "Client/src/pages/main-page/ChannelController.ts",
|
||
"line": 172,
|
||
"severity": "medium",
|
||
"why": "`invalidateChannelMessageWindow` is called in exactly one place — mountChannel, for `previousChannelId` (line 193) — and `previousChannelId` is null whenever the previous mount was torn down by a bare `destroyChannel()` instead of by a direct channel-to-channel switch (line 176-179 capture it, but destroyChannel resets `_currentChannelId = null` at line 172 without invalidating). The channel therefore stays in messagesStore.loadedChannels holding the window it had at teardown, and MessageController.loadMessages short-circuits on `isChannelLoaded(channelId)` (MessageController.ts:76-79, \"Messages already loaded\"), so the next visit renders the pre-teardown snapshot as if it were current. The server only broadcasts chat_message for the *focused* channel, so every message posted during the gap is genuinely missing — this is the exact hole the switch-path invalidation (OC fix at MessageController.ts:76) was added to close, on the one teardown path that fix did not cover. Two live triggers reach it: the NSFW gate's \"Go Back\" (ChannelController.ts:581-582 `destroyChannel(); setActiveChannel(null);`) and dispatcher.ts:389 `setActiveChannel(null)` on a resync whose ready payload no longer lists the active channel, which MainPage.ts:827 turns into `channelCtrl?.destroyChannel()`.",
|
||
"repro": "1. Open NSFW-flagged channel #spicy (id 42). mountChannel kicks off loadMessages, which resolves and calls setMessages(42, …) long before a human can click, so 42 is now in loadedChannels with, say, messages up to id 500.\n2. Click the age gate's \"Go Back\". onCancel runs `destroyChannel(); setActiveChannel(null);` — 42 is NOT invalidated (destroyChannel has no invalidateChannelMessageWindow call, and mountChannel is not involved).\n3. Click #general. mountChannel runs with `_currentChannelId === null`, so `previousChannelId === null` and the invalidate at line 193 is skipped for both channels.\n4. Someone posts 10 messages in #spicy (ids 501-510). The client is not focused there, so no chat_message frames arrive for it.\n5. Click #spicy again. mountChannel invalidates #general only; loadMessages logs \"Messages already loaded\" and returns without fetching. The list renders the id≤500 snapshot from step 1 — ids 501-510 never appear for the rest of the session, while the sidebar unread badge (restated by the next `ready`) still counts them. Only switching away to another channel and back a second time (which finally invalidates 42 via the previousChannelId path) recovers.\nSame outcome via the resync trigger: while viewing channel A, take a full-resync `ready` whose payload no longer contains A (deleted / READ revoked / DM closed elsewhere) — dispatcher.ts:389 clears the active channel, MainPage.ts:827 destroys the mount, and every channel that was loaded before the outage keeps both its rows and its loaded flag, so the first visit to each one after reconnect shows its pre-outage window with the outage's messages silently missing.",
|
||
"evidence": " function destroyChannel(): void {\n …\n clearChildren(slots.messagesSlot);\n clearChildren(slots.typingSlot);\n clearChildren(slots.inputSlot);\n\n _currentChannelId = null; // <- no invalidateChannelMessageWindow(_currentChannelId)\n }\n\n function mountChannel(channelId: number, channelName: string, channelType?: ChannelType): void {\n if (_currentChannelId === channelId) return;\n const previousChannelId = _currentChannelId;\n destroyChannel();\n _currentChannelId = channelId;\n if (previousChannelId !== null) {\n markChannelRead(previousChannelId);\n invalidateChannelMessageWindow(previousChannelId); // only reached on a direct switch\n }\n\n// ChannelController.ts:578-583 (NSFW decline)\n onCancel: () => {\n destroyChannel();\n setActiveChannel(null);\n },\n\n// MessageController.ts:76-79\n if (isChannelLoaded(channelId)) {\n log.debug(\"Messages already loaded\", { channelId });\n return;\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-stores",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "In destroyChannel, invalidate the window it is tearing down before clearing the id — one guard in the shared choke point covers every teardown path: replace `_currentChannelId = null;` (line 172) with `if (_currentChannelId !== null) invalidateChannelMessageWindow(_currentChannelId); _currentChannelId = null;`. mountChannel's existing call at line 193 then becomes redundant (invalidateChannelMessageWindow early-returns when the flag is already gone) and can stay or be removed; existing tests still pass because the first mount's destroyChannel sees _currentChannelId === null and the switch case still invalidates the previous id. The dispatcher.ts:410 gap is a separate fix (invalidate loaded windows on a resync even when the active channel was cleared).",
|
||
"fix": {
|
||
"commit": "5118428",
|
||
"test": "Client/tests/unit/channel-controller.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0248",
|
||
"title": "Infinite scroll loses the reading position: every \"load older\" page teleports the reader ~50 messages backwards",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 731,
|
||
"severity": "medium",
|
||
"why": "renderAll() has a scroll-anchor branch for the bottom (wasAtBottom) but none for a prepend. A history page is prepended above the current window, yet root.scrollTop is left untouched — so the same pixel offset now points at content 50 messages older, and renderWindow rebuilds the range from that unchanged scrollTop. The item the reader was looking at is pushed far below the viewport.",
|
||
"repro": "Open a channel with >100 messages. Scroll up until scrollTop < 50 — handleScroll fires onScrollTop -> MessageController.loadOlderMessages -> prependMessages. The store update is not a suffix extension, so MessageList's subscriber falls through tryAppendMessages() (next[0] !== prev[0]) into renderAll(). isNearBottom() is false, so both scroll-restore branches are skipped and scrollTop stays at its pre-prepend value (~0-50px). Result: the viewport now shows the OLDEST message of the newly fetched page at the top instead of staying on the message that was at the top a moment ago — the reader is thrown ~50 messages further back on every page load, and must scroll forward again to find where they were. Repeat the scroll-up and it happens again for each page. No test covers this (tests/unit/message-list.test.ts only asserts onScrollTop firing and the loadingOlder latch).",
|
||
"evidence": "renderAll():\n wasAtBottom = isNearBottom(); // line 716 — false when scrolled to the top\n rebuildItems(); // virtualItems now has ~50 NEW items in front\n if (wasAtBottom && root !== null) { ... root.scrollTop = ... } // line 731, skipped\n renderedStart = -1; renderedEnd = -1;\n renderWindow(); // reads root.scrollTop, still ~0 -> start = 0\n if (wasAtBottom) { scrollToBottom(); } // line 746, skipped\n\nThe ResizeObserver anchor (mount(), ~line 875) cannot repair it either: it computes\n anchorIdx = offsetToIndex(root.scrollTop) // = 0\n anchorOffset = root.scrollTop - offsetBefore(0) // = scrollTop\n root.scrollTop = offsetBefore(anchorIdx) + anchorOffset // = same value\nafter virtualItems already changed, so it re-pins scrollTop to where it already is.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-stores",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "In renderAll(), anchor on the previous head instead of only on the bottom: before rebuildItems() capture `const prevHeadId = allMessages[0]?.id ?? null` and `const prevScrollTop = root.scrollTop`; after rebuildItems() (and before renderWindow()) locate that id's new index in virtualItems and, when !wasAtBottom and the index moved, set `root.scrollTop = prevScrollTop + offsetBefore(newIdx)` (the summed height of the prepended rows) so the previously-visible row keeps its pixel position.",
|
||
"fix": {
|
||
"commit": "66d405d",
|
||
"test": "Client/tests/unit/message-list-prepend-scroll-anchor.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0249",
|
||
"title": "An unrelated server error during a voice join clears the voice store but never tears down the in-flight LiveKit connect — the call goes live with a hot mic and no UI",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 1191,
|
||
"severity": "high",
|
||
"why": "The generic `error` handler rolls back optimistic voice state whenever `voiceStatus === \"joining\"`, but only tears the media session down when `isVoiceConnected()` is true. `isVoiceConnected()` reads `session.getRoom()`, and the `_room` getter returns `null` for the entire `\"connecting\"` state — which is exactly the state a voice join is in while `voiceStatus` is `\"joining\"`. So for an in-flight `connectAndSetup()` the branch performs only the destructive half (`leaveVoiceChannel()`), while the connect attempt — whose supersession checkpoints read `_state`, never the store — runs to completion and reaches `\"connected\"`: room joined, microphone published, E2EE room key installed and rotating, `voice_states` row intact on the server. The store meanwhile says the user is not in voice, so `VoiceWidget.render()` hides the whole widget (leave/mute/deafen buttons included) and `ChannelSidebar` shows the row as joinable rather than leavable.",
|
||
"repro": "1. User clicks a voice channel: `createSidebarVoiceCallbacks.onVoiceJoin` runs `joinVoiceChannel(ch)` (currentChannelId=ch, voiceStatus=\"joining\") and sends `voice_join`.\n2. The server accepts and sends `voice_token`. `handleVoiceToken` calls `connectAndSetup`, which sets `_state = {type:\"connecting\"}` and `setVoiceStatus(\"joining\")`, then awaits `createRoom()` and `resolveLiveKitUrl()` (a Tauri IPC to `start_livekit_proxy`, i.e. a TCP bind + cert-store read) before it reaches `setVoiceStatus(\"securing\")`.\n3. Inside that window any server `error` frame that is not correlated to a pending send/reaction arrives — e.g. RATE_LIMITED from `channel_focus`/`mark_read` (5/s, Server/ws/handlers_presence.go:107,135), from `typing_start` (1 per 3 s, Server/service/channel.go:144, right at the client's own 3000 ms throttle boundary in MessageInput.ts:125), from a second `voice_join` on a fast double-click (ALREADY_JOINED, Server/ws/voice_join.go:191), or RATE_LIMITED from `voice_join` itself during rapid channel switching (5/s, voice_join.go:23).\n4. `voiceStatus === \"joining\"` is true, so the branch fires. `isVoiceConnected()` returns false because `_state.type` is \"connecting\", so `leaveVoice(true)` is skipped; only `leaveVoiceChannel()` runs — currentChannelId becomes null, voiceStatus becomes \"idle\".\n5. `connectAndSetup` is unaffected (checkpoints 1-5 compare `_state`/`joinGeneration`, not the store) and finishes normally: `setState({type:\"connected\"})`, `restoreLocalVoiceState` publishes the microphone, `setVoiceStatus(\"connected\")`.\n6. End state: the user is live in the SFU with the mic publishing and the E2EE rotation timer armed, the server still holds their `voice_states` row and other participants still see them in the roster — while their own VoiceWidget is hidden (currentChannelId is null) and the sidebar offers \"join\" instead of \"leave\". There is no in-app control that sends `voice_leave`; the only escape is clicking the channel again, which sends a fresh `voice_join`, gets ALREADY_JOINED back, and only then hits the branch with `isVoiceConnected()` finally true.",
|
||
"evidence": "dispatcher.ts:1191-1196\n if (voiceStore.getState().voiceStatus === \"joining\") {\n void livekitSession().then(({ isVoiceConnected, leaveVoice }) => {\n if (isVoiceConnected()) leaveVoice(true);\n });\n leaveVoiceChannel();\n }\n\nlivekitSession.ts:1803-1805\nexport function isVoiceConnected(): boolean {\n return session.getRoom() !== null;\n}\n\nlivekitSession.ts:208-211 (the getter it bottoms out in)\n /** Room from state, or null when idle/connecting/reconnecting. */\n private get _room(): Room | null {\n return this._state.type === \"connected\" ? this._state.room : null;\n }\n\nlivekitSession.ts:1066-1067 / 1100 (the window where both conditions hold)\n const myGeneration = ++this._joinGenerationCounter;\n this.setState({ type: \"connecting\", pendingJoin: null, joinGeneration: myGeneration });\n setVoiceStatus(\"joining\");\n ...\n localRoom = await this.createRoom();\n resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); // Tauri IPC: start_livekit_proxy\n ...\n setVoiceStatus(\"securing\");\n\nVoiceWidget.ts:233-241 (the consequence)\n const channelId = voice.currentChannelId;\n if (channelId === null) {\n root.classList.remove(\"visible\");\n ...\n return;\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Stop asking whether a Room object exists and ask whether a session is in flight. Add one accessor in Client/src/lib/livekitSession.ts next to isVoiceConnected() — e.g. a LiveKitSession method `hasActiveSession(): boolean { return this._state.type !== \"idle\"; }` plus `export function isVoiceSessionActive(): boolean { return session.hasActiveSession(); }` — and change dispatcher.ts:1192-1194 to `void livekitSession().then(({ isVoiceSessionActive, leaveVoice }) => { if (isVoiceSessionActive()) leaveVoice(true); });`. That single guard covers \"connecting\"/\"reconnecting\"/\"connected\" (leaveVoice() already handles a null _room and sets _state=idle, so the in-flight attempt aborts at checkpoint 1 and calls disconnectSupersededLocalRoom) while preserving the deliberate no-spurious-voice_leave behaviour for a first-time join refusal, where _state is still \"idle\".",
|
||
"fix": {
|
||
"commit": "82a57f4",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0250",
|
||
"title": "UpdateProfile / UpdateIdentityKey re-read the committed row on the request context, so a cancelled request commits the change and permanently skips the user_update broadcast",
|
||
"file": "Server/service/user.go",
|
||
"line": 230,
|
||
"severity": "medium",
|
||
"why": "The write at user.go:224 (`UpdateUserProfile`) is already committed when line 230 re-reads the row with the *request* ctx. Any failure of that read — most reachably `context.Canceled` when the client hangs up — is converted into `ErrInternal`, so `handleUpdateProfile` (api/profile_handler.go:303-313) returns via `writeServiceError` and never reaches `broadcastUserUpdate`. The DB has the new username/display_name/avatar; every other connected client keeps the old one until a full `ready`. Every sibling post-commit step in this repo is explicitly detached (`db.WriteAudit(context.WithoutCancel(ctx), …)` two lines below; `DMService.CreateGroupDM` uses `context.WithoutCancel` for exactly this read and cites OC-0004; `message_crud.go` does the same for its post-commit attachment/participant reads).",
|
||
"repro": "1. Alice PATCHes /api/v1/users/me with {\"username\":\"alice2\"}.\n2. `s.st.UpdateUserProfile` commits (users.username = 'alice2').\n3. Alice's client aborts the TCP connection (or the reader-pool query returns any transient error) before `GetUserByID(ctx, userID)` at user.go:230 returns → ctx.Err() = context.Canceled.\n4. `UpdateProfile` returns `ErrInternal`; `handleUpdateProfile` returns 500 and never calls `broadcastUserUpdate`.\n5. Result: Alice is 'alice2' in the DB and in her own next `ready`, but every other connected client's member list, message author labels and mention pills still say 'alice' indefinitely. The identical defect is at Server/service/user.go:274 (`UpdateIdentityKey`), where the committed E2EE identity key is never broadcast, so peers keep the stale pinned key.",
|
||
"evidence": "if err := s.st.UpdateUserProfile(ctx, userID, username, avatar, displayName, about); err != nil { ... }\nuser, err := s.st.GetUserByID(ctx, userID)\nif err != nil {\n\treturn nil, fmt.Errorf(\"%w: failed to fetch updated user: %v\", ErrInternal, err)\n}\n// Audit rows must survive a request canceled after the write committed.\ndb.WriteAudit(context.WithoutCancel(ctx), s.st, userID, \"profile_update\", ...)",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "db-storage",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Detach the post-commit re-reads in the shared service functions rather than guarding each caller: change Server/service/user.go:230 to `user, err := s.st.GetUserByID(context.WithoutCancel(ctx), userID)` and Server/service/user.go:274 likewise. That removes the only reachable trigger (ctx.Err() after commit) with one change per function. For full correctness the error branch should also stop reporting a committed write as ErrInternal — log and fall back to the merged snapshot the way DMService.CreateGroupDM does (`slog.Error(...); participants = nil`) — but the WithoutCancel change alone is the minimal fix that closes the repro, and it also stops profile_handler.go:614 from deleting a live avatar's bytes.",
|
||
"fix": {
|
||
"commit": "7501910",
|
||
"test": "Server/service/user_postcommit_ctx_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0251",
|
||
"title": "Ready-time GET /emoji has no staleness guard, so it silently reverts an emoji_update that lands while it is in flight",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 528,
|
||
"severity": "low",
|
||
"why": "The `ready` handler fires an unawaited `api.listEmoji()` and applies the full-set reply with `setCustomEmoji(list)` unconditionally. `ws.on(S.EMOJI_UPDATE)` writes the same store from the socket. The WS frame arrives on the already-open socket while the GET is still travelling over a separate connection (the Rust HTTP TOFU proxy), so the stale full-set snapshot lands last and clobbers the fresher one. This is exactly the hazard the sibling `listBlocks()` fetch three lines above (dispatcher.ts:515-517) guards against with the OC-0218 `blockedByMeRev` revision snapshot; the emoji fetch has no equivalent.",
|
||
"repro": "1. Client A is connecting (or taking a full-resync `ready`). The dispatcher's READY handler runs and issues GET /api/v1/emoji; the response is still in flight.\n2. While that GET is outstanding, an admin deletes emoji `:wave:` (Server/api/emoji_handler.go) — or uploads a new one. The server commits and fans out `emoji_update` with the post-change full set over A's already-open WebSocket.\n3. A's EMOJI_UPDATE handler runs first and calls setCustomEmoji(newSet) — `:wave:` is correctly gone.\n4. The GET response (taken from the pre-delete state) then resolves and calls setCustomEmoji(oldSet) with no guard.\nResult: `:wave:` is resurrected in emojiStore. Every message naming it keeps rendering the deleted image (a 404 on /api/v1/emoji/{id}/image), it stays selectable in EmojiPicker/MessageInput autocomplete and ReactionController's picker, and a reaction sent with it is rejected by the server. In the upload direction the new emoji disappears from the picker and its shortcode renders as literal `:code:`. The store stays wrong until the next `emoji_update` broadcast or the next reconnect — nothing re-fetches on its own.",
|
||
"evidence": "dispatcher.ts:525-530 —\n if (api?.listEmoji !== undefined) {\n api\n .listEmoji()\n .then((list) => setCustomEmoji(list))\n .catch((err) => log.warn(\"Failed to load custom emoji\", { error: String(err) }));\n }\n\ndispatcher.ts:850-853 —\n ws.on(S.EMOJI_UPDATE, (payload) => {\n log.info(\"Custom emoji updated\", { count: payload.emoji?.length ?? 0 });\n setCustomEmoji(payload.emoji ?? []);\n }),\n\nstores/emoji.store.ts:50-66 — setCustomEmoji() replaces `emoji` and `byShortcode` wholesale with no revision/generation parameter, so it cannot reject a stale writer.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "concurrency",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Put the guard in the shared writer, mirroring OC-0218 rather than special-casing the caller. In Client/src/stores/emoji.store.ts add an optional `readonly rev?: number` to EmojiState (0 in INITIAL, so pre-existing state literals and tests keep working), have setCustomEmoji take `(list: readonly CustomEmoji[], rev?: number)` and become `emojiStore.setState((prev) => { if (rev !== undefined && rev !== (prev.rev ?? 0)) return prev; return { emoji: next, byShortcode, rev: (prev.rev ?? 0) + 1 }; })`. Then in dispatcher.ts:525-530 snapshot before issuing the fetch: `const emojiRevAtFetch = emojiStore.getState().rev ?? 0; api.listEmoji().then((list) => setCustomEmoji(list, emojiRevAtFetch)).catch(...)`. The EMOJI_UPDATE handler at :850-853 keeps calling setCustomEmoji with no rev (always applies) and its bump is what invalidates an in-flight GET. Since the ready fetch is the only revision-passing caller, every other existing call site (tests, and the WS handler) is unaffected.",
|
||
"fix": {
|
||
"commit": "82a57f4",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0252",
|
||
"title": "Fresh-connect stale-voice cleanup deletes the DB row + SFU participant but never touches the still-registered old client's in-memory voice state; the two handshake early-returns between them leave a permanent phantom key holder",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 761,
|
||
"severity": "low",
|
||
"why": "handleFreshConnect calls freshConnectCleanStaleVoice (which deletes the voice_states row, broadcasts voice_leave and removes the LiveKit participant) and then relies entirely on registerNow (line 822) to clear the OLD, still-registered *Client's voiceChID and re-elect the key holder. Two error paths return between them (refreshUserSnapshot at 771-775, GetRoleByID at 779-783) without ever reaching registerNow and without calling unregisterFailedHandshake, so the old client stays in h.clients with voiceChID set and voiceKeyHolders still naming it — a memory-without-row ghost that sweepStaleVoiceStates cannot see because it iterates GetAllVoiceStates (DB rows), exactly as freshConnectCleanStaleVoice's own comment at serve.go:884-889 states. Every other participant-removal path re-elects here (finishVoiceLeave voice_leave.go:80, rollbackVoiceJoin voice_join.go:659, sweepStaleVoiceStates hub_sweep.go:301, CleanupVoiceForChannel hub_sweep.go:451, livekit_webhook.go:313, registerNow hub.go:607); this one does not.",
|
||
"repro": "User U has the lowest user id in voice channel 7: h.clients[U].voiceChID==7, voiceKeyHolders[7]==U, voice_states row present. U's socket half-opens (laptop sleep / network switch) so readPump has not yet exited, and U reconnects with last_seq==0. handleFreshConnect (serve.go:761) reads the row and runs freshConnectCleanStaleVoice: LeaveVoiceChannelIfMatch deletes the row, broadcastVoiceEvent(7, voice_leave(7,U)) tells every client U left, RemoveParticipant drops U from the LiveKit room. The very next statement, refreshUserSnapshot -> database.GetUserByID (serve.go:771), hits a transient SQLITE_BUSY/IO error; the handler closes the new conn and returns at line 772 — registerNow (822) never runs. The old *Client is still in h.clients with voiceChID==7, so updateKeyHolder would still elect U and voiceKeyHolders[7] stays U. Remaining participant V (uid > U) now sends voice_e2ee_offer: handleVoiceE2EEOfferV2 (voice_e2ee.go) calls IsVoiceKeyHolder(7, V) -> false and replies NOT_KEY_HOLDER, so room-key rotation for the live call is dead and any new joiner never receives the room key. sweepStaleVoiceStates cannot heal it (no DB row to iterate) and sweepStaleVoiceEvictRevoked skips U (CONNECT_VOICE not revoked); it only clears when the old socket's readPump finally exits — up to staleClientTimeout (90s) later.",
|
||
"evidence": "serve.go:761-783 —\n\tif vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil {\n\t\th.freshConnectCleanStaleVoice(ctx, database, c, vs)\n\t}\n\tif err := h.refreshUserSnapshot(ctx, database, c); err != nil {\n\t\t_ = conn.Close(websocket.StatusInternalError, \"user lookup failed\")\n\t\treturn err // <-- registerNow never runs\n\t}\n\tuserRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID)\n\tif roleErr != nil || userRole == nil {\n\t\t_ = conn.Close(websocket.StatusInternalError, \"role lookup failed\")\n\t\treturn fmt.Errorf(...) // <-- registerNow never runs\n\t}\n...\n\th.registerNow(c, allowedChannelIDs) // serve.go:822 — the ONLY place old.clearVoiceState()+updateKeyHolder happen (hub.go:531,607)\n\nfreshConnectCleanStaleVoice (serve.go:879-921) mutates only DB/LiveKit/broadcast — no c.clearVoiceState, no h.updateKeyHolder — while its own comment (serve.go:884-889) records that \"sweepStaleVoiceStates never heals memory-without-row\".",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "state-desync",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Make freshConnectCleanStaleVoice complete its own removal instead of delegating the in-memory half to registerNow. After the LeaveVoiceChannelIfMatch call in Server/ws/serve.go (~line 895), before the broadcast, add:\n\n if old := h.GetClient(c.userID); old != nil {\n if _, cleared := old.clearVoiceStateIfMatch(vs.ChannelID); cleared {\n h.pubsub.Unsubscribe(old, VoiceTopic(vs.ChannelID))\n }\n }\n h.updateKeyHolder(vs.ChannelID)\n\nclearVoiceStateIfMatch (client.go:164) makes it safe for the lastSeq>0 fall-through case where old is in a different channel, and updateKeyHolder is idempotent, so registerNow's later re-election (which will now see replacedVoiceChID==0) is redundant rather than conflicting. No lock is held at that point, satisfying updateKeyHolder's 'must not hold h.mu/keyHolderMu' contract.",
|
||
"fix": {
|
||
"commit": "c291acd",
|
||
"test": "Server/ws/oc_0252_0269_0272_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0253",
|
||
"title": "First-run setup orphans the Owner behind a 500 — the account commits, everything after it fails, and the retry is refused forever",
|
||
"file": "Server/admin/setup_handler.go",
|
||
"line": 246,
|
||
"severity": "low",
|
||
"why": "`setupCreateOwner` commits the Owner row with `CreateOwnerIfEmpty` (line 224) and then performs three more fallible steps — `auth.GenerateToken` (235), `database.CreateSession(r.Context(), …)` (246) and `database.CreateInvite(r.Context(), …)` (259) — each of which returns a bare 500 and `ok=false` with no compensating delete of the just-created owner. Because the setup gate is \"no users exist\", the retry hits `errors.Is(err, db.ErrConflict)` at line 225 and answers 403 \"setup has already been completed\", so the wizard payload (`setupApplyWizard`, called at handleSetup line 114) is never applied and the bootstrap invite is never minted. The sibling code states this exact invariant twice and honours it everywhere else: setupPrecheck's comment at line 197 (\"Validate the whole wizard payload BEFORE creating the account so a bad value … [does not] leave a half-configured server behind an already-created owner\") and setupApplyWizard's doc (\"The account exists from here on, so any failure downgrades to a warning — never a 5xx that would orphan the owner behind an opaque error\").",
|
||
"repro": "Fresh install, operator fills the first-run wizard (server name, MOTD, port, TLS mode) and submits POST /admin/api/setup. `CreateOwnerIfEmpty` commits. Now make any later step fail — the simplest realistic trigger is aborting the request (browser reload / navigating away / a proxy timeout) right after that commit, which cancels `r.Context()` and makes `CreateSession` at line 246 return `context.Canceled`; a transient SQLite write error at `CreateInvite` (line 259) does the same. Handler answers 500. Operator reloads /admin and resubmits the wizard: `CreateOwnerIfEmpty` now returns `db.ErrConflict` → 403 \"setup has already been completed\". Result: no bootstrap invite, no wizard settings ever written (server_name/motd/registration/config.yaml patch all skipped), on the CreateSession path not even the default #general/#General channels — and the endpoint refuses to ever run again. The only recovery is guessing that the credentials the UI reported as failing actually work.",
|
||
"evidence": "uid, err := database.CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID)\nif errors.Is(err, db.ErrConflict) {\n\twriteErr(w, http.StatusForbidden, \"FORBIDDEN\", \"setup has already been completed\")\n\treturn 0, \"\", \"\", false\n}\n...\nif _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, host); err != nil {\n\twriteErr(w, http.StatusInternalServerError, \"INTERNAL_ERROR\", \"failed to create session\")\n\treturn 0, \"\", \"\", false\n}\n\n_, _ = database.CreateChannel(r.Context(), \"general\", \"text\", \"Text Channels\", \"Welcome to the server!\", 0)\n_, _ = database.CreateChannel(r.Context(), \"General\", \"voice\", \"Voice Channels\", \"\", 0)\n...\ninviteCode, err := database.CreateInvite(r.Context(), uid, 5, &bootstrapInviteExpiry)\nif err != nil {\n\twriteErr(w, http.StatusInternalServerError, \"INTERNAL_ERROR\", \"failed to generate invite code\")\n\treturn 0, \"\", \"\", false\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "In setupCreateOwner, once CreateOwnerIfEmpty returns successfully, stop using the cancellable request context and stop 5xx-ing: bind `ctx := context.WithoutCancel(r.Context())` for the CreateSession/CreateChannel/CreateInvite calls, and on CreateInvite failure return an empty inviteCode with ok=true (letting setupApplyWizard-style warnings carry the problem) instead of `return 0, \"\", \"\", false`. A CreateSession failure should likewise return ok=true with an empty token so the response reports the account that actually exists.",
|
||
"fix": {
|
||
"commit": "8fc3c47",
|
||
"test": "Server/admin/setup_owner_orphan_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0254",
|
||
"title": "Client log rotation never runs unless the app stays open past midnight — client-logs/ grows one file per day forever",
|
||
"file": "Client/src/lib/logPersistence.ts",
|
||
"line": 137,
|
||
"severity": "low",
|
||
"why": "`rotateOldFiles()` has exactly one call site: the `date !== currentDate` branch inside `flushBuffer` (line 47-51). `initLogPersistence` seeds `currentDate = today()` at line 137 without ever rotating, so the branch is false for every flush of a session that starts and ends on the same calendar day. A desktop client launched and closed daily therefore never invokes rotation at all, and the module's documented contract (line 5: \"Rotation: keeps the most recent MAX_LOG_FILES days of logs\") is never enforced.",
|
||
"repro": "Fresh install. Day 1: launch the client, log some activity, quit. `client-logs/2026-08-20.jsonl` exists. Repeat on days 2..10, always quitting before midnight. After day 10 `appLogDir()/client-logs/` holds 10 `.jsonl` files, not the 5 MAX_LOG_FILES promises — `readDir`/`remove` were never called even once, because `flushBuffer`'s `date !== currentDate` guard was false on every flush in every session (`currentDate` was set to that same day at init, line 137). Growth is unbounded in days-of-use. The existing unit tests only exercise a session that crosses midnight (tests/unit/log-persistence.test.ts 'rotates old files when date changes' calls `vi.setSystemTime` to the next day mid-session), so the startup path is untested.",
|
||
"evidence": "line 47-51: `const date = today(); if (date !== currentDate) { currentDate = date; await rotateOldFiles(); }` — and line 137 in initLogPersistence: `currentDate = today();` with no rotate call anywhere in the init path.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ordering-boundary",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "In `initLogPersistence`, after `currentDate = today();` (line 137) and before `initialized = true`, add `await rotateOldFiles();`. logDir is already resolved at that point and rotateOldFiles already swallows its own errors, so a failing readDir cannot break init. One call in the shared init path; no change to flushBuffer.",
|
||
"fix": {
|
||
"commit": "19e9437",
|
||
"test": "Client/tests/unit/log-persistence.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0255",
|
||
"title": "voice_max_users capacity check counts the requester's own existing voice_states row, so a user cannot reclaim the slot their own ghost row occupies",
|
||
"file": "Server/db/queries/sqlite/voice.sql",
|
||
"line": 21,
|
||
"severity": "low",
|
||
"why": "JoinVoiceChannelIfCapacity's guard is `(SELECT COUNT(*) FROM voice_states vs2 WHERE vs2.channel_id = ?) < ?` — an unconditional channel-wide row count. When the joining user already has a voice_states row on that same channel, their own row is counted against them, so a full channel refuses their re-insert (0 rows affected -> db.ErrChannelFull) even though the upsert would only replace the row already there. The camera/screenshare gates in the same file were explicitly fixed for exactly this (\"- voice_states.camera\" / \"- voice_states.screenshare\", OC-0081 comment at voice.sql:102-115); the user-capacity gate never got the same subtraction. voice_join.go:246-249 explicitly documents the recovery this breaks: \"the user_id-PK upsert lets the user rejoin immediately\".",
|
||
"repro": "Voice channel V has voice_max_users = 2 with users A and B inside. A sends voice_join for a different channel W. voiceJoinLeaveCurrent (voice_join.go:215-217) runs handleVoiceLeave, whose synchronous LeaveVoiceChannelIfMatch fails (SQLITE_BUSY / IO error — voice_leave.go:115 logs and schedules background retries). The GetVoiceState re-check at voice_join.go:224-234 still sees A's row on V, so the switch aborts: A's in-memory voice state is cleared and A is told \"voice channel switch failed — please try again\". A retries voice_join for V. currentChID is now 0 so ALREADY_JOINED does not fire, and JoinVoiceChannelIfCapacity evaluates COUNT(V) = 2 (A's own stale row + B), 2 < 2 is false, 0 rows -> ErrChannelFull. A is refused with \"voice channel is full\" from a channel their own ghost row is filling, until sweepStaleVoiceStates reaps it up to 60 s later — the exact opposite of the comment at voice_join.go:246-249. No test covers a re-join by a user who already holds a row on the target channel at capacity (db/coverage_boost_test.go:60 only covers a cross-channel move under the limit).",
|
||
"evidence": "Server/db/queries/sqlite/voice.sql:18-29\n-- name: JoinVoiceChannelIfCapacity :execresult\nINSERT INTO voice_states (...)\nSELECT ?, ?, 0, 0, 0, 0, 0, ?\nWHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) < ?\nON CONFLICT(user_id) DO UPDATE SET channel_id = excluded.channel_id, ...\n\nCompare voice.sql:113-115 (fixed sibling):\n AND (SELECT COALESCE(SUM(vs2.camera),0) + COALESCE(SUM(vs2.screenshare),0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) - voice_states.camera < sqlc.arg(max_video);\n\nServer/db/voice_queries.go:57-60 maps 0 rows to ErrChannelFull; Server/ws/voice_join.go:263-266 turns that into ErrCodeChannelFull \"voice channel is full\".",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Mirror OC-0081 in the one query: exclude the joining user's own row from the count — `WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.user_id <> ?) < ?` — and pass userID as the new bound parameter from db/voice_queries.go's JoinVoiceChannelIfCapacity. Semantics stay exact for the normal case (a user with no row on that channel contributes 0 either way) and become correct for the upsert case (others < max means others+1 <= max after the replace). Regenerate the sqlc layer with the db-change skill; do not hand-edit db/dbgen/.",
|
||
"fix": {
|
||
"commit": "36a731f",
|
||
"test": "Server/db/coverage_boost_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0256",
|
||
"title": "Post-resume echo reconciliation compares the raw typed text against the server's sanitized content, so any message the sanitizer rewrites is duplicated on the screen with a live \"Retry\" button",
|
||
"file": "Client/src/stores/messages.store.ts",
|
||
"line": 200,
|
||
"severity": "low",
|
||
"why": "`isUnreconciledEcho` decides \"this replayed broadcast is the echo of my own lost send\" by requiring `optimistic.content === candidate.content`, on the stated assumption that \"our own echo always carries identical content\". That assumption is false: the server runs every send through `sanitizeToFixpoint` (Server/service/message.go:243, via `sendMessagePrecheck`) and broadcasts `result.Content`, not the bytes the client submitted. When the sanitizer changes the text, the resume-time reconcile misses, so the optimistic row stays `failed`/`OFFLINE` next to the real message that did commit — and Retry re-posts it.",
|
||
"repro": "1. Connected, viewing #general. Type a message the sanitizer rewrites — e.g. `<b>hello</b>` (tags stripped -> `hello`), or `&lt;` / `&` (entity-unescaped to `<` / `&`) — and press Enter. `ws.send` returns correlation id X; `addOptimisticMessage` inserts a `pending` row whose `content` is the RAW `<b>hello</b>`.\n2. The server accepts it: `sanitizeContent` produces `hello`, the row commits, `chat_send_ok{id:X}` and `chat_message{id:M, content:\"hello\", seq:N}` are written to the socket.\n3. The socket dies before either frame is read (proxy blip / NAT drop). ws.ts emits `ws-state: closed` -> `scheduleReconnect()` -> state becomes \"reconnecting\". dispatcher.ts:1078-1085 then runs `markSendFailed(id, \"OFFLINE\")` over every entry in `pendingSends`, so row X becomes `status: \"failed\", errorCode: \"OFFLINE\"`.\n4. The client reconnects with `last_seq = N-1`; the server serves a buffer-tier resume (`Server/ws/serve.go` handleReconnect) and replays `chat_message{id:M, content:\"hello\"}`.\n5. `addMessage` step 1 finds no id match (the optimistic row's id is still 0). Step 2 calls `isUnreconciledEcho`: `\"<b>hello</b>\" !== \"hello\"` -> false. Step 3 appends the message as new.\n6. Observed: #general now shows BOTH a red \"Failed - Retry\" bubble reading `<b>hello</b>` and the delivered message `hello`. Clicking Retry calls `performSend(draft.content, ...)` with the raw text and posts the message a second time.\n\nThe identical mismatch hits the full-resync path through the same predicate at messages.store.ts:429-434 (`setMessages`' carry-over filter), so a `ready`-tier reconnect leaves the same orphaned failed row after the history refetch. The existing unit tests (tests/unit/messages.store.test.ts:1423 \"reconciles an OFFLINE-failed row when its echo arrives\") only exercise byte-identical content, so nothing locks this behaviour.",
|
||
"evidence": "messages.store.ts:194-201\n function isUnreconciledEcho(optimistic: Message, candidate: Message): boolean {\n return (\n (optimistic.status === \"pending\" ||\n (optimistic.status === \"failed\" && optimistic.errorCode === \"OFFLINE\")) &&\n optimistic.correlationId !== null &&\n optimistic.user.id === candidate.user.id &&\n optimistic.content === candidate.content // <-- raw typed text vs sanitized echo\n );\n }\n\nmessages.store.ts:241-243 (comment stating the false premise)\n // Content must match too — our own echo always carries identical content, ...\n\nChannelController.ts:249-251 (optimistic row carries the RAW composer text)\n const cid = ws.send({ type: \"chat_send\", payload: { channel_id: channelId, content, reply_to: replyTo, attachments } });\n addOptimisticMessage({ correlationId: cid, channelId, user, content, replyTo, timestamp });\n\nServer/service/message.go:239-250 (server rewrites it)\n func sanitizeContent(raw string, allowEmpty bool) (string, error) {\n content := sanitizeToFixpoint(raw) // html.UnescapeString(bluemonday.Sanitize(html.UnescapeString(s))) to a fixpoint\n\nServer/ws/handlers_chat.go:57 (the broadcast carries the sanitized value)\n Content: result.Content,\n\nClient/src/pages/main-page/ChannelController.ts:254-261 (Retry re-sends the raw draft)\n removeOptimistic(correlationId);\n performSend(draft.content, draft.replyTo, draft.attachments);",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Fix once in the shared predicate at messages.store.ts:194 rather than at either call site: add a module-local `echoNormalize(s)` that mirrors the server's sanitizePass approximation (decode HTML entities, strip tags, decode again) and change the last clause to `optimistic.content === candidate.content || echoNormalize(optimistic.content) === candidate.content`. Both consumers (addMessage step 2 at :244 and setMessages' carry-over at :429-434) inherit it, and the widening stays bounded by the existing same-author / pending-or-OFFLINE / consume-each-candidate-once guards. Also correct the now-wrong comment at :240-243.",
|
||
"fix": {
|
||
"commit": "6293792",
|
||
"test": "Client/tests/unit/messages.store.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0257",
|
||
"title": "A key holder demoted by a lower-user-id joiner is never told, so it fires a server-refused voice_e2ee_offer and the user gets a red \"only the key holder may send key offers\" toast",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 847,
|
||
"severity": "low",
|
||
"why": "Key-holder election is server-authoritative (lowest connected user id in the room, Server/ws/voice_e2ee.go:47) and is re-run on every join (voice_join.go:474), but the server has exactly one channel for telling a client its holder status — the `is_key_holder` field of `voice_token`, sent only at join and at token refresh. There is no demotion message, and `handleVoiceTokenRefresh` (livekitSession.ts:850, called from handleVoiceToken:1375 with only `token`) throws the refreshed `is_key_holder` away. So when a lower-uid user joins an ongoing call, the incumbent holder's `_isKeyHolder` stays true; its `handleAnnounceInner` reaches the holder branch on the newcomer's relayed announce, wraps the OLD room key and sends `voice_e2ee_offer` (line 879), which the server refuses with NOT_KEY_HOLDER (voice_e2ee.go:199). No client code handles that code (grep: only comments), so it falls through dispatcher.ts's catch-all at line 1249 and is shown as an error toast. Server/ws/voice_keyholder_test.go's own header comment already records that NOT_KEY_HOLDER is \"not handled\" by the client and that a stale holder \"has already applied its rotated key locally, splitting keys across the room\".",
|
||
"repro": "Deterministic, no race needed. 1) User A (user_id 10) joins voice channel C alone. Server: computeIsKeyHolder→true, voiceKeyHolders[C]=10; A's client sets _isKeyHolder=true and generates the room key. 2) User B (user_id 5) joins C. voiceJoinGrantToken sends B `voice_token{is_key_holder:true}`; voiceJoinComplete's updateKeyHolder sets voiceKeyHolders[C]=5. Nothing is sent to A. 3) B's setupKeyExchange sends `voice_e2ee_announce`; the hub relays it to A over VoiceTopic. 4) A's handleAnnounceInner takes the branch at line 847 (still _isKeyHolder), wraps its stale room key and sends `voice_e2ee_offer` targeting B. 5) Server refuses: NOT_KEY_HOLDER. 6) A's dispatcher error handler falls through every branch (no pendingSend id, no reaction id, voiceStatus is \"connected\" not \"joining\", code is not CHANNEL_FULL/VIDEO_LIMIT) and calls showToast(\"only the key holder may send key offers\", \"error\"). A sees a red error toast for a call that is working. Until B's own offer lands and stands A down (handleOfferInner:1024), A also still holds a live 5-minute rotation timer and would, on any peer leave in that window (handleParticipantLeft's `else if (wasKeyHolder && hadPeerKey)` branch), call rotateRoomKey() — installing a key on its own keyProvider that every refused offer means no peer has.",
|
||
"evidence": "livekitE2EE.ts:845-892\n const keypair = this._ecdhKeyPair;\n const currentRoomKey = this._roomKey;\n if (this._isKeyHolder && currentRoomKey && keypair) { // <-- purely local flag, never corrected by the server\n ...\n const sent = await this.sendOfferPaced(userId, encryptedKey, iv, ...);\n\nServer/ws/voice_e2ee.go:198-200\n if !d.KeyHolder.IsVoiceKeyHolder(voiceChID, info.UserID) {\n return Result{Error: ClientError{Code: ErrCodeNotKeyHolder, Message: \"only the key holder may send key offers\"}}\n }\n\nClient/src/lib/livekitSession.ts:850 handleVoiceTokenRefresh(token?: string): void { // is_key_holder from the refresh is never passed in, never applied\n\nClient/src/lib/dispatcher.ts:1249 showToast(payload.message || \"Server error\", \"error\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-voice",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "One guard in the shared error handler: in dispatcher.ts's ws.on(S.ERROR) branch, add `if (payload.code === \"NOT_KEY_HOLDER\") { return; }` (log-only) ahead of the catch-all showToast at :1249 — this refusal is a benign, self-healing election race the client already recovers from via handleOfferInner's stand-down. Optionally also stop emitting the doomed offer at all: in handleAnnounceInner, gate the holder branch on `userId > myUserId` (the server always elects the lowest connected uid, the same rule handleParticipantLeft already applies at :1286).",
|
||
"fix": {
|
||
"commit": "c518032",
|
||
"test": "Client/tests/unit/livekit-e2ee.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0258",
|
||
"title": "A message arriving before the history fetch resolves latches the \"NEW\" divider onto the newest message, so the actually-unread messages render above the line",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 308,
|
||
"severity": "low",
|
||
"why": "resolveNewDividerIndex latches newDividerAnchorId on the FIRST rebuild that produces a valid index, but firstUnreadIndex is a count-from-the-end offset clamped with Math.max(0, ...). On mount the window is empty (mountChannel invalidates on leave, so a revisited channel starts with no rows and loadMessages is async), so the first rebuild with a valid index can be one driven by a single live chat_message — length 1 with unreadOnOpen = N > 1 gives index 0, anchoring the divider to that brand-new message. Once latched, the anchor is never re-evaluated (line 305-307), so when the 50-message history page merges in, the divider is rendered above the newest row instead of above the first unread one.",
|
||
"repro": "#general shows a badge of 3 unread. Alice clicks it: setActiveChannel snapshots unreadOnOpen=3 and clears the badge (channels.store.ts:249-254); mountChannel destroys the previous list, creates MessageList and fires the async api.getMessages(#general, {limit:50}). mount() -> renderAll() runs with 0 rows, so firstUnreadIndex returns -1 and nothing is latched. Before the fetch resolves, Bob posts message 1001; dispatcher.ts:600 addMessage inserts it, the store subscriber (MessageList.ts:905-914) calls tryAppendMessages which returns false (prev.length === 0) and falls through to renderAll. resolveNewDividerIndex now sees messages = [1001], idx = Math.max(0, 1-3) = 0, and latches newDividerAnchorId = 1001. The history response then lands; setMessages carries 1001 (it is \"sent\" and newer than maxSnapshotId), producing [951..1000, 1001]. The next rebuild resolves the anchor to index 50, so the \"NEW\" line is drawn immediately above message 1001 and the three genuinely unread messages 998/999/1000 sit above it, indistinguishable from already-read history. tests/unit/message-list-new-divider.test.ts always seeds the full window before mount(), so no test covers this ordering.",
|
||
"evidence": "MessageList.ts:181-184\nfunction firstUnreadIndex(messages: readonly Message[], unreadOnOpen: number): number {\n if (unreadOnOpen <= 0 || messages.length === 0) return -1;\n return Math.max(0, messages.length - unreadOnOpen); // 1 - 3 -> clamped to 0\n}\n\nMessageList.ts:304-314\n function resolveNewDividerIndex(messages: readonly Message[]): number {\n if (newDividerAnchorId !== null) {\n return messages.findIndex((m) => m.id === newDividerAnchorId); // never re-derived\n }\n const idx = firstUnreadIndex(messages, unreadOnOpen);\n const anchor = idx !== -1 ? messages[idx] : undefined;\n if (anchor !== undefined && anchor.id !== 0) {\n newDividerAnchorId = anchor.id; // latched from a 1-row window\n }\n return idx;\n }\n\ntryAppendMessages bails on an empty prior window (MessageList.ts:621: `if (prev.length === 0 || next.length <= prev.length) return false;`), so the 0 -> 1 transition goes through the full renderAll path that calls resolveNewDividerIndex.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Only latch when the clamp did not fire, i.e. when the window is long enough to actually contain the unread boundary. In resolveNewDividerIndex (MessageList.ts:309-312): `if (anchor !== undefined && anchor.id !== 0 && messages.length >= unreadOnOpen) { newDividerAnchorId = anchor.id; }`. Until then the index-0 fallback keeps rendering the line at the top (which is correct while the whole loaded window is unread) and latches at the true boundary once the history page lands. This preserves the existing 'every loaded message is unread' test (line 153) and the anchor-stability test (line 215).",
|
||
"fix": {
|
||
"commit": "66d405d",
|
||
"test": "Client/tests/unit/message-list-new-divider.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0259",
|
||
"title": "An empty history page never clears a stale message window — deleted messages keep rendering as live ones",
|
||
"file": "Client/src/stores/messages.store.ts",
|
||
"line": 419,
|
||
"severity": "low",
|
||
"why": "setMessages' merge keeps a previously loaded \"sent\" row when its id is greater than `maxSnapshotId`, the newest id in the fetched page. `maxSnapshotId` is computed with `reduce(..., 0)`, so an empty page degenerates to 0 and the rule — meant to catch only rows that arrived after the snapshot was taken — becomes true for every stale row. A refetch that legitimately returns no messages (every message in the channel has since been deleted or purged, and the client was not subscribed to hear about it) therefore preserves the whole stale window verbatim and marks the channel loaded with hasMore=false, so nothing corrects it.",
|
||
"repro": "1) User opens #general; MessageController.loadMessages fetches messages 1..50 and setMessages marks the channel loaded. 2) User clicks #random: ChannelController.mountChannel calls invalidateChannelMessageWindow(#general), which drops #general from loadedChannels but deliberately keeps its 50 rows in messagesByChannel. 3) A moderator purges #general (POST /channels/{id}/messages/purge) — or, in the single-message variant, the sole author deletes the channel's only message from another client. The chat_bulk_deleted / chat_deleted frame goes only to the #general pub/sub topic, and this socket is now focused on #random, so it never arrives and no row is marked deleted locally. 4) User clicks back to #general. isChannelLoaded is false, so loadMessages refetches and the server answers {messages: [], has_more: false} because every row now has deleted = 1. 5) setMessages(#general, [], false) runs with trimmed = [] and maxSnapshotId = 0, so `carried` keeps all 50 stale rows and merged === carried. The channel renders 50 messages that no longer exist — full author and content, with Reply/React/Pin/Edit/Delete affordances that all fail against the server — the \"no messages yet\" empty state is unreachable, and hasMore=false blocks any scroll-back that could correct it. Repeating the switch-away/switch-back cycle reproduces the same result every time; only a brand-new message posted to the channel (giving a non-zero maxSnapshotId) ever flushes the stale rows.",
|
||
"evidence": "418 const snapshotIds = new Set(trimmed.map((m) => m.id));\n419 const maxSnapshotId = trimmed.reduce((max, m) => Math.max(max, m.id), 0);\n...\n427 const carried = previous.filter((m) => {\n428 if (snapshotIds.has(m.id)) return false;\n429 if (m.status === \"sent\") return m.id > maxSnapshotId;\n...\n437 let merged = carried.length > 0 ? [...trimmed, ...carried] : trimmed;\n\nSupporting: Server/db/message_queries.go:466,476 — `WHERE m.channel_id = ? AND m.deleted = 0` (soft-deleted rows are absent from the page, not returned as tombstones).\nServer/ws/hub_broadcast.go:582-584 — BroadcastChatBulkDeleted -> BroadcastToChannel, i.e. the channel pub/sub topic only (clients whose channel_focus is that channel).\nClient/src/stores/messages.store.ts:586 — invalidateChannelMessageWindow drops the loaded flag but keeps the rows.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-stores",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Do not let an empty snapshot act as a carry threshold of 0, but keep protecting rows that genuinely arrived after the fetch started. Capture a watermark when the fetch begins and use it as the floor: in setChannelLoading (messages.store.ts:380, which MessageController.ts:82 calls synchronously before the GET) record `loadWatermark.set(channelId, max id currently in prev.messagesByChannel.get(channelId))`; in setMessages compute `const carryFloor = Math.max(maxSnapshotId, prev.loadWatermark.get(channelId) ?? 0)` and use `m.id > carryFloor` at line 429, deleting the watermark entry in the same update. A purged channel then drops every pre-fetch row while a live broadcast that landed mid-fetch (id above the watermark) is still carried. Do NOT use the one-line `trimmed.length > 0 ? m.id > maxSnapshotId : false` — it would discard a real live row that arrived during the fetch of an empty channel, which the reduce(...,0) default currently protects.",
|
||
"fix": {
|
||
"commit": "6293792",
|
||
"test": "Client/tests/unit/messages.store.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0260",
|
||
"title": "DM typing indicators are routed onto the high-priority send queue, so a cosmetic ephemeral frame can disconnect a backpressured client (and jumps ahead of queued chat messages)",
|
||
"file": "Server/ws/event.go",
|
||
"line": 228,
|
||
"severity": "low",
|
||
"why": "`TypingChannelEvent` implements `ExcludeSenderEvent`, which `EmitEvents` routes to `broadcastExcludeLow` → `sendLowMsg` — documented as \"correct for typing indicators (dropped on overflow instead of disconnecting)\". `TypingDMEvent`, the DM half of the very same handler, instead implements only `UserTargetedEvent`, and `EmitEvents`' `case UserTargetedEvent` calls `h.SendToUserHigh` (emit.go:94) → `Client.sendHighMsg`, which on a full `sendHigh` falls back to `send` and, if that is full too, calls `closeAllSendLocked()` and kills the connection. The identical event therefore has the strictest durability class in a DM and the most droppable one in a channel.",
|
||
"repro": "Disconnect symptom: user B's socket stalls (slow link / paused writePump) while a busy DM plus a large channel burst fills B's `c.sendHigh` (64 slots, fed by dm_channel_open/mention frames) and `c.send` (256 slots, fed by sequenced chat frames). User A then presses a key in the 1:1 DM with B → `handleTypingV2` (handlers_presence.go:48) emits `TypingDMEvent` → `EmitEvents` `case UserTargetedEvent` (emit.go:79) → `SendToUserHigh` → `sendHighMsg` sees both buffers full → `closeAllSendLocked()`, B's WebSocket is closed and B must reconnect — caused solely by a typing indicator. The same typing indicator sent in a server text channel takes `TypingChannelEvent` → `broadcastExcludeLow` → `sendLowMsg`, which silently drops it and leaves B connected.\nOrdering symptom: A sends a DM message (queued on `c.send` via `sendSequencedToUsers`) and immediately starts typing the next one; the typing frame lands on `c.sendHigh`, and `writePump` (serve_pumps.go:74-84) drains `sendHigh` first, so B's client renders \"A is typing…\" before it receives the message A already sent.",
|
||
"evidence": "Server/ws/event.go:225-235 — `type TypingDMEvent struct { targetUserID int64; payload []byte }` with only `EventType()/TargetUserID()/Payload()`, so it matches `UserTargetedEvent`.\nServer/ws/handlers_presence.go:48-64 — the DM branch of `handleTypingV2` emits one `TypingDMEvent` per participant; the non-DM branch (line 67-75) emits `TypingChannelEvent`.\nServer/ws/emit.go:79-94 — `case UserTargetedEvent: ... h.SendToUserHigh(e.TargetUserID(), e.Payload())`.\nServer/ws/client.go:221-245 — `sendHighMsg`: `case c.sendHigh <- msg` … `default:` fall back to `c.send`; both full ⇒ `c.hub.bpQueueDisconnects.Add(1)` + `c.closeAllSendLocked()`.\nServer/ws/client.go:253-271 — `sendLowMsg`: on overflow `bpLowDrops.Add(1)`, \"Do NOT disconnect — low-priority messages are safely droppable.\"\nServer/ws/handlers.go:325-329 — `broadcastExcludeLow` doc: \"This is correct for typing indicators (dropped on overflow instead of disconnecting)\".\nServer/ws/client.go:13-15 — `sendBufSize = 256`, `sendHighBufSize = 64`.\nServer/ws/serve_pumps.go:70-100 — `writePump` drains `c.sendHigh` to exhaustion before it ever reads `c.send`.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Mirror the OC-0166 fix in the shared router rather than at the handler: add `case TypingDMEvent:` immediately BEFORE `case UserTargetedEvent` in EmitEvents (Server/ws/emit.go:85) routing to a low-priority targeted send, so DM typing shares the ephemeral drop-on-overflow class its channel sibling already uses. That needs a two-line sibling of SendToUser in Server/ws/hub_broadcast.go: `func (h *Hub) SendToUserLow(userID int64, msg []byte) bool { h.mu.RLock(); c, ok := h.clients[userID]; h.mu.RUnlock(); if !ok { return false }; c.sendLowMsg(msg); return true }`. Go type switches take the first matching case, so no other event's routing changes and no caller or test needs touching.",
|
||
"fix": {
|
||
"commit": "b342b21",
|
||
"test": "Server/ws/emit_typing_dm_priority_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0261",
|
||
"title": "A successful message jump raises a spurious \"Failed to load message history\" toast — loadMessages' catch path lacks the staleness guard its .then sibling has",
|
||
"file": "Client/src/pages/main-page/MessageController.ts",
|
||
"line": 106,
|
||
"severity": "low",
|
||
"why": "loadMessages' success path re-checks `!isChannelLoaded(channelId)` after the await, precisely because a same-channel jump can install an around-window (setAroundMessages) while the mount-time tail fetch is still in flight and nothing aborts that fetch's signal. The failure path re-checks only `signal.aborted`, so a tail fetch that rejects after the jump's window already landed flags a perfectly-loaded channel as load-errored and toasts an error for an operation the user saw succeed.",
|
||
"repro": "From a search hit / pinned entry / permalink, jump to an old message in a channel that is not currently open. ChannelController.mountChannel mounts the channel and calls msgCtrl.loadMessages(channelId, signal) (ChannelController.ts:269), which starts the 50-row tail fetch. MessageJump then fetches the around-window; it resolves first and calls setAroundMessages, which marks the channel loaded, clears historyLoadState and renders the jump target. Now let the still-in-flight tail GET /channels/{id}/messages reject (server 500, proxy blip, timeout) — its signal is NOT aborted because the same channel is still mounted. The catch runs: historyLoadState is set back to \"error\" for a channel whose window is correct and fully rendered, and because getChannelMessages(channelId).length > 0 the user gets an error toast \"Failed to load message history\" on top of a jump that visibly worked. The stale \"error\" entry also survives until the next loadMessages call overwrites it.",
|
||
"evidence": "async function loadMessages(channelId, signal) {\n ...\n const resp = await api.getMessages(channelId, { limit: PAGE_SIZE }, signal);\n if (!signal.aborted && !isChannelLoaded(channelId)) { // line 90 — guarded\n setMessages(channelId, resp.messages, resp.has_more);\n }\n} catch (err) {\n if (!signal.aborted) { // line 99 — NOT guarded\n setChannelLoadError(channelId); // line 106\n if (getChannelMessages(channelId).length > 0) {\n showError(\"Failed to load message history\"); // line 111\n }\n }\n}\n\nMessageJump.jumpTo issues its around-fetch with no abort signal at all\n(MessageJump.ts:95 `opts.api.getMessagesAround(channelId, messageId, { limit: AROUND_WINDOW })`)\nand setAroundMessages (messages.store.ts:508-515) both adds the channel to\nloadedChannels and deletes its historyLoadState entry.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-stores",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Mirror the success guard in the catch: `if (!signal.aborted && !isChannelLoaded(channelId))` at MessageController.ts:99, so a tail fetch that lost the race to setAroundMessages/setMessages neither marks the channel load-errored nor toasts.",
|
||
"fix": {
|
||
"commit": "95cd575",
|
||
"test": "Client/tests/unit/message-controller.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0262",
|
||
"title": "Search results inside a DM are labelled with a bare \"#\" — DM channels have an empty channels.name",
|
||
"file": "Client/src/components/SearchOverlay.ts",
|
||
"line": 84,
|
||
"severity": "low",
|
||
"why": "The result header renders the location as `#${r.channel_name}`, taking the raw `channels.name` column straight from the search API. A 1:1 DM row is created with `INSERT INTO channels (name, type) VALUES ('', 'dm')` (Server/db/dm_queries.go:148), so every DM hit renders as a lone \"#\" with no conversation name. Every other DM-labelling surface routes through `dmDisplayName` (Client/src/stores/dm.store.ts:190) precisely so they cannot disagree; this one bypasses it and also stamps a DM with the '#' sigil the chat header itself replaces with '@'.",
|
||
"repro": "Open a DM conversation, then open search (OverlayManagers.ts:351 passes `currentChannelId`, so the query is scoped to the open DM). Server-side SearchMessages joins `channels c` and returns `c.name`, which is '' for the DM, so each row renders `setText(channel, \"#\" + \"\")` → the header is just \"#\" with no way to tell which conversation the hit came from. Unscoped search reaches the same code: accessibleChannelIDs appends GetUserDMChannelIDs (Server/service/message_perms.go:50), so DM hits are in scope there too.",
|
||
"evidence": "const channel = createElement(\"span\", { class: \"search-result-channel\" });\nsetText(channel, `#${r.channel_name}`); // r.channel_name === \"\" for every 1:1 DM",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "In SearchOverlay.renderResults, resolve the DM label from the store the rest of the client uses, keeping the '#' form for real channels: `const dm = dmStore.getState().channels.find((c) => c.channelId === r.channel_id); setText(channel, dm !== undefined ? `@${dmDisplayName(dm)}` : `#${r.channel_name}`);` (import { dmStore, dmDisplayName } from \"@stores/dm.store\"). Existing search-overlay tests keep passing because dmStore is empty for text-channel hits.",
|
||
"fix": {
|
||
"commit": "ded5fc6",
|
||
"test": "Client/tests/unit/search-overlay.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0263",
|
||
"title": "Who-reacted tooltip lists raw usernames, ignoring the nicknames every other roster shows",
|
||
"file": "Client/src/components/message-list/reaction-tooltip.ts",
|
||
"line": 194,
|
||
"severity": "low",
|
||
"why": "`buildReactionTooltip` formats the reactor list from `users.map((u) => u.username)`. ReactionUser carries the user id, so the nickname is resolvable from members.store via `memberDisplayName`, exactly as the member list, typing indicator, voice roster and message rows all do. The tooltip is therefore the last identity surface still printing the handle.",
|
||
"repro": "User B sets display_name \"Bee\" (username \"bob\"); B reacts 👍 to a message. Hover the 👍 pill: the tooltip reads \"bob reacted with 👍\" while the same user renders as \"Bee\" in the member list, the typing indicator, the voice roster and on their own message rows.",
|
||
"evidence": "setText(\n names,\n formatReactorNames(\n users.map((u) => u.username),\n totalCount,\n ),\n);",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Resolve once inside the shared builder rather than at the call site — in buildReactionTooltip replace `users.map((u) => u.username)` with `users.map((u) => { const m = membersStore.getState().members.get(u.id); return m !== undefined ? memberDisplayName(m) : u.username; })`, importing { membersStore, memberDisplayName } from \"@stores/members.store\". formatReactorNames and the textContent-only rendering stay untouched.",
|
||
"fix": {
|
||
"commit": "b995b89",
|
||
"test": "Client/tests/unit/reaction-tooltip.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0264",
|
||
"title": "DM profile sidebar shows the raw username, contradicting the header that opens it",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 270,
|
||
"severity": "low",
|
||
"why": "`toggleDmProfileSidebar` builds DmProfileData from `dmChannel.recipient` but drops `recipient.displayName`, and DmProfileData (DmProfileSidebar.ts:23) has no display-name field at all, so the panel renders `setText(nameEl, user.username)` (DmProfileSidebar.ts:264) and the avatar initial from `user.username.charAt(0)` (line 155). The panel is opened by clicking the DM header, which shows `dmDisplayName(dmChannel)` — the nickname. Unlike UserProfilePopup (display name plus an \"@handle\" line, UserProfilePopup.ts:179/215) there is no handle line here, so the nickname never appears at all. Same shape as the already-fixed voice-roster case where VoiceUser carried no display name.",
|
||
"repro": "DM with \"bob\" whose display_name is \"Bee\". The DM sidebar row and the chat header both read \"Bee\" (dmDisplayName prefers displayName, dm.store.ts:201). Click the header to open the profile sidebar: the title reads \"bob\" and the 80px avatar fallback letter is \"b\", not \"B\" — a different identity from the header just clicked.",
|
||
"evidence": "dmProfileSidebar = createDmProfileSidebar({\n user: {\n id: recipient.id,\n username: recipient.username, // recipient.displayName is dropped\n avatar: recipient.avatar || null,\n ...\n// DmProfileSidebar.ts:264\nsetText(nameEl, user.username);",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Add `readonly displayName?: string | null;` to DmProfileData (DmProfileSidebar.ts:23), pass `displayName: recipient.displayName ?? null,` in the MainPage.ts:268 options object, and render through the existing helpers inside the component so name and avatar letter cannot disagree: `const label = resolveDisplayName({ username: user.username, displayName: user.displayName ?? null });` used at DmProfileSidebar.ts:264 and for the initial at :155 (avatarInitial from @lib/avatar).",
|
||
"fix": {
|
||
"commit": "705651c",
|
||
"test": "Client/tests/unit/dm-profile-sidebar.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0265",
|
||
"title": "A plugin upgrade destroys the installed version on disk before the DB row is written, and the write is tied to the aborted request's context",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 284,
|
||
"severity": "medium",
|
||
"why": "`installZipPromote` does `os.RemoveAll(finalDir)` + `os.Rename(stage, finalDir)` — irreversibly replacing the currently-installed plugin's files — and only afterwards calls `installFromDisk(ctx, …)`, whose very first action is a DB upsert on the caller's *request* context. Any failure there (including plain `context.Canceled`) returns an error with no rollback: the previous version's files are already gone, the DB row and the live in-memory `Instance` still describe the old version, and `installZipReactivate` never runs.",
|
||
"repro": "1. Plugin `foo` v1 is installed and enabled (directory `plugins/foo`, DB row version=1, enabled=1, live Instance in r.byName).\n2. Admin POSTs a `foo` v2 zip to /api/v1/plugins.\n3. installZipPromote removes `plugins/foo` (v1's files) and renames the staged v2 tree into place.\n4. The admin's browser aborts the request in that window (tab close, navigation, reverse-proxy timeout) — or the writer hits SQLITE_BUSY/disk error.\n5. `Store.InstallPlugin(ctx, …)` fails with context.Canceled; InstallFromZip returns an error and the API replies \"install failed\".\n6. End state: v1's files are permanently gone; the DB still says version=1, enabled=1; `r.byName[\"foo\"]` still points at the old Instance and was never deactivated (installFromDisk returned before reaching that code). The admin panel shows v1 running and the install as failed, but the next server restart's LoadAll scans the directory and silently loads v2 — an install the operator was told did not happen, with no way back to v1.",
|
||
"evidence": "registry.go:283-296\n\tfinalDir := filepath.Join(r.cfg.Directory, manifest.Name)\n\tif err := installZipPromote(stageAbs, finalDir); err != nil { // RemoveAll(finalDir) then Rename\n\t\tcleanup()\n\t\treturn \"\", err\n\t}\n\t// Stage 4: register via the existing on-disk install path.\n\tif err := r.installFromDisk(ctx, foundPlugin{...}); err != nil {\n\t\treturn manifest.Name, fmt.Errorf(\"installFromDisk: %w\", err) // <-- no rollback of the promote\n\t}\n\nregistry.go:422-433 (installZipPromote)\n\tif _, err := os.Stat(finalDir); err == nil {\n\t\tif err := os.RemoveAll(finalDir); err != nil { return ... }\n\t}\n\tif err := os.Rename(stageAbs, finalDir); err != nil { return ... } // old version already deleted\n\nregistry.go:196 (installFromDisk)\n\tid, err := r.cfg.Store.InstallPlugin(ctx, ...) // ctx == r.Context()\n\nServer/api/plugins_handler.go:98\n\tname, err := h.registry.InstallFromZip(r.Context(), body)\n\nServer/db/plugin_queries.go:19\n\terr := d.writer.QueryRowContext(ctx, `INSERT INTO plugins ... RETURNING id`, ...)",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "error-paths",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Make the promote reversible in the one helper both stages share. Change installZipPromote to rename an existing finalDir aside instead of deleting it — e.g. backup := finalDir + \".old-<pid/rand>\"; os.Rename(finalDir, backup) (fall back to RemoveAll only if the rename fails, e.g. cross-device) — and return the backup path plus a restore closure. In InstallFromZip, on the installFromDisk error at registry.go:294-296, run the restore (os.RemoveAll(finalDir); os.Rename(backup, finalDir)) before returning the error, and on the success path (after installZipReactivate) os.RemoveAll(backup). Give the backup a \".install-\"-style prefix so LoadAll's existing stale-staging sweep (registry.go:161-170) reaps it after a crash and scanPluginDirectory does not treat it as a plugin.",
|
||
"fix": {
|
||
"commit": "2f996a5",
|
||
"test": "Server/plugin/registry_zip_rollback_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0266",
|
||
"title": "channel_focus's post-Subscribe revalidation treats a transient permission-lookup failure as a denial, silently killing the channel's live message stream",
|
||
"file": "Server/ws/handlers.go",
|
||
"line": 276,
|
||
"severity": "medium",
|
||
"why": "applySetChannelID documents (handlers.go:263-267) that \"A transient lookup error is NOT a denial ... unwinding on error would turn any DB hiccup into a silently dead message stream with no error frame sent to the client\", and implements that only for the GetChannel leg (handlers.go:268-271). The non-DM leg calls hasChannelAccess, which collapses every DB failure to false — PermissionService.HasChannelPerm returns false whenever getOrPopulate's GetRoleForUser/GetChannelOverridesFor read fails (service/permission.go:69-73, 163-180), and hasChannelAccess itself denies on its own GetChannel error (deps.go:220-226). So a read hiccup on those queries unwinds the subscription and clears c.channelID with no error frame. The inconsistency is stark: the very same GetChannel(newChID) query is issued twice milliseconds apart — at handlers.go:268 where an error is explicitly not a denial, and again at deps.go:220 where an error is a denial.",
|
||
"repro": "Member M holds READ_MESSAGES on text channel #42 and is connected.\n1. An admin edits a role or a channel override anywhere on the server -> PermissionService.InvalidateAll() empties the per-user cache.\n2. M sends channel_focus{channel_id: 42}. ChannelService.HandleChannelFocus passes (its own HasChannelPerm repopulates the cache successfully) and returns SetChannelID=42.\n3. handleMessageApply -> applySetChannelID(c, 42): Subscribe(ChannelTopic(42)) succeeds; h.db.GetChannel(ctx, 42) at handlers.go:268 succeeds.\n4. The cache entry is evicted again (a second override edit, or the 30s permCacheTTL elapses) so hasChannelAccess must repopulate, and GetChannelOverridesFor (or GetRoleForUser, or hasChannelAccess's own second GetChannel) returns SQLITE_BUSY / \"database is locked\" / an I/O error.\n5. getOrPopulate returns nil -> HasChannelPerm false -> hasChannelAccess false -> the else-if at handlers.go:276 is not taken.\n6. applySetChannelID unsubscribes ChannelTopic(42) and sets c.channelID = 0. No error frame is emitted (handleMessageApply has no failure channel for this), and the client's mountChannel early-returns on the same channel so it never re-sends channel_focus.\nResult: every chat_message / chat_edited / reaction_update / typing broadcast into #42 is delivered to nobody on this socket. The user stares at a frozen channel until they navigate to another channel and back, or the socket reconnects. Note the existing test TestApplySetChannelID_TransientLookupError_KeepsFocus (Server/ws/handler_focus_revoke_race_test.go:147) closes the whole DB, so the FIRST GetChannel fails and the function returns at handlers.go:270 — the permission leg is never exercised, and the test passes while the documented behaviour is violated.",
|
||
"evidence": "handlers.go:268-278\n\tch, chErr := h.db.GetChannel(c.ctx, newChID)\n\tif chErr != nil {\n\t\treturn // transient error => keep focus (documented contract)\n\t}\n\tif ch != nil && ch.Type == \"dm\" { ... }\n\t} else if ch != nil && !ch.Archived && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {\n\t\treturn\n\t}\n\th.pubsub.Unsubscribe(c, ChannelTopic(newChID))\n\tc.mu.Lock()\n\tif c.channelID == newChID { c.channelID = 0 }\n\tc.mu.Unlock()\n\ndeps.go:217-226 (hasChannelAccess, permSvc branch)\n\tif !permSvc.HasChannelPerm(ctx, userID, channelID, perm) { return false }\n\tch, err := database.GetChannel(ctx, channelID)\n\tif err != nil { ...; return false }\n\nservice/permission.go:69-73 + 163-180 (any store read failure -> nil -> false)\n\tcp := s.getOrPopulate(ctx, userID)\n\tif cp == nil { return false }\n\t...\n\trole, err := s.st.GetRoleForUser(ctx, userID)\n\tif err != nil || role == nil { return nil }\n\traw, oErr := s.st.GetChannelOverridesFor(ctx, role.ID, userID)\n\tif oErr != nil { ...; return nil }",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-ws",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Do not fix this inside hasChannelAccess — it is the shared authorization gate for requireChannelAccess and friends and must keep failing closed there. Add an error-distinguishing sibling in Server/ws/deps.go, e.g. `func channelAccessChecked(ctx, database, perms, permSvc, userID, channelID, perm) (bool, error)` holding the existing body but returning the GetChannel error instead of swallowing it (and having PermissionService expose an error-returning HasChannelPerm variant, or having the ws caller pre-resolve the role), with hasChannelAccess kept as a thin `ok, err := channelAccessChecked(...); return err == nil && ok` wrapper so every other caller's fail-closed semantics are byte-for-byte unchanged. Then in applySetChannelID replace the :276 condition with `ok, accErr := channelAccessChecked(...); if accErr != nil { return }` before the `ok` test, so a transient failure keeps the just-admitted focus exactly as the :261-267 comment specifies for the GetChannel leg. Since `ch` is already in hand from :268, the new call can also skip its redundant second GetChannel, removing the double read that makes this fire in the first place. Extend TestApplySetChannelID_TransientLookupError_KeepsFocus with a case where only the permission lookup fails (a store stub erroring on GetChannelOverridesFor, or a hub whose second GetChannel fails) so the leg is no longer covered vacuously.",
|
||
"fix": {
|
||
"commit": "0b85483",
|
||
"test": "Server/ws/handler_focus_revoke_race_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0267",
|
||
"title": "rollbackVoiceJoin's compensating voice_leave is not addressed to the leaver, so a CONNECT_VOICE-only joiner is never told the join was undone",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 675,
|
||
"severity": "low",
|
||
"why": "rollbackVoiceJoin clears the client's voice state at line 652 and only then broadcasts voice_leave with the plain broadcastVoiceEvent. That helper's audience is `channelReadAudience ∪ clients currently in the room` (Server/ws/hub_broadcast.go:95-115) — and the leaver was just removed from the second term by the clear. Voice membership is gated on CONNECT_VOICE alone (voiceJoinPrecheck only checks permissions.ConnectVoice), so a participant without READ_MESSAGES on the channel is in neither term and receives nothing. Every sibling path that clears client voice state before broadcasting uses broadcastVoiceEventWithLeaver for exactly this reason (finishVoiceLeave voice_leave.go:77, webhookLeftFinishLeave livekit_webhook.go:322, CleanupVoiceForChannel's manual leaver union hub_sweep.go:434-439); rollbackVoiceJoin is the one path that was missed. Its doc comment states the rule: \"Every path that tears down a voice participant whose client state is cleared before the voice_leave goes out needs this.\"",
|
||
"repro": "Give a role CONNECT_VOICE but deny READ_MESSAGES on voice channel V via a channel override (a supported combination the codebase repeatedly accommodates). Have that user voice_join V. voiceJoinComplete broadcasts their voice_state and hands them a LiveKit token, then GetChannelVoiceStates fails (SQLITE_BUSY / transient I/O — the exact fault Server/ws/oc_0172_voice_join_getchannelvoicestates_error_test.go injects). voice_join.go:494 calls rollbackVoiceJoin(..., broadcast=true): every other client receives voice_leave and drops the user from the roster, but the user themselves is in neither audience term and gets only a generic INTERNAL error frame — their client keeps the voice widget up for a room the server and every peer agree they are not in.",
|
||
"evidence": "// voice_join.go:652\n\th.clearVoiceAndUnsubscribe(c) // leaver's voiceChID -> 0\n...\n// voice_join.go:674-676\n\tif broadcast {\n\t\th.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, c.userID))\n\t}\n\n// hub_broadcast.go:107-114 — the only two audience terms\n\th.mu.RLock()\n\tfor uid, c := range h.clients {\n\t\tif _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID { audience = append(audience, uid) }\n\t}",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ws-hub",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Server/ws/voice_join.go:675 — replace h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, c.userID)) with h.broadcastVoiceEventWithLeaver(ctx, channelID, buildVoiceLeave(channelID, c.userID), c.userID). One-line change in the shared rollback helper; the two broadcast=false call sites are unaffected.",
|
||
"fix": {
|
||
"commit": "f49e0e2",
|
||
"test": "Server/ws/oc_0267_voice_join_rollback_leaver_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0268",
|
||
"title": "Every settings-tab rebuild registers its listeners on the overlay-lifetime AbortSignal, so each open/close cycle permanently retains a full discarded tab subtree",
|
||
"file": "Client/src/components/SettingsOverlay.ts",
|
||
"line": 131,
|
||
"severity": "low",
|
||
"why": "`ac` is a single AbortController created once per SettingsOverlay instance (line 89), and the overlay is created once at MainPage mount and destroyed only at MainPage teardown. `renderActiveTab()` throws away the previous pane with `clearChildren(contentArea)` and calls a builder that registers a fresh set of element listeners with `{ signal: ac.signal }` — but nothing aborts the previous build's registrations. Per the DOM spec an `addEventListener` with a `signal` installs an abort algorithm on that signal holding the (target, listener) pair, so every pane ever built stays reachable from `ac` until `destroy()`. This is the exact mechanism VoiceAudioTab.ts already calls out in its own comment (\"registering here too would add one more permanent listener (and retain this build's DOM subtree via closure) every time the tab is rebuilt, since `signal` is shared for the whole overlay lifetime, not per-build\"), yet all nine entries of TAB_BUILDERS still take `ac.signal` and are re-invoked on every render. `cleanupActiveTab()` only covers the two stateful tabs (Voice & Audio, Logs) and does not touch listener registration at all.",
|
||
"repro": "Log in (MainPage mounts and creates the SettingsOverlay once, line 442/527 of pages/MainPage.ts). Open Settings — `show()` sees `contentLive === false` and calls `renderActiveTab()`, which builds the Account pane; `buildAccountTab(options, ac.signal)` registers ~18 element listeners (AccountTab.ts lines 118,126,182,273,359,462,474,575,619,702,714,725,888,994,1007,1019,1130,1139) plus every `createToggle(..., { signal })`. Close Settings — `hide()` sets `contentLive = false` but never aborts anything, and `clearChildren(contentArea)` on the next render detaches the pane. Repeat open/close 50 times, or click through the nine tabs (each `setActiveTab` also calls `renderActiveTab`): after N renders, N-1 fully detached tab subtrees (avatar elements, TOTP forms, password inputs, theme swatches) are still retained by `ac`'s abort-algorithm set, together with their closures over `options`. Take a heap snapshot after the loop and the detached `.settings-pane` nodes and their listeners are all still reachable from the single AbortController; they are released only when the user logs out and MainPage.destroy() runs `ac.abort()` (line 406).",
|
||
"evidence": "components/SettingsOverlay.ts:89 const ac = new AbortController();\ncomponents/SettingsOverlay.ts:110-119 const TAB_BUILDERS = { Account: () => buildAccountTab(options, ac.signal), Appearance: () => buildAppearanceTab(ac.signal), ... };\ncomponents/SettingsOverlay.ts:124-133 function renderActiveTab(): void { ... clearChildren(contentArea); ... contentArea.appendChild(builder()); contentLive = true; }\ncomponents/SettingsOverlay.ts:164 if (!contentLive) renderActiveTab(); // in show()\ncomponents/SettingsOverlay.ts:177 contentLive = false; // in hide() — no abort of the discarded build\ncomponents/SettingsOverlay.ts:139-144 function cleanupActiveTab() { if (activeTab === \"Voice & Audio\") voiceTab.cleanup(); if (activeTab === \"Logs\") logsTab.cleanup(); } // no listener release",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "lifecycle",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Smallest correct fix for the provable part: hoist the `devicechange` registration out of the per-build path. In `createVoiceAudioTab` (VoiceAudioTab.ts:21-80), keep `let repopulate: (() => void) | null = null`, have `buildVoiceAudioTabInner` hand its `populateDevices` back through a registrar the way it already does for the mic/camera, and register `navigator.mediaDevices.addEventListener(\"devicechange\", () => repopulate?.(), { signal })` exactly once in the factory — mirroring the single `signal.addEventListener(\"abort\", cleanupMic)` at line 77 that the comment at 490-494 already points to. Then delete lines 393-401 from `buildVoiceAudioTabInner`.\n\nThe general fix, if the per-render DOM retention is also to be closed in one place rather than nine: give `renderActiveTab()` a per-render `AbortController`, abort the previous one immediately before `clearChildren(contentArea)` (SettingsOverlay.ts:126), and pass `AbortSignal.any([ac.signal, renderAC.signal])` to the builders instead of `ac.signal` (110-120) — `voiceTab.build()`/`logsTab.build()` would need to take that signal as a parameter. `destroy()`'s `ac.abort()` still cascades through `AbortSignal.any`.",
|
||
"fix": {
|
||
"commit": "29a2d1c",
|
||
"test": "Client/tests/unit/settings-overlay.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0269",
|
||
"title": "A transient GetRoleByID failure at handshake silently pins the whole session to role \"member\", and the resume path never re-validates it",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 139,
|
||
"severity": "low",
|
||
"why": "upgradeAndAuth swallows a role-lookup error and substitutes the literal \"member\" as the connection's cached role name. That value is authoritative for the wire: it is what auth_ok reports as the user's own role, what member_join broadcasts to every other client, and what every chat_message carries. The sibling lookup one function later (handleFreshConnect, serve.go:779) treats the identical failure as fatal and disconnects precisely to avoid serving a wrong-role payload — but the resume path has no such gate, and refreshUserSnapshot (serve.go:354) only refreshes roleName when the RoleID actually changed, so a stale \"member\" survives untouched for the life of the socket.",
|
||
"repro": "An admin's socket drops and reconnects while SQLite is momentarily busy (SQLITE_BUSY / an I/O hiccup / a maintenance window). The GetRoleByID at serve.go:139 returns an error, so c.roleName = \"member\". The resume takes the replay tier: reconnectPrecheck -> refreshUserSnapshot sees the same RoleID and leaves roleName alone, and reconnectWriteReplay calls h.buildAuthOK(ctx, c.user, c.roleName, \"buffer\"), which ships {\"role\":\"member\"} (serve_ready.go:34). The client's AUTH_OK handler runs setAuth(...payload.user...), so authStore.user.role becomes \"member\"; Client/src/lib/permissions.ts:106/136/146 read exactly that field, so canManageChannels/canModerateVoice/moderationGates all return false and every channel-management, voice-moderation and member-moderation affordance disappears for the rest of the connection (ChannelSidebar.ts:919, MemberList.ts:284, SidebarMemberSection.ts:152). The same fallback also mis-labels the user to everyone else via buildMemberJoin(c.user, c.roleName) on the fresh-connect path when the first lookup fails transiently and the second succeeds. Nothing self-heals until the user reconnects and the lookup happens to succeed.",
|
||
"evidence": "roleName := \"member\"\nif role, roleErr := database.GetRoleByID(r.Context(), user.RoleID); roleErr == nil && role != nil {\n\troleName = strings.ToLower(role.Name)\n}\nc.roleName = roleName\n\n// serve.go:779, the sibling on the same handshake, fails closed instead:\n// if roleErr != nil || userRole == nil { ... _ = conn.Close(...); return err }\n\n// serve.go:354, the resume path's only refresh, is conditional:\n// if user.RoleID != c.user.RoleID { ...re-read roleName... }",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Fail closed in the shared function rather than in each caller: in upgradeAndAuth (Server/ws/serve.go:138-142), treat `roleErr != nil || role == nil` the same way handleFreshConnect:780 does — log, `_ = conn.Close(websocket.StatusInternalError, \"role lookup failed\")`, and return the error — so no connection (fresh or resumed) is ever served with a fabricated role name. That one guard covers both paths; the conditional refresh in refreshUserSnapshot then only ever sees a truthful starting value.",
|
||
"fix": {
|
||
"commit": "c291acd",
|
||
"test": "Server/ws/oc_0252_0269_0272_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0270",
|
||
"title": "registerNow's voice-state transfer makes an in-flight voice_join on the replaced connection abort at its supersession guard, leaving a voice membership no sweep can heal",
|
||
"file": "Server/ws/hub.go",
|
||
"line": 499,
|
||
"severity": "low",
|
||
"why": "registerNow clears the replaced client's voice state and moves it onto the resuming client. voice_join's own supersession guard (voice_join.go:417) reads that same field on the OLD client to decide whether the join was evicted, and cannot distinguish a genuine eviction from this transfer. So the old connection's join aborts before delivering the token and before voiceJoinComplete, while the voice_states row it already committed (voice_join.go:263-300) stays, and the new connection now owns exactly that (channelID, joinToken) pair. Because hub state and the DB row agree, sweepStaleVoiceStates's staleness test (`!ok || c.getVoiceChID() != vs.ChannelID`, hub_sweep.go:244) never fires.",
|
||
"repro": "User U's connection O sends voice_join(7). O's readPump reaches voiceJoinPersist, which commits the voice_states row and calls O.setVoiceState(7, joinedAt) (voice_join.go:300), then stalls on the DB round trips in voiceJoinRestoreModFlags/voiceJoinPublishPerms. U's network dies without the server observing the close (half-open TCP), so O stays in h.clients; U's client reconnects and its handshake reaches reconnectRegister -> registerNow with lastSeq > 0. registerNow runs old.clearVoiceState() (O.voiceChID -> 0) and transfers (7, joinedAt) onto the new client N, closes O's send channels, and calls updateKeyHolder(7) — which now counts N as a channel-7 participant and may elect U key holder. O's handler resumes at voice_join.go:417, sees (0, \"\") != (7, joinedAt), logs \"join superseded before token delivery\" and returns false, so voiceJoinComplete never runs: no voice_token is sent, no voice_state is broadcast, no VoiceTopic subscription, and RemoveParticipant is called for a participant that never reached the SFU. End state: the voice_states row exists, N.voiceChID == 7 with the matching join token, and the two agree — so sweepStaleVoiceStates skips it forever. U permanently occupies a voice_max_users slot, appears in channel 7's voice roster in every subsequently built `ready` payload, and every voice_join retry is refused with ALREADY_JOINED (voice_join.go:191), while U's own client never received a token and is not in the call. Only an explicit voice_leave or a full disconnect clears it.",
|
||
"evidence": "// hub.go:498-511 — the transfer\noldE2EEKey, oldE2EESig := old.getE2EEPubKey()\noldVoiceChID, oldVoiceJoinToken := old.clearVoiceState()\nif c.lastSeq > 0 {\n\tif c.getVoiceChID() == 0 {\n\t\tc.setVoiceState(oldVoiceChID, oldVoiceJoinToken)\n\n// voice_join.go:417 — the guard that now misreads the transfer as an eviction\nif curChID, curToken := c.getVoiceState(); curChID != channelID || curToken != state.JoinedAt {\n\tslog.Info(\"ws handleVoiceJoin: join superseded before token delivery\", ...)\n\t...\n\treturn false\n}\n\n// hub_sweep.go:244 — why nothing reaps the result\nif !ok || c.getVoiceChID() != vs.ChannelID { stale = append(stale, ...) }",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-reconnect",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Gate the transfer on the old connection's join actually having completed, in the one place that performs it. Add a completion flag set in voiceJoinComplete (after the guard at voice_join.go:464, alongside the VoiceTopic subscribe) and cleared by clearVoiceState/clearVoiceStateIfMatch, then in hub.go:501-512 transfer oldVoiceChID/oldVoiceJoinToken only when that flag is set. An incomplete join then leaves the new client with voiceChID 0, so the committed row fails hub_sweep.go:244's test and is reaped on the next tick (broadcasting voice_leave and re-electing the key holder), and the user can rejoin immediately.",
|
||
"fix": {
|
||
"commit": "c28379f",
|
||
"test": "Server/ws/oc_0270_voice_join_resume_incomplete_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0271",
|
||
"title": "@here raises a permanent phantom mention badge on the reconnect-replay tier — the wire collapses @here into `mentions_everyone`, so the client cannot apply the server's offline narrowing",
|
||
"file": "Client/src/lib/mentions.ts",
|
||
"line": 124,
|
||
"severity": "low",
|
||
"why": "The server deliberately withholds a mention badge for an @here from every reader who has no live connection (`applyMentionCounts`, mentions.go:187 — `set.HereOnly && (BroadcastStatus == offline || !s.online(uid))` → skip `IncrementMentionCounts`). The wire format carries only one bit, `mentions_everyone`, which is set for @everyone AND @here alike (mentions.go:110 sets `set.Everyone = true` for either token; messages.go:83 ships it). `highlightsCurrentUser` returns true on that single bit, so on a seq-replay reconnect the client raises `incrementMention` for exactly the messages the server refused to badge — and the replay tier never sends `ready`, so nothing ever restates the server's authoritative `mention_count` to correct it.",
|
||
"repro": "B is a member of #general and is focused on #random (so B holds only channel:random's topic). B's socket drops (proxy blip). While B is disconnected, A posts \"@here standup\" in #general with MENTION_EVERYONE; the server's applyMentionCounts skips B because `s.online(B)` is false, leaving `read_states(B,#general).mention_count = 0`. B reconnects inside the ring-buffer window, so handleReconnect serves the \"buffer\" tier and replays the chat_message (channel filter passes — #general is in B's allowed set) with `mentions_everyone: true`. dispatcher.ts:648 fires `incrementMention(#general)` because `payload.channel_id !== activeId`. B now shows a red @mention badge on #general that the server never recorded; because the replay tier sends no `ready`, the badge survives every subsequent replay reconnect and only clears if B opens #general.",
|
||
"evidence": "mentions.ts:124-127 `export function highlightsCurrentUser(content, info) { if (info?.mentionsEveryone === true) return true; ... }` — no way to distinguish @here from @everyone. dispatcher.ts:634-649 `const isMention = highlightsCurrentUser(payload.content, {mentions: payload.mentions, mentionsEveryone: payload.mentions_everyone}); ... if (isMention) incrementMention(payload.channel_id, isDetached);`. Server/service/mentions.go:187 `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID))) { continue }`. Server/ws/messages.go:83 `MentionsEveryone bool \\`json:\"mentions_everyone\"\\`` is the only mention-scope field on the wire.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-message",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating protocol/schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.",
|
||
"fix": {
|
||
"commit": "6f6d0ae",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0272",
|
||
"title": "A ban committing during the WS handshake produces a live, fully authorized socket for the banned user, and its member_join re-adds them to every client's roster permanently",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 770,
|
||
"severity": "low",
|
||
"why": "IsEffectivelyBanned is evaluated exactly once per connection, in authenticateConn, long before registerNow makes the client reachable. handleFreshConnect/reconnectPrecheck deliberately re-read the user row via refreshUserSnapshot to catch mid-handshake changes, but that function only compares RoleID and never re-checks banned/ban_expires from the row it just fetched. BroadcastMemberBan's DisconnectUser is a no-op against a client not yet in h.clients, so a ban landing inside the handshake window is missed twice — and the joiner's own member_join broadcast then lands after the member_ban the other clients already applied.",
|
||
"repro": "User U opens a WebSocket; authenticateConn passes (U not yet banned). While U's handshake is still running its DB work (GetVoiceState, refreshUserSnapshot, GetRoleByID, buildReady — tens of ms), an admin PATCHes /admin/api/users/{U} with banned=true. patchUserApplyBan commits the ban and calls hub.BroadcastMemberBan(U): every connected client receives member_ban and the dispatcher's removeMember(U) deletes U from its member store (dispatcher.ts:814); DisconnectUser(U) finds no entry in h.clients and returns. handleFreshConnect then calls registerNow(U) and BroadcastToAll(buildMemberJoin(U)) — every client's addMember puts the banned user straight back into its roster. U now has a fully authorized socket (can send messages, join voice) until sweepRevokedSessions' next 30 s tick, and after that kick only an offline presence is broadcast, so U stays visible in every already-connected client's member list until each of them reconnects (buildReady's ListMembers excludes banned users). The same window on the resume path (reconnectPrecheck, serve.go:325) yields the ≤30 s authorized session without the roster desync.",
|
||
"evidence": "Server/ws/serve_auth.go:91 (the only handshake ban check, before registration):\n\tif auth.IsEffectivelyBanned(user) { ... return ... }\n\nServer/ws/serve.go:346-362 (refreshUserSnapshot — re-reads the row, checks only the role):\n\tuser, err := database.GetUserByID(ctx, c.userID)\n\t...\n\tif user.RoleID != c.user.RoleID { ... c.roleName = roleName }\n\tc.user = user\n\nServer/ws/serve.go:770 / 822 / 870 (fresh-connect path: re-read, register, then announce):\n\tif err := h.refreshUserSnapshot(ctx, database, c); err != nil { ... }\n\t...\n\th.registerNow(c, allowedChannelIDs)\n\t...\n\th.BroadcastToAll(buildMemberJoin(c.user, c.roleName))\n\nServer/ws/hub_broadcast.go:621-627 (DisconnectUser silently does nothing for an unregistered client):\n\tc, ok := h.clients[userID]\n\th.mu.RUnlock()\n\tif !ok { return }\n\ngrep of Server/ws/*.go (non-test) shows IsEffectivelyBanned only at serve_auth.go:91, handlers.go:139 and hub_sweep.go:154 — nothing between authenticateConn and registerNow.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-session",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "One guard in the shared function both handshake paths already call. In Server/ws/serve.go refreshUserSnapshot, after the nil check at line 353, add: if auth.IsEffectivelyBanned(user) { return fmt.Errorf(\"refreshUserSnapshot: user %d is banned\", c.userID) }. Both callers are already fail-closed on its error (handleFreshConnect closes the conn at serve.go:771-774; reconnectPrecheck falls through to the full-ready path, which then hits the same guard at serve.go:770), so no caller changes are needed. To also close the narrower register-then-ban window, extend the existing post-registerNow re-read at serve.go:832-836 to kick when the freshly read row is banned, the same way it already handles a mid-window role change.",
|
||
"fix": {
|
||
"commit": "c291acd",
|
||
"test": "Server/ws/oc_0252_0269_0272_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0273",
|
||
"title": "EventPersister.Enqueue has no post-Stop guard, and shutdown stops it before the hub, so events broadcast during an early-return shutdown vanish without even incrementing the dropped counter",
|
||
"file": "Server/ws/event_persister.go",
|
||
"line": 114,
|
||
"severity": "low",
|
||
"why": "`Enqueue` sends straight into `p.queue`, whose 4096-slot buffer keeps accepting sends long after `run()` has exited and stopped reading. Those events are neither persisted nor counted — `p.dropped` is only bumped on the `default` (buffer-full) branch. `db.AuditWriter`, which audit_writer.go:5 says is modeled on this type, was given exactly this guard (`select { case <-w.done: dropped.Add(1); log; return; ... }` at audit_writer.go:117-128) plus a post-Stop queue sweep (audit_writer.go:177-190), because main.go's defer order lets both writers be stopped while the hub is still broadcasting. The persister received neither, so its own doc contract (\"when the queue is full, events are dropped and a counter is incremented\") is false in this window: the counter stays flat and the OTel `WSEventsDropped` gauge under-reports.",
|
||
"repro": "`event_persistence.enabled: true`, clients connected and chatting. `runServeAndWait` returns a non-nil error (e.g. the HTTPS listener dies mid-life), so `run()` takes the early return at main.go:217-219 instead of reaching the explicit `hub.GracefulStopContext` at main.go:686.\n\n1. Defers unwind LIFO: `runStopAuditWriter` (registered 186), then `runStopEventPersistence` (176), then `hub.GracefulStop` (172).\n2. `persister.Stop(stopCtx)` at main.go:474 drains the queue and `run()` returns, closing `p.done`. The channel is now empty with 4096 free slots and no reader.\n3. `hub.GracefulStop` then runs: it broadcasts `server_restart` and holds the notice window open before closing client sockets. Every frame fanned out in that window calls `wrapWithSeq` (allocating a seq under seqMu) and `EventPersister.Enqueue`.\n4. Each such send takes the first `select` case and returns successfully. Nothing reads it; `p.persisted`, `p.dropped`, `WSEventsDropped` and `WSEventsPersistErrors` are all unchanged.\n\nThose seqs are allocated but their rows never reach `events`, so on the next boot a reconnecting client's cold-tier replay hits an interior gap for events the server's own metrics report as neither persisted nor dropped. `TestAuditWriter_EnqueueAfterStopDropsLoudly` (Server/db/audit_writer_test.go:238-252) locks this exact behavior for the audit writer; `Server/ws/event_persister_test.go` has no counterpart.",
|
||
"evidence": "Server/ws/event_persister.go:114-121 (no done-check; silent success into a dead channel)\n\tselect {\n\tcase p.queue <- pendingEvent{seq: seq, eventType: eventType, channelID: channelID, payload: payload}:\n\tdefault:\n\t\tp.dropped.Add(1)\n\nServer/db/audit_writer.go:117-128 (the guard the sibling has)\n\tselect {\n\tcase <-w.done:\n\t\tw.dropped.Add(1)\n\t\tslog.Error(\"audit log dropped: writer stopped\", ...)\n\t\treturn\n\tdefault:\n\t}\n\nServer/main.go:172-176 (defer registration order; LIFO runs 176 before 172)\n\tdefer hub.GracefulStop()\n\tpersister, prunerDone := runStartEventPersistence(bgCtx, log, cfg, hub, database)\n\tdefer runStopEventPersistence(log, bgCancel, persister, prunerDone)",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Mirror AuditWriter.Enqueue: at the top of EventPersister.Enqueue (Server/ws/event_persister.go:114), before the queue send, add `select { case <-p.done: p.dropped.Add(1); slog.Error(\"event dropped: persister stopped\", \"seq\", seq, \"event_type\", eventType, \"channel_id\", channelID); return; default: }`. One guard in the shared function covers every hub call site; no caller or shutdown-order change needed.",
|
||
"fix": {
|
||
"commit": "ed18432",
|
||
"test": "Server/ws/event_persister_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0274",
|
||
"title": "First-run setup resolves the client IP from raw RemoteAddr, ignoring trusted_proxies — unlike every sibling session-creating path",
|
||
"file": "Server/admin/setup_handler.go",
|
||
"line": 157,
|
||
"severity": "low",
|
||
"why": "setupPrecheck derives `host` straight from `net.SplitHostPort(r.RemoteAddr)` and then uses it both as the rate-limit bucket key (`\"setup:\"+host`) and as the session IP passed to `database.CreateSession` (setup_handler.go:246). Every other session-creating path resolves the client IP through `clientIPWithProxies(r, proxyNets)` (auth_handler.go:185 register, :338 login, and the TOTP-verify path via the challenge IP), and the very same request already passed through `AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)` (router.go:172), which *does* honour the proxy. So within one request the perimeter uses the real client IP while the handler three frames deeper uses the proxy's. This is the un-fixed sibling of the already-fixed auth_handler.go:241 finding (\"Registration records the reverse-proxy's address as the session IP while login records the real client IP\").",
|
||
"repro": "Deploy exactly as docs/deployment.md:281-300 prescribes (nginx in front, `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`, `server.trusted_proxies: [\"127.0.0.1/32\"]`). Complete first-run setup from a browser at 203.0.113.9. (1) The Owner's first session row is written with ip = \"127.0.0.1\" (nginx), while the same operator's next login writes ip = \"203.0.113.9\" — the account-settings \"Active sessions\" list shows the wrong origin for the one session an operator most wants to identify. (2) The 5-per-minute setup limiter keys on \"setup:127.0.0.1\" for every caller, so any other host inside admin_allowed_cidrs that fires 5 POST /admin/api/setup requests in a minute makes the legitimate operator's first-run setup answer 429, instead of only rate-limiting the abuser's own address.",
|
||
"evidence": "setup_handler.go:157-160\n\thost, _, err := net.SplitHostPort(r.RemoteAddr)\n\tif err != nil {\n\t\thost = r.RemoteAddr\n\t}\n\tsetupKey := \"setup:\" + host\n\nsetup_handler.go:246\n\tif _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, host); err != nil {\n\nadmin/api.go:31 states the intent this breaks: \"Every distinct source IP that ever hits POST /setup leaves an [entry]\".\n\nContrast api/middleware.go:274 clientIPWithProxies, used by register/login/search/livekit-proxy/AdminIPRestrict.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "high",
|
||
"suggestedFix": "Thread the trusted-proxy list into the setup handler and resolve the IP the same way AdminIPRestrict does. admin cannot import api (api imports admin — cycle), so hoist clientIPWithProxies/parseCIDRList/ipInNets out of Server/api/middleware.go into a small shared package and have both call it; then pass cfg.Server.TrustedProxies through admin.NewHandler -> NewAdminAPI -> handleSetup, parse the CIDRs once at construction (never per request, per W3-3a), and replace setup_handler.go:157-161 with `host := clientIPWithProxies(r, proxyNets)`. One resolution site fixes both the limiter key and the session IP, since setupPrecheck already returns the single value both use.",
|
||
"fix": {
|
||
"commit": "d1c1dc6",
|
||
"test": "Server/admin/setup_clientip_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0275",
|
||
"title": "read_states.mention_count is never decremented when a mentioning message is deleted or purged, leaving a red mention badge on a channel with zero unread",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 461,
|
||
"severity": "low",
|
||
"why": "mention_count is a stored counter with exactly one incrementer (db.IncrementMentionCounts, mention_queries.go:202) and exactly one clearer (UpdateReadState, driven by channel_focus/mark_read via service/channel.go:288). DeleteMessage and PurgeMessages soft-delete the row and never touch read_states, while the unread count beside it is computed live with `AND m.deleted = 0` (queries/sqlite/messages.sql:51-54, dm.sql:53-55). The two badges therefore disagree the moment a mentioning message is removed: unread drops to 0, mentions stays. The client does not compensate either — dispatcher.ts:707/713 route CHAT_DELETED and CHAT_BULK_DELETED to deleteMessage/bulkDeleteMessages, neither of which adjusts mentionCount. This is the same 'mention badge on a channel with zero unread' defect class the repo already fixed for the applyMentionCounts/mark_read race (message_crud.go:218). EditMessage documents its non-adjustment as deliberate (message_crud.go:340-345, about *raising* a badge twice); nothing states an intent for the delete/purge direction.",
|
||
"repro": "User A posts \"@bob ping\" in #general; Bob is not focused on #general, so applyMentionCounts sets read_states.mention_count = 1 for Bob. A (or a moderator, or POST /channels/{id}/messages/purge) deletes that message — it is the only unread message in the channel. Bob's next `ready`/GetChannelUnreadCounts returns unread = 0 (deleted rows excluded) and mentions = 1, so #general renders a red mention badge that points at nothing. Opening #general clears it, but until then the badge and the empty channel contradict each other; a purge of N mentioning messages leaves a badge of N.",
|
||
"evidence": "Server/service/message_crud.go:461\n\tif err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: cannot delete this message\", ErrForbidden)\n\t}\n(no read_states / mention_count write anywhere in DeleteMessage, and none in message_purge.go's PurgeMessages either)\n\nOnly writers of the column, repo-wide:\n Server/db/mention_queries.go:227-231 INSERT ... ON CONFLICT DO UPDATE SET mention_count = mention_count + 1\n Server/db/queries/sqlite/messages.sql UpdateReadState (zeroes it)\n\nqueries/sqlite/messages.sql:51-56 — unread excludes deleted rows, mentions is read verbatim from read_states.",
|
||
"status": "fixed",
|
||
"found": "2026-08-21",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-api",
|
||
"finder": "opus",
|
||
"confidence": "medium",
|
||
"suggestedFix": "Decrement on the delete/purge paths, symmetrically with the increment's own guard. Add one db helper — `UPDATE read_states SET mention_count = mention_count - 1 WHERE channel_id = ? AND mention_count > 0 AND last_message_id < ? AND user_id IN (SELECT user_id FROM message_mentions WHERE message_id = ?)` — and call it from s.st.DeleteMessage's service wrapper (message_crud.go:461, detached via context.WithoutCancel like the sibling fan-outs) and once per purged batch in message_purge.go. Note the scope limit: @everyone/@here recipients are not stored per user (only the everyone flag), and the send-time set was filtered by presence (OC-0223), so only stored direct mentions can be reversed exactly — the `mention_count > 0` and `last_message_id < msgID` guards keep the partial correction monotonic and underflow-free.",
|
||
"fix": {
|
||
"commit": "ce252b0",
|
||
"test": "Server/service/mentions_test.go",
|
||
"revertProof": "self-reported"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0276",
|
||
"title": "voice_e2ee_announce is unsequenced and un-replayable, and the only other relay of a peer's ECDH key runs at voice_join — a WS reconnect permanently loses peer keys",
|
||
"file": "Server/ws/voice_e2ee.go",
|
||
"line": 271,
|
||
"severity": "high",
|
||
"why": "`sendToVoiceChannelExcept` publishes `voice_e2ee_announce` straight onto the VoiceTopic pub/sub (`h.pubsub.Publish`), bypassing `h.broadcast`/`deliverBroadcast` — so the frame never gets a `seq`, never enters `h.replayBuf`, and is never persisted. Neither reconnect tier can therefore redeliver it: the replay tiers only resend sequenced frames, and `buildReady`'s payload carries `voice_states` but no `identity`/ECDH key material at all (Server/ws/serve_ready.go:355-368). The server's ONLY other relay of a stored ECDH key is inside `voiceJoinComplete` (Server/ws/voice_join.go:527), which runs on a fresh `voice_join` and never on a resume — a fact the file itself asserts (\"this read is the ONLY place the server ever relays an existing participant's stored ECDH public key\", voice_join.go:502-506). Meanwhile `voice_state` IS sequenced and replayed, so after a WS blip the client's voice roster and its `_peerPublicKeys` map permanently disagree. The client has no compensation either: dispatcher.ts's OC-0201 resync reconciliation (dispatcher.ts:374-384) only fires `handleParticipantLeft` for peers that DEPARTED during the outage, and only on the full-`ready` tier — nothing handles peers that ARRIVED or re-announced, and the replay tiers never run it at all.",
|
||
"repro": "Users H (uid 1, key holder), A (uid 2) and later J (uid 9) in voice channel C.\n1. A's WebSocket dies (network blip). The server has not noticed yet, so A is still in `h.clients` and still subscribed to `VoiceTopic(C)`.\n2. J sends voice_join for C. `voiceJoinComplete` relays H's and A's stored keys to J, then J's `voice_e2ee_announce` is published to VoiceTopic → queued into dead-socket A's `c.send`.\n3. A reconnects. `registerNow` (hub.go:559) calls `old.closeSend()`, discarding the queued announce, and re-subscribes the new connection. A resumes on the buffer/DB replay tier: J's `voice_state` IS replayed (it went through deliverBroadcast and holds a seq), J's `voice_e2ee_announce` is not — it has no seq and is not in `replayBuf`.\n4. A's `voiceStore.voiceUsers` now lists J; A's `E2EEManager._peerPublicKeys` does not.\n5. H leaves. A is the lowest remaining uid, so `handleParticipantLeft` promotes A, `rotateRoomKey()` installs a fresh key, and `distributeRoomKey(keypair, roomKey, peersSnapshot)` iterates `_peerPublicKeys` — J is absent and gets no offer. J never re-announces (mid-call peers only re-announce on their own LiveKit reconnect), so every subsequent 5-minute rotation excludes J too.\n6. J can neither decrypt nor be decrypted for the rest of the call, while every client's VoiceWidget still shows \"🔒 Secured\".\n\nSame root cause, second variant: replace step 2 with \"H's LiveKit connection blips and `reannounceForReconnect()` publishes H's NEW ephemeral key\". A misses it and keeps H's OLD public key; from then on every offer H wraps (ECDH(H_new_priv, A_pub)) fails GCM authentication when A unwraps it with ECDH(A_priv, H_old_pub), so A is stuck on the pre-rotation room key permanently — `handleOfferInner` just logs \"failed to handle offer\" on each rotation.",
|
||
"evidence": "Server/ws/voice_e2ee.go:270-272\n func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) {\n \th.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID)\n }\n(compare hub_broadcast.go:984-1009 `deliverBroadcast`, which is the only path that allocates `seq = h.nextSeq()` and calls `h.replayBuf.Push(seq, ...)` / `h.persistEvent(...)`)\n\nServer/ws/voice_join.go:527-529 — the sole re-relay, reachable only from voiceJoinComplete:\n if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != \"\" {\n \tc.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))\n }\n\nServer/ws/hub.go:559 — registerNow discards everything queued for the replaced socket: `old.closeSend()`",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "voice-e2ee",
|
||
"suggestedFix": "Factor voice_join.go:520-530's per-participant relay loop (voice_state + buildVoiceE2EEAnnounce for each other participant with a stored key) into one helper, e.g. func (h *Hub) sendVoicePeerKeys(ctx, c *Client, channelID int64), and call it from the resume path in serve.go once registerNow has restored the client's voice channel — so BOTH reconnect tiers re-sync every current peer's ECDH public key. One shared call on the resume path fixes arrivals, re-announces missed during the outage, and the LiveKit-reconnect variant at once; making announces sequenced/replayable is a much larger change and not required.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "201e2bc",
|
||
"test": "Server/ws/oc_0276_voice_e2ee_resync_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0277",
|
||
"title": "RNNoise processor's restart() destroys the pipeline and then rebuilds it from `opts.audioContext`, which livekit-client never sends on restart — any mic restart while Enhanced Noise Suppression is on permanently publishes silence",
|
||
"file": "Client/src/lib/noise-suppression.ts",
|
||
"line": 264,
|
||
"severity": "high",
|
||
"why": "`init()` reads the AudioContext straight off `opts` (`const ctx = opts.audioContext`) and never retains it, and `restart()` forwards livekit-client's restart opts into it. livekit-client populates `audioContext` only in `LocalTrack.setProcessor()`; its one and only `processor.restart(...)` call site — `LocalTrack.setMediaStreamTrack()` — passes `{track, kind, element, localTrack}` with no `audioContext` (the `AudioProcessorOptions` d.ts declares it required, so TypeScript never catches the omission). Because `restart()` tears the live pipeline down *before* re-initialising, the failure is not a no-op: the old worklet is disconnected (its dest track goes silent but is never stopped or replaced), `pipeline` is left null, and `init()` rejects — so `restartTrack()` unwinds before it ever reaches `sender.replaceTrack(...)`, leaving the RTCRtpSender wired to the now-dead audio graph.",
|
||
"repro": "1. Join a voice channel. 2. Settings -> Voice & Audio -> turn on \"Enhanced Noise Suppression\" (`applyNoiseSuppressor()` -> `LocalAudioTrack.setProcessor(rnnoiseProcessor)`, which supplies `audioContext`, so this succeeds and the sender ends up on the audioPipeline dest fed by the worklet's output). 3. In the same tab, toggle any of \"Echo Cancellation\" / \"Noise Suppression\" / \"Automatic Gain Control\" (Client/src/lib/audioPipeline.ts:471 `await (micPub.track as LocalAudioTrack).restartTrack(captureOptions)`). Equivalent triggers: picking a different microphone (deviceManager.ts:176 `room.switchActiveDevice(\"audioinput\", id)` -> `setDeviceId` -> `restartTrack`), unplugging the active mic (livekit `handleTrackEnded` -> `restartTrack({deviceId:'default'})`), or a post-sleep full reconnect (`republishAllTracks` -> `restartTrack`).\n\nWhat happens: `restartTrack` -> `restart()` stops the old raw mic track (esm.mjs:20597), acquires a new one, then `setMediaStreamTrack` calls our `restart(opts)` with `opts.audioContext === undefined`. We `pipeline.destroy()` (worklet disconnected -> its dest MediaStreamTrack now emits pure silence, and it is never stopped), set `pipeline = null`, then `init(opts)`: `createWorkletPipeline(track, undefined)` throws `TypeError: Cannot read properties of undefined (reading 'audioWorklet')`, is swallowed by the fallback catch, and `createScriptProcessorPipeline(track, undefined)` then throws the same TypeError at `audioContext.createMediaStreamSource(...)` — uncaught, so `init` rejects.\n\nResulting state: `processor.processedTrack === undefined`; livekit's `_mediaStreamTrack` still points at the *stopped* old track; the freshly acquired track is live but referenced by nothing (mic-in-use indicator stays lit); `sender.replaceTrack` never ran, so the peer connection keeps sending the audioPipeline dest track whose upstream worklet was just disconnected. The user sees only a \"Failed to update audio settings\" / \"Failed to switch microphone\" toast, still shows as unmuted to every peer, and transmits silence. Retrying the toggle fails identically; toggling Enhanced NS off does not recover it either (`internalStopProcessor` re-wires the stopped `_mediaStreamTrack`). Only leaving and rejoining the voice channel restores audio. No test in tests/unit locks this behaviour (tests/unit/rnnoise-worklet.test.ts never touches `restart` or `audioContext`).",
|
||
"evidence": "noise-suppression.ts:242-265\n async init(opts: AudioProcessorOptions): Promise<void> {\n ...\n const ctx = opts.audioContext; // <- never cached\n if (supportsAudioWorklet()) {\n try { pipeline = await createWorkletPipeline(opts.track, ctx); return; }\n catch (err) { log.warn(\"AudioWorklet failed, falling back to ScriptProcessorNode\", err); }\n }\n pipeline = await createScriptProcessorPipeline(opts.track, ctx);\n },\n async restart(opts: AudioProcessorOptions): Promise<void> {\n if (pipeline !== null) { pipeline.destroy(); pipeline = null; } // destroy first\n await this.init(opts); // then rebuild from opts.audioContext\n },\n\nnode_modules/livekit-client/dist/livekit-client.esm.mjs:20414 (the ONLY `processor.restart` call site in the bundle)\n yield this.processor.restart({\n track: newTrack,\n kind: this.kind,\n element: this.processorElement,\n localTrack: this\n }); // no audioContext field\n\ncontrast with setProcessor(), esm.mjs:20750-20755, which does pass it:\n const processorOptions = { kind, track, element: processorElement, audioContext: _this3.audioContext, localTrack: _this3 };\n\nesm.mjs:20597 (LocalTrack.restart) stops the old raw mic track *before* acquiring the new one, and esm.mjs:20425 (`sender.replaceTrack`) / 20429 (`this._mediaStreamTrack = newTrack`) sit *after* the `processor.restart` await, so a rejection strands both.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Cache the AudioContext in the processor closure so restart() can reuse it. In createRNNoiseProcessor add `let ctx: AudioContext | null = null;` alongside `pipeline`, and in init() replace `const ctx = opts.audioContext;` with `const audioCtx = opts.audioContext ?? ctx; if (audioCtx == null) throw new Error(\"RNNoise processor: no AudioContext available\"); ctx = audioCtx;` then pass `audioCtx` to both createWorkletPipeline/createScriptProcessorPipeline. One guard in the shared init() covers restart() too. (Optionally also build the new pipeline before destroying the old one so a future failure is not destructive, but the ctx cache alone fixes the reported defect.)",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "15d65e1",
|
||
"test": "Client/tests/unit/noise-suppression-restart.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0278",
|
||
"title": "voice_mod_move stashes the server-mute before the eviction that justifies it, so a refused move leaves a phantom mute that fires on an unrelated later join",
|
||
"file": "Server/ws/voice_moderation.go",
|
||
"line": 443,
|
||
"severity": "medium",
|
||
"why": "stashPendingModFlags writes pendingModServerMuted/Deafened onto the target's live *Client *before* disconnectFromVoiceIn runs. When that call reports false the handler returns an error and the move never happens, but the stash is left behind. It has no expiry, no binding to the move's destination channel and no binding to the voice_states row it was copied from, so the *next* voice_join that connection makes from a not-in-voice state re-imposes a server mute that no moderator ordered — including one that was explicitly lifted in the meantime.",
|
||
"repro": "1. U is in voice channel A, server-muted by a moderator (voice_states.server_muted=1).\n2. A moderator issues voice_mod_move(U -> C). voiceModTarget loads U's row (state.ServerMuted=true) and stashPendingModFlags sets U's client pendingModServerMuted=true (voice_moderation.go:443).\n3. Concurrently, on U's own read-pump goroutine, U switches to voice channel B. DisconnectFromVoiceInChannel(U, A) now finds U.voiceChID==B, returns false, and handleVoiceModMoveV2 returns VOICE_ERROR \"user is not connected\" — the move is correctly refused. The stash is NOT cleared.\n4. A moderator un-mutes U in B (voice_mod_mute muted=false -> voice_states.server_muted=0).\n5. U leaves voice entirely (row deleted, mute state gone, per the existing 'a leave drops server_muted' semantics) and later joins any voice channel on the same WS connection.\n6. voiceJoinLeaveCurrent sees currentChID==0, takePendingModFlags() returns (true,false), and voiceJoinRestoreModFlags calls SetVoiceServerMute(..., true).\nResult: U is server-muted in a channel no moderator ever muted them in, the earlier un-mute is silently reverted, and the voice_state broadcast tells every client U is moderator-muted. Expected: a refused move leaves no residue — the stash should be written only after the eviction that erased the row actually succeeded (and cleared if the move errors out).",
|
||
"evidence": "voice_moderation.go:443-450\n\tstashPendingModFlags(d.Mod, c.TargetID(), state.ServerMuted, state.ServerDeafened)\n\tif !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) {\n\t\t// No live connection on this node ... or the target left the checked\n\t\t// channel while this handler was deciding, in which case the move must\n\t\t// not follow them.\n\t\treturn Result{Error: ClientError{Code: ErrCodeVoiceError, Message: \"user is not connected\"}}\n\t}\n\nvoice_moderation.go:551-557 — the false return is reachable whenever the target switched channels concurrently:\n\tfunc (h *Hub) DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool {\n\t\tc := h.GetClient(userID); if c == nil { return false }\n\t\treturn h.handleVoiceLeaveIfStillIn(ctx, c, channelID) // false when c.voiceChID != channelID\n\t}\n\nThe stash is unconditional and unscoped (voice_moderation.go:181-188, client.go:234-239); the only consumer is voice_join.go:218, which fires on ANY join taken with currentChID==0, in ANY channel, at any later point in the connection's life.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ws-hub",
|
||
"suggestedFix": "Clear the stash on the refusal branch in handleVoiceModMoveV2 - one guard in the shared handler, not in the consumer: change voice_moderation.go:444-450 to call stashPendingModFlags(d.Mod, c.TargetID(), false, false) (or a dedicated clearPendingModFlags helper, since stashPendingModFlags early-returns when both flags are false - so a small clearPendingModFlags that calls setter.SetPendingVoiceModFlags(target, false, false) unconditionally is needed) immediately before `return Result{Error: ... \"user is not connected\"}`. Moving the stash to after the successful disconnect is not equivalent: the target could send an unrelated voice_join in the gap between the eviction and the stash, leaving the same residue.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "66681d1",
|
||
"test": "Server/ws/voice_moderation_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0279",
|
||
"title": "Deleting a message strands its uploaded attachment files on disk forever — the only reaper requires message_id IS NULL",
|
||
"file": "Server/db/message_queries.go",
|
||
"line": 203,
|
||
"severity": "medium",
|
||
"why": "A message delete is a soft delete: the `messages` row survives, so `attachments.message_id` stays set and the row never becomes an orphan. The only code path that ever removes a stored blob from disk is the maintenance sweep's `DeleteOrphanedAttachments`, whose SQL is `DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? ...`. Nothing else deletes attachment files (grep for `store.Delete`/`fileStorage.Delete` finds only the orphan sweep, the emoji delete, and the upload-failure rollback). The bytes are simultaneously made unreachable (`serveFileResolve` 404s an attachment whose message is deleted) and permanently retained.",
|
||
"repro": "1. As any user, POST /api/v1/files with a 25 MB image → file written to `data/uploads/<uuid>`, `attachments` row with `message_id NULL`.\n2. Send a message with that attachment id → `LinkAttachmentsToMessage` sets `message_id = <msgID>`.\n3. Delete the message (DELETE /channels/{id}/messages/{msgID}, or bulk purge, or the author's account deletion). `SoftDeleteMessage` runs `UPDATE messages SET deleted = 1 WHERE id = ?`; the attachments row is untouched and still has `message_id = <msgID>`.\n4. Wait any number of 15-minute maintenance ticks: `DeleteOrphanedAttachments` never matches the row (`message_id IS NULL` is false), so `data/uploads/<uuid>` is never deleted.\n5. Meanwhile GET /api/v1/files/<uuid> returns 404 (serveFileResolve's deleted-message check), so the 25 MB is unreachable AND unreclaimable. Repeat N times → N×25 MB of permanently dead disk, driven entirely by an ordinary user.",
|
||
"evidence": "Server/db/message_queries.go:203 `if err := d.q.SoftDeleteMessage(ctx, id); err != nil {` (SQL: `UPDATE messages SET deleted = 1 WHERE id = ?` — attachments untouched)\nServer/db/message_queries.go:263-266 purge: `UPDATE messages SET deleted = 1 WHERE id IN (...)` — same, no unlink\nServer/db/account.go:74-76 `UPDATE messages SET deleted = 1, content = '' WHERE user_id = ?` — same, no unlink\nServer/db/queries/sqlite/attachments.sql:22-28 `DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? ... RETURNING stored_as;` (the ONLY reaper)\nServer/main.go:610-620 the only caller of `fileStorage.Delete` for attachments\nServer/api/upload_handler.go:381-384 comment already states the fact: \"no sweep can ever reclaim a linked row either, since the only reaper requires message_id IS NULL\"\nContrast — the project treats this exact invariant as load-bearing elsewhere: Server/db/dm_queries.go:367-373 and Server/db/account.go:293-304 both explicitly `UPDATE attachments SET message_id = NULL` before a channel delete \"so the sweep already reclaims them\", and migration 030 was written solely to preserve it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "db-storage",
|
||
"suggestedFix": "Widen the single shared reaper instead of editing every delete path: in Server/db/queries/sqlite/attachments.sql, change DeleteOrphanedAttachments' predicate to `WHERE uploaded_at < ? AND (message_id IS NULL OR EXISTS (SELECT 1 FROM messages m WHERE m.id = attachments.message_id AND m.deleted = 1)) AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar = '/api/v1/files/' || attachments.id)` and regenerate via the db-change skill. That covers single delete, purge and account deletion at once, keeps the avatar guard, and does not reopen the serve path (unlinking message_id instead would make the file downloadable again by the uploader and by admins via serveFileAuthorize's ChannelID==nil branch, upload_handler.go:429-458).",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "140633a",
|
||
"test": "Server/db/attachment_orphan_softdelete_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0280",
|
||
"title": "Any DM-store change destroys and recreates the whole DM sidebar, wiping the \"Find a conversation\" filter and stealing keyboard focus mid-typing",
|
||
"file": "Client/src/pages/main-page/SidebarArea.ts",
|
||
"line": 664,
|
||
"severity": "medium",
|
||
"why": "In `dms` sidebar mode, `refreshDmSidebar()` calls `activeSidebarContent.destroy()` + `clearChildren(contentSlot)` and rebuilds a brand-new `createDmSidebar(...)`, and it is subscribed to `dmStore.subscribeSelector((s) => s.channels, ...)`. `DmSidebar.mount()` creates the `.dm-search` input fresh with an empty value and only filters via a local `input` listener over the closure-captured `items` array, so the search text and the input's focus are state that lives only in the destroyed subtree. dmStore's `channels` array identity changes far more often than \"the DM list changed\": `updateDmParticipant` (dispatcher PRESENCE handler) rebuilds it on any DM partner's status flip, `updateDmLastMessage`/`updateDmLastMessagePreview` on any DM message including the user's own, and `clearDmUnread` unconditionally recreates the matching channel object even when both counts are already 0.",
|
||
"repro": "1) Open the full DM sidebar (\"View all messages\", sidebarMode = \"dms\") with at least two conversations. 2) Click into \"Find a conversation\" and type `ali` — the list filters and the caret is in the input. 3) Have any DM partner change presence (online -> idle) or send/receive any DM message. The dispatcher calls `updateDmParticipant` / `updateDmLastMessage`, dmStore's `channels` reference changes, `refreshDmSidebar()` destroys the subtree and mounts a new one: the filter box is empty, every conversation is shown again, and focus has moved to `document.body`, so the rest of the user's keystrokes go nowhere. On a server where DM partners' presence flips regularly the search box is effectively unusable. (Note `tests/unit/dm-sidebar.test.ts` exercises the filter on a standalone `createDmSidebar` only — nothing locks the refresh behaviour.)",
|
||
"evidence": "SidebarArea.ts:664-678\n function refreshDmSidebar(): void {\n if (activeSidebarContent !== null) { activeSidebarContent.destroy?.(); }\n clearChildren(contentSlot);\n const freshDm = buildDmSidebar();\n freshDm.mount(freshSlot);\n ...\n }\nSidebarArea.ts:684-690\n const unsubDmStore = dmStore.subscribeSelector((s) => s.channels, () => { refreshDmSidebar(); });\n\nDmSidebar.ts:310-314 const searchInput = createElement(\"input\", { class: \"dm-search\", placeholder: \"Find a conversation\" });\nDmSidebar.ts:334-353 searchInput.addEventListener(\"input\", () => { const q = searchInput.value.trim().toLowerCase(); items.forEach(...) }, { signal: ac.signal });\n\ndm.store.ts:236-252 updateDmParticipant -> returns `{ channels }` (new array, patched objects) for any DM partner presence/profile change\ndm.store.ts:189-195 clearDmUnread -> always maps to a new object for the matching channel, so the selector always fires",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "client-state",
|
||
"suggestedFix": "Preserve the search state across the rebuild in the single shared place, `refreshDmSidebar`: before `activeSidebarContent.destroy?.()`, capture `const oldInput = contentSlot.querySelector<HTMLInputElement>(\".dm-search\"); const q = oldInput?.value ?? \"\"; const hadFocus = oldInput !== null && document.activeElement === oldInput; const caret = oldInput?.selectionStart ?? null;` and after `freshDm.mount(freshSlot)` restore it: `const newInput = freshSlot.querySelector<HTMLInputElement>(\".dm-search\"); if (newInput !== null && q !== \"\") { newInput.value = q; newInput.dispatchEvent(new Event(\"input\")); } if (newInput !== null && hadFocus) { newInput.focus(); if (caret !== null) newInput.setSelectionRange(caret, caret); }`. (A cleaner long-term variant is an `initialQuery` option on `createDmSidebar` plus a `dmQuery` variable in `createSidebarArea`, but the above needs no signature change and fixes every refresh path — dmStore, activeChannelId, and the mute-toggle `refreshDmSidebarRef?.()` at SidebarArea.ts:543 — at once.)",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "4da361a",
|
||
"test": "Client/tests/unit/sidebar-area.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0281",
|
||
"title": "Voice E2EE identity-mismatch modal is bound to the sidebar's per-render signal, so an unrelated re-render destroys the TOFU re-pin prompt (or swallows it before it opens)",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 497,
|
||
"severity": "medium",
|
||
"why": "The mismatch badge passes the per-render `signal` (`currentRenderAc.signal`, handed down at line 823) into `openIdentityMismatchModal`, which registers `signal.addEventListener(\"abort\", closeIdentityModal, { once: true })` at line 152 to \"close if the owning sidebar is destroyed\". That signal is not the sidebar's lifetime signal — `renderChannels()` aborts and replaces it on every render (line 795). Any store change that re-renders the sidebar therefore destroys the open security prompt, and a re-render landing during the modal's `await computeKeyFingerprint(...)` trips the `if (signal.aborted) return` guard so the click produces nothing at all.",
|
||
"repro": "In a voice call, a peer's identity key changes so the row shows the red mismatch badge. Click it -> the re-pin modal mounts. Now any of these fire while the user is comparing the fingerprint out-of-band: a chat message arrives in any non-active channel (dispatcher -> incrementUnread -> `channels: new Map(...)` in channels.store.ts:359 -> `subscribeSelector((s) => s.channels)` -> renderChannels()), or any voice peer toggles mute/camera/screenshare (the voiceStore structural-signature subscription), or the WS flips to reconnecting. renderChannels() runs `renderAc?.abort()`, the abort listener fires `closeIdentityModal()`, and the modal disappears mid-verification with no way to tell it happened. In the pre-click window it is worse: a re-render during the `importIdentityPublicKey`/`computeKeyFingerprint` round trip makes the click a silent no-op. No test covers this — channel-sidebar.test.ts only asserts the modal closes on `sidebar.destroy()` (line ~2226), which passes because destroy() also aborts renderAc.",
|
||
"evidence": "ChannelSidebar.ts:795-796 renderAc?.abort(); const currentRenderAc = new AbortController();\nChannelSidebar.ts:823 currentRenderAc.signal, // -> renderCategoryGroup -> renderChannelItem -> renderVoiceChannelItem(signal)\nChannelSidebar.ts:497 void openIdentityMismatchModal(user.userId, user.username || \"Unknown\", signal);\nChannelSidebar.ts:126 if (signal.aborted) return; // after the async fingerprint compute\nChannelSidebar.ts:152 signal.addEventListener(\"abort\", closeIdentityModal, { once: true });",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "lifecycle",
|
||
"suggestedFix": "Give openIdentityMismatchModal the sidebar-lifetime signal instead of the render signal: thread `ac.signal` down beside the per-render one (a single extra parameter through renderCategoryGroup/renderChannelItem/renderVoiceChannelItem) and use it only for the :152 abort bridge; keep `{ signal }` (per-render) on the badge's click listener at :497 so OC-0229's retention fix stays intact.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "0e3435a",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0282",
|
||
"title": "Sidebar right-click popovers (channel context menu, voice-user volume/moderation menu) close themselves on any unrelated sidebar re-render",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 610,
|
||
"severity": "medium",
|
||
"why": "Both popovers receive the per-render signal and register their \"parent destroyed\" bridge on it: context-menu.ts:188 `signal.addEventListener(\"abort\", closeMenu, { signal: menuAc.signal })` and volume-menu.ts:136 `signal.addEventListener(\"abort\", () => { menu.remove(); dismissAc.abort(); }, ...)`. Since `renderChannels()` aborts that controller on every render, an open menu is dismissed by events that have nothing to do with it — a message in another channel, a peer toggling their mic, the connection flipping to reconnecting. The per-item click handlers are also registered with the same `{ signal }`, so they are dead the moment the render is superseded.",
|
||
"repro": "Right-click a text channel to open the Mark as Read / Mute / Edit / Delete / Purge menu. A message lands in any other channel -> incrementUnread -> new `channels` Map -> renderChannels() -> `renderAc?.abort()` -> closeMenu() -> the menu vanishes before it can be clicked; on a busy server it is effectively unusable. Same for the voice-user right-click menu: open it on a participant to reach the volume slider or Server Mute / Server Deafen / Move / Disconnect, then have any peer in that channel toggle mute or camera — the voiceStore structural-signature subscription re-renders and the menu is torn out from under the pointer (mid-drag on the volume slider included).",
|
||
"evidence": "ChannelSidebar.ts:610 attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);\nChannelSidebar.ts:517 showUserVolumeMenu(user.userId, user.username || \"Unknown\", e.clientX, e.clientY, signal, buildVoiceModOptions(...));\ncontext-menu.ts:188 signal.addEventListener(\"abort\", closeMenu, { signal: menuAc.signal });\nvolume-menu.ts:136-142 signal.addEventListener(\"abort\", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal });\nChannelSidebar.ts:795 renderAc?.abort(); // fires both bridges on every render",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "lifecycle",
|
||
"suggestedFix": "Same single root fix: thread the sidebar-lifetime `ac.signal` down alongside the per-render signal and hand it to attachChannelContextMenu and showUserVolumeMenu as the teardown-bridge owner (context-menu.ts:188, volume-menu.ts:136) and as the owner of the context menu's item click listeners; keep the per-render signal on the row-level `contextmenu` listener (context-menu.ts:53) so the OC-0229 retention fix and its test still hold.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "0e3435a",
|
||
"test": "Client/tests/unit/channel-sidebar.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0283",
|
||
"title": "OC-0239's `stillInRoster` parameter is true for every genuine departure, so the departed-peer key retirement (OC-0020) never runs in production",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 1298,
|
||
"severity": "medium",
|
||
"why": "`handleParticipantLeft` only retires a departing peer's ephemeral key when `!stillInRoster`, but the sole production caller computes `stillInRoster` from the roster snapshot taken *before* `removeVoiceUser()` — and a peer is always present in `voiceUsers` right up until the `voice_leave` frame that removes them. The flag is therefore `true` on a genuine departure just as it is on the stale/superseded-rejoin case it was added to detect, so the guard is unconditionally short-circuited and `_retiredPeerKeys` is never populated on leave. The only other writer of `_retiredPeerKeys` is `handleAnnounceInner`'s in-session key-change branch, which cannot fire after a leave because `handleParticipantLeft` already deleted the peer from `_peerPublicKeys` (so `existingKey` is undefined and the retire branch is skipped). The OC-0020 replay defence is dead.",
|
||
"repro": "Alice (key holder) and Bob are in voice channel 7; Bob has announced ephemeral key A, which Alice holds in `_peerPublicKeys`. Bob leaves: the server broadcasts `voice_leave{channel_id:7,user_id:bob}`. Alice's dispatcher reads `voiceUsers.get(7).has(bob)` → **true** (Bob's `voice_state` put him there on join and nothing has removed him yet), calls `removeVoiceUser`, then `handleParticipantLeft(bob, true)`. Line 1298 evaluates `departingKey && !true && ...` → false, so key A is never added to `_retiredPeerKeys`. Bob rejoins and announces fresh key B; `handleAnnounceInner` finds no `existingKey`, so it also never retires A. A malicious or compromised relay now re-emits Bob's still-validly-signed pre-leave announce for key A (the signed message carries no channel/epoch/nonce, per the F3 comment at livekitE2EE.ts:58-63): `isRetiredPeerKey` returns false, `verifyPeerAnnounce` passes against Bob's unchanged pinned identity key, and `_peerPublicKeys.set(bob, keyA)` overwrites the live key. Alice, as holder, then wraps the *current* room key under key A — decryptable by whoever holds A's private half and undecryptable by Bob, who is blackholed for the rest of the call. The existing test `[OC-0020]` passes only because it calls `mgr.handleParticipantLeft(PEER_ID)` with the default `stillInRoster = false`, which no production caller ever does (tests/unit/livekit-e2ee.test.ts:1281).",
|
||
"evidence": "livekitE2EE.ts:1254 `async handleParticipantLeft(userId: number, stillInRoster = false)`\nlivekitE2EE.ts:1298 `if (departingKey && !stillInRoster && !channelUsers?.has(userId)) {`\nlivekitE2EE.ts:1299 ` this.retirePeerKey(userId, await exportPublicKey(departingKey));`\ndispatcher.ts:1062 `const stillInRoster =`\ndispatcher.ts:1063 ` voiceStore.getState().voiceUsers.get(payload.channel_id)?.has(payload.user_id) ?? false;`\ndispatcher.ts:1064 `removeVoiceUser(payload);`\ndispatcher.ts:1080 `void handleParticipantLeft(payload.user_id, stillInRoster);`\nvoice.store.ts:231 `nextUsers.set(payload.user_id, { userId: payload.user_id, ... })` // updateVoiceState keeps every in-channel peer in the roster until their voice_leave\nlivekitE2EE.ts:836 `if (!isDuplicate) { this._peerPublicKeys.set(userId, peerKey); ... }` // no retire on first-sight re-announce after a leave",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-voice",
|
||
"suggestedFix": "Delete the `!stillInRoster` term at livekitE2EE.ts:1298 (and the now-unused parameter plus the dispatcher.ts:1062-1063 snapshot), restoring unconditional retirement on leave — it is the only replay defence and the roster cannot distinguish the two cases it was added to separate, since the pre-mutation snapshot is identical for a genuine departure and for a stale superseded leave. The OC-0213 stale-leave case needs a discriminator the roster does not carry: have the server stamp the join instance on `voice_leave` (an epoch/join id, matching what `_peerOfferEpochs` already tracks) and drop a leave whose epoch is older than the peer's current announce, rather than suppressing retirement.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "3767be1",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0284",
|
||
"title": "chat_delete is idempotent-but-not-guarded, so a repeated delete decrements mention_count again and wipes an unrelated, genuinely-unread mention badge",
|
||
"file": "Server/service/message_crud.go",
|
||
"line": 477,
|
||
"severity": "medium",
|
||
"why": "MessageService.DeleteMessage has no `msg.Deleted` guard (its siblings EditMessage, handleReaction and SetMessagePinned all have one), and every layer beneath it is silently idempotent: db.DeleteMessage does not check `deleted`, and `SoftDeleteMessage` is a bare `UPDATE messages SET deleted = 1 WHERE id = ?`. So a second chat_delete for the same message id succeeds and runs `DecrementMentionCounts(channelID, []int64{msgID})` a second time. That statement decrements every recipient row in `message_mentions` for that message whose `last_message_id < msgID` and `mention_count > 0` — it has no per-message idempotence, only a monotonic floor at 0 — so the second run eats one mention that a *different, still-live* message raised.",
|
||
"repro": "In text channel #general: Alice sends M1 = \"@bob a\" (id 100) and M2 = \"@bob b\" (id 101). Bob has never focused the channel, so read_states(bob,#general) = (last_message_id 0, mention_count 2). Alice (or any MANAGE_MESSAGES holder) sends `{\"type\":\"chat_delete\",\"payload\":{\"message_id\":100}}` twice — chat_delete's rate limit is 10/s, so both frames are accepted. First call: SoftDeleteMessage sets deleted=1, DecrementMentionCounts drops Bob to 1 (correct). Second call: GetMessage still returns row 100 (tombstones are returned), the Deleted flag is never inspected, the ownership/permission checks pass again, SoftDeleteMessage is a no-op UPDATE that returns nil, and DecrementMentionCounts drops Bob to 0. Bob's red mention badge for M2 disappears even though M2 is live, unread, and mentions him; nothing ever restores it (only IncrementMentionCounts raises it, and only on the original insert). The non-adversarial version is two moderators clicking Delete on the same message before either sees the chat_deleted broadcast, or the author deleting their own message while a moderator's client still shows it live.",
|
||
"evidence": "Server/service/message_crud.go:419-422 — `msg, err := s.st.GetMessage(ctx, msgID); if err != nil || msg == nil { return ... ErrForbidden }` (no `if msg.Deleted { ... }`, unlike EditMessage:309-311 `if msg.Deleted { return ... ErrDeletedMessage }`).\nServer/service/message_crud.go:463-465 — `if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { ... }`\nServer/service/message_crud.go:477 — `if mcErr := s.st.DecrementMentionCounts(context.WithoutCancel(ctx), msg.ChannelID, []int64{msgID}); mcErr != nil {`\nServer/db/message_queries.go:191-207 — db.DeleteMessage checks ownership only, then `d.q.SoftDeleteMessage(ctx, id)` and returns nil.\nServer/db/queries/sqlite/messages.sql:23-24 — `-- name: SoftDeleteMessage :exec` / `UPDATE messages SET deleted = 1 WHERE id = ?;` (no `AND deleted = 0`, and `:exec` so rows-affected is discarded).\nServer/db/queries/sqlite/messages.sql:6-9 — GetMessage has no `deleted = 0` filter, so the tombstone is returned as a normal row.\nServer/db/mention_queries.go:274-281 — `UPDATE read_states SET mention_count = mention_count - 1 WHERE channel_id = ? AND mention_count > 0 AND last_message_id < ? AND user_id IN (SELECT mentioned_user_id FROM message_mentions WHERE message_id = ?)` — nothing records that this message was already reversed.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-message",
|
||
"suggestedFix": "Make the soft delete a compare-and-set and skip the mention reversal when it did not transition, which fixes both the sequential repeat and the concurrent race in one place. In Server/db/queries/sqlite/messages.sql change SoftDeleteMessage to `-- name: SoftDeleteMessage :execresult` / `UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0;`, regenerate via the db-change skill, then in db.DeleteMessage (Server/db/message_queries.go:189-206) check RowsAffected() == 0 and return a sentinel (ErrNotFound or a new ErrAlreadyDeleted). MessageService.DeleteMessage then returns ErrDeletedMessage on that sentinel before reaching the DecrementMentionCounts call at message_crud.go:477. Adding `if msg.Deleted { return nil, fmt.Errorf(\"%w: cannot delete this message\", ErrDeletedMessage) }` after message_crud.go:422 is a cheap complement that saves the extra round trip, but it is not sufficient on its own.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "d08c7e0",
|
||
"test": "Server/service/mentions_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0285",
|
||
"title": "kickClient never stops readPump, so a banned / revoked WS principal keeps executing fully authorized commands after the server decides to cut it off",
|
||
"file": "Server/ws/hub_sweep.go",
|
||
"line": 57,
|
||
"severity": "medium",
|
||
"why": "kickClient only deletes the hub entry, closes the send channels and unsubscribes topics — it never touches the WebSocket and never signals readPump. readPump (serve_pumps.go:203-211) loops on conn.Read and calls hub.handleMessage for every frame with no check of c.isSendClosed() or hub membership (isSendClosed is consulted only by pubsub.Subscribe, pubsub.go:94). The connection only dies once writePump finishes draining and calls conn.Close, and each drained frame is written under a 10 s writeTimeout (serve_pumps.go:15-23), a delay the remote peer controls. Everything the kicked user sends in that window is dispatched with full authority: nothing on the per-message path re-checks ban/session state except handleMessageSessionRecheck itself, which handlers.go:116 has just reset (c.msgCount = 0), so the next nine frames skip revalidation entirely.",
|
||
"repro": "User U is connected and holds SEND_MESSAGES on #general. U's client keeps sending frames but stops reading its socket (or simply lets its TCP receive window fill). An admin bans U via PATCH /admin/api/users/{id} {banned:true} while U is offline from the hub's point of view for that instant, or U is force-logged-out via DELETE /admin/api/users/{id}/sessions. On U's next 10th frame, handleMessageSessionRecheck reads the ban/missing session, queues the BANNED error frame and calls kickClient. writePump wakes on the closed channel, picks up that still-buffered frame and blocks in conn.Write for the full 10 s writeTimeout because the peer window is shut. Throughout those 10 s readPump keeps reading U's pipelined frames and handleMessage executes each one end to end: chat_send persists the message and broadcasts it to every other client in the channel, reaction/mark_read/channel_focus mutate state, voice_join writes a voice_states row and mints a LiveKit token. U sees no replies (sendMsg no-ops on a closed channel), so the writes are silent from U's side but visible to everyone else. Only when the write deadline fires does coder/websocket close the conn and end readPump. In the benign case the same defect still lets every frame already buffered in the kernel through, and handlers.go:116's counter reset means the nine frames after any kick are never revalidated. No test pins the current behavior (Server/ws/handlers_test.go:253 and :1910 assert only that the kick happens).",
|
||
"evidence": "hub_sweep.go:54-69 — \"closes its send channel, which causes writePump to exit and the WebSocket connection to close\":\n func (h *Hub) kickClient(c *Client) {\n h.mu.Lock(); ... delete(h.clients, c.userID); h.mu.Unlock()\n c.closeSend()\n h.pubsub.UnsubscribeAll(c)\n } // no conn.Close, no cancel, no flag readPump reads\n\nserve_pumps.go:203-211 —\n for {\n _, msg, err := conn.Read(ctx)\n if err != nil { lastReadErr = err; return }\n c.touch()\n hub.handleMessage(c, msg) // no isSendClosed()/registration guard\n }\n\nhandlers.go:112-143 —\n c.msgCount++\n shouldCheck := c.msgCount >= SessionCheckInterval\n if shouldCheck { c.msgCount = 0 } // reset BEFORE the kick below\n ...\n if result == nil || auth.IsSessionExpired(result.ExpiresAt) { h.kickClient(c); return true }\n ...\n c.sendMsg(buildErrorMsg(ErrCodeBanned, \"you are banned\"))\n h.kickClient(c); return true\n\nserve_pumps.go:15-23 — every drained frame is written under writeTimeout (serve.go:22 = 10 * time.Second) before writePumpDrainAndClose reaches conn.Close.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-session",
|
||
"suggestedFix": "Add one guard at the top of the shared dispatch function instead of at each kick site — in Server/ws/handlers.go, first statement of handleMessage: `if c.isSendClosed() { return }`, before the handleMessageSessionRecheck call. c.isSendClosed() is already the canonical 'this client has been cut off' flag (set by closeSend under c.mu, the same flag pubsub.Subscribe uses as its re-take guard), it is set synchronously by kickClient before it returns, and handleMessage is the only path a frame reaches a handler through. This drops every post-kick frame regardless of which goroutine kicked, without touching writePump's drain-before-close contract that TestWritePump_DrainsQueuedFramesAfterCloseSend locks. It also makes the msgCount reset harmless. Optionally pair it with a conn close-deadline in kickClient, but that is not required to close the hole.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "51b3144",
|
||
"test": "Server/ws/handlers_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0286",
|
||
"title": "MessageList registers every message row's listeners on the component-lifetime AbortSignal, so each rebuild permanently retains a full window of detached rows",
|
||
"file": "Client/src/components/MessageList.ts",
|
||
"line": 332,
|
||
"severity": "medium",
|
||
"why": "`createMessageList` has exactly one `AbortController` (`ac`, line 244), aborted only in `destroy()`. `renderVirtualItem` hands `ac.signal` to `renderMessage`, and `renderWindow` does `releaseTrackedMedia(); clearChildren(contentContainer);` then re-renders the whole window (lines 563-569). `addEventListener(..., { signal })` keeps a strong abort-algorithm entry (holding the event target) on that signal until it fires, so every rebuild leaves one full set of discarded rows — and everything they reference (videos, images, embeds, reaction tooltips) — reachable for the rest of the channel visit. This is the exact defect the codebase already fixed in ChannelSidebar (`renderAc`, comment at ChannelSidebar.ts:748-757 citing OC-0229), SettingsOverlay (`renderAC` at SettingsOverlay.ts:104-139), reaction-tooltip.ts (`hoveringChips`, comment at lines 232-241) and MessageList's own scrollToMessage flash timer (ledger MessageList.ts:1021). MessageList's row rendering is the one path none of those fixes covered, and it is by far the highest-volume one.",
|
||
"repro": "Open a channel with a few hundred messages. Each `renderMessage` registers ~8-10 listeners on `ac.signal` (react/reply/pin/edit/delete/copy-link buttons, the reply-ref click+keydown, and per reaction chip click+keydown+4 tooltip listeners via reactions.ts:36-47). Scroll up and down so the visible range leaves the ±20 overscan ~50 times, or trigger 50 `renderAll()` rebuilds (any edit, delete, reaction_update, history prepend, or membersStore roleRevision bump). Every rebuild clears `contentContainer` and re-renders ~40-60 rows against the same signal, so after 50 rebuilds ~2000-3000 detached `.message` subtrees (with their <video>/<img>/embed children) are still retained by `ac.signal`'s abort-listener list. Nothing releases them until the channel is switched away and `destroy()` runs. Contrast ChannelSidebar.renderChannels(), which aborts and replaces `renderAc` at the top of every render precisely to avoid this.",
|
||
"evidence": "MessageList.ts:244 const ac = new AbortController();\nMessageList.ts:332 return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal);\nMessageList.ts:563-569\n releaseTrackedMedia();\n clearChildren(contentContainer);\n const fragment = document.createDocumentFragment();\n for (let i = start; i < end; i++) {\n fragment.appendChild(renderVirtualItem(virtualItems[i]!));\n }\n contentContainer.appendChild(fragment);\n(only uses of `signal` in the file are lines 332, 895, 904, 916, 958 — no per-render controller exists)",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Add a render-scoped controller in createMessageList and scope row listeners to it, mirroring SettingsOverlay's pattern rather than patching each renderer: `let rowAc: AbortController | null = null; let rowSignal: AbortSignal = ac.signal;` then, immediately before each `clearChildren(contentContainer)` rebuild in renderWindow (MessageList.ts:502 and :563), do `rowAc?.abort(); rowAc = new AbortController(); rowSignal = AbortSignal.any([ac.signal, rowAc.signal]);`. Change renderVirtualItem (:332) to `renderMessage(item.message, item.isGrouped, allMessages, options, rowSignal)` so the append fast path keeps using the current window's signal (it appends to rows that are still live and must not be aborted until the next rebuild). Also `rowAc?.abort(); rowAc = null;` in destroy() alongside `ac.abort()`. One change in the shared renderVirtualItem covers every row renderer; no signature changes to renderers.ts/reactions.ts are needed.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "3bc52ea",
|
||
"test": "Client/tests/unit/message-list-row-listener-leak.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0287",
|
||
"title": "An unmute that cannot acquire the microphone fails silently — the client reports itself unmuted to the server and every peer while publishing no audio, with no error and no recovery button",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 1640,
|
||
"severity": "medium",
|
||
"why": "applyMicMuteState()'s re-enable branch awaits setMicrophoneEnabled(true) with no try/catch, and every caller fires it forgetfully (`setMuted`/`setDeafened` do `.catch(e => log.warn(...))`). A rejection therefore never reaches setListenOnly(true), never reaches onErrorCallback, and never re-mutes — yet setLocalMuted(false) has already run and VoiceCallbacks.onMuteToggle has already sent `voice_mute {muted:false}` on the wire. Both sibling mic-acquisition paths in the same file handle this correctly: restoreLocalVoiceState (line 925-939) sets listen-only and raises \"Microphone permission denied — joined in listen-only mode\", and retryMicPermission (line 1512-1515) catches and toasts. The re-enable path is the one that swallows.",
|
||
"repro": "1. Revoke the OS microphone permission (or unplug the only capture device). 2. Join a voice channel with the mic already muted — either toggled off before joining, or simply carried over, since leaveVoiceChannel() never resets localMuted. restoreLocalVoiceState computes `muted = pttArmed || localMuted || localDeafened` = true, so it calls setMicrophoneEnabled(false), which resolves without ever touching the device; the catch block never runs and setListenOnly(false) executes at line 924. 3. Click the mic button to unmute. onMuteToggle sends `voice_mute {muted:false}` to the server and calls setMuted(false) → setLocalMuted(false) → applyMicMuteState(false) → setMicrophoneEnabled(true) rejects with NotAllowedError/NotFoundError. 4. Result: the local widget, the server's voice_states row, and every other participant's roster all show the user unmuted and live; no audio track is ever published; no toast, no log above debug/warn, and the \"Grant Microphone\" button stays hidden because listenOnly is still false. The user has no in-app signal at all that they are inaudible. The same swallow is reachable from setDeafened's undeafen branch (line 1614) and from roomEventHandlers.ts:92.",
|
||
"evidence": "applyMicMuteState (livekitSession.ts:1630-1644):\n } else {\n if (isMicPolicyGated()) { ...; return; }\n // Re-enable mic — this re-publishes the track to the SFU\n await room.localParticipant.setMicrophoneEnabled(true); // <-- no catch, no setListenOnly(true)\n this._audioPipeline.setupAudioPipeline();\n\nsetMuted (livekitSession.ts:1597-1598):\n setLocalMuted(muted);\n this.applyMicMuteState(muted).catch((e) => log.warn(\"applyMicMuteState failed\", e));\n\nVoiceCallbacks.ts:76-78 sends the wire frame unconditionally:\n voiceSessionSetMuted(false);\n ws.send({ type: \"voice_mute\", payload: { muted: false } });\n\nVoiceWidget.ts:307-309 — the only recovery affordance is gated on listenOnly, which is still false:\n if (grantMicBtn) { grantMicBtn.style.display = voice.listenOnly ? \"block\" : \"none\"; }\n\nContrast restoreLocalVoiceState (livekitSession.ts:925-931), which does it right:\n } catch (micErr) {\n setListenOnly(true);\n ... this.onErrorCallback?.(\"Microphone permission denied — joined in listen-only mode\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Wrap only the re-enable branch in applyMicMuteState (livekitSession.ts:1640-1642) — one guard in the shared function covers setMuted, setDeafened, ptt.ts and roomEventHandlers: `try { await room.localParticipant.setMicrophoneEnabled(true); this._audioPipeline.setupAudioPipeline(); } catch (err) { setListenOnly(true); setLocalMuted(true); log.warn(...); this.onErrorCallback?.(\"Microphone unavailable — you are muted\"); }`. setLocalMuted(true) stops the widget claiming a live mic and setListenOnly(true) reveals the existing Grant Microphone button; re-sending voice_mute{muted:true} to resync the server is the follow-on, but the shared catch is the minimum that removes the silent state.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "3767be1",
|
||
"test": "Client/tests/unit/livekit-session.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0288",
|
||
"title": "Pre-scoping mute list is copied into every server the user connects to, silently muting unrelated channels on each new host",
|
||
"file": "Client/src/lib/channel-mutes.ts",
|
||
"line": 98,
|
||
"severity": "medium",
|
||
"why": "readMuted()'s legacy read-through fires for ANY host that has no scoped key yet, not just the first one migrated, and it never consumes or deletes the legacy unscoped key. Because channel ids are per-server SQLite autoincrement integers, the second and every subsequent server inherits server A's id list and persists it under its own scoped key — reintroducing exactly the cross-server mute bleed the host scoping was added to fix, permanently and per-host.",
|
||
"repro": "1. On a build predating host scoping, mute channel 5 on server A → localStorage gets `owncord:settings:mutedChannels` = [5]. 2. Upgrade. Connect to server A: MainPage.ts:106 calls setChannelMutesHost(\"a.example.com\"); readMuted takes the legacy branch and writes `owncord:settings:mutedChannels:a.example.com` = [5]. Correct so far. 3. Connect to an unrelated server B for the first time. setChannelMutesHost(\"b.example.com\") invalidates the cache; readMuted finds no `mutedChannels:b.example.com`, finds the legacy key still present, and writes `owncord:settings:mutedChannels:b.example.com` = [5]. 4. Channel 5 on server B — a channel the user has never muted and possibly never seen — is now muted: no desktop notification, no chime, dimmed badge, and the state is persisted so it survives restarts. Repeats for server C, D, … forever, since the legacy key is never cleared.",
|
||
"evidence": "channel-mutes.ts:85-106:\n const scopedKey = mutedKey();\n if (currentHost === null || keyExists(scopedKey)) {\n cache = parseMutedIds(loadPref<unknown[]>(scopedKey, []));\n return cache;\n }\n // Miss at the scoped key: read through to the pre-scoping legacy key once\n if (keyExists(MUTED_KEY)) {\n const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));\n writeMuted(legacy); // <-- writes server A's ids under THIS host's key\n return legacy;\n }\n\nNothing ever removes `owncord:settings:mutedChannels`, so keyExists(MUTED_KEY) stays true for every future host. The guard the doc comment relies on (\"A different host with its OWN explicit (even empty) mute list is not touched\") only excludes hosts that already have a scoped key — a brand-new host has none and falls straight into the branch. tests/unit/channel-mutes.test.ts:127 only covers the host-with-an-explicit-empty-list case, so the fresh-host path is unlocked.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Consume the legacy key on migration — in the branch at channel-mutes.ts:98-102, after `writeMuted(legacy)` add `localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);` (STORAGE_PREFIX is already imported at line 21). One removal in the shared read-through makes the migration fire exactly once; every later host then falls through to `cache = new Set()` at line 104.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "27f42c1",
|
||
"test": "Client/tests/unit/channel-mutes.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0289",
|
||
"title": "The DM \"Start a call\" button has no already-in-this-voice-channel guard, so a redial inside a live call errors with ALREADY_JOINED",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 297,
|
||
"severity": "medium",
|
||
"why": "startCall() checks only the socket state; it always calls onVoiceJoin(active.id) even when voiceStore.currentChannelId already equals that channel. The server's voiceJoinLeaveCurrent refuses a same-channel re-join with ALREADY_JOINED, and the dispatcher's catch-all error branch turns that into a user-facing error toast. The call button itself is never hidden or relabelled while the call is live (updateChatHeaderForDm only toggles display on DM mode), so the affordance invites the click.",
|
||
"repro": "Alice starts a call in a group DM; Bob accepts, Carol does not. Alice (still in the call) clicks the phone icon again to nudge Carol. onVoiceJoin sends voice_join for the channel she is already in -> server replies ALREADY_JOINED -> Alice sees a red \"already in this voice channel\" toast, and (per the sibling finding) Bob's client starts ringing.",
|
||
"evidence": "Client/src/pages/MainPage.ts:297-310\n function startCall(): void {\n const active = getActiveChannel();\n if (active === null || active.type !== \"dm\") return;\n if (uiStore.getState().connectionStatus !== \"connected\") { showToast(\"Not connected\", \"error\"); return; }\n createSidebarVoiceCallbacks(ws).onVoiceJoin(active.id);\n ws.send({ type: \"call_ring\", payload: { channel_id: active.id } });\n\nServer/ws/voice_join.go:186-190\n\tif currentChID == channelID {\n\t\tc.sendMsg(buildErrorMsg(ErrCodeAlreadyJoined, \"already in this voice channel\"))\n\nClient/src/lib/dispatcher.ts:1355 — the catch-all: showToast(payload.message || \"Server error\", \"error\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Add the guard once in the shared function rather than in startCall: at Client/src/pages/main-page/VoiceCallbacks.ts:183, right after the socketLive() check, `if (voiceStore.getState().currentChannelId === channelId) return;` (voiceStore is already imported at line 8). That covers startCall and any future caller, matches what ChannelSidebar.ts:535/564 already do by hand, and leaves the call_ring nudge in startCall intact.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "f4a60a6",
|
||
"test": "Client/tests/unit/voice-callbacks.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0290",
|
||
"title": "A rate-limited voice_leave leaves the user permanently stuck in voice — client tears down unconditionally, server keeps the membership, and rejoin is refused with ALREADY_JOINED",
|
||
"file": "Server/ws/handlers_voice.go",
|
||
"line": 46,
|
||
"severity": "medium",
|
||
"why": "handleVoiceLeaveV2 refuses a throttled voice_leave with a RATE_LIMITED ClientError and LeaveVoice unset, so handleMessageApply never runs handleVoiceLeave and the hub keeps c.voiceChID plus the voice_states row. The client already tore its LiveKit session down and cleared the voice store *before* sending the frame and never reconciles on the error, so the two sources of truth diverge permanently. It is the only rate-limited WS handler whose refusal is not idempotent-safe: voice_token_refresh's refusal carries LeaveVoice:true precisely to force reconciliation, and voice_mute/voice_camera/channel_focus refusals leave no client-side state to undo.",
|
||
"repro": "User is in voice channel X. Send six `{\"type\":\"voice_leave\",\"payload\":{}}` frames on one authenticated socket inside one second (the UI's leave button has no client-side limiter; `VoiceCallbacks.onVoiceLeave` at line 190 has no in-voice re-entry guard either). Frames 1-5 pass; the 6th is refused with RATE_LIMITED. Frame 1 already ran handleVoiceLeave, so in the ordinary single-click case the state is consistent — but in the burst case the *last* frame is the one whose teardown the client performed and the server refused: `voiceSessionLeave(false)` + `leaveVoiceChannel()` have run client-side after a re-join, while `c.voiceChID` and the `voice_states` row still name X. Observable outcome: (a) every other connected client's voice roster keeps showing the user in X, since no voice_leave was broadcast; (b) the user's own client shows no call; (c) clicking X again sends voice_join, which hits voiceJoinLeaveCurrent's `currentChID == channelID` branch and returns ALREADY_JOINED — forever, for the life of the WebSocket. sweepStaleVoiceStates cannot repair it (hub_sweep.go:280 only deletes rows whose client is *not* in the channel; here the in-memory state and the DB row agree), and on a default deployment the LiveKit participant_left webhook is not wired. The only escape is joining a different voice channel first, which routes through the un-throttled internal handleVoiceLeave — impossible on a server with a single voice channel.",
|
||
"evidence": "Server/ws/handlers_voice.go:43-50\n\tfunc handleVoiceLeaveV2(_ context.Context, cmd Command, _ ClientInfo, deps any) Result {\n\t\td := deps.(VoiceDeps)\n\t\tratKey := auth.Key(\"voice_leave\", cmd.UserID())\n\t\tif d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) {\n\t\t\treturn Result{Error: ClientError{Code: ErrCodeRateLimited, Message: \"too many voice leave attempts\"}}\n\t\t}\n\t\treturn Result{LeaveVoice: true}\n\t}\n\nServer/ws/handlers.go:85-100 (the error path only runs the leave when the handler asked for it)\n\tif result.Error != nil {\n\t\t... c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID)) ...\n\t\tif result.LeaveVoice { h.handleVoiceLeave(c.ctx, c) }\n\t\treturn\n\t}\n\nServer/ws/voice_join.go:25-26\n\tvoiceLeaveRateLimit = 5\n\tvoiceLeaveWindow = time.Second\n\nServer/ws/voice_join.go:189-193 (rejoin of the same channel is refused)\n\tif currentChID == channelID {\n\t\tc.sendMsg(buildErrorMsg(ErrCodeAlreadyJoined, \"already in this voice channel\"))\n\t\treturn false, false, false\n\t}\n\nClient/src/pages/main-page/VoiceCallbacks.ts:62-68 and 190-193 (teardown happens first, answer never inspected)\n\tvoiceSessionLeave(false);\n\tleaveVoiceChannel();\n\tws.send({ type: \"voice_leave\", payload: {} });\n\nClient/src/lib/dispatcher.ts:1298-1354 (rollback branch is gated on voiceStatus === \"joining\"; everything else just toasts)\n\tif (voiceStore.getState().voiceStatus === \"joining\") { ... }\n\t...\n\tshowToast(payload.message || \"Server error\", \"error\");",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Make the refusal idempotent-safe by mirroring handleVoiceTokenRefreshV2's refusal — one line in the shared handler, Server/ws/handlers_voice.go:47:\n\n return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: \"too many voice leave attempts\"}, LeaveVoice: true}\n\nhandlers.go:96 already runs h.handleVoiceLeave for an error result carrying LeaveVoice, and handleVoiceLeave is a documented no-op when the client is not in voice (voice_leave.go:28-32), so a burst of spurious leaves stays free. The fan-out this limiter guards is still capped, because every leave that actually broadcasts requires a preceding join and voice_join has its own 5/s limiter (voice_join.go:90-94). Note the alternative — only consuming the limiter when info.VoiceChannelID != 0 — does NOT fully close it: an initial join that predates the 1s window still allows a 6th in-window in-voice leave. Existing test handler_v2_migration_test.go:69-90 stays green either way.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "82c202d",
|
||
"test": "Server/ws/handler_v2_migration_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0291",
|
||
"title": "require_2fa's \"all users enrolled\" gate ignores lapsed temporary bans, permanently locking those users out of their own account",
|
||
"file": "Server/admin/handlers_settings.go",
|
||
"line": 133,
|
||
"severity": "medium",
|
||
"why": "validateRequire2FAUpdate gates enabling require_2fa on CountUsersWithoutTOTP, whose query is `WHERE banned = 0 AND totp_secret IS NULL`. A user serving a temporary ban that has already lapsed still has banned = 1, so they are invisible to the count — yet auth.IsEffectivelyBanned treats them as fully active, so they can log in. Enabling require_2fa is therefore allowed while an active, TOTP-less account exists, and that account's next login is refused forever with no self-service recovery. Every other surface in the repo was already fixed to treat a lapsed ban as active (ListMembers in users.sql:58 and the api-token notBannedClause in apitokens.sql:57 both carry the `OR (ban_expires IS NOT NULL AND replace(ban_expires,' ','T') <= strftime(...))` arm); this query never got it.",
|
||
"repro": "1. User B has no TOTP. Admin PATCHes /admin/api/users/{B} with {\"banned\":true,\"ban_duration_hours\":1} (handlers_users.go:171-179 writes ban_expires = now+1h, banned = 1).\n2. Wait for the hour to pass. Nothing clears banned; the only unban path is an explicit admin action (users.sql:46).\n3. Admin PATCHes /admin/api/settings {\"registration_open\":\"false\",\"require_2fa\":\"true\"}. CountUsersWithoutTOTP filters on banned = 0, so B is not counted; count == 0 and the write commits.\n4. B's session expires (or B logs in from a new device). POST /api/v1/auth/login: IsEffectivelyBanned(B) == false (ban lapsed), B.TOTPSecret == nil, require2FA == true → 403 forever.\n5. B cannot enroll: /api/v1/users/me/totp/enable and /confirm are behind AuthMiddleware (auth_handler.go:128-136) and B can no longer obtain a session. The only fix is disabling require_2fa server-wide.",
|
||
"evidence": "Server/db/queries/sqlite/users.sql:64-65\n -- name: CountUsersWithoutTOTP :one\n SELECT COUNT(*) FROM users WHERE banned = 0 AND totp_secret IS NULL;\n\nServer/admin/handlers_settings.go:133-139\n count, err := database.CountUsersWithoutTOTP(ctx)\n ...\n if count > 0 {\n return fmt.Errorf(\"require_2fa cannot be enabled until all users have 2FA enabled\")\n }\n\nServer/auth/helpers.go:73-90 — IsEffectivelyBanned returns false once ban_expires is in the past, even though users.banned is still 1.\n\nServer/api/auth_handler.go:345-385 — login passes the IsEffectivelyBanned check, then:\n if require2FA { 403 \"two-factor authentication must be enabled on this account before login\" }\n\nContrast: Server/db/queries/sqlite/users.sql:58 (ListMembers) and apitokens.sql:57 both include the lapsed-ban arm this query lacks.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Give CountUsersWithoutTOTP the same lapsed-ban predicate every other user-visibility query uses, in the one shared query rather than at the call site — Server/db/queries/sqlite/users.sql:65 becomes `SELECT COUNT(*) FROM users WHERE (banned = 0 OR (ban_expires IS NOT NULL AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))) AND totp_secret IS NULL;` then regenerate the sqlc layer via the db-change skill (Server/db/dbgen/users.sql.go).",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "140633a",
|
||
"test": "Server/db/count_users_without_totp_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0292",
|
||
"title": "Every settings PATCH is gated on the 2FA-enrollment precondition even when require_2fa is not being changed, wedging the whole Settings page",
|
||
"file": "Server/admin/handlers_settings.go",
|
||
"line": 52,
|
||
"severity": "medium",
|
||
"why": "handlePatchSettings runs validateRequire2FAUpdate on every request, and targetBoolSetting falls back to the *current stored* require_2fa when the key is absent from the payload. So once require_2fa is on and any non-banned user lacks TOTP, an unrelated PATCH (motd, server_name, backup_schedule, backup_retention) is rejected with 400 \"require_2fa cannot be enabled until all users have 2FA enabled\" — a precondition about a value the request never touches, applied to state that is not changing. The transaction never runs, so the intended change is silently lost behind a misleading error.",
|
||
"repro": "1. Admin bans user B, who has no TOTP (B is now excluded from CountUsersWithoutTOTP by the banned = 0 filter).\n2. Admin PATCHes /admin/api/settings {\"registration_open\":\"false\",\"require_2fa\":\"true\"} → 200, require_2fa = '1'.\n3. Admin unbans B (PATCH /admin/api/users/{B} {\"banned\":false}) → banned = 0, totp_secret still NULL, so CountUsersWithoutTOTP now returns 1.\n4. Admin edits only the message of the day: PATCH /admin/api/settings {\"motd\":\"Back online\"} → 400 BAD_REQUEST \"require_2fa cannot be enabled until all users have 2FA enabled\". motd is not written.\n5. Every subsequent settings change (server name, backup schedule, retention) fails the same way. B cannot clear the condition either — with require_2fa on, login refuses B (auth_handler.go:379-385) so B can never reach the enrollment endpoints. The only escape is turning require_2fa back off.",
|
||
"evidence": "Server/admin/handlers_settings.go:52-55 — runs unconditionally, before the write transaction:\n if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {\n writeErr(w, http.StatusBadRequest, \"BAD_REQUEST\", err.Error()); return\n }\n\nServer/admin/handlers_settings.go:117-139 — target comes from the DB when the key is absent, and the CountUsersWithoutTOTP gate then applies to the unchanged value:\n targetRequire2FA, err := targetBoolSetting(ctx, database, updates, \"require_2fa\")\n if !targetRequire2FA { return nil }\n ...\n if count > 0 { return fmt.Errorf(\"require_2fa cannot be enabled until all users have 2FA enabled\") }\n\nServer/admin/handlers_settings.go:143-150 — targetBoolSetting's DB fallback for a key not present in the payload.\n\nNo test locks this: Server/admin/api_test.go:1021-1057 (AcceptsAllWhitelistedKeys) patches single keys against a fresh DB where require_2fa is '0', so validate short-circuits at line 121.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Scope the enrollment gate to requests that actually set require_2fa, leaving the registration_open cross-check on the resulting state. In validateRequire2FAUpdate, guard lines 133-139: `if _, changing := updates[\"require_2fa\"]; !changing { return nil }` placed immediately before the CountUsersWithoutTOTP call.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "140633a",
|
||
"test": "Server/admin/api_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0293",
|
||
"title": "DecrementMentionCounts reverses mentions that were never counted, wiping a blocker's genuine mention badge",
|
||
"file": "Server/db/mention_queries.go",
|
||
"line": 280,
|
||
"severity": "low",
|
||
"why": "The increment side (`applyMentionCounts`) removes the author's blockers and non-readers from the recipient set before calling `IncrementMentionCounts`, but `insertMentionRows` stores every resolved mention id, blockers included. The decrement side then targets the full stored set (`user_id IN (SELECT mentioned_user_id FROM message_mentions WHERE message_id = ?)`) with no block/readership filter, so deleting the message decrements a counter that message never incremented — destroying an unrelated, legitimate mention badge.",
|
||
"repro": "1. Bob blocks Alice (POST /api/v1/blocks with Alice's id).\n2. Carol posts \"@bob standup?\" in #general → message m1; `applyMentionCounts` increments Bob's `read_states.mention_count` for #general to 1. Bob does not open #general.\n3. Alice posts \"@bob hello\" in #general → message m2 (m2 > m1). `applyMentionCounts` builds recipients={Bob}, then `ListBlockersOf(Alice)` returns [Bob] and deletes him → Bob's mention_count stays 1. But `CreateMessageWithMentions` still wrote row (m2, Bob) into message_mentions.\n4. Alice deletes her own message m2. `MessageService.DeleteMessage` calls `DecrementMentionCounts(#general, [m2])`.\n5. The UPDATE matches Bob (channel_id = #general ✓, mention_count 1 > 0 ✓, last_message_id < m2 ✓, user_id in message_mentions(m2) ✓) → Bob's mention_count drops to 0.\n6. Bob's red \"1\" badge for Carol's genuine @mention is gone and never comes back. The same happens via PurgeMessages (message_purge.go:84).",
|
||
"evidence": "Server/db/mention_queries.go:274-281\n `UPDATE read_states SET mention_count = mention_count - 1\n WHERE channel_id = ? AND mention_count > 0 AND last_message_id < ?\n AND user_id IN (SELECT mentioned_user_id FROM message_mentions WHERE message_id = ?)`\nServer/service/mentions.go:213-220 (increment side excludes blockers):\n `blockers, err := s.st.ListBlockersOf(ctx, authorID)` ... `for _, b := range blockers { delete(recipients, b) }`\nServer/service/mentions.go:202-206 (increment side also excludes non-readers): `if _, ok := readerIDs[uid]; ok { recipients[uid] = struct{}{} }`\nServer/db/mention_queries.go:119-120 (storage keeps everyone): \"Self-mentions are stored like any other: the fan-out, not storage, is what excludes the author.\"\nNo test covers DecrementMentionCounts (no `_test.go` in Server/ mentions it).",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "db-storage",
|
||
"suggestedFix": "Mirror the increment's blocker exclusion inside the shared statement (Server/db/mention_queries.go:274-281) rather than in each caller: add `AND NOT EXISTS (SELECT 1 FROM user_blocks b WHERE b.blocker_id = read_states.user_id AND b.blocked_id = (SELECT user_id FROM messages WHERE id = ?))`, binding msgID a third time (user_blocks columns per migrations/012_user_blocks.sql:4-9; idx_user_blocks_blocked covers the lookup). The fully symmetric fix is to persist which ids IncrementMentionCounts actually bumped (e.g. a `counted` column on message_mentions) and decrement only those, which would also close the non-reader case and make the decrement idempotent.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "582dff6",
|
||
"test": "Server/db/mention_queries_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0294",
|
||
"title": "Account deletion soft-deletes every message the user wrote but never reverses their mention counts, leaving permanent phantom badges",
|
||
"file": "Server/db/account.go",
|
||
"line": 75,
|
||
"severity": "low",
|
||
"why": "`DeleteAccount` mass-soft-deletes all of the departing user's messages inside its transaction. The unread count is computed live and excludes `deleted = 1` rows, but `read_states.mention_count` is a stored counter — and this is the one message-removal path that does not call `DecrementMentionCounts`, which OC-0275 added to both `DeleteMessage` and `PurgeMessages` for exactly this reason.",
|
||
"repro": "1. Alice posts \"@bob can you review this?\" in #general → Bob's `read_states.mention_count` for #general becomes 1 (applyMentionCounts). Bob stays offline / never focuses #general.\n2. Alice deletes her account (DELETE /api/v1/users/me). `DeleteAccount` runs `UPDATE messages SET deleted = 1, content = '' WHERE user_id = <alice>` and commits; `read_states` is purged for Alice only (`DELETE FROM read_states WHERE user_id = ?`), Bob's row is untouched and message_mentions(msg, Bob) survives (messages are not hard-deleted, so nothing cascades).\n3. Bob connects. `GetChannelUnreadCounts` reports #general as `unread = 0` (the only message is deleted) but `mentions = 1`.\n4. Bob sees a red mention badge on a channel with zero unread messages and nothing to read, and it persists across every reconnect until he happens to focus that channel.",
|
||
"evidence": "Server/db/account.go:74-79\n `if _, err := tx.ExecContext(ctx,\n \"UPDATE messages SET deleted = 1, content = '' WHERE user_id = ?\", userID,\n ); err != nil { return fmt.Errorf(\"DeleteAccount messages: %w\", err) }`\n — no DecrementMentionCounts anywhere in DeleteAccount / deleteAccountCloseDMChannels.\nContrast, Server/service/message_crud.go:477-479 (single delete):\n `if mcErr := s.st.DecrementMentionCounts(context.WithoutCancel(ctx), msg.ChannelID, []int64{msgID}); ...`\nand Server/service/message_purge.go:84-86 (bulk purge): same call for the whole id set.\nServer/db/message_queries.go:629-634 shows unread is live-computed (`m.deleted = 0`) while `mentions` is read straight out of read_states.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "db-storage",
|
||
"suggestedFix": "Do it inside DeleteAccount's existing transaction — DecrementMentionCounts opens its own writer tx and would contend with it. Immediately before the soft-delete at account.go:72-79 (while the rows still have deleted = 0), run one clamped UPDATE: `UPDATE read_states SET mention_count = MAX(0, mention_count - (SELECT COUNT(*) FROM message_mentions mm JOIN messages m ON m.id = mm.message_id WHERE mm.mentioned_user_id = read_states.user_id AND m.channel_id = read_states.channel_id AND m.user_id = ? AND m.deleted = 0 AND m.id > read_states.last_message_id)) WHERE mention_count > 0`, binding the departing userID — the `m.id > last_message_id` term reproduces the same guard IncrementMentionCounts/DecrementMentionCounts use, and MAX(0, …) keeps it monotonic.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "6b42aeb",
|
||
"test": "Server/db/account_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0295",
|
||
"title": "MemberList re-registers per-row click/contextmenu listeners on the component-lifetime AbortSignal on every rebuild, permanently retaining every discarded row set",
|
||
"file": "Client/src/components/MemberList.ts",
|
||
"line": 469,
|
||
"severity": "low",
|
||
"why": "`renderList()` starts with `clearChildren(root)` and then rebuilds every member row, registering two listeners per row with `{ signal }` where `signal` is `disposable.signal` — the MemberList's own lifetime signal, which only aborts in `destroy()`. Per the DOM spec `addEventListener({signal})` adds a removal algorithm to the signal's abort-algorithm set, and that algorithm holds a strong reference to the event target, so every detached row stays reachable until the component is destroyed. This is exactly the defect `ChannelSidebar` was converted to a per-render `renderAc` to fix (see its OC-0229 comment at ChannelSidebar.ts:747-758); MemberList was never converted and still hands the long-lived signal down through `renderList` -> `appendGroup` -> `createMemberItem`.",
|
||
"repro": "Sign in to a server whose member list is mounted (channels-mode sidebar). Any membersStore change that is NOT presence-only routes to the full-rebuild branch — `isPresenceOnlyChange` (MemberList.ts:410) returns false on a size change or on a differing username/role/avatar/displayName/customStatus/identityPublicKey — so a `member_join`, `member_leave`, `member_ban`, `member_update` (role), `user_update` (rename/avatar), a custom-status change, or a `roles_update` (the second `disposable.onStoreChange` at line 490) each fires `renderList`. On a 200-member server, one user reconnecting produces one join + one leave, i.e. two full rebuilds = 400 detached `.member-item` rows retained by `disposable.signal`, each holding its avatar `<img>` whose `src` is a fetched base64 data: URI (avatar.ts `createAvatarElement`). Nothing releases them until MainPage tears the sidebar down at logout, so a long-lived session accumulates every historical member-list render in memory.",
|
||
"evidence": "MemberList.ts:234 item.addEventListener(\"click\", (e) => { ... }, { signal });\nMemberList.ts:269 item.addEventListener(\"contextmenu\", (e) => { ... }, { signal });\nMemberList.ts:331 function renderList(root, opts, signal, rowsByUserId) { clearChildren(root); rowsByUserId.clear(); ... }\nMemberList.ts:469/479/495 renderList(root, opts, disposable.signal, rowsByUserId); // disposable.signal aborts only in destroy()\n\nvs. the already-fixed sibling:\nChannelSidebar.ts:758 let renderAc: AbortController | null = null; // \"aborted and replaced at the top of every renderChannels() call\"",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "client-state",
|
||
"suggestedFix": "Mirror the OC-0229 fix in the one shared place. In `createMemberList` add `let renderAc: AbortController | null = null;` and wrap the render: at the top of `renderList` (or in a small wrapper called from all three sites at MemberList.ts:469/479/495) do `renderAc?.abort(); renderAc = new AbortController();` and pass `renderAc.signal` instead of `disposable.signal`; add `renderAc?.abort(); renderAc = null;` beside `disposable.destroy()` in `destroy()`. One change covers both per-row listeners for every group.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "7e767fe",
|
||
"test": "Client/tests/unit/member-list.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0296",
|
||
"title": "Channel drag-reorder registers the per-render signal as its global-listener \"owner\", so a mid-drag re-render cancels the drag and makes retargetDetachedDrag unreachable",
|
||
"file": "Client/src/components/channel-sidebar/drag-reorder.ts",
|
||
"line": 42,
|
||
"severity": "low",
|
||
"why": "`attachDragHandlers` is called with the per-render signal (ChannelSidebar.ts:612) and passes it to `ensureGlobalDragListeners(signal)`, which stores it as `activeDrag.owner`. `releaseOwner` runs on that signal's abort and nulls `activeDrag` when it owns the drag. Because `renderChannels()` aborts the previous render's controller as its first statement, a sidebar re-render during a drag destroys the in-flight drag state before `retargetDetachedDrag` — the function added specifically to survive that re-render — ever runs. The same abort also empties `listenerOwners` and aborts `globalDragAc`, tearing down the document mousemove/mouseup handlers. The module comment states the owner is \"the sidebar's lifetime controller\", which is no longer true.",
|
||
"repro": "As a MANAGE_CHANNELS holder, press and drag a channel row past the 5px threshold (`activeDrag` set, row gets `.dragging`). While the button is still down, a message arrives in any non-active channel -> incrementUnread allocates a fresh channels Map -> renderChannels() -> `renderAc?.abort()` -> releaseOwner(prevRenderSignal) -> activeDrag = null and globalDragAc.abort(). Releasing the mouse now does nothing: the document mouseup handler was removed and re-registered by the new render with `activeDrag === null`, so it returns immediately. The channel silently stays where it was. drag-reorder.test.ts's \"mid-drag sidebar re-render\" suite passes only because its `rebuildContainer` helper re-attaches under `rig.abort.signal` (one sidebar-lifetime owner) instead of a fresh per-render controller, so it models the pre-OC-0229 wiring rather than the current one.",
|
||
"evidence": "drag-reorder.ts:38-52 function releaseOwner(owner) { listenerOwners.delete(owner); if (activeDrag !== null && activeDrag.owner === owner) { ... activeDrag = null; } if (listenerOwners.size === 0 && globalDragAc !== null) { globalDragAc.abort(); globalDragAc = null; } }\ndrag-reorder.ts:93 owner.addEventListener(\"abort\", () => releaseOwner(owner), { once: true });\ndrag-reorder.ts:246 owner: signal, // signal === currentRenderAc.signal\nChannelSidebar.ts:612 attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);\nChannelSidebar.ts:795 renderAc?.abort();",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "lifecycle",
|
||
"suggestedFix": "Own the global drag listeners with the sidebar-lifetime signal, not the render signal: pass `ac.signal` as the owner argument to attachDragHandlers/ensureGlobalDragListeners (and store it as DragState.owner) while keeping the per-render `signal` for the three row-scoped mousedown/mousemove/mouseup listeners at drag-reorder.ts:256/268/304. That restores the module comment's stated invariant and re-enables retargetDetachedDrag.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "0e3435a",
|
||
"test": "Client/tests/unit/drag-reorder.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0297",
|
||
"title": "Avatar upload deletes the newly stored file on an error path where users.avatar has already committed to it — the avatar is permanently broken and the row can never be reclaimed",
|
||
"file": "Server/api/profile_handler.go",
|
||
"line": 613,
|
||
"severity": "low",
|
||
"why": "handleUploadAvatar's error branch asserts \"The column never moved\" and unlinks the just-stored blob, but UserService.UpdateProfile can return ErrInternal *after* UpdateUserProfile committed: its post-commit re-read (Server/service/user.go:236-239) returns `%w: failed to fetch updated user` on any DB read error. In that branch users.avatar already points at the file the handler then deletes, so the user's old avatar is overwritten, the new bytes are gone, and no user_update is broadcast.",
|
||
"repro": "1. User A has avatar /api/v1/files/OLD. 2. POST /api/v1/users/me/avatar with a valid PNG. 3. store.Save writes NEW, CreateAttachment inserts the NEW row, UpdateUserProfile commits users.avatar='/api/v1/files/NEW'. 4. The immediately following GetUserByID fails (SQLITE_BUSY / I/O error / pool exhaustion). 5. UpdateProfile returns ErrInternal; the handler runs store.Delete(NEW) and answers 500. Result: users.avatar='/api/v1/files/NEW' with no file on disk (permanent 404 from handleServeFile for every viewer), the OLD avatar bytes are unreferenced and reaped, the NEW attachments row is pinned alive forever by the sweep's `NOT EXISTS users.avatar` clause, and no user_update was broadcast so connected clients keep showing OLD until they reconnect.",
|
||
"evidence": "Server/api/profile_handler.go:609-619\n\t\tupdated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{\n\t\t\tAvatar: &avatarURL,\n\t\t})\n\t\tif err != nil {\n\t\t\t// The column never moved, so the file and its row are orphans.\n\t\t\tif delErr := store.Delete(fileID); delErr != nil { ... }\n\t\t\twriteServiceError(r.Context(), w, err)\n\t\t\treturn\n\t\t}\n\nServer/service/user.go:224-239\n\t\tif err := s.st.UpdateUserProfile(ctx, userID, username, avatar, displayName, about); err != nil { ... } // <-- commits\n\t\tuser, err := s.st.GetUserByID(context.WithoutCancel(ctx), userID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: failed to fetch updated user: %v\", ErrInternal, err) // <-- reached AFTER the commit\n\t\t}\n\nServer/db/dbgen/attachments.sql.go:42-50 (the sweep that would otherwise reclaim the row)\nDELETE FROM attachments\nWHERE message_id IS NULL AND uploaded_at < ?\n AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar = '/api/v1/files/' || attachments.id)",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "error-paths",
|
||
"suggestedFix": "Make the post-commit re-read non-fatal in UserService.UpdateProfile (Server/service/user.go:236-239): the row is already committed, so on re-read failure return a locally merged *db.User (current with username/avatar/displayName/about applied) instead of ErrInternal. One change in the shared service fixes the handler's bogus delete and the missing user_update broadcast at once, and no caller has to learn to distinguish pre- from post-commit errors.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "ec6e357",
|
||
"test": "Server/service/user_postcommit_readerror_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0298",
|
||
"title": "applyConnectStatus swallows the UpdateUserStatus failure but still stamps and broadcasts the new status, leaving users.status permanently disagreeing with the live roster",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 715,
|
||
"severity": "low",
|
||
"why": "The DB write is the only durable record of the connect status; on failure the code still sets c.user.Status and announceConnectPresence fans the new value out. buildReady reads users.status via ListMembers, and presentableMembers only ever downgrades a status to offline for a disconnected user — it never upgrades a connected one — so every client that builds a fresh ready afterwards renders this connected user with their stale disconnect-time status.",
|
||
"repro": "1. User A disconnects; MarkUserDisconnected writes users.status='offline'. 2. A reconnects; applyConnectStatus computes ConnectStatus('offline')='online' but UpdateUserStatus fails transiently (write lock contention). 3. A's own auth_ok says online and the presence_update broadcast says online, so already-connected clients look right. 4. User B now connects: buildReady -> ListMembers reads users.status='offline' for A, presentableMembers leaves it (A is connected, so the downgrade branch does not fire and there is no upgrade branch). B renders A as offline for the rest of A's session, with no event that ever corrects it.",
|
||
"evidence": "Server/ws/serve.go:713-719\nfunc applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {\n\tstatus := db.ConnectStatus(c.user.Status)\n\tif updateErr := database.UpdateUserStatus(ctx, c.userID, status); updateErr != nil {\n\t\tslog.Warn(\"ws UpdateUserStatus\", \"err\", updateErr) // swallowed\n\t}\n\tc.user.Status = status\n}\n\nServer/ws/serve_ready.go:65-76 (only downgrades, never upgrades)\n\t\tif !connected[m.ID] {\n\t\t\tm.Status = db.StatusOffline\n\t\t\tm.CustomStatus = nil\n\t\t}",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "error-paths",
|
||
"suggestedFix": "Only stamp the new value when the write succeeded: move `c.user.Status = status` inside the success path of applyConnectStatus (Server/ws/serve.go:713-719). On failure the client and the broadcast then keep the value that is actually in users.status, so auth_ok, the presence broadcast and every later ready agree instead of diverging.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "e436acd",
|
||
"test": "Server/ws/oc_0298_apply_connect_status_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0299",
|
||
"title": "refreshUserSnapshot silently substitutes role name \"member\" on a role lookup failure — the exact fail-open that upgradeAndAuth 230 lines above was fixed to reject",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 374,
|
||
"severity": "low",
|
||
"why": "c.roleName is authoritative on the wire (auth_ok's `role`, member_join, every chat_message) and drives every client-side permission gate via authStore.user.role. upgradeAndAuth closes the connection when GetRoleByID fails for exactly this reason; refreshUserSnapshot, which is the resume/fresh-connect re-validation added for that same class, instead defaults to \"member\" and pins the session to a fabricated role.",
|
||
"repro": "1. Admin U opens a WebSocket; authenticateConn snapshots RoleID=2 (Admin). 2. Before refreshUserSnapshot runs (handleFreshConnect:789 / reconnectPrecheck:335), an admin PATCH commits U's role_id to 5. 3. refreshUserSnapshot sees user.RoleID(5) != c.user.RoleID(2) and calls GetRoleByID(5), which fails transiently. 4. c.roleName becomes \"member\"; auth_ok ships role=\"member\" and member_join broadcasts it. For the whole session the client's canManageChannels/canViewAuditLog/canModerateVoice gates (Client/src/lib/permissions.ts, read from authStore.user.role) are off and the Audit Log button is hidden, and every other client sees U as a member.",
|
||
"evidence": "Server/ws/serve.go:373-379\n\tif user.RoleID != c.user.RoleID {\n\t\troleName := \"member\"\n\t\tif role, roleErr := database.GetRoleByID(ctx, user.RoleID); roleErr == nil && role != nil {\n\t\t\troleName = strings.ToLower(role.Name)\n\t\t}\n\t\tc.roleName = roleName\n\t}\n\ncompare Server/ws/serve.go:145-151 (the fail-closed sibling)\n\trole, roleErr := database.GetRoleByID(r.Context(), user.RoleID)\n\tif roleErr != nil || role == nil {\n\t\tslog.Error(\"ws: role lookup failed during handshake, closing connection\", ...)\n\t\t_ = conn.Close(websocket.StatusInternalError, \"role lookup failed\")\n\t\treturn nil, 0, fmt.Errorf(...)\n\t}",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "error-paths",
|
||
"suggestedFix": "Return an error instead of defaulting: in Server/ws/serve.go:373-379 do `role, roleErr := database.GetRoleByID(ctx, user.RoleID); if roleErr != nil || role == nil { return fmt.Errorf(\"refreshUserSnapshot GetRoleByID: %w\", roleErr) }` before assigning c.roleName. Both callers are already fail-closed on this function's error (handleFreshConnect closes the conn, reconnectPrecheck falls back to full ready), so the one guard is enough.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "e436acd",
|
||
"test": "Server/ws/oc_0299_refresh_snapshot_role_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0300",
|
||
"title": "Ctrl+I on a double-clicked bold word downgrades it to italic — the outer-unwrap check matches one asterisk of a `**` pair",
|
||
"file": "Client/src/components/MessageInput.ts",
|
||
"line": 110,
|
||
"severity": "low",
|
||
"why": "`wrapWithMarker`'s second branch decides \"already wrapped\" by testing only the `len` characters immediately outside the selection, with no check that those characters are not part of a *longer* run of the same marker rune. For the italic marker `*` against a `**` bold wrapper, `value.slice(start-len,start)` and `value.slice(end,end+len)` each match a single `*` borrowed from the bold pair, so the toggle strips one asterisk from each side instead of adding a pair — silently destroying the bold. The first branch (selection that includes the markers) already guards against exactly this via its `!selected.slice(len, selected.length-len).includes(marker)` interior test, and tests/unit/message-input.test.ts:1414 pins the intended behaviour (\"wraps rather than downgrades bold text when italicizing\") for that selection shape only; the branch on line 110 has no equivalent guard.",
|
||
"repro": "Composer contains `**bold**`. Double-click the word (selects only `bold`, i.e. start=2, end=6) and press Ctrl+I. wrapWithMarker(\"**bold**\", 2, 6, \"*\"): the first branch is skipped (selected=\"bold\" does not start with \"*\"), then line 110 sees value.slice(1,2)===\"*\" and value.slice(6,7)===\"*\" and takes the unwrap path, returning value = \"*\" + \"bold\" + \"*\" = \"*bold*\". Expected \"***bold***\" (bold + italic); actual: the bold is gone. The mirrored case Ctrl+B on `*italic*` correctly yields `***italic***`, so the two shortcuts disagree.",
|
||
"evidence": "if (value.slice(start - len, start) === marker && value.slice(end, end + len) === marker) {\n return {\n value: value.slice(0, start - len) + selected + value.slice(end + len),\n selectionStart: start - len,\n selectionEnd: start - len + selected.length,\n };\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "In wrapWithMarker's second branch, count the contiguous run of the marker's rune immediately left of `start` and right of `end` (all markers are one repeated char), and take the unwrap path only when `run - len !== len` on both sides — a run whose residue is exactly another whole marker means the neighbours are a *different* emphasis marker (`**` seen from `*`), so fall through to the wrap branch. Checks out on every shipped marker: `**bold**`+`*` run=2 → wrap → `***bold***`; `***bold***`+`*` run=3 → unwrap → `**bold**`; `***bold***`+`**` run=3 → unwrap → `*bold*`; `__u__`+`__` run=2,len=2 → unwrap → `u`. One guard in the shared function, no caller changes.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "31f73e3",
|
||
"test": "Client/tests/unit/message-input.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0301",
|
||
"title": "updateDmLastMessagePreview writes lastMessageId with no monotonicity guard, so it can regress the watermark updateDmLastMessage's replay guard depends on",
|
||
"file": "Client/src/stores/dm.store.ts",
|
||
"line": 180,
|
||
"severity": "low",
|
||
"why": "`updateDmLastMessage` (line 148) guards its unread/mention increments with `messageId <= updated.lastMessageId` (the OC-0242 fix) — the guard's entire correctness rests on `lastMessageId` being monotonic. Its sibling writer `updateDmLastMessagePreview`, which is dispatched for the very same chat_message frames whenever the message is the user's own or the DM is the active attached channel (dispatcher.ts:723-730), assigns `lastMessageId: messageId` unconditionally. A frame carrying an id below the current watermark therefore rolls the watermark backwards, and the next redelivered frame in the same burst slips past the guard and is counted twice. The unit suite pins only the no-unread-increment and reorder behaviour of the preview writer (tests/unit/dm-store.test.ts:364-411); nothing pins monotonicity.",
|
||
"repro": "1:1 DM channel 5 with the user signed in on two devices; on device A the DM is NOT the active channel. Between the server's registerNow and buildReady on device A's reconnect, two messages land in DM 5: id 495 sent by the user from device B, then id 500 from the peer. `ready` is written straight to the socket and arrives first, so setDmChannels applies lastMessageId=500 and unreadCount already including 500. The queued burst then drains in seq order: frame 495 is own -> updateDmLastMessagePreview(5, 495, ...) sets lastMessageId = 495 (regression); frame 500 is the peer's and the DM is not active -> updateDmLastMessage(5, 500, ...) computes isReplay = 500 <= 495 = false and does unreadCount + 1. Message 500 is now counted twice in the DM badge, and the badge stays wrong until the next ready or mark-read. The same regression also rolls lastMessage/lastMessageAt back to the older message's text and re-sorts the DM to the top of the sidebar on stale content.",
|
||
"evidence": "// updateDmLastMessage (guarded):\nconst isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;\n...\nunreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,\n\n// updateDmLastMessagePreview (unguarded, same field):\nchannels: [\n { ...updated, lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp },\n ...rest,\n],",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Give the preview writer the same watermark guard as its sibling — inside updateDmLastMessagePreview's setState, after resolving `updated`, add `if (updated.lastMessageId !== null && messageId <= updated.lastMessageId) return prev;`. That keeps lastMessageId monotonic (so updateDmLastMessage's OC-0242 guard stays sound) and also stops the stale preview text and stale reorder. All existing preview tests start from lastMessageId: null and stay green.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "770c849",
|
||
"test": "Client/tests/unit/dm-store.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0302",
|
||
"title": "registerNow's client-replacement transfer still misses pendingModServerMuted/pendingModServerDeafened, so a WS blip during a voice_mod_move silently lifts the moderator's mute",
|
||
"file": "Server/ws/hub.go",
|
||
"line": 532,
|
||
"severity": "low",
|
||
"why": "The `c.lastSeq > 0` transfer block in registerNow hands the replacement connection the old *Client's voice state, join token, join-completed flag, announced ECDH key/signature and focused channel — but not the `pendingModServerMuted` / `pendingModServerDeafened` stash. That stash is the ONLY place a moderator-imposed mute/deafen lives between voice_mod_move's eviction (which deletes the voice_states row those flags normally live in) and the target's own re-join, and it is consumed off the *Client (`voice_join.go:218`, `c.takePendingModFlags()`). A replacement connection starts with both flags false, so the stash is destroyed by the very reconnect the rest of this block exists to survive.",
|
||
"repro": "1) User B is in voice channel #1 with server_muted=1 (a moderator ran voice_mod_mute). 2) Moderator runs voice_mod_move(B -> #2). handleVoiceModMoveV2 stashes (true,false) onto B's live *Client, then DisconnectFromVoiceInChannel deletes B's voice_states row and clears B's in-memory voice state, then sends voice_moved. 3) B's WebSocket drops before its answering voice_join reaches the server (proxy blip / Wi-Fi handoff — the client's VOICE_MOVED handler in dispatcher.ts:1004 does an async `livekitSession()` import before sending, so this window is a full module load plus one RTT). 4) B reconnects with last_seq > 0; handleReconnect -> reconnectRegister -> registerNow builds a fresh *Client whose pendingMod* fields are false and never copies the old ones. 5) B's client re-sends voice_join for #2. voiceJoinLeaveCurrent sees currentChID == 0 and calls c.takePendingModFlags() -> (false,false), so voiceJoinRestoreModFlags is skipped. The new voice_states row is inserted with server_muted=0 and broadcast as unmuted. Expected: B stays server-muted in #2. Actual: the moderator's mute is silently lifted, B can talk, and every client's roster shows B unmuted.",
|
||
"evidence": "Server/ws/hub.go:498-545 — `oldE2EEKey, oldE2EESig := old.getE2EEPubKey()` / `oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()` are read off `old` and re-applied to `c` (`c.setVoiceState`, `c.markVoiceJoinCompleteIfMatch`, `c.setE2EEPubKey`, `c.channelID = oldChID`); nothing reads `old.pendingModServerMuted` / `old.pendingModServerDeafened` (Server/ws/client.go:56-57, set only by `setPendingModFlags`, Server/ws/client.go:234). Producer: Server/ws/voice_moderation.go:443 `stashPendingModFlags(d.Mod, c.TargetID(), state.ServerMuted, state.ServerDeafened)` -> Server/ws/voice_moderation.go:565-571 `SetPendingVoiceModFlags` -> `h.GetClient(userID).setPendingModFlags(...)`. Sole consumer: Server/ws/voice_join.go:217-219 `} else { wasServerMuted, wasServerDeafened = c.takePendingModFlags() }`. No test in Server/ws/*_test.go references pendingMod/SetPendingVoiceModFlags at all.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "flow-reconnect",
|
||
"suggestedFix": "In Server/ws/hub.go registerNow, carry the stash across with the other per-connection transfers, and place it OUTSIDE the `if c.lastSeq > 0` gate (a full-resync reconnect, lastSeq==0, loses it identically, and the stash has none of the voiceJoinCompleted supersession concerns that gate exists for):\n\n if pm, pd := old.takePendingModFlags(); pm || pd {\n c.setPendingModFlags(pm, pd)\n }\n\nPut it right after the `oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()` line (~hub.go:500). take-and-clear keeps the old client from double-serving it; both helpers take c.voiceMu, the same lock order registerNow already uses for getE2EEPubKey/clearVoiceState under h.mu, so no new lock-order edge. One guard in the shared replacement path covers every reconnect flavor; no caller-side change needed.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "956271f",
|
||
"test": "Server/ws/oc_0302_pending_mod_flags_transfer_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0303",
|
||
"title": "Incoming-call banner prints the caller's raw username, ignoring the nickname every other identity surface shows",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 606,
|
||
"severity": "low",
|
||
"why": "The ring model carries only `fromUsername` (call-ring.ts:25) and MainPage fills it straight from the wire payload, so IncomingCallBanner renders `${state.fromUsername} is calling`. Every other identity surface in the client resolves through members.store's memberDisplayName (ChannelSidebar voice roster, TypingIndicator, MemberList, reaction tooltip, remote video tiles) and the DM chat header uses dmDisplayName — so the one surface that has to be recognised in two seconds is the only one still showing the raw handle.",
|
||
"repro": "User `alice_1998` sets display_name \"Ali\". Bob's DM sidebar, chat header, member list and message rows all read \"Ali\". Alice calls Bob: the incoming-call banner reads \"alice_1998 is calling\" — a name Bob may never have seen.",
|
||
"evidence": "Client/src/lib/call-ring.ts:22-26\nexport interface RingState { readonly channelId: number; readonly fromUserId: number; readonly fromUsername: string; }\n\nClient/src/pages/MainPage.ts:603-607\n channelId: payload.channel_id,\n fromUserId: payload.from_user,\n fromUsername: payload.username,\n\nClient/src/components/IncomingCallBanner.ts:83\n setText(title, `${state.fromUsername} is calling`);\n\nCompare Client/src/pages/main-page/ChannelController.ts:617 (`dmDisplayName(dmChannel)`) and stores/members.store.ts:182 (`memberDisplayName`). `fromUserId` is already in RingState, so the member lookup is available at the construction site.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Resolve at the construction site in MainPage.ts:603-607, exactly as OC-0233 was fixed: `const m = membersStore.getState().members.get(payload.from_user); ... fromUsername: m !== undefined ? memberDisplayName(m) : payload.username`. Leaves RingState, the banner and the protocol untouched, and keeps the raw username as the fallback for a caller who is not in the members store.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "f4a60a6",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0304",
|
||
"title": "dm_channel_open (and POST /dms) report a DM partner's stale saved idle/dnd status, contradicting the member list, which shows them offline",
|
||
"file": "Server/api/dm_handler.go",
|
||
"line": 169,
|
||
"severity": "low",
|
||
"why": "Every DM presence surface except the `ready` payload applies only `db.StatusForViewer` (invisible→offline) and omits the second half of the rule — \"a member with no live connection is offline, whatever the row says\" — which `serve_ready.go`'s `presentableMembers`/`presentableDMChannels` apply. `MarkUserDisconnected` deliberately preserves a chosen idle/dnd across a disconnect, so the row for a signed-out user still says \"dnd\"; the DM push paths ship it verbatim and the client's DM sidebar renders it as a live presence dot.",
|
||
"repro": "User B sets status \"Do Not Disturb\", then signs out (users.status stays 'dnd'; `MarkUserDisconnected` clears only 'online'). User A, already connected, opens A's member list — B renders offline (presentableMembers applied the connection rule). A then clicks \"Message\" on B: `POST /dms` returns `recipient.status = \"dnd\"`, `handleCreateDm` (SidebarDmHelpers.ts:110-118) writes it into dmStore, and the DM sidebar row shows a red DND dot for a user the member list beside it shows as offline. No presence event will ever arrive for an offline B, so the wrong dot persists for the whole session. The same happens on the push path: any group-DM rename or leave sends a refreshed `dm_channel_open` built from `DMSummaryFor`, and `addDmChannel` overwrites every participant's status with the stale row value, clobbering the correct offline state `ready` had established.",
|
||
"evidence": "Server/api/dm_handler.go:169 (POST /dms response)\n```go\nStatus: db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID),\n```\nSame gap on the push path: Server/service/dm.go:366 `return db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID), nil` where `participants` comes from Server/db/dm_queries.go:435 `Status: StatusForViewer(rows[i].Status, rows[i].ID, viewerID)` — and Server/ws/messages.go:794 (`buildDMChannelOpenFor`) does the same.\nThe rule these skip, Server/ws/serve_ready.go:86-99 (`presentableDMChannels`): `if !connected[...] { ... Status = db.StatusOffline }`, and its own comment at serve_ready.go:258 says GetUserDMChannels \"passes a disconnected recipient's saved idle/dnd through verbatim\" and that `ready` adds the missing half.\nClient render path: dispatcher.ts → `addDmChannel` (dm.store.ts:70) takes `channel.recipient`/`participants` verbatim (it merges only unread/mention/lastMessage), and SidebarDmSection.ts:85-89 branches on `dm.recipient.status === \"online\"/\"idle\"/\"dnd\"` for the presence dot.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-server-ws",
|
||
"suggestedFix": "Apply the existing \"no live connection means offline\" rule at the shared DM-payload choke point instead of only in ws. Mirror the existing precedent at Server/ws/hub.go:186 (svc.Messages.SetOnlineChecker(h.IsUserConnected)): give DMService an `online func(int64) bool` and one unexported helper that rewrites any participant with no live connection to db.StatusOffline, then run DMSummaryFor's and ListDMs' db.DMChannelInfo through it (that covers GET /dms, POST /dms/group, PATCH /dms/{id} and every broadcastDMOpen). handleCreateDM's hand-built db.DMUser at dm_handler.go:163-170 and the group branch at Server/ws/handlers_chat.go:88-90 must go through the same helper (the ws side can simply reuse Hub.presentableDMChannels). Once the service-level rule exists, presentableDMChannels in serve_ready.go becomes a redundant second application rather than the only one.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "201e2bc",
|
||
"test": "Server/service/dm_test.go; Server/api/dm_handler_presence_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0305",
|
||
"title": "Diagnostics endpoint reports the reverse proxy's address as the client address, ignoring the trusted_proxies config its own rate limiter uses",
|
||
"file": "Server/api/diagnostics_handler.go",
|
||
"line": 46,
|
||
"severity": "low",
|
||
"why": "`handleDiagnosticsConnectivity` resolves the client with `clientIP(r)`, which is `clientIPWithProxies(r, nil)` — RemoteAddr only, proxy headers deliberately ignored. The whole purpose of the `client` block it fills in is to tell an admin what address the client is coming from and whether it is on a private network, and `cfg` (carrying `Server.TrustedProxies`) is already passed into the handler; the route's own `RateLimitMiddleware` on `router.go:157` resolves the real IP from those same proxies. Behind the project's own documented nginx/Caddy deployment the endpoint therefore always reports the proxy's loopback address and `is_private_network: true`, for every client on earth. Same class as the already-accepted OC finding \"Registration records the reverse-proxy's address as the session IP while login records the real client IP\" (Server/api/auth_handler.go:241).",
|
||
"repro": "Deploy per docs/deployment.md behind nginx on the same host with `server.trusted_proxies: [\"127.0.0.1/32\"]`. An administrator on a public IP 203.0.113.9 opens the client and hits `GET /api/v1/diagnostics/connectivity`. nginx connects from 127.0.0.1 and forwards `X-Forwarded-For: 203.0.113.9`. The response contains `\"client\": {\"remote_addr\": \"127.0.0.1\", \"is_private_network\": true}` instead of `203.0.113.9` / `false` — the connectivity diagnostic reports the proxy, not the client, and the same answer comes back for every user regardless of where they connect from.",
|
||
"evidence": "func handleDiagnosticsConnectivity(cfg *config.Config, ver string, hub *ws.Hub) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tclientAddr := clientIP(r) // <- RemoteAddr only; cfg.Server.TrustedProxies unused\n\t\t...\n\t\tClient: clientDiag{\n\t\t\tRemoteAddr: clientAddr,\n\t\t\tIsPrivateNet: isPrivateIP(clientAddr),\n\t\t},\n\n// middleware.go:258 — clientIP is documented as the no-proxy-trust variant\nfunc clientIP(r *http.Request) string { return clientIPWithProxies(r, nil) }\n\n// router.go:155-159 — the very same route's limiter DOES honour the proxies\n\tr.With(AuthMiddleware(database), RequirePermission(permissions.Administrator),\n\t\tRateLimitMiddleware(limiter, \"diag:\", 5, time.Minute, cfg.Server.TrustedProxies)).\n\t\tGet(\"/api/v1/diagnostics/connectivity\", handleDiagnosticsConnectivity(cfg, ver, hub))",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "In Server/api/diagnostics_handler.go, parse the CIDR list once at handler construction and use the proxy-aware resolver: inside handleDiagnosticsConnectivity, before the returned closure, add `proxyNets := parseCIDRList(cfg.Server.TrustedProxies)`, then change line 46 to `clientAddr := clientIPWithProxies(r, proxyNets)`. One change in the single handler; no caller or signature changes (cfg is already passed in).",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "ab4b1ed",
|
||
"test": "Server/api/diagnostics_handler_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0306",
|
||
"title": "EmojiPicker re-registers every emoji cell's click listener on the picker-lifetime AbortSignal on each search keystroke, permanently retaining every discarded cell set",
|
||
"file": "Client/src/components/EmojiPicker.ts",
|
||
"line": 620,
|
||
"severity": "low",
|
||
"why": "`signal` is the single AbortController created once per picker (line 544-545) and aborted only in destroy(). renderAllCategories() detaches and rebuilds the whole ~250-cell grid on every `input` event, and each rebuilt cell registers a listener against that one long-lived signal. addEventListener({signal}) installs an abort algorithm on the signal that holds a reference to the target element, so every detached span (and, for custom emoji, its <img> subtree) stays reachable until abort() finally runs — the same defect already confirmed at MemberList.ts:469 and MessageList.ts:332.",
|
||
"repro": "Open the composer's emoji picker on a server with custom emoji and type \"smile\" (5 keystrokes). renderAllCategories runs 6 times (initial + 5), building ~250 spans each time. After typing, the picker's AbortSignal retains ~1500 detached <span class=\"ep-emoji\"> elements plus one re-created custom-emoji <img> per server emoji per render; none are released until the picker is closed. Verifiable in DevTools: heap snapshot shows the detached spans retained via the AbortSignal's listener list, and detached-node count grows linearly with keystrokes.",
|
||
"evidence": "const abortController = new AbortController();\nconst signal = abortController.signal; // picker-lifetime, aborts only in destroy()\n...\nfunction buildEmojiSpan(emoji: string): HTMLSpanElement {\n const span = createElement(\"span\", { class: \"ep-emoji\", ... });\n const image = buildCustomEmojiNode(emoji);\n ...\n span.addEventListener(\"click\", () => handleEmojiClick(emoji), { signal }); // <-- line 620\n return span;\n}\n\nfunction renderAllCategories(categories: readonly EmojiCategory[]): void {\n clearChildren(scrollArea); // old cells detached, listeners still held by `signal`\n ... grid.appendChild(buildEmojiSpan(emoji)); ...\n}\n\nsearchInput.addEventListener(\"input\", () => {\n searchQuery = searchInput.value.trim();\n renderAllCategories(getAllCategories()); // full rebuild per keystroke\n}, { signal });",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Match the SearchOverlay.ts:196-250 fix: drop the per-span listener at :620, give each span a `data-emoji` attribute (the emoji string) in buildEmojiSpan, and register one delegated handler once at picker construction — `scrollArea.addEventListener(\"click\", (e) => { const cell = (e.target as HTMLElement | null)?.closest<HTMLElement>(\".ep-emoji\"); if (cell?.dataset.emoji !== undefined) handleEmojiClick(cell.dataset.emoji); }, { signal });`",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "f468bab",
|
||
"test": "Client/tests/unit/emoji-picker.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0307",
|
||
"title": "QuickSwitcher re-registers every result row's click listener on the overlay-lifetime AbortSignal on each keystroke and each arrow key",
|
||
"file": "Client/src/components/QuickSwitcher.ts",
|
||
"line": 83,
|
||
"severity": "low",
|
||
"why": "`signal` (line 17-18) is created once per overlay and aborted only in destroy(). renderResults() clears resultsDiv and rebuilds every row from scratch, registering each row's click listener against that one long-lived signal. It is re-invoked on every keystroke (handleInput, line 110), on every ArrowUp/ArrowDown (lines 124 and 133 — arrow keys re-render purely to move the highlight), and on every channelsStore.channels notification (refreshFromStore, line 169). Every discarded row set stays reachable from the signal's retained abort algorithms until the overlay closes — identical mechanism to the confirmed MemberList.ts:469 / MessageList.ts:332 findings.",
|
||
"repro": "On a server with ~200 channels press Ctrl+K to open the quick switcher, then hold ArrowDown for about one second (~30 key repeats). renderResults runs ~30 more times, each building 200 rows; the overlay's AbortSignal now retains roughly 6000 detached `.quick-switcher__item` elements, released only when the overlay is closed. Typing a query reproduces the same growth one rebuild per character.",
|
||
"evidence": "const ac = new AbortController();\nconst signal = ac.signal; // overlay-lifetime, aborted only in destroy()\n...\nfunction renderResults(): void {\n clearChildren(resultsDiv); // previous rows detached, listeners still held\n for (let i = 0; i < filteredChannels.length; i++) {\n const item = createElement(\"div\", { ... });\n ...\n item.addEventListener(\"click\", () => { options.onSelectChannel(ch.id); options.onClose(); }, { signal }); // <-- line 83\n resultsDiv.appendChild(item);\n }\n}\n\nif (e.key === \"ArrowDown\") { ... renderResults(); return; } // line 124 — full rebuild per keypress\nif (e.key === \"ArrowUp\") { ... renderResults(); return; } // line 133",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "One delegated listener instead of one per row: delete the addEventListener block at :81-89 and register once in mount (next to the other `{ signal }` listeners at :219-223) — `resultsDiv.addEventListener(\"click\", (e) => { const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(\".quick-switcher__item\"); const id = row?.dataset.channelid; if (id !== undefined) { options.onSelectChannel(Number(id)); options.onClose(); } }, { signal });` — each row already carries `data-channelid` (:59), so no other change is needed.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "1158506",
|
||
"test": "Client/tests/unit/quick-switcher.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0308",
|
||
"title": "Recent-emoji list is a single unscoped localStorage key that stores server-specific `:shortcode:` tokens, so one server's custom emoji leak into every other server's picker (and can be posted there as a permanent literal-text reaction)",
|
||
"file": "Client/src/components/EmojiPicker.ts",
|
||
"line": 508,
|
||
"severity": "low",
|
||
"why": "Every other piece of per-server client state in this client is host-scoped (`owncord:nsfw-ack:{id}:{host}` via setNsfwGateHost, `owncord:dm-note:{host}:{id}`, channel mutes via setChannelMutesHost, collapsed categories). `owncord:recent-emoji` is the outlier: it is global, and `addRecentEmoji` is fed *any* selection, including the `:shortcode:` tokens the Server category is built from — which are meaningless on a different server (or after the emoji is deleted on the same one).",
|
||
"repro": "On server A open the composer emoji picker and click the custom emoji `:blobwave:` — `addRecentEmoji(\":blobwave:\")` writes it to the global `owncord:recent-emoji`. Switch to server B (in-document SPA navigation, same localStorage) where no such shortcode exists. Open any emoji picker: the Recent row now contains a cell whose visible content is the literal string `:blobwave:` (EmojiPicker.ts:613 falls through to `setText`). Click it from the reaction picker (pages/main-page/ReactionController.ts:85) → `sendReaction(msgId, \":blobwave:\")`; the server's `validateEmoji` (Server/service/message_reactions.go:68) only checks length/control-chars/sanitizer, so it is accepted and persisted. Every client on server B now renders a permanent reaction pill reading `:blobwave:` (components/message-list/reactions.ts:31, which falls back to a text node). From the composer picker the same click inserts dead `:blobwave:` text into the message. The identical failure happens within one server as soon as an admin deletes a custom emoji that is still in Recent.",
|
||
"evidence": "L508 `const RECENT_KEY = \"owncord:recent-emoji\";` (no host component)\nL583 `emoji: options.customEmoji.map((e) => `:${e.shortcode}:`)` // Server category rows are shortcode tokens\nL596-599 `function handleEmojiClick(emoji: string): void { addRecentEmoji(emoji); options.onSelect(emoji); }` // no discrimination between unicode and custom\nL613 `const image = buildCustomEmojiNode(emoji); if (image !== null) {...} else { setText(span, emoji); }`\ncustom-emoji.ts:111-114 `buildCustomEmojiNode` returns null when `resolveEmoji` misses → the cell renders the literal text.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "One guard in the shared reader: in `getRecentEmoji()` (EmojiPicker.ts:513-524) drop entries that are shortcode-shaped but unresolvable, e.g. after the existing string filter add `.filter((e) => !(e.startsWith(\":\") && e.endsWith(\":\")) || resolveEmoji(e) !== null)` (importing `resolveEmoji` from @stores/emoji.store). That fixes both the cross-server leak and the deleted-emoji case in one place; host-scoping the key alone would not fix the deleted-emoji case.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "f468bab",
|
||
"test": "Client/tests/unit/emoji-picker.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0309",
|
||
"title": "DM profile sidebar renders the partner's status and name from an open-time snapshot and never subscribes, so it sits beside a live chat header showing the opposite for as long as it stays open",
|
||
"file": "Client/src/components/DmProfileSidebar.ts",
|
||
"line": 190,
|
||
"severity": "low",
|
||
"why": "The panel paints the status dot, the status label and the name once in `mount()` from the `DmProfileData` it was constructed with, and registers no store subscription. It is only torn down on a channel switch, so a presence change, a rename or a nickname change while it is open leaves it permanently disagreeing with the header it was opened from — the exact failure ChannelController.ts:621-638 added a subscription to prevent for that header.",
|
||
"repro": "Open a DM with Bob while he is online, then click the DM header to open the profile sidebar — it shows a green dot and \"Online\". Bob then goes idle or offline: the server sends `presence_update`, dispatcher.ts:827 calls `updateDmParticipant(user_id, { status })`, which repaints the DM sidebar row and (via the subscription above) flips the chat-header subtitle to \"Offline\". The profile panel rendered immediately to the right of that header keeps the green dot and the word \"Online\" indefinitely — it is never rebuilt while the same DM stays active. A rename or nickname change (dispatcher.ts:941) produces the same split: header updates, panel keeps the old name.",
|
||
"evidence": "DmProfileSidebar.ts:190 `statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;`\nL288 `statusDotInline.style.background = STATUS_COLORS[user.status] ?? ...`, L290 `createElement(\"span\", {}, STATUS_LABELS[user.status] ?? \"Offline\")`, L268 `setText(nameEl, resolveDisplayName(user));` — all one-shot; the file contains no `subscribe`/`subscribeSelector` call at all.\nMainPage.ts:267-286 builds `user` from a `dmStore` snapshot at click time and mounts; the only teardown paths are toggleDmProfile (L241) and closeDmProfile (L313), the latter called solely from the activeChannelId subscription (L813-830).\nContrast ChannelController.ts:621-638: `// Keep the subtitle live across presence and roster changes — otherwise it is set once from a snapshot and never updated until the channel is re-mounted` + `membersStore.subscribeSelector((s) => s.members.get(dmRecipientId)?.status, refreshDmHeader)` and a matching `dmStore` subscription.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Keep the component presentational and fix it once at the owner: in MainPage.toggleDmProfile, after `dmProfileSidebar.mount(dmProfileSlot)`, push a subscription (torn down in closeDmProfile alongside the destroy) on `membersStore.subscribeSelector((s) => s.members.get(recipient.id)?.status, ...)` and the matching dmStore selector, whose callback re-reads the recipient and rebuilds the panel (destroy + createDmProfileSidebar + mount) — mirroring ChannelController.ts:621-638. Alternatively expose an `update(user: DmProfileData)` on the component and repaint the three nodes in place to avoid losing the note field's focus.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "f4a60a6",
|
||
"test": "Client/tests/unit/main-page.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0310",
|
||
"title": "The status picker's custom-status input is seeded only from an unscoped localStorage pref, never from the server's authoritative auth_ok.user.custom_status — so it disagrees with every other surface, leaks across servers, and cannot clear a status the server still holds",
|
||
"file": "Client/src/components/UserBar.ts",
|
||
"line": 170,
|
||
"severity": "low",
|
||
"why": "The server delivers the signed-in user's own custom_status on every connect (Server/ws/serve_ready.go:39, stored verbatim into authStore.user by setAuth), and StatusPicker exposes setCustomStatus() built and unit-tested for exactly that (\"setCustomStatus updates the input without firing the handler\" / \"from the server\"). UserBar never calls it: it seeds the input from loadCustomStatus() (localStorage key `owncord:settings:customStatus`, unscoped by host or account, never cleared by clearAuth) and wires a sync subscription for status only (onUserStatusChange) with no custom-status counterpart. That leaves two sources of truth for one value, and StatusPicker.commit()'s `if (text === lastCommittedCustom) return` guard turns the wrong seed into an unclearable state, because there is no other UI anywhere in the client that writes custom_status.",
|
||
"repro": "(a) Unclearable: user sets custom status \"In a meeting\" on machine A. On machine B (fresh install / cleared app data) they sign in. auth_ok carries custom_status=\"In a meeting\" and the member list + profile popup render it under their name, but UserBar builds the picker with currentCustomStatus = loadCustomStatus() = \"\" and lastCommittedCustom = \"\". The user opens the picker to clear it, sees an already-empty input, presses Enter (or blurs): commit() computes text = \"\", hits `text === lastCommittedCustom`, returns — no onCustomStatusChange, no presence_update. Picking Online/Idle/DND sends presence_update with no custom_status, which HandlePresenceUpdate explicitly preserves. The status stays live on the server with no reachable way to clear it. (b) Cross-server leak: user sets \"In a meeting\" on server A, then quick-switches to server B (different account/host). clearAuth leaves the global pref intact, so B's UserBar picker pre-fills \"In a meeting\" while B's member list and every other client on B show no custom status for that user — and typing that same text back to make it true is suppressed by the same equality guard.",
|
||
"evidence": "UserBar.ts:169-170 currentStatus: loadUserStatus(),\n currentCustomStatus: loadCustomStatus(), // localStorage, not authStore.getState().user?.custom_status\nUserBar.ts:194-202 onUserStatusChange((status) => { statusPicker?.setStatus(status); ... }) // status only — no custom-status sync anywhere\nStatusPicker.ts:78 let lastCommittedCustom = options.currentCustomStatus ?? \"\";\nStatusPicker.ts:184-188 const text = input.value.trim()...; if (text === lastCommittedCustom) return; // \"\" === \"\" short-circuits\nStatusPicker.ts:307-310 function setCustomStatus(text) { lastCommittedCustom = text; ... } // exported, unit-tested, called from nowhere (grep: only StatusPicker.ts)\nuserStatus.ts:98-101 loadCustomStatus() -> loadPref(CUSTOM_STATUS_PREF_KEY, \"\") -> localStorage \"owncord:settings:customStatus\" (preferences.ts:12,20) — one global key, no host/account scope\nauth.store.ts:86-116 clearAuth() resets voice/messages/channels/blocks/sidebarMode/NSFW acks — never the settings prefs\nServer/ws/serve_ready.go:39 \"custom_status\": user.CustomStatus, // in every auth_ok\nServer/service/channel.go:218 if customStatus == nil { return storedCustomStatus, nil } // a plain status change never clears it server-side",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-21",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Seed and sync from the store instead of the pref, in UserBar.ts only. Replace line 170 with `currentCustomStatus: authStore.getState().user?.custom_status ?? loadCustomStatus(),` and add one subscription beside the existing auth subscription (UserBar.ts:254-258): `disposable.onStoreChange(authStore, (s) => s.user?.custom_status ?? \"\", (text) => statusPicker?.setCustomStatus(text))` — that reuses the already-built, already-tested setCustomStatus and needs no change in StatusPicker.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixedDate": "2026-08-22",
|
||
"fix": {
|
||
"commit": "d0791c4",
|
||
"test": "Client/tests/unit/status-picker-userbar.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0311",
|
||
"title": "handleParticipantLeft is channel-blind: a voice_leave for any readable channel mutates this session's E2EE peer state",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 1077,
|
||
"severity": "medium",
|
||
"why": "The VOICE_LEAVE handler has payload.channel_id in hand (it uses it two lines above for shouldTeardownSession) but calls handleParticipantLeft(payload.user_id) with no channel. E2EEManager.handleParticipantLeft then unconditionally deletes that user from _peerPublicKeys/_peerOfferEpochs, clears their verification badge, retires their key, and re-runs the key-holder election against the client's OWN voice channel — even though the leave was for a different channel entirely. voice_leave is broadcast to channelReadAudience(thatChannel), i.e. every client with READ_MESSAGES on it, not just the room's participants.",
|
||
"repro": "I am in voice channel A and hold the room key; I also have READ_MESSAGES on voice channel B. Peer P (in B) switches to A. Server order: voice_leave(B,P) and voice_state(A,P) are enqueued on the buffered h.broadcast queue by P's voice_join; P's voice_token is sent directly, so P connects and its voice_e2ee_announce is relayed to me via pubsub.Publish. With the broadcast goroutine backed up, my socket sees: (1) announce(P,K) -> I verify P, store K, offer P the current room key; (2) voice_leave(B,P) -> handleParticipantLeft(P) with no channel filter: hadPeerKey=true, P deleted from _peerPublicKeys, clearPeerVerification(P) wipes the verified badge, and because A's roster does not list P yet, retirePeerKey(P,K) retires P's LIVE key; then the `wasKeyHolder && hadPeerKey` branch rotates the room key excluding P; (3) voice_state(A,P) -> P appears in my voice widget. Result: P is visibly in my call but holds a superseded key — nothing I send decrypts for them and nothing they send decrypts for me. Nothing heals it: mid-call peers never re-announce, my 5-minute rotation iterates _peerPublicKeys (P is gone), and any later replay of P's stored key (e.g. sendVoicePeerKeys on my WS reconnect, hub.go:664-666) is rejected by the retirement guard at livekitE2EE.ts:782. Passing payload.channel_id and ignoring leaves for other channels fixes it; the ready-resync call site at dispatcher.ts:374-384 already scopes by channel.",
|
||
"evidence": "dispatcher.ts:1071-1079:\n const shouldTeardownSession =\n isSelf && voiceStore.getState().currentChannelId === payload.channel_id;\n void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {\n void handleParticipantLeft(payload.user_id); // <- payload.channel_id dropped\n\nlivekitE2EE.ts:1246-1253 (no channel parameter; acts on this._channelId):\n async handleParticipantLeft(userId: number): Promise<void> {\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n this._peerOfferEpochs.delete(userId);\n clearPeerVerification(userId);\n const channelId = this._channelId ?? this.deps.getCurrentChannelId();\n\nAudience proof, hub_broadcast.go:126-143: broadcastVoiceEventWithLeaver resolves h.channelReadAudience(ctx, channelID) — everyone with READ on the channel, regardless of voice membership.\n\nReordering proof (already documented in-tree): voice_leave goes through the async hub queue (hub_broadcast.go:160-168 `h.broadcast <- bm`), while voice_e2ee_announce is published straight into the recipient's send queue from the announcer's read pump (voice_e2ee.go:270-272 `h.pubsub.Publish(VoiceTopic(channelID), ...)`) — the same hazard livekitE2EE.ts:1268-1272 cites for OC-0213.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "voice-e2ee",
|
||
"suggestedFix": "Scope the E2EE notification to this client's own voice channel, mirroring removeVoiceUser and the shouldTeardownSession comparison already computed in the same handler. In dispatcher.ts, reuse the pre-leaveVoiceChannel store read: `const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id;` (the value shouldTeardownSession already derives at :1071-1072), then at :1077 call it only when it matches — `if (sameChannel) void handleParticipantLeft(payload.user_id);` — leaving `if (shouldTeardownSession) void leaveVoice(false);` untouched. This keeps the one-argument call shape that dispatcher.test.ts:2728 asserts, and needs no change in livekitE2EE.ts. (Threading payload.channel_id into handleParticipantLeft and early-returning on mismatch against `this._channelId ?? this.deps.getCurrentChannelId()` is the alternative single-guard form, but it breaks that arity assertion and would require updating it to toHaveBeenCalledWith(7, 3).) The other caller, the ready-resync at dispatcher.ts:374-384, is already channel-scoped and unaffected.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "a231108f",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0312",
|
||
"title": "Binding a push-to-talk key mid-call leaves PTT permanently dead — the `pttOwnsMute` latch is cleared by the store subscriber before the deferred `setMuted(true)` lands",
|
||
"file": "Client/src/lib/ptt.ts",
|
||
"line": 286,
|
||
"severity": "medium",
|
||
"why": "The mid-call PTT arming block writes `pttOwnsMute = true` synchronously, but the mute it is describing (`setMuted(true)`) is applied inside a `void import(...).then(...)` callback that runs a microtask *later*. `setPttGated(true)` on the line above schedules voiceStore's notification microtask first, and the subscriber registered in `initPtt` (`if (!s.localMuted) pttOwnsMute = false`) still observes `localMuted === false` and resets the latch. When `setMuted(true)` finally runs, the mic is PTT-muted while `pttOwnsMute` is false, so every subsequent press takes the \"never lift a mute the user asked for\" early return.",
|
||
"repro": "User is in a voice call, unmuted, with no PTT key bound (`localMuted=false`, `pttGated=false`, `isPttPollingLive()===true`). They open Settings -> Keybinds and bind a PTT key, which calls `updatePttKey(0x20)`.\n\nExecution order:\n1. `updatePttKey` -> `await initPtt()` registers `pttStoreUnsubscribe = voiceStore.subscribe(s => { if (!s.localMuted) pttOwnsMute = false; })` (ptt.ts:163-165).\n2. ptt.ts:277 destructures `localMuted === false`.\n3. ptt.ts:279 `setPttGated(true)` -> `voiceStore.setState` applies state synchronously and queues the notify microtask (store.ts:132-155). No prior notify was scheduled (initPtt writes no store state), so this microtask is fresh.\n4. ptt.ts:283-285 `void import(\"./livekitSession\").then(({setMuted}) => setMuted(true))` — the `.then` callback is queued *after* the dynamic-import promise resolves, i.e. strictly after the microtask queued in step 3.\n5. ptt.ts:286 `pttOwnsMute = !localMuted` -> `true`.\n6. Microtask from step 3 runs: subscriber sees `s.localMuted === false` (setMuted has not run yet) -> `pttOwnsMute = false`.\n7. Microtask from step 4 runs: `setMuted(true)` -> `setLocalMuted(true)` (livekitSession.ts:1597), mic muted.\n\nEnd state: mic muted by PTT, `pttOwnsMute === false`.\n\nUser presses the PTT key: the ptt-state handler (ptt.ts:203) evaluates `localMuted && !pttOwnsMute` -> `true && true` -> returns with \"PTT pressed — staying muted\". The mic never opens. On release, ptt.ts:216 recomputes `pttOwnsMute = !localMuted = !true = false`, so the latch stays false forever: every press for the rest of the voice session is refused and the user is stuck muted with a non-functional PTT key, until they manually unmute via the voice widget.\n\nNot test-locked: tests/unit/ptt.test.ts mocks `@stores/voice.store` with a stub `setPttGated` and a hand-driven `subscribe`, so the real store's microtask notification never fires during the OC-0162 arming tests (tests/unit/ptt.test.ts:512-608).",
|
||
"evidence": "ptt.ts:277-287\n const { currentChannelId, pttGated, localMuted } = voiceStore.getState();\n if (currentChannelId !== null && isPttPollingLive() && pttGated !== true) {\n setPttGated(true);\n // Muting is always safe (mirrors the ptt-state release handler\n // below) — record whether this is what muted the mic so the next\n // press may lift it (v006: never lift a mute the user asked for).\n void import(\"./livekitSession\")\n .then(({ setMuted }) => setMuted(true))\n .catch((e) => log.warn(\"Failed to gate mic after binding PTT key mid-call\", e));\n pttOwnsMute = !localMuted;\n }\n\nptt.ts:163-165 (the clobbering subscriber)\n pttStoreUnsubscribe = voiceStore.subscribe((s) => {\n if (!s.localMuted) pttOwnsMute = false;\n });\n\nContrast with the ptt-state release handler (ptt.ts:215-216), which is correct because setMuted runs *synchronously before* the latch write:\n setMuted(true);\n pttOwnsMute = !localMuted;",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "client-state",
|
||
"suggestedFix": "Move the latch write into the callback so it lands after setMuted, mirroring the release handler at ptt.ts:215-216: replace lines 283-286 with `void import(\"./livekitSession\").then(({ setMuted }) => { setMuted(true); pttOwnsMute = !localMuted; }).catch((e) => log.warn(\"Failed to gate mic after binding PTT key mid-call\", e));` — one edit in updatePttKey, no change to the subscriber or to any caller.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0313",
|
||
"title": "Pre-scoping per-user volume is copied into every server the user connects to — the legacy key is read through but never consumed",
|
||
"file": "Client/src/lib/audioElements.ts",
|
||
"line": 57,
|
||
"severity": "medium",
|
||
"why": "`getSavedUserVolume` falls back to the unscoped `userVolume_{id}` key on a miss at the host-scoped key and persists the result under the scoped key, but unlike the identical migration in channel-mutes.ts it never removes the legacy key. Every subsequent brand-new host also misses its own scoped key, reads through to the same legacy value, and adopts it — so a volume (including a 0 = silenced) set for user id N on one server is silently applied to the unrelated user id N on every other server.",
|
||
"repro": "Pre-scoping install has `owncord:settings:userVolume_7 = 0` (the user silenced person #7 on server A before host-scoping existed).\n\n1. Connect to server A. MainPage.ts:108 calls `setAudioVolumeHost(\"a.example.com\")`. `getSavedUserVolume(7)`: scoped key `userVolume_7:a.example.com` misses -> legacy `userVolume_7` returns 0 -> `savePref(\"userVolume_7:a.example.com\", 0)` -> returns 0. The legacy key `userVolume_7` is still present.\n2. Log out, connect to server B. `setAudioVolumeHost(\"b.example.com\")`. `getSavedUserVolume(7)`: scoped key `userVolume_7:b.example.com` misses -> legacy `userVolume_7` still returns 0 -> `savePref(\"userVolume_7:b.example.com\", 0)` -> returns 0.\n3. In a voice call on server B, `handleTrackSubscribedAudio` calls `participant.setVolume(this.getEffectiveVolume(7))` = 0. User id 7 on server B — a different person, never touched by the user — is inaudible, with the volume slider showing 0 and no explanation.\n\nThe same happens on every further new host. channel-mutes.ts:106-111 fixes exactly this shape by consuming the legacy key (`localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY)`) so the migration can only apply to the first host; audioElements.ts has no such removal.\n\nNot test-locked: tests/unit/audio-elements.test.ts:256-273 only covers the case where the second host has its OWN explicit value (80), which short-circuits before the legacy branch.",
|
||
"evidence": "audioElements.ts:50-63\nfunction getSavedUserVolume(userId: number): number {\n const scopedKey = userVolumeKey(userId);\n if (currentHost === null) return loadPref<number>(scopedKey, 100);\n\n const scoped = loadPref<number>(scopedKey, VOLUME_NOT_SET);\n if (scoped !== VOLUME_NOT_SET) return scoped;\n\n const legacy = loadPref<number>(`userVolume_${userId}`, VOLUME_NOT_SET);\n if (legacy !== VOLUME_NOT_SET) {\n savePref(scopedKey, legacy); // <-- legacy key is never removed\n return legacy;\n }\n return loadPref<number>(scopedKey, 100);\n}\n\nchannel-mutes.ts:106-111 (the corrected sibling)\n if (keyExists(MUTED_KEY)) {\n const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));\n writeMuted(legacy);\n localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);\n return legacy;\n }",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "client-state",
|
||
"suggestedFix": "Consume the legacy key in the same branch, exactly as channel-mutes.ts does. In getSavedUserVolume, after `savePref(scopedKey, legacy)` at audioElements.ts:59, add `localStorage.removeItem(\"owncord:settings:\" + `userVolume_${userId}`)` (use the same STORAGE_PREFIX constant the settings helpers use rather than a literal), so the migration can only ever apply to the first host connected post-upgrade and every later host falls through to the 100 default at line 62.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0314",
|
||
"title": "The server's \"password changed but other sessions could NOT be revoked\" partial-success warning is discarded by the client, which reports an unqualified success",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 500,
|
||
"severity": "medium",
|
||
"why": "PUT /users/me/password, POST /users/me/totp/confirm and DELETE /users/me/totp are deliberately built as partial-success endpoints: when the credential/2FA change commits but `DeleteOtherSessions` fails (twice — there is one bounded retry), the server returns HTTP 200 with `{\"warning\": \"...other sessions could not be revoked; revoke them from the sessions list\", \"sessions_revoked\": N}` instead of 204. `service.ChangePasswordResult.RevokeFailed`'s own doc comment says this is \"a partial success the caller must surface as a warning\". The client throws the whole body away — `api.changePassword` / `confirmTotp` / `disableTotp` are all typed `Promise<void>` and every call site shows an unconditional green success toast — so the one signal the server designed for this case never reaches a human. (The admin SPA does render the analogous `warnings` array from the setup wizard at Server/admin/static/index.html:621, which is what the design intends.)",
|
||
"repro": "1. A user believes their session was stolen and changes their password from Settings → Account.\n2. `UpdateUserPassword` commits, then both `DeleteOtherSessions` attempts fail (SQLite writer busy / write-lock contention — the exact transient the one retry was added for). `service.ChangePassword` returns `ChangePasswordResult{RevokeFailed: true}`.\n3. The handler returns HTTP 200 with the `warning` body instead of 204.\n4. `doFetch` sees `res.ok && status !== 204`, parses the body, and hands it back as `T = void`; `MainPage.ts:499` awaits it and `MainPage.ts:500` shows \"Password changed successfully\" in green.\n5. The attacker's session row is still in `sessions` and its bearer token still authenticates. The user has been told the operation fully succeeded and has no reason to open the sessions list, which is the only remaining way to revoke it. Identical outcome for enabling and disabling 2FA.",
|
||
"evidence": "Server/api/profile_handler.go:442\n\t\tif res.RevokeFailed {\n\t\t\t// Partial success: the password IS changed; only revoking the\n\t\t\t// other sessions failed. ...\n\t\t\twriteJSON(w, http.StatusOK, map[string]any{\n\t\t\t\t\"warning\": \"password changed, but other sessions could not be revoked; revoke them from the sessions list\",\n\t\t\t\t\"sessions_revoked\": res.SessionsRevoked,\n\t\t\t})\n\nClient/src/lib/api.ts:372\n changePassword(currentPassword, newPassword, signal?): Promise<void> {\n return request<void>(\"PUT\", \"/users/me/password\",\n { old_password: currentPassword, new_password: newPassword }, signal);\n },\n\nClient/src/pages/MainPage.ts:498\n await api.changePassword(oldPassword, newPassword);\n showToast(\"Password changed successfully\", \"success\");\n\nSame shape at MainPage.ts:558-560 (confirmTotp -> \"Two-factor authentication enabled\") and MainPage.ts:569-571 (disableTotp -> \"Two-factor authentication disabled\"), against Server/api/totp_handler.go:359-370 and :460-471.\n\ndoFetch does receive the body — only 204 short-circuits:\nClient/src/lib/api.ts:173\n if (res.status === 204) { return undefined as T; }\n return res.json() as Promise<T>;",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "error-paths",
|
||
"suggestedFix": "Give the three partial-success endpoints a body type instead of void and surface it in one place. In Client/src/lib/api.ts, declare `type PartialSuccess = { warning?: string; sessions_revoked?: number } | undefined;` and change changePassword/confirmTotp/disableTotp to `request<PartialSuccess>(...)`. Then in the three MainPage.ts handlers use the returned value for the toast, e.g. `const res = await api.changePassword(old, new_); showToast(res?.warning ?? \"Password changed successfully\", res?.warning ? \"warning\" : \"success\");` (and the analogous two lines at :558 and :569). No server change is needed.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0315",
|
||
"title": "Replay-gate boundary compares a naive-UTC server timestamp parsed as LOCAL time against a local wall-clock anchor",
|
||
"file": "Client/src/lib/dispatcher.ts",
|
||
"line": 688,
|
||
"severity": "medium",
|
||
"why": "`payload.timestamp` is the raw SQLite `datetime('now')` string (\"2026-08-22 12:00:01\" — UTC, no zone designator; Server/migrations/001_initial_schema.sql:81, passed through verbatim by service/message_crud.go:79). `Date.parse` treats it as LOCAL time, so the parsed epoch is off by the viewer's UTC offset. The codebase already has `parseTimestamp()` (components/message-list/formatting.ts:25-33) that exists solely to append the missing \"Z\"; this comparison bypasses it. The bias cancels once `serverClockSkewMs` has been sampled (line 765 uses the same biased parse), but it is 0 until the first accepted live message — so the very first reconnect of a session is decided by the viewer's timezone instead of by the timestamp.",
|
||
"repro": "Cold-skew case (serverClockSkewMs still 0 — no chat_message received since login, i.e. a quiet channel).\nEast of UTC, e.g. viewer at UTC+2: socket blips and reconnects at wall time H; 1 s later a peer posts a genuinely LIVE message. Date.parse(ts) = T - 2h, so `T - 2h < H - 0` is true → isReplayFrame = true → notifyIncomingMessage is skipped (no desktop notification, no sound, no taskbar flash). Worse, line 700-702 computes `isMention = ... && !(mentions_here && isReplayFrame)`, so a live `@here` that names the viewer raises no mention badge at all — and the reconnect tier sends no follow-up `ready` to correct it (OC-0271), so the badge is lost permanently.\nWest of UTC, e.g. viewer at UTC-5: Date.parse(ts) = T + 5h, so the test is false for every frame → the entire replayed burst is classified live and fires one desktop notification + sound per already-seen message, which is exactly what the gate was added to prevent.\nNot caught by tests: every timestamp in tests/unit/dispatcher.test.ts (lines 515, 541, 597, 622, 672, 779, 810…) is an ISO `Z` string, a form the server never emits.",
|
||
"evidence": "685: const isReplayFrame =\n686: lastReconnectHandshakeAt !== null &&\n687: Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&\n688: Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;\n...\n765: serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);\n\n(the helper that exists for exactly this, formatting.ts:31-34:)\n const date = !raw.endsWith(\"Z\") && !raw.includes(\"+\") && !/T\\d{2}:\\d{2}:\\d{2}[+-]/.test(raw)\n ? new Date(raw.replace(\" \", \"T\") + \"Z\") : new Date(raw);",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Route both parses through the existing UTC-normalizing helper instead of bare Date.parse — e.g. import { parseTimestamp } from \"@components/message-list/formatting\" (or lift it into @lib) and use `parseTimestamp(payload.timestamp).getTime()` at dispatcher.ts:688 and :765. One shared helper at both sites, no per-caller guards.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "a231108f",
|
||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0316",
|
||
"title": "WS resume re-syncs peer ECDH keys but never the room key — a rotation that lands during the outage strands a non-key-holder on a dead key while the UI still says \"Secured\"",
|
||
"file": "Server/ws/hub.go",
|
||
"line": 665,
|
||
"severity": "medium",
|
||
"why": "registerNow's resume-time E2EE resync (OC-0276) only pushes other participants' stored ECDH public keys *to* the resuming client. The room key itself travels the other way, as a targeted unsequenced voice_e2ee_offer, and one sent while the socket was down is dropped outright. Nothing on either side re-runs the exchange after the resume: the server never re-offers, and the client's only re-announce paths (setupKeyExchange, reannounceForReconnect) are both driven by the LiveKit room, not by the WebSocket, so a pure WS blip leaves a non-key-holder holding the pre-rotation key with no signal and no retry.",
|
||
"repro": "Users A (lower user id, key holder) and B are in a voice call; both LiveKit sessions are healthy. B's WebSocket drops (WiFi blip / proxy restart) but its LiveKit room stays up — nothing tears voice down on a socket drop alone (dispatcher.ts READY comment, livekitSession.ts). The server has not yet observed B's TCP close, so B's old *Client is still in h.clients. While B is offline a third participant C leaves (or A's 5-minute KEY_ROTATION_INTERVAL_MS timer fires, livekitE2EE.ts:109): A rotates the room key and sends a voice_e2ee_offer for B. sendToUserIfInVoiceChannel queues it onto B's dead client and it is lost. B reconnects with last_seq > 0; handleReconnect replays the sequenced voice_state/voice_leave frames, registerNow transfers B's voice state and calls sendVoicePeerKeys — so B's roster and peer-key map are correct — but B's keyProvider still holds the pre-rotation key. From that moment A and B cannot decrypt each other's frames: both hear silence while VoiceWidget still shows \"Secured\", and the only recovery is A's next 5-minute periodic rotation.",
|
||
"evidence": "Server/ws/hub.go:664-666 (registerNow tail):\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n }\nsendVoicePeerKeys (Server/ws/voice_e2ee.go:344-350) only sends buildVoiceE2EEAnnounce(uid, pubKey, sig) for every *other* participant — no room-key material.\n\nThe offer path drops silently while the socket is down (Server/ws/voice_e2ee.go:239-259):\n target, ok := h.clients[targetUserID]\n if !ok { slog.Debug(\"e2ee: key offer dropped, target not connected\", ...); return }\n ...\n target.sendMsg(msg)\n(and while the dead old *Client is still registered, sendMsg queues into a send buffer that registerNow's old.closeSend() then discards).\n\nClient side, the only two re-announce entry points are LiveKit-driven:\n Client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup)\n Client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect)\nNeither is reachable from a WS resume: dispatcher.ts's AUTH_OK handler (lines 270-290) does exactly setAuth() + one channel_focus send, and the READY handler's E2EE work (OC-0201, dispatcher.ts:360-384) runs only on the full-resync tier, which a successful replay resume never takes.\n\nreannounceForReconnect's own comment states the assumption that is unmet here: \"the key holder will send a fresh offer if the key was rotated during our absence\" (livekitE2EE.ts:343-344) — true only because that path re-announces; the WS-resume path does not.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "flow-reconnect",
|
||
"suggestedFix": "One server-side addition at registerNow's resync call site (Server/ws/hub.go:664-666) — do not put it inside sendVoicePeerKeys, since voice_join.go:531 shares that function and the joiner's own announce already comes from its client there:\n\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n // Re-relay THIS client's own stored key back onto VoiceTopic so the\n // key holder's duplicate-announce branch re-wraps the CURRENT room\n // key for us — a rotation offer sent while this socket was down was\n // dropped and no replay tier can recover it.\n if key, sig := c.getE2EEPubKey(); key != \"\" {\n h.sendToVoiceChannelExcept(voiceChID, c.userID,\n buildVoiceE2EEAnnounce(c.userID, key, sig))\n }\n }\n\nThis needs no client change: handleAnnounceInner's dedup branch (livekitE2EE.ts:~812, \"duplicate announce — will re-send offer if key holder\") deliberately falls through to the wrap-and-offer branch on an identical key, so the holder re-offers the live room key. The announce is not blocked by _retiredPeerKeys (that set holds only keys a peer has been moved OFF of, never the live one) and re-runs verifyPeerAnnounce exactly as reannounceForReconnect's announce already does. Guard it on c.lastSeq > 0 if you want it strictly on the resume path.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "cd4cc850",
|
||
"test": "Server/ws/oc_0316_voice_e2ee_resume_rotation_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0317",
|
||
"title": "updateDmLastMessage writes a regressing lastMessageId on the replay branch, defeating its own OC-0242 double-count guard",
|
||
"file": "Client/src/stores/dm.store.ts",
|
||
"line": 153,
|
||
"severity": "medium",
|
||
"why": "The replay branch suppresses the unread/mention increment but still writes `lastMessageId: messageId` unconditionally. When the redelivered id is *lower* than the stored watermark, the watermark is rolled backwards, so the very next frame in the same replay burst no longer looks like a replay and is counted as new. Its sibling `updateDmLastMessagePreview` (lines 186-190) documents this exact hazard (OC-0301) and returns `prev` instead — `updateDmLastMessage` never got the same treatment.",
|
||
"repro": "DM channel 5. Two messages (ids 101 then 102) are delivered by the server in the registerNow→buildReady window, so `ready` lands with unreadCount=2 / lastMessageId=102, and both frames are then drained from the queue as `chat_message` (dispatcher.ts:740 calls updateDmLastMessage for each, since the DM is neither own-message nor active).\n1. frame 101: isReplay = (101 <= 102) = true → unreadCount stays 2, but lastMessageId is overwritten with 101.\n2. frame 102: isReplay = (102 <= 101) = false → unreadCount = 3, and mentionCount = +1 if the message mentioned the reader.\nThe DM sidebar badge shows 3 unread (and a phantom mention) for 2 messages, and it survives until the next full `ready`. Nothing in tests/unit/dm-store.test.ts asserts lastMessageId after a stale call, so the behavior is not locked.",
|
||
"evidence": "const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;\nreturn { channels: [ { ...updated,\n lastMessageId: messageId, // <-- regresses the watermark on a replay\n lastMessage: content,\n lastMessageAt: timestamp,\n unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,\n mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,\n}, ...rest ] };",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Mirror the sibling: in updateDmLastMessage's setState, replace the isReplay ternaries with an early `if (isReplay) return prev;` right after the isReplay computation (dm.store.ts:149). One guard in the shared function; the equal-id case is the same message ready already previewed, so nothing visible is lost.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "7c159c11",
|
||
"test": "Client/tests/unit/dm-store.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0318",
|
||
"title": "Zip install validates plugin.json while the on-disk loader prefers plugin.toml — a plugin's capabilities/commands/entrypoint change out from under the admin at the next restart",
|
||
"file": "Server/plugin/registry.go",
|
||
"line": 422,
|
||
"severity": "medium",
|
||
"why": "Two sources of truth for one plugin directory, read with opposite precedence by the two paths that consume it. `installZipStagedManifest` reads *only* `plugin.json` from the staged zip, and that is the manifest that is validated, shown to the admin, persisted to `plugins.manifest_json`, and used to activate the instance. `scanPluginDirectory` — the path that runs on every server start — prefers `plugin.toml` and only falls back to `plugin.json` when the TOML file is absent. A zip may contain both files (installZipExtract rejects only symlinks and path escapes, not extra regular files), so the manifest that governs the plugin after a restart is one that was never examined at install time. The manifest is the per-plugin ACL (manifest.go:64-67, errors.go:18-22: \"the manifest — not the guest module — is the authority ... so an admin can see the full command surface before enabling the plugin\"), so this defeats exactly the review it exists for.",
|
||
"repro": "Server built with `-tags wazero` (the build where plugins actually execute and where TOML is parsed). Upload a zip through POST /api/v1/admin/plugins/install containing plugin.json with `\"permissions\": [\"commands\"]`, `\"commands\": [{\"name\":\"hello\"}]`, `\"entrypoint\":\"hello.wasm\"` — plus a plugin.toml at the same root declaring `permissions = [\"commands\",\"http\",\"storage\",\"ui\"]`, extra `[[commands]]` entries, and `entrypoint = \"other.wasm\"`. Install succeeds; installZipStagedManifest parses only the JSON, so the admin list, the stored manifest_json, and the immediately-activated instance all show the narrow JSON surface. Restart the server: LoadAll → scanPluginDirectory (loader.go:64) picks plugin.toml, installFromDisk upserts *that* manifest, and activateAll brings the plugin up with the broader capability set, the undeclared-at-review commands, and a different .wasm entrypoint — with no new admin action and no log line noting that the effective manifest changed. The same mechanism bites non-maliciously: an author who ships both files and later edits only plugin.json sees the stale TOML silently win after every restart while the freshly installed process used the JSON.",
|
||
"evidence": "registry.go:421-427 (install path)\n\tmanifestPath := filepath.Join(stageAbs, \"plugin.json\")\n\traw, err := os.ReadFile(manifestPath)\n\tif err != nil { return nil, fmt.Errorf(\"plugin zip: missing plugin.json at root: %w\", err) }\n\tmanifest, err := ParseManifest(raw)\n\nloader.go:63-86 (load path)\n\t// Prefer plugin.toml (wazero build) over plugin.json.\n\tmanifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)\n\t...\n\tif !ok { /* only now read plugin.json */ }\n\nregistry.go:286-296 — the staged tree (including any plugin.toml) is promoted verbatim into finalDir and registered with the JSON manifest.\n`grep -rn \"plugin.toml\" Server/ --include=*.go` matches only manifest_toml.go and the loader comment: nothing in the install path ever looks at it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Make both paths resolve the manifest through one function instead of guarding each caller. Extract loader.go:62-86's precedence into a shared helper and call it from the install path too:\n\n\t// loader.go\n\tfunc loadManifestFromDir(dir string) (*Manifest, error) {\n\t\tif m, ok, err := tryLoadPluginTOML(dir); err != nil {\n\t\t\treturn nil, err\n\t\t} else if ok {\n\t\t\treturn m, nil\n\t\t}\n\t\traw, err := os.ReadFile(filepath.Join(dir, \"plugin.json\"))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ParseManifest(raw)\n\t}\n\nThen replace registry.go:422-427 with `manifest, err := loadManifestFromDir(stageAbs)` (keeping the existing \"missing plugin.json at root\" wrapping for os.IsNotExist) and have scanPluginDirectory call the same helper. The manifest the admin's install validates is then byte-for-byte the one the next restart loads, in both build tags. If keeping JSON-only at install is preferred, the equally small alternative is to reject the ambiguity at the single install site — after extraction, `if _, err := os.Stat(filepath.Join(stageAbs, \"plugin.toml\")); err == nil { return nil, fmt.Errorf(\"plugin zip: must not contain both plugin.json and plugin.toml\") }` — but the shared-helper version also fixes the plain on-disk case where an author edits only one of the two files.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "bbbaeed4",
|
||
"test": "Server/plugin/registry_test.go, Server/plugin/registry_zip_toml_wazero_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0319",
|
||
"title": "\"Large Font\" accessibility toggle is inert — the inline --font-size written on <html> outranks the .large-font class rule",
|
||
"file": "Client/src/styles/app.css",
|
||
"line": 5248,
|
||
"severity": "medium",
|
||
"why": "`.large-font { --font-size: 18px }` targets `document.documentElement`, but that same element permanently carries an *inline* `--font-size` written by `applyStoredAppearance()` and `buildAppearanceTab()`. An inline declaration beats an author class rule without `!important` on the same element, so the class can never take effect. The sibling `.high-contrast` rule six lines above (app.css:5239-5243) carries a comment describing exactly this hazard and was given `!important`; `.reduced-motion` (app.css:5228) also uses `!important`. `.large-font` is the one that was left out.",
|
||
"repro": "1. Fresh install; never touch the Appearance > Font Size slider. At startup main.ts:102 calls applyStoredAppearance(), which writes inline `--font-size: 16px` on <html> (loadPref fallback 16).\n2. Open Settings > Accessibility and toggle \"Large Font\" ON. AccessibilityTab.ts:65 adds the `large-font` class to <html> and the pref is persisted.\n3. Computed `--font-size` on <html> is still `16px` — the inline declaration wins over `.large-font`'s `18px`. `body`'s font-size (base.css:22) is unchanged, so no text anywhere grows.\n4. The toggle renders as ON forever after, and survives restart, while having zero visual effect. The only way to change text size remains the Appearance slider.",
|
||
"evidence": "app.css:5247-5250\n /* Accessibility: large font */\n .large-font {\n --font-size: 18px;\n }\n\nappearance.ts:37-40 (runs unconditionally at startup, main.ts:102)\n document.documentElement.style.setProperty(\"--font-size\", `${loadPref<number>(\"fontSize\", 16)}px`);\nappearance.ts:53\n document.documentElement.classList.toggle(\"large-font\", loadPref<boolean>(\"largeFont\", false));\n\nAppearanceTab.ts:232\n document.documentElement.style.setProperty(\"--font-size\", `${currentFontSize}px`);\nAppearanceTab.ts:100 (slider input)\n document.documentElement.style.setProperty(\"--font-size\", `${size}px`);\n\nAccessibilityTab.ts:60-66\n key: \"largeFont\", ... sideEffect: (nowOn) => { document.documentElement.classList.toggle(\"large-font\", nowOn); }\n\nbase.css:22\n font-size: var(--font-size, 14px); /* on body, inherits html's inline value */\n\nContrast — app.css:5234-5243 (same file, six lines earlier):\n /* ... an inline declaration beats a plain class rule on the same element,\n so these overrides must be !important ... */\n .high-contrast, .high-contrast body { --text-normal: #ffffff !important; ... }\n\nNothing ever clears the inline property: the only removeProperty() calls are helpers.ts:133 (THEME_KEYS only — its own comment at line 131 says --font-size is deliberately excluded) and themes.ts:57 (document.body, not documentElement).",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "One character class of change in the shared rule: `.large-font { --font-size: 18px !important; }` (app.css:5248-5250), matching the .high-contrast/.reduced-motion precedent six lines above. Unlike .high-contrast it need not also target `body` — --font-size is only ever written inline on documentElement, never on body. Note the resulting semantics: while Large Font is on it pins 18px and the Appearance slider is overridden until the toggle is turned off, which is the intended 'accessibility override wins' behavior.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0320",
|
||
"title": "Server self-update is architecture-blind: an ARM64 Linux server downloads, verifies and commits the amd64 binary, then cannot restart",
|
||
"file": "Server/updater/download.go",
|
||
"line": 256,
|
||
"severity": "medium",
|
||
"why": "serverDownloadAssetName keys only on runtime.GOOS and hardcodes the amd64 tarball; runtime.GOARCH is never consulted anywhere in the updater package. The signature/manifest/checksum chain all validate (they correctly describe the amd64 artifact), so nothing fails closed, and the sibling downloader Server/ws/livekit_download.go:49 shows the intended arch gate that is missing here.",
|
||
"repro": "On a linux/arm64 server built from source (docs/contributing.md:13 lists Linux ARM64 server as supported; README.md:48 says no ARM64 binary is published, so source build is the documented path), the operator clicks Apply Update in the admin panel:\n1. fetchLatestRelease sets downloadURL to the release's chatserver-linux-amd64.tar.gz; hasRequiredServerAssetsFor(\"linux\", ...) (updater.go:299) returns true, so UpdateInfo.UpdateAvailable is true and handleApplyUpdate (Server/admin/update_handlers.go:84-95) proceeds.\n2. DownloadAndVerify succeeds end to end: parseChecksumFileAny matches the bare `chatserver-linux-amd64.tar.gz` checksum line, VerifyReleaseManifest binds that same asset name, the minisign signature on the manifest verifies, and the tarball SHA256 matches. Nothing rejects the wrong architecture.\n3. extractChatserverFromTarGz writes the amd64 ELF to exePath+\".new\" and chmods it 0755.\n4. applyStagedUpdate (update_handlers.go:198-212) renames the working arm64 binary to exePath+\".old\" and commits the amd64 file to exePath; os.SameFile passes because it is the verified file.\n5. performRestartHandoff (Server/restart.go:183) calls SpawnDetached(exePath, os.Args[1:]), which fails with `fork/exec ...: exec format error`; it logs \"spawning the replacement process FAILED — manual restart required\" and the process exits.\nResult: server is down, exePath holds an unrunnable foreign-arch binary, and recovery requires manually restoring chatserver.old on the host. Expected: the update check should refuse (RequiredAssetsPresent=false / an explicit unsupported-architecture error) the way livekitAssetName does. No test locks the current behavior — updater_test.go:462 asserts only the GOOS->name mapping.",
|
||
"evidence": "Server/updater/download.go:252-260\n\tfunc serverDownloadAssetName(goos string) string {\n\t\tswitch goos {\n\t\tcase \"windows\":\n\t\t\treturn windowsServerBinary\n\t\tcase \"linux\":\n\t\t\treturn linuxServerArchive // const = \"chatserver-linux-amd64.tar.gz\"\n\t\tdefault:\n\t\t\treturn \"\"\n\nServer/updater/updater.go:256\n\twantBinary := serverDownloadAssetName(runtime.GOOS) // no GOARCH\n\nServer/updater/verify.go:48-56 checksumEntryNamesForGOOS(goos) likewise hardcodes\n\t\"linux/chatserver-linux-amd64.tar.gz\", \"chatserver-linux-amd64.tar.gz\"\n\nContrast, Server/ws/livekit_download.go:49-57 (fails closed on arch):\n\tfunc livekitAssetName(version, goos, goarch string) (string, error) {\n\t\tswitch goarch {\n\t\tcase \"amd64\", \"arm64\": arch = goarch\n\t\tcase \"arm\": arch = \"armv7\"\n\t\tdefault: return \"\", fmt.Errorf(\"livekit auto-download does not support architecture %s ...\")",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Give serverDownloadAssetName a goarch parameter and fail closed on anything the release does not actually ship, so the empty downloadURL makes hasRequiredServerAssetsFor return false and the apply endpoint answers MISSING_ASSETS instead of installing a foreign-arch binary. In Server/updater/download.go:252: `func serverDownloadAssetName(goos, goarch string) string { if goarch != \"amd64\" { return \"\" }; switch goos { ... } }`, and pass runtime.GOARCH at Server/updater/updater.go:256. That single gate is sufficient — no other call site chooses the asset — though mirroring it in checksumEntryNamesForGOOS (Server/updater/verify.go:48) keeps the two name tables consistent when an arm64 tarball is eventually published.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0321",
|
||
"title": "LoadOrGenerateTOTPKey treats ANY totp.key read error as \"no key yet\" and overwrites the file with a fresh key, permanently orphaning every stored TOTP secret",
|
||
"file": "Server/auth/totp_encrypt.go",
|
||
"line": 45,
|
||
"severity": "medium",
|
||
"why": "Step 2 only handles the success branch (`if data, err := os.ReadFile(keyPath); err == nil`). Every non-nil read error — EACCES, EIO, EISDIR, ELOOP, EMFILE, or a dangling symlink's ENOENT — falls through to step 3, which generates a brand-new random key and writes it with `os.WriteFile(keyPath, ..., 0o600)` (O_WRONLY|O_CREATE|O_TRUNC). Wherever the write then succeeds, the original key is destroyed. This directly contradicts the invariant the file's own siblings enforce and that TestLoadOrGenerateTOTPKey_RejectsBadKeyFile pins for the corrupt-content cases (\"a corrupt totp.key is a hard error rather than a silent regeneration (which would orphan every stored secret)\" — Server/auth/totp_encrypt_test.go:212-249, which asserts the file is *not* rewritten). Only the invalid-hex and wrong-length branches fail closed; the read-error branch fails open.",
|
||
"repro": "On a running server with 2FA users: `chmod 200 data/totp.key` (write-only — an ACL/umask/restore artifact; the same happens on a transient EIO, EMFILE, or a symlinked key file whose target is temporarily gone). Restart the server. os.ReadFile returns EACCES, the read branch is skipped, a fresh 32-byte key is generated and os.WriteFile truncates data/totp.key with it (the file is writable, so the write succeeds). The server logs \"auto-generated TOTP encryption key and saved to disk\" and starts normally. Now every users.totp_secret in the DB is AES-GCM ciphertext under the destroyed key: totpChallengeSecret (Server/api/totp_handler.go:160) calls DecryptTOTPSecret, GCM authentication fails, the fail-closed branch (totp_encrypt.go:139-149) returns an error and POST /api/v1/auth/verify-totp answers 500 INTERNAL_ERROR for every 2FA account, forever. The accounts cannot re-enroll either, because login never gets past the second factor. The old key is gone, so there is no recovery. The correct behaviour is the one the wrong-hex/wrong-length branches already implement: return an error for any error other than os.IsNotExist(err).",
|
||
"evidence": "Server/auth/totp_encrypt.go:45-55\n\tkeyPath := filepath.Join(dataDir, \"totp.key\")\n\tif data, err := os.ReadFile(keyPath); err == nil {\n\t\t...\n\t\treturn key, nil\n\t}\n\n\t// 3. Auto-generate a new key.\n\tkey := make([]byte, totpKeyBytes)\n\tif _, err := rand.Read(key); err != nil { ... }\n\tif err := os.MkdirAll(dataDir, 0o700); err != nil { ... }\n\tif err := os.WriteFile(keyPath, []byte(hex.EncodeToString(key)), 0o600); err != nil { ... }\n\tslog.Warn(\"auto-generated TOTP encryption key and saved to disk; ...\")",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Distinguish \"absent\" from \"failed\" at the single read site: `data, err := os.ReadFile(keyPath); if err == nil { ...existing decode/length checks... }; if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf(\"reading totp.key: %w\", err) }` before falling through to generation. routerTOTPKey already panics on a non-nil error with a configured DataDir, so this fails the boot closed exactly like the corrupt-content branches.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0322",
|
||
"title": "isValidHost accepts an underscore in a hostname that every Rust proxy rejects, so the client saves and accepts a server it can never reach",
|
||
"file": "Client/src/lib/hostValidation.ts",
|
||
"line": 33,
|
||
"severity": "low",
|
||
"why": "The DNS-name branch uses `[\\w.-]+`, and JS `\\w` is `[A-Za-z0-9_]` — so a host containing `_` passes. Both `http_proxy::validate_remote_host` and `livekit_proxy::validate_remote_host` allow only `is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']'` and reject `_`. Since every REST call routes through `ensureHttpProxy` (api.ts:88), an underscore host is accepted by the Add Server modal and by `api.setConfig`, then fails 100% of REST traffic. The file's own header comment and ServerPanel.ts:310-313 both state the invariant that this validator mirrors the Rust one (\"an address accepted here is also accepted by the actual connection path, and vice versa\").",
|
||
"repro": "Connect page -> \"Add Server\" -> address `my_server.lan:8443`. ServerPanel.ts:314 `isValidHost(addr)` returns true (JS `\\w` matches `_`), so the profile is saved. The connect page then health-checks it: `api.getHealth(\"my_server.lan:8443\")` -> `ensureHttpProxy(host)` -> `invoke(\"start_http_proxy\", {remoteHost})` -> http_proxy.rs:101 `validate_remote_host` -> Err(\"remote_host contains unexpected characters\"). Every REST call fails identically, so login is impossible and the profile shows permanently unreachable; `start_livekit_proxy` rejects the same host, so voice is dead too. The WS proxy has no charset check, so `wss://my_server.lan/api/v1/ws` would have connected — the client accepts an address that only one of its three transports can use.",
|
||
"evidence": "hostValidation.ts:33 return /^[\\w.-]+(:\\d+)?$/.test(host); // \\w includes '_'\n\nhttp_proxy.rs:83-88\n if !remote_host\n .chars()\n .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))\n {\n return Err(\"remote_host contains unexpected characters\".into());\n }\n\nlivekit_proxy.rs:110-116 (identical charset, same rejection)\n\napi.ts:87-89\n async function baseUrl(): Promise<string> {\n return `${await ensureHttpProxy(config.host)}/api/v1`;\n }\n\ncommands.rs tests pin the Rust side as deliberate:\n (\"underscore\", \"chat_example.com\".into()), // expected to be rejected\n\ntests/unit/host-validation.test.ts has no underscore case, so nothing locks the TS behavior.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "tauri-rust",
|
||
"suggestedFix": "One character-class change in the shared validator, hostValidation.ts:33: replace `\\w` with an explicit ASCII class so the DNS branch matches the Rust charset — `return /^[A-Za-z0-9.-]+(:\\d+)?$/.test(host);`. Add an underscore rejection case to tests/unit/host-validation.test.ts mirroring commands.rs:364 so the two validators stay pinned together.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "e95c57a4",
|
||
"test": "Client/tests/unit/host-validation.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0323",
|
||
"title": "mark_read/channel_focus zeroes mention_count from a stale latest-message snapshot, permanently destroying a mention raised during the round trip",
|
||
"file": "Server/service/channel.go",
|
||
"line": 289,
|
||
"severity": "low",
|
||
"why": "HandleChannelFocus reads `latestID` on the reader pool and then, two round trips later, issues `UpdateReadState(user, channel, latestID)` — whose SQL unconditionally sets `mention_count = 0` with no message-id guard. A mention raised for a message NEWER than the snapshotted `latestID` (by SendMessage's background `applyMentionCounts` goroutine) is wiped by that blind write. The sibling writer `IncrementMentionCounts` was explicitly hardened against the mirror-image race with an atomic `WHERE read_states.last_message_id < ?` guard; the clearing side has no equivalent, and nothing ever recomputes mention_count (GetChannelUnreadCounts reads the stored column), so the badge is lost forever.",
|
||
"repro": "User U has read state (last_message_id=90, mention_count=0) in channel C, whose newest message id is 100. G1 = U's WS readPump handling `mark_read` for C (right-click → \"Mark as Read\", or one frame of the client's markAllRead burst — U is NOT looking at C). G2 = another user's send of \"@U ping\" in C.\n\n1. G1: GetLatestMessageID(C) -> 100.\n2. G1: GetReadState(U,C) -> (90, 0, found) — the skip branch does not fire.\n3. G2: CreateMessageWithMentions commits message id=101 mentioning U; SendMessage returns and `s.bg` spawns applyMentionCounts.\n4. G2 (background goroutine): IncrementMentionCounts(C, 101, [U]) -> guard `90 < 101` passes -> read_states.mention_count becomes 1.\n5. G1: UpdateReadState(U, C, 100) -> last_message_id=100, mention_count=0.\n\nResult: U's row is (last_message_id=100, mention_count=0) while message 101 mentions U and is unread. The channel still shows an unread count (101 > 100) but the @-mention badge is gone, and no code path ever re-derives it — IncrementMentionCounts for 101 has already run and DecrementMentionCounts only ever subtracts. The same unguarded wipe also runs from Server/service/message_crud.go:71 (SendMessage advancing the author's own read state to msgID), where a concurrent mention from another user with a higher id is destroyed identically.",
|
||
"evidence": "Server/service/channel.go:281-291\n latestID, err := s.st.GetLatestMessageID(ctx, channelID)\n if err == nil {\n lastRead, mentions, found, rsErr := s.st.GetReadState(ctx, userID, channelID)\n if rsErr == nil && found && lastRead == latestID && mentions == 0 { ... return ch, nil }\n if wErr := s.st.UpdateReadState(ctx, userID, channelID, latestID); wErr != nil { ... }\n\nServer/db/queries/sqlite/messages.sql:32-39 (UpdateReadState)\n ON CONFLICT(user_id, channel_id) DO UPDATE SET\n last_message_id = excluded.last_message_id,\n mention_count = 0; -- no msgID guard\n\nServer/db/mention_queries.go:229-231 (IncrementMentionCounts — the guard that exists on the other side)\n ON CONFLICT(user_id, channel_id) DO UPDATE SET\n mention_count = mention_count + 1\n WHERE read_states.last_message_id < ?\n\nServer/service/message_crud.go:104-106 — the increment runs on its own goroutine:\n s.bg(func() { s.applyMentionCounts(context.WithoutCancel(ctx), channelID, msgID, authorID, mentions, isDM, participantIDs) })\nServer/service/message.go:173 — bg: func(fn func()) { go fn() }",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "concurrency",
|
||
"suggestedFix": "Make the read-and-clear atomic in one writer statement instead of passing a snapshot: replace the GetLatestMessageID/UpdateReadState pair with a single upsert that computes the id inline, e.g. `INSERT INTO read_states (user_id, channel_id, last_message_id, mention_count) VALUES (?, ?, (SELECT COALESCE(MAX(id),0) FROM messages WHERE channel_id = ? AND deleted = 0), 0) ON CONFLICT(user_id, channel_id) DO UPDATE SET last_message_id = excluded.last_message_id, mention_count = 0`. Any message committed before it is covered by last_message_id; any committed after finds `last_message_id < msgID` false only when it is genuinely covered, so IncrementMentionCounts' existing guard closes the other direction. One shared query change; HandleChannelFocus keeps its GetReadState skip-check, and message_crud.go:72 (which already has the exact msgID) can keep calling the existing signature.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0324",
|
||
"title": "Login's per-username lockout key uses Go Unicode case-folding while the account lookup uses SQLite COLLATE NOCASE — two distinct accounts share one lockout bucket",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 471,
|
||
"severity": "low",
|
||
"why": "Identity is normalized by two different rules that disagree. `loginAuthenticate` derives the per-username failure/lockout key with `strings.ToLower` (full Unicode folding), while the account it is protecting is resolved with `WHERE username = ? COLLATE NOCASE` (Server/db/queries/sqlite/users.sql:5) and stored under `username TEXT NOT NULL UNIQUE COLLATE NOCASE` (Server/migrations/001_initial_schema.sql:24) — SQLite's NOCASE folds ASCII A–Z only. `auth.ValidateUsername` (Server/auth/helpers.go:19-47) rejects only control and Cf runes, and registration applies no case normalization (auth_handler.go:298 `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))`), so two accounts that differ only by a non-ASCII letter's case are two independent rows — yet they collapse to a single `login_user_fail:` / `login_user_lock:` key. Failed logins against one account therefore lock the other out of its own account. The disagreement is one-directional (anything equal under NOCASE is also equal under ToLower), so this is collateral over-locking, not an auth bypass.",
|
||
"repro": "1. Register \"ärger\" (password P1) and \"Ärger\" (password P2). Both succeed: the UNIQUE COLLATE NOCASE index does not fold \"Ä\"/\"ä\", so two separate users rows exist. 2. POST /api/v1/auth/login 10 times with username \"ärger\" and a wrong password. At auth_handler.go:471 unameKey = \"ärger\"; :515 records each attempt under \"login_user_fail:ärger\"; on the 10th, :541-542 calls limiter.Lockout(\"login_user_lock:ärger\", loginUserLockoutDuration = 15m) (Server/api/constants.go:119,126). 3. The owner of \"Ärger\" now POSTs /auth/login with the CORRECT password P2. Line 471 computes strings.ToLower(\"Ärger\") == \"ärger\", so line 473's IsLockedOut(\"login_user_lock:ärger\") is true and the request is answered 429 RATE_LIMITED \"account temporarily locked due to too many failed attempts\" for the full 15 minutes — even though that account has had zero failed attempts. The same collision also means an attacker can spend attempts against a username string that matches no account at all (the constant-time path at :525-535 still records the failure for a non-existent user) and have it trip a real account's lockout.",
|
||
"evidence": "Server/api/auth_handler.go:471-473,501,515,541-542:\n\tunameKey := strings.ToLower(req.Username)\n\tuserLockKey := \"login_user_lock:\" + unameKey\n\tif limiter.IsLockedOut(userLockKey) { ... 429 ... }\n\t...\n\tuserFailKey := \"login_user_fail:\" + unameKey\n\t... limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) ...\n\t\tif !limiter.Check(userFailKey, ...) { limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration) }\n\nServer/db/queries/sqlite/users.sql:5:\n\tFROM users WHERE username = ? COLLATE NOCASE;\n\nServer/migrations/001_initial_schema.sql:24:\n\tusername TEXT NOT NULL UNIQUE COLLATE NOCASE,\n\nThe comment above line 471 states the intent explicitly — \"canonicalize the username the same way GetUserByUsername does (COLLATE NOCASE)\" — which strings.ToLower does not do for non-ASCII.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "state-desync",
|
||
"suggestedFix": "One-token change at Server/api/auth_handler.go:471 — use the fold the repo already established for this exact hazard: `unameKey := db.LowerASCII(req.Username)` instead of `strings.ToLower(req.Username)` (package api already imports Server/db). That makes the lockout key agree exactly with COLLATE NOCASE, keeps the intended admin/Admin/ADMIN bucket sharing, and stops distinct non-ASCII-case accounts from colliding. Drop the now-unused `strings` import only if nothing else in the file uses it (it does — leave it).",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0325",
|
||
"title": "Search overlay parses the server's naive-UTC timestamp as local time, so a search hit shows a different clock time than the same message in the message list",
|
||
"file": "Client/src/components/SearchOverlay.ts",
|
||
"line": 57,
|
||
"severity": "low",
|
||
"why": "`r.timestamp` is `messages.timestamp` verbatim (db/models.go:168, populated by the raw column in SearchMessages), i.e. \"2026-08-22 15:00:00\" — UTC with no zone designator. `new Date(ts)` interprets that as local wall-clock, shifting the rendered time by the viewer's UTC offset. The message list renders the identical string through `parseTimestamp()` (message-list/formatting.ts:25), which appends \"Z\" — so the two surfaces disagree about the same message.",
|
||
"repro": "Viewer in UTC-5. A message is sent at 10:00 local (15:00 UTC); the server stores \"2026-08-22 15:00:00\". The message list shows \"10:00 AM\". Search for that message in the Search overlay: `new Date(\"2026-08-22 15:00:00\")` parses as 15:00 local → the result row is labelled \"Aug 22 03:00 PM\". Clicking through jumps to a message the list says was sent at 10:00 AM.",
|
||
"evidence": "55: function formatTimestamp(ts: string): string {\n56: try {\n57: const d = new Date(ts);\n58: return (\n59: d.toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) +\n60: \" \" +\n61: d.toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\" })",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Use the shared helper in formatTimestamp: `const d = parseTimestamp(ts);` (import { parseTimestamp } from \"@components/message-list/formatting\") in place of `new Date(ts)` at SearchOverlay.ts:57.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0326",
|
||
"title": "Pinned-messages list parses the naive-UTC timestamp as local time, mislabelling the date by a full day near the UTC day boundary",
|
||
"file": "Client/src/components/PinnedMessages.ts",
|
||
"line": 27,
|
||
"severity": "low",
|
||
"why": "`msg.timestamp` comes from `GetPinnedMessages` → `MessageAPIResponse.Timestamp`, the raw SQLite \"YYYY-MM-DD HH:MM:SS\" UTC string. `new Date(iso)` reads it as local wall-clock rather than UTC (the project's `parseTimestamp()` helper exists for exactly this), and because only the *date* is rendered, any message whose UTC time and local time fall on different calendar days is labelled with the wrong day.",
|
||
"repro": "Viewer in UTC-5. A message is sent at 20:00 local on Aug 21, i.e. 01:00 UTC on Aug 22; the server stores \"2026-08-22 01:00:00\". The message list (via parseTimestamp) groups it under Aug 21. Open the pinned-messages panel: `new Date(\"2026-08-22 01:00:00\")` is Aug 22 01:00 local, so the pin is labelled \"Aug 22, 2026\" — one day later than the same message's day divider in the channel.",
|
||
"evidence": "26:function formatPinTime(iso: string): string {\n27: const d = new Date(iso);\n28: if (isNaN(d.getTime())) return iso;\n29: return d.toLocaleDateString(undefined, { month: \"short\", day: \"numeric\", year: \"numeric\" });",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Replace `const d = new Date(iso);` at PinnedMessages.ts:27 with `const d = parseTimestamp(iso);` (import { parseTimestamp } from \"@components/message-list/formatting\"); the existing isNaN guard still covers a malformed string.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0327",
|
||
"title": "Moderator server-mute/deafen also mutes the target's screen-share audio at the SFU, contradicting both its own contract and the client's mute policy",
|
||
"file": "Server/ws/livekit.go",
|
||
"line": 205,
|
||
"severity": "low",
|
||
"why": "MuteParticipantAudio documents itself as muting \"every microphone track the participant publishes\", but its filter is `t.Type != livekit.TrackType_AUDIO` — which admits screen-share audio as well as the microphone. livekit.TrackInfo carries a Source field (TrackSource_MICROPHONE = 2 vs TrackSource_SCREEN_SHARE_AUDIO = 4) that is never consulted, and the client publishes screen-share audio under exactly that source (Client/src/lib/screenShare.ts:373, `source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio`). The client's own mute/deafen implementation states and enforces the opposite rule in two places — audioElements.ts:121-127 (`publication.source !== Track.Source.ScreenShareAudio` before refusing to attach while deafened) and audioElements.ts:216 (`if (publication.source === Track.Source.ScreenShareAudio) continue;`), both commented \"Screen-share/stream audio is exempt: muting or deafening yourself gates voices, not the content someone is streaming\". So the server's moderator mute silently reaches past the voice channel it is scoped to and kills a live screen share's audio for every viewer.",
|
||
"repro": "User B joins voice channel #10 and starts a screen share with audio (screenShare.ts publishes the tab/system audio track as Track.Source.ScreenShareAudio). Users A and C are listening to the shared content. A moderator sends {\"type\":\"voice_mod_mute\",\"payload\":{\"channel_id\":10,\"user_id\":B,\"muted\":true}} intending to silence B's microphone. Server: ApplyVoiceServerMute sets server_muted=1/muted=1, then MuteParticipantAudio iterates p.Tracks and calls MutePublishedTrack on BOTH B's microphone SID and B's screen-share-audio SID (both are TrackType_AUDIO). B's client receives RemoteMute for the screen-share-audio publication and calls pub.mute() — A and C abruptly lose the shared application's audio while the video keeps playing, and nothing in the OwnCord UI shows why (B's screen-share tile has no publisher-side audio mute control). Symmetrically, when the moderator clears the mute, MuteParticipantAudio(muted=false) force-unmutes that screen-share-audio publication again. The same happens via voice_mod_deafen, which passes c.Deafened() into the identical call. Expected per the function's own doc comment and the client's stated policy (audioElements.ts:118-120): only the microphone track (t.Source == livekit.TrackSource_MICROPHONE) should be affected.",
|
||
"evidence": "Server/ws/livekit.go:186-217\n\n// MuteParticipantAudio mutes or unmutes every microphone track the participant\n// publishes, so a moderator's server mute holds at the SFU instead of relying\n// on the target's client to honor it.\nfunc (c *LiveKitClient) MuteParticipantAudio(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error {\n\t...\n\tfor _, t := range p.Tracks {\n\t\tif t.Type != livekit.TrackType_AUDIO {\n\t\t\tcontinue\n\t\t}\n\t\tif _, mErr := c.roomSvc.MutePublishedTrack(ctx, &livekit.MuteRoomTrackRequest{\n\t\t\tRoom: roomName, Identity: identity, TrackSid: t.Sid, Muted: muted,\n\t\t}); mErr != nil { ... }\n\t}\n\nCallers: Server/ws/voice_moderation.go:237 (voice_mod_mute -> MuteParticipant(..., c.Muted())) and Server/ws/voice_moderation.go:306 (voice_mod_deafen -> MuteParticipant(..., c.Deafened())).\n\nDelivery on the target's side: node_modules/livekit-client/src/room/participant/LocalParticipant.ts:248-256 —\n this.engine.on(EngineEvent.RemoteMute, (trackSid, muted) => {\n const pub = this.trackPublications.get(trackSid);\n if (!pub || !pub.track) return;\n if (muted) pub.mute(); else pub.unmute();\n });",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "flow-voice",
|
||
"suggestedFix": "Skip screen-share audio explicitly rather than whitelisting the microphone, so audio tracks that report TrackSource_UNKNOWN (0) still get muted: in Server/ws/livekit.go:206 change the filter to `if t.Type != livekit.TrackType_AUDIO || t.Source == livekit.TrackSource_SCREEN_SHARE_AUDIO { continue }`. One guard in the shared function covers both the voice_mod_mute and voice_mod_deafen call sites.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0328",
|
||
"title": "Channel unread/mention badges have no message-id replay guard at all, while the DM path has one and Channel.lastMessageId is populated for exactly that purpose but never read",
|
||
"file": "Client/src/stores/channels.store.ts",
|
||
"line": 346,
|
||
"severity": "low",
|
||
"why": "incrementUnread/incrementMention bump unconditionally. `Channel.lastMessageId` is declared (line 30) and filled from `ready`'s `last_message_id` (line 100), but no call site anywhere in the client reads it — the identical registerNow->buildReady double-delivery window that OC-0242 fixed for DMs (dm.store.ts) is unguarded for server channels.",
|
||
"repro": "A message is broadcast into channel #general between registerNow (Server/ws/serve.go:853, which subscribes the socket) and buildReady (serve.go:884) on a fresh connect or a full resync. The server counts it in read_states.unread_count, so `ready` carries unread_count = 1 and last_message_id = <that id>; `ready` is written straight to the connection by handshakeWrite while the broadcast waits in the client's send queue. setChannels applies unreadCount = 1, then writePump drains the queued chat_message and dispatcher.ts:711 calls incrementUnread -> the sidebar shows 2 unread for 1 message, and an @mention in it shows a mention count of 2. dm.store.ts:148 guards this exact case for DMs with `messageId <= updated.lastMessageId`; the channel path has no equivalent.",
|
||
"evidence": "channels.store.ts:346-363\n export function incrementUnread(channelId: number, evenIfActive = false): void {\n channelsStore.setState((prev) => {\n if (prev.activeChannelId === channelId && !evenIfActive) return prev;\n const existing = prev.channels.get(channelId);\n if (existing === undefined) return prev;\n const updated: Channel = { ...existing, unreadCount: existing.unreadCount + 1 };\n ...\n\nchannels.store.ts:30 / :100 — the watermark is stored and never consulted\n readonly lastMessageId: number | null;\n lastMessageId: ch.last_message_id ?? null,\n\n`grep -rn lastMessageId` over src/ shows the only readers are dm.store.ts and SidebarDmHelpers.ts — nothing reads Channel.lastMessageId.\n\ncaller: dispatcher.ts:706-715\n if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {\n incrementUnread(payload.channel_id, isDetached);\n if (isMention) incrementMention(payload.channel_id, isDetached);\n }",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"suggestedFix": "Mirror the DM shape in the one shared store function rather than at the call site: give channels.store a single guarded entry point, e.g. `noteChannelMessage(channelId, messageId, isMention, evenIfActive)`, whose setState computes `const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId;` and writes `unreadCount: isReplay ? existing.unreadCount : existing.unreadCount + 1`, `mentionCount: isMention && !isReplay ? existing.mentionCount + 1 : existing.mentionCount`, and `lastMessageId: Math.max(messageId, existing.lastMessageId ?? 0)` — both counters behind ONE watermark read, exactly as OC-0242 required for updateDmLastMessage (a guard split across the two functions cannot work: the first call would already have advanced the watermark). Then replace the pair at dispatcher.ts:710/713 with the single call; it is the only production caller of incrementUnread/incrementMention, so the existing exports can stay for the tests.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "3e74c968",
|
||
"test": "Client/tests/unit/channels.store.test.ts, Client/tests/unit/dispatcher.test.ts",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0329",
|
||
"title": "DM profile note falls back to the pre-scoping unscoped key on every miss and never consumes it, so one server's private note about user N is shown for user N on every other server, forever",
|
||
"file": "Client/src/components/DmProfileSidebar.ts",
|
||
"line": 115,
|
||
"severity": "low",
|
||
"why": "loadNote reads the host-scoped key, and on a miss reads through to the legacy unscoped `owncord:dm-note:{userId}` key — but it neither migrates the value into the scoped key nor removes the legacy one. Because user ids are per-server SQLite autoincrement integers, that read-through fires on every server the user connects to. The two other modules with the same shape got this right: channel-mutes.ts:106-111 writes the scoped key and then `localStorage.removeItem` on the legacy one, citing OC-0288 for why leaving it in place lets every subsequent host inherit server A's data; identity.ts::migrateLegacyIdentityKey saves-then-deletes for the same reason.",
|
||
"repro": "On a build predating the host-scoping fix (MainPage.ts:253, OC-0177), write a DM note about user 7 on server A — it lands at `owncord:dm-note:7`. Upgrade. Connect to server B (a completely different host) and open the DM profile sidebar for B's user 7: loadNote misses `owncord:dm-note:B:7` and returns server A's note text about a different person. The same happens on server C, D, ... because saveNote (line 121) only ever writes the scoped key and nothing ever deletes the legacy entry, so the read-through is permanent rather than one-time.",
|
||
"evidence": "DmProfileSidebar.ts:107-118\n function loadNote(userId: number, host: string): string {\n try {\n if (host !== \"\") {\n const scoped = localStorage.getItem(scopedNoteKey(userId, host));\n if (scoped !== null) return scoped;\n }\n // Fall back to the legacy key so a note saved before per-server scoping\n // (or while the host was unknown) is not silently lost.\n return localStorage.getItem(legacyNoteKey(userId)) ?? \"\";\n\ncontrast channel-mutes.ts:106-111\n if (keyExists(MUTED_KEY)) {\n const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));\n writeMuted(legacy);\n localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);\n return legacy;\n }",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-lib",
|
||
"suggestedFix": "Consume the legacy key in the one shared reader. In loadNote (DmProfileSidebar.ts:107-118), replace the fallback `return localStorage.getItem(legacyNoteKey(userId)) ?? \"\";` with a read-migrate-delete: read the legacy value; if it is non-null and host !== \"\", `localStorage.setItem(scopedNoteKey(userId, host), legacy); localStorage.removeItem(legacyNoteKey(userId));` before returning it (all inside the existing try/catch). One host inherits the pre-scoping note exactly once — as the existing test expects — and every later host sees an empty note.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0330",
|
||
"title": "Pinned-messages panel prints the raw username and drops the author's user id, so a nickname can never be shown",
|
||
"file": "Client/src/pages/main-page/OverlayManagers.ts",
|
||
"line": 77,
|
||
"severity": "low",
|
||
"why": "mapToPinnedMessage flattens the pinned message's author to `msg.user.username` before handing it to PinnedMessages, which renders that string verbatim (PinnedMessages.ts:54). Because the user id is discarded in the mapping, the panel cannot resolve the live membersStore nickname the way every other author surface does, so a renamed/nicknamed user is labelled differently in the pinned panel than in the message list the panel jumps into. The avatar colour is also hashed from the raw username (line 80), so the letter and colour disagree with the message row's avatar too.",
|
||
"repro": "1. Give user `bob` the nickname \"Bobby\" (membersStore.displayName). 2. Pin one of bob's messages. 3. The message row in the channel reads \"Bobby\"; open the pin panel (pin button in the chat header) and the same pinned message is attributed to \"bob\". 4. Jump from the pin to the message and the name changes under you.",
|
||
"evidence": "OverlayManagers.ts:73-81\n return {\n id: msg.id,\n author: msg.user.username,\n content: msg.content,\n timestamp: msg.created_at ?? msg.timestamp ?? \"\",\n avatarColor: pickPinAvatarColor(msg.user.username),\n };\n\nPinnedMessages.ts:54\n const authorEl = createElement(\"span\", { class: \"pinned-msg__author\" }, msg.author);\n\nmembers.store.ts:182 `memberDisplayName()` and avatar.ts:51 `resolveDisplayName()` are the shared resolvers every other surface uses; neither is reachable from here because `PinnedMessage` carries no id.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Fix in the shared mapper only: widen the param to `user: { id: number; username: string; avatar?: string|null; display_name?: string|null }` and set `author: resolveDisplayName(resolveAuthor(msg.user))` (imports from @lib/avatar and @components/message-list/formatting). Keep `pickPinAvatarColor(msg.user.username)` so the hue stays stable across renames. Existing tests still pass — resolveAuthor falls back to the payload/username when the member is not in the store.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0331",
|
||
"title": "Admin API-token table parses two naive-UTC SQLite timestamps as local time, so Created / Last Used are shown shifted by the viewer's UTC offset",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 1679,
|
||
"severity": "low",
|
||
"why": "`api_tokens.created_at` defaults to SQLite `datetime('now')` and `last_used_at` is written with `datetime('now')` — both produce \"YYYY-MM-DD HH:MM:SS\" in UTC with no zone suffix, and both reach the panel as plain strings (db/models.go:80-81, dbgen ListAPITokensRow.CreatedAt is `string`). `new Date(\"2026-03-19 08:29:41\")` is a non-ISO form that V8 parses as LOCAL time, so the rendered clock time is wrong by the browser's UTC offset. The `expires_at` column in the very same row is correct, because CreateAPIToken formats it with an explicit `Z` (db/apitoken_queries.go:27) — so one column in the table is right and two are wrong.",
|
||
"repro": "Run the server with a browser in UTC+02:00. Mint an API token at 10:29 local (08:29 UTC). Open Admin → API Tokens: the Created column reads \"08:29:41 AM\" (parsed as local) instead of \"10:29:41 AM\". Use the token once and Last Used is wrong by the same two hours, while the Expires column for the same row — stored with a trailing Z — renders correctly, so the table contradicts itself.",
|
||
"evidence": "Server/admin/static/index.html:1679-1681\n html+='<td>'+(t.created_at?new Date(t.created_at).toLocaleString():'')+'</td>';\n html+='<td>'+(t.last_used?new Date(t.last_used).toLocaleString():'<span ...>never</span>')+'</td>';\n html+='<td>'+(t.expires_at?new Date(t.expires_at).toLocaleString():'<span ...>never</span>')+'</td>';\n\nServer/migrations/018_api_tokens.sql:19 created_at TEXT NOT NULL DEFAULT (datetime('now')),\nServer/db/queries/sqlite/apitokens.sql:34 UPDATE api_tokens SET last_used_at = datetime('now') ...\nServer/db/apitoken_queries.go:27 s := expiresAt.UTC().Format(\"2006-01-02T15:04:05Z\") // <- the one that is right",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Add one helper next to fmtBytes and use it at the three token-table sites: `function utcDate(s){return new Date(/[Zz]|[+-]\\d\\d:?\\d\\d$/.test(s)?s:s.replace(' ','T')+'Z')}` then `utcDate(t.created_at).toLocaleString()` and `utcDate(t.last_used).toLocaleString()`. Passing expires_at through the same helper is a no-op (it already ends in Z) and keeps the column consistent. Server-side alternative — formatting created_at/last_used_at with an explicit Z like CreateAPIToken does — would need a migration for existing rows.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0332",
|
||
"title": "Client auto-update is permanently and silently dead on a bare-IPv6 server — MainPage builds the updater URL without bracketing the host",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 845,
|
||
"severity": "low",
|
||
"why": "`https://${apiConfig.host}` splices a possibly-bare IPv6 literal straight into a URL authority with no `bracketBareIPv6Host` call, unlike every other URL-building site in the client (ws.ts:544, admin-panel.ts:27, attachments.ts:60). The resulting string is not a parseable URL, so `build_updater` rejects it before any network call, and `checkForUpdate` swallows the rejection and reports \"no update available\".",
|
||
"repro": "Log in to a server saved with a bare IPv6 host, e.g. `2001:db8::1` (accepted by `isValidHost`, hostValidation.ts:31, and by `api.setConfig`). MainPage.ts:845 produces `serverUrl = \"https://2001:db8::1\"`. In update_commands.rs, `validate_server_url` passes (the `starts_with(\"https://\")` check succeeds, and its userinfo check is skipped because `url::Url::parse` returns Err), then `build_updater` line 98-100 does `endpoint.parse::<url::Url>()` on `https://2001:db8::1/api/v1/client-update/...`, which fails with an invalid-port error because the URL host parser enters the port state at the first colon and chokes on `db8::1`. `check_client_update` returns Err; updater.ts:35-38 catches it and returns `{available:false, version:null, body:null}` and the banner never appears. The same user never learns an update exists, on every launch, for the life of the install. Bracketed (`[2001:db8::1]`) and DNS/IPv4 hosts are unaffected.",
|
||
"evidence": "MainPage.ts:844-846\n if (apiConfig.host) {\n const serverUrl = `https://${apiConfig.host}`;\n const updateNotifier = createUpdateNotifier({ serverUrl });\n\nsrc-tauri/src/update_commands.rs:98-100\n let url: url::Url = endpoint\n .parse()\n .map_err(|e: url::ParseError| format!(\"bad endpoint URL: {e}\"))?;\n\nsrc/lib/updater.ts:35-38\n } catch (err) {\n log.error(\"Update check failed\", { error: String(err) });\n return { available: false, version: null, body: null };\n }\n\nThe helper that exists for exactly this (`bracketBareIPv6Host`, ws.ts:146) is used by ws.ts:544, admin-panel.ts:27 and attachments.ts:60 but not here.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Use the existing shared helper at the one construction site — in Client/src/pages/MainPage.ts add `import { bracketBareIPv6Host } from \"@lib/ws\";` (ws.ts:146) and change line 845 to `const serverUrl = `https://${bracketBareIPv6Host(apiConfig.host)}`;`. That mirrors ws.ts:544 / admin-panel.ts:27 / attachments.ts:60, leaves DNS, IPv4 and already-bracketed hosts byte-identical, and keeps the Rust TOFU key aligned (url::Url::host_str returns the bracketed form and tofu::cert_store_key strips the brackets back off).",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0333",
|
||
"title": "ChannelSidebar's voice re-render signature carries no identity, so a rename mid-call leaves the voice roster stale for the whole session",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 1010,
|
||
"severity": "low",
|
||
"why": "The sidebar's only voice repaint trigger is `unsubVoiceStructure`, whose signature string covers channel id, mute/deafen/camera/screenshare/serverMuted/serverDeafened and E2EE verification — but not the participant's username or display name. The row label at line 431 is resolved from `membersStore` via `memberDisplayName`, and ChannelSidebar subscribes to `membersStore` nowhere at all (it imports it only for one-shot `getState()` reads). So the store writes that a rename performs — `updateMemberProfile` (bumps `roleRevision`, which only MessageList watches) and `updateVoiceUserProfile` (voice.store.ts:262, whose doc comment says it exists so \"a rename [doesn't] leave the voice roster showing the old name for the rest of the call\") — produce no re-render here. The same defect shape as the already-fixed OC finding at ChannelSidebar.ts:943 (signature omitted sessionFingerprint), on the field the roster is actually named by.",
|
||
"repro": "Alice and Bob are both in voice channel #general; the sidebar shows both rows. Alice opens Settings → Account and sets her nickname to \"Ali\" (or renames her username). The server broadcasts `user_update`; dispatcher.ts:929-950 patches membersStore, dmStore and voiceStore. `structSig` is unchanged (no identity field is in it), so `renderChannels()` never runs: Bob's voice sidebar keeps showing \"Alice\" indefinitely, while his member list, message rows and DM sidebar all show \"Ali\". It only corrects if some unrelated event (someone mutes, joins, leaves, or a channel changes) happens to re-render the sidebar.",
|
||
"evidence": "structSig += `:${uid}${u.muted ? \"m\" : \"\"}${u.deafened ? \"d\" : \"\"}${u.camera ? \"c\" : \"\"}${u.screenshare ? \"s\" : \"\"}${u.serverMuted === true ? \"M\" : \"\"}${u.serverDeafened === true ? \"D\" : \"\"}${verif ? `@${verif.status}/${verif.safetyNumber ?? \"\"}/${verif.sessionFingerprint ?? \"\"}` : \"\"}`;\n// line 430-431, the label this signature is supposed to keep fresh:\nconst member = membersStore.getState().members.get(user.userId);\nconst label = (member !== undefined ? memberDisplayName(member) : user.username) || \"Unknown\";",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Add one subscription next to the existing voice ones in ChannelSidebar's setup: `unsubscribers.push(membersStore.subscribeSelector((s) => s.roleRevision ?? 0, () => renderChannels()));`. updateMemberProfile bumps roleRevision on every USER_UPDATE (members.store.ts:154) and always runs before updateVoiceUserProfile in the dispatcher, so this single hook covers both the displayName and username rename paths without touching structSig.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0334",
|
||
"title": "Escape is both \"close Settings\" and a capturable push-to-talk key, so backing out of the PTT capture silently binds Escape as PTT",
|
||
"file": "Client/src/components/settings/KeybindsTab.ts",
|
||
"line": 49,
|
||
"severity": "low",
|
||
"why": "`ptt_listen_for_key`'s allowlist includes VK 0x1B (Escape), and the capture UI has no cancel path and no aborted-signal guard — while the Rust poll is blocking for up to 10s, pressing Escape closes the Settings overlay (SettingsOverlay.ts:362-369, which even labels ESC as the close affordance) *and* resolves the capture with 0x1B, so `updatePttKey(0x1B)` runs against a torn-down tab and permanently rebinds push-to-talk to Escape without the user ever seeing the result.",
|
||
"repro": "1. Open Settings → Keybinds. 2. Click the \"Push to Talk\" chip; it reads \"Press a supported key...\" and the Rust poll starts. 3. Press Escape to back out. 4. The Settings overlay closes (Escape handler) and, on key release, `ptt_listen_for_key` returns 0x1B, so `updatePttKey(0x1B)` persists Escape as the PTT binding — no visible confirmation, since the tab is gone. 5. From then on the mic is gated closed in every voice call and every Escape keypress anywhere in the OS un-gates it; if step 2-3 is done while already in a call, the mic is muted immediately by `updatePttKey`'s mid-call gate.",
|
||
"evidence": "KeybindsTab.ts:49-62 — `void captureKeyPress().then((vk) => { ... if (vk === 0) { restore; return; } currentVk = vk; setText(pttValue, vkName(vk)); ... void updatePttKey(vk); })` (no `signal.aborted` check, no cancel button).\nsrc-tauri/src/ptt.rs:34-63 `is_allowed_ptt_capture_vk` — `matches!(vk, 0x1B | // Escape\\n 0x20 | ...)`.\nSettingsOverlay.ts:360-369 — `document.addEventListener(\"keydown\", (e) => { if (e.key === \"Escape\" && root?.classList.contains(\"open\")) options.onClose(); })`.\nlib/ptt.ts:262-289 `updatePttKey` — persists `pttVk`, calls `ptt_set_key`, starts the poller and, when a call is live, immediately `setMuted(true)` + `setPttGated(true)`.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-pages",
|
||
"suggestedFix": "Treat Escape as cancel in one place, in Rust: drop `0x1B` from `is_allowed_ptt_capture_vk` (Client/src-tauri/src/ptt.rs:48) and, in both capture loops of `ptt_listen_for_key` (~ptt.rs:477 and ~ptt.rs:502), `return 0` when the detected vk is 0x1B. The existing `vk === 0` branch in KeybindsTab.ts:53-57 then restores the previous binding with no client change and no per-caller guard.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0335",
|
||
"title": "ServerPanel's \"Add Server\" modal registers its listeners on the page-lifetime AbortSignal, so every open/close cycle permanently retains a discarded modal subtree",
|
||
"file": "Client/src/pages/connect-page/ServerPanel.ts",
|
||
"line": 325,
|
||
"severity": "low",
|
||
"why": "`handleAddServer` builds a fresh overlay on every click and wires six listeners to the ConnectPage-lifetime `signal`. `closeModal()` only does `overlay.remove()` — it never aborts anything — so each abandoned modal stays reachable from that signal's abort-listener list for the life of the connect page, along with its inputs and the closure state.",
|
||
"repro": "On the connect page click \"+ Add Server\" and Cancel ten times. Ten complete modal subtrees (overlay + two inputs + four buttons) are still retained through `signal`'s abort-listener list; only navigating away from the connect page (login) releases them. The correct shape is a per-modal AbortController aborted by `closeModal`, as `createModal`/`modalFactory` does elsewhere.",
|
||
"evidence": "ServerPanel.ts:300-347 — inside `handleAddServer`: `function closeModal(): void { overlay.remove(); }` and then `closeBtn.addEventListener(\"click\", closeModal, { signal }); cancelBtn.addEventListener(...,{ signal }); saveBtn.addEventListener(...,{ signal }); overlay.addEventListener(\"click\", ..., { signal }); modal.addEventListener(\"click\", ..., { signal }); hostAddrInput.addEventListener(\"keydown\", ..., { signal });`\n`signal` comes from ConnectPage.ts:66-67 (`const abortController = new AbortController(); const { signal } = abortController;`) and is only aborted in `destroy()` (ConnectPage.ts:288).",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-pages",
|
||
"suggestedFix": "Inside `handleAddServer`, mint `const modalAc = new AbortController();`, pass `{ signal: modalAc.signal }` to all six registrations, and make `closeModal()` call `modalAc.abort()` before `overlay.remove()`. Chain it to the page signal with `signal.addEventListener(\"abort\", () => modalAc.abort(), { once: true, signal: modalAc.signal })` so page teardown still tears down an open modal without itself accumulating.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0336",
|
||
"title": "ServerPanel re-registers every profile row's listeners on the page-lifetime AbortSignal on each re-render",
|
||
"file": "Client/src/pages/connect-page/ServerPanel.ts",
|
||
"line": 122,
|
||
"severity": "low",
|
||
"why": "`renderServerProfiles` clears `serverListEl` and rebuilds every row, attaching up to three listeners per row to the ConnectPage-lifetime `signal`. Nothing aborts the previous generation, so each re-render — and it is re-rendered on profile load, add, delete and every auto-login toggle — permanently pins the whole discarded row set. Same defect class as the already-fixed MemberList/SearchOverlay/QuickSwitcher/EmojiPicker re-render leaks.",
|
||
"repro": "On the connect page with 5 saved servers, click the auto-login (zap) toggle five times. Each click calls `refreshProfiles`, which rebuilds all 5 rows and adds 15 more listener registrations to the same never-aborted `signal`; the 25 previously-detached row elements stay reachable until the connect page itself is destroyed. A per-render AbortController (aborted at the top of `renderServerProfiles`) is the fix used by the sibling components already corrected for this.",
|
||
"evidence": "ServerPanel.ts:122-124 — `function renderServerProfiles(profiles) { clearChildren(serverListEl); healthElements.clear(); for (const profile of profiles) { ... } }`\nServerPanel.ts:173-181 `autoLoginBtn.addEventListener(\"click\", ..., { signal })`, :193-201 `deleteBtn.addEventListener(\"click\", ..., { signal })`, :206-224 `item.addEventListener(\"click\", ..., { signal })` — all inside that loop.\nCallers: main.ts:590, :597, :602 and :659 all call `connectPage.refreshProfiles(getProfileList())`, which is `serverPanel.renderProfiles` (ConnectPage.ts:326-328 → ServerPanel.ts:362).",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-pages",
|
||
"suggestedFix": "Mirror MemberList.ts:475-483: hold `let renderAc: AbortController | null = null;` in the factory, and at the top of `renderServerProfiles` do `renderAc?.abort(); renderAc = new AbortController();`, then use `renderAc.signal` for the three per-row registrations (the footer/add-button listener at :115 stays on the page signal). Abort `renderAc` from a page-signal `abort` handler registered once at construction.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0337",
|
||
"title": "liveVoiceEventsSince replays a silently truncated cold-tier window: the row cap drops the NEWEST voice events, leaving a phantom (or missing) participant in the resumed call",
|
||
"file": "Server/ws/serve.go",
|
||
"line": 641,
|
||
"severity": "low",
|
||
"why": "The cold-tier query is `ORDER BY seq ASC LIMIT n`, so when the range exceeds the cap it is the newest rows that are discarded. The main replay path 220 lines above (reconnectSelectReplay, serve.go:422 and serve.go:442) explicitly detects both of that query's failure modes — `len(persisted) >= coldCap` (cap hit, newest dropped) and a retention-pruned prefix (oldest-seq probe) — and forces a full ready. liveVoiceEventsSince calls the identical db.GetEventsSinceForChannels with the identical cap and has neither guard, so a truncated window is handed to the client as if it were the complete voice history for that room. Worse, the cap is spent on UNFILTERED rows: the query is `channel_id = 0 OR channel_id IN (chID)`, so global broadcasts and the DM's ordinary chat messages consume the budget, and the voice_state/voice_leave filter at serve.go:655 only runs on whatever survived. A peer's voice_leave that falls in the dropped tail is never delivered and never re-sent (the client tracks only max(seq)), so the resumed client renders a participant who has left; symmetrically, a dropped voice_state hides a peer who is really in the call, which also starves that peer of the E2EE announce/offer exchange keyed on the roster.",
|
||
"repro": "Config: event_persistence.enabled = true, event_persistence.replay_cold_limit = 50 (a legal value; ConfigureReplay accepts any positive int, Server/ws/hub.go:751). Alice and Bob are both in a voice call inside a 1:1 DM that Alice has since closed, so the DM id is outside Alice's allowedChannelIDs (computeAllowedChannels sources DM ids from dm_open_state) and handleReconnect takes the liveVoiceChID supplement branch at serve.go:286. Alice's socket drops. While she is offline: (1) Bob posts 60 messages into that DM — each is a persisted event on that channel_id — and then (2) Bob leaves voice, emitting voice_leave. Alice's readable channels stay quiet, so the main cold-tier replay at serve.go:417 returns well under 50 rows and succeeds (tier \"db\"), and the ring buffer no longer covers her last_seq. liveVoiceEventsSince then runs GetEventsSinceForChannels(lastSeq, [dmID], 50), which returns the OLDEST 50 rows — the first 50 chat messages — and drops the remaining 10 rows including Bob's voice_leave. Alice's client resumes with Bob still listed in the voice roster and never receives a correction; the same window would equally have swallowed a voice_state for a peer who joined late, leaving that peer invisible to her for the rest of the call.",
|
||
"evidence": "// Server/ws/serve.go:637-649 (liveVoiceEventsSince)\nif buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {\n\traw = buf\n} else if esp := h.eventStore.Load(); esp != nil {\n\tes := *esp\n\tpersisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, h.maxColdReplayLimit())\n\tif err != nil {\n\t\treturn nil\n\t}\n\traw = make([][]byte, 0, len(persisted))\n\tfor _, p := range persisted {\n\t\traw = append(raw, p.Payload)\n\t}\n}\n// no `len(persisted) >= coldCap` check, no oldest-seq retention probe — compare\n// Server/ws/serve.go:422-453, which has both for the same query:\n// case len(persisted) >= coldCap: \"...the NEWEST events were dropped...forcing full ready\"\n// case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: \"retention pruning left a gap...forcing full ready\"\n//\n// Server/db/event_queries.go:136-144 — the cap is applied before any type filter:\n// WHERE seq > ? AND (channel_id = 0 OR channel_id IN (...)) ORDER BY seq ASC LIMIT ?\n// Server/ws/serve.go:653-659 — voice filtering happens only on the truncated result.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Stop spending the cap on non-voice rows and stop replaying a truncated window. Smallest change: give this call its own store method that applies the type filter in SQL — `WHERE seq > ? AND channel_id = ? AND event_type IN ('voice_state','voice_leave') ORDER BY seq ASC LIMIT ?` — so chat and global broadcasts can no longer evict voice events from the budget, and in liveVoiceEventsSince add the sibling's cap check: `if len(persisted) >= cap { slog.Warn(\"live voice supplement hit the row cap, skipping truncated window\"); return nil }`. Returning nil is the correct degradation here (a full ready is no longer available — registerNow already ran at serve.go:268 before the supplement at serve.go:287), and it restores the documented best-effort miss instead of installing a join whose matching leave was discarded. Do not simply raise the limit: that leaves the same silent-truncation hole one order of magnitude further out.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "7aeab0ed",
|
||
"test": "Server/ws/reconnect_voice_supplement_coldtier_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0338",
|
||
"title": "plugin.toml silently drops resources.max_memory_mb / cpu_budget_ms — BurntSushi/toml matches Go field names, and Manifest carries only `json` tags",
|
||
"file": "Server/plugin/manifest_toml.go",
|
||
"line": 32,
|
||
"severity": "low",
|
||
"why": "`toml.Decode` resolves a TOML key to a struct field via the `toml` struct tag, or, when absent, the Go field name matched with `strings.EqualFold`. `Manifest`/`Resources` declare only `json` tags, so `max_memory_mb` and `cpu_budget_ms` never match `MaxMemoryMB` / `CPUBudgetMs` (underscores break EqualFold) and are left undecoded with no error. Every other manifest key happens to be a single word (`name`, `version`, `entrypoint`, `permissions`, `commands`, `ui`, `asset`, …) and case-folds fine, which is why the breakage is invisible — only the two snake_case resource keys are silently discarded, and `Validate()` only checks `>= 0`, so zero passes.",
|
||
"repro": "Build with `-tags wazero` (the only build where plugin.toml is parsed at all — manifest_nottoml.go:9 stubs it out). Ship `plugins/foo/plugin.toml`:\n\n name = \"foo\"\n version = \"1.0.0\"\n entrypoint = \"foo.wasm\"\n permissions = [\"commands\"]\n [[commands]]\n name = \"foo\"\n [resources]\n cpu_budget_ms = 2000\n max_memory_mb = 128\n\nscanPluginDirectory (loader.go:64) loads it via tryLoadPluginTOML; `Manifest.Resources` is `{0, 0}`. Invoke `/foo`: sandbox_wazero.go:317 falls through to `r.cfg.CPUBudgetMs` (config default 100), so a command the author budgeted 2000 ms for is killed at 100 ms with \"command exceeded CPU budget of 100ms\". The byte-identical plugin.json (`\"resources\": {\"cpu_budget_ms\": 2000}`) behaves correctly, so the same plugin works as JSON and misbehaves as TOML. installFromDisk then serializes the zeroed Resources back into `plugins.manifest_json` (loader.go:132-138, registry.go:192-196), so the admin plugin list also reports a budget the author never wrote. No test covers TOML decoding (`grep -rn toml Server/plugin/*_test.go` is empty), so nothing locks this in as intended.",
|
||
"evidence": "manifest_toml.go:31-38\n\tvar m Manifest\n\tif _, err := toml.Decode(string(raw), &m); err != nil { ... }\n\tif err := m.Validate(); err != nil { ... }\n\nmanifest.go:77-80\ntype Resources struct {\n\tMaxMemoryMB int `json:\"max_memory_mb\"`\n\tCPUBudgetMs int `json:\"cpu_budget_ms\"`\n}\n\ntoml@v1.6.0/decode.go:311-318 — `if ff.name == key { ... }` else `if f == nil && strings.EqualFold(ff.name, key) { f = ff }`\ntoml@v1.6.0/type_fields.go:108-113 — `name := opts.name; if name == \"\" { name = sf.Name }`, where `opts` comes from `tag.Get(\"toml\")` (encode.go:647-648).\n\nConsumer: sandbox_wazero.go:317-323\n\tbudgetMs := inst.Manifest.Resources.CPUBudgetMs\n\tif budgetMs <= 0 { budgetMs = r.cfg.CPUBudgetMs }\n\tif budgetMs <= 0 { budgetMs = 100 }",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Add toml tags to the two snake_case fields in Server/plugin/manifest.go:77-80:\n\ttype Resources struct {\n\t\tMaxMemoryMB int `json:\"max_memory_mb\" toml:\"max_memory_mb\"`\n\t\tCPUBudgetMs int `json:\"cpu_budget_ms\" toml:\"cpu_budget_ms\"`\n\t}\nThat is the minimal fix and is safe for the JSON path (encoding/json ignores the toml tag). Optionally harden the shared decode site instead of every future field: in tryLoadPluginTOML (manifest_toml.go:31) keep the MetaData and reject leftovers — `md, err := toml.Decode(...)`; `if u := md.Undecoded(); len(u) > 0 { return nil, false, fmt.Errorf(\"plugin.toml: unknown keys %v\", u) }` — which turns any future tag/name mismatch or manifest typo into a loud load error rather than a silent zero.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-28",
|
||
"fix": {
|
||
"commit": "073e8799",
|
||
"test": "Server/plugin/manifest_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0339",
|
||
"title": "config.Load warns \"unknown key ignored — value has NO effect (typo?)\" for a valid, present-but-empty config section",
|
||
"file": "Server/config/config.go",
|
||
"line": 519,
|
||
"severity": "low",
|
||
"why": "`unknownFileKeys` compares the file's flattened leaf keys against the defaults layer's flattened leaf keys. A YAML section header whose children are all commented out parses to a nil value, and koanf's maps.Flatten emits it as a *leaf* key (bare `voice`, `telemetry`, ...). The defaults layer only ever contains the dotted child paths (`voice.quality`, ...), never the bare section name, so every such section is reported as an unrecognised typo whose value has no effect — which is false, and which the shipped defaultYAML actively teaches operators to produce by commenting out a section's children.",
|
||
"repro": "Given a config.yaml such as:\n\n server:\n port: 8443\n voice:\n # livekit_url: \"ws://localhost:7880\"\n # quality: \"medium\"\n\nthe YAML parses to map[\"voice\"] = nil, Flatten emits the leaf key \"voice\", and knownKeys holds only \"voice.livekit_api_key\", \"voice.quality\", ... — never \"voice\". Startup therefore logs:\n\n WARN config: unknown key ignored — value has NO effect (typo?) key=voice file=config.yaml\n\nThe operator is told a real, fully supported section is a typo. The same fires for any commented-out-children section (telemetry:, plugins:, gif:, event_persistence:, github:).",
|
||
"evidence": "Server/config/config.go:488-491 (allowlist = defaults-layer leaf keys only)\n knownKeys := make(map[string]struct{}, len(k.Keys()))\n for _, key := range k.Keys() { knownKeys[key] = struct{}{} }\n\nServer/config/config.go:519-522\n for _, key := range unknownFileKeys(cfgPath, knownKeys) {\n slog.Warn(\"config: unknown key ignored — value has NO effect (typo?)\", \"key\", key, \"file\", cfgPath)\n }\n\nServer/config/config.go:596-599 (fileK.Keys() is the flattened leaf set)\n for _, key := range fileK.Keys() {\n if _, ok := knownKeys[key]; !ok { unknown = append(unknown, key) }\n }\n\nkoanf v2@v2.3.6 koanf.go:128-137 — Keys() ranges over confMapFlat, i.e. the maps.Flatten output.\nkoanf/maps@v0.1.2 maps.go:43-59 — flatten()'s type switch only recurses for `map[string]interface{}`; a nil section value falls to `default:` and is emitted as a leaf key (`out[\"voice\"] = nil`).",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Suppress bare section names in unknownFileKeys (Server/config/config.go:596-600) rather than at the call site, so both the warning and any future caller agree. Smallest precise form — a bare key that is a prefix of a real dotted key is a known section, not a typo:\n\n\tfor _, key := range fileK.Keys() {\n\t\tif _, ok := knownKeys[key]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownSection(knownKeys, key+\".\") { // some known key starts with key+\".\"\n\t\t\tcontinue\n\t\t}\n\t\tunknown = append(unknown, key)\n\t}\n\nThis keeps reporting a genuinely misspelled bare header (`databsae:`), which the blunter alternative (`if fileK.Get(key) == nil { continue }`) would silently swallow.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0340",
|
||
"title": "`server token create --expires <negative>` silently mints a token that never expires — the exact fail-open the HTTP sibling was fixed to reject",
|
||
"file": "Server/token_cli.go",
|
||
"line": 118,
|
||
"severity": "low",
|
||
"why": "`--expires` is a `flag.Duration` with no lower bound, and the only test applied to it is `if *expires > 0`. A negative duration therefore falls into the same branch as the documented `0 = never` value, so `expiresAt` stays nil and `CreateAPIToken` writes `expires_at = NULL`. The operator asked for a bounded credential and gets a permanent one, with a success message and exit code 0. The sibling HTTP path (`Server/admin/handlers_tokens.go:70`) carries an explicit `if req.ExpiresHours < 0 { 400 }` guard with a comment saying precisely this — \"Negatives must not fall into that same nil-expiresAt branch\" — so the guard exists on one path and is missing on the other.",
|
||
"repro": "Run `server token create --label ci --expires -1h` (e.g. from a script that computes the remaining window and lands on a negative value, or a typo'd `-1h` where `1h` was meant). flag.Duration parses -1h0m0s without error; `*expires > 0` is false; `expiresAt` stays nil; the row is inserted with `expires_at = NULL`. stdout prints a raw token and stderr says \"Created API token #N ... Store this token now\", exit 0. `server token list` then shows EXPIRES as `-`. The operator believes they minted a one-hour token and has actually minted a permanent full-privilege (owner-bound by default) API credential. The same input against `POST /admin/api/tokens` is rejected with 400.",
|
||
"evidence": "Server/token_cli.go:83 expires := fs.Duration(\"expires\", 0, \"validity duration, e.g. 720h (default: never)\")\nServer/token_cli.go:117 var expiresAt *time.Time\nServer/token_cli.go:118 if *expires > 0 {\nServer/token_cli.go:119 t := time.Now().Add(*expires)\nServer/token_cli.go:120 expiresAt = &t\nServer/token_cli.go:121 }\nServer/token_cli.go:122 id, err := database.CreateAPIToken(ctx, user.ID, auth.HashToken(raw), *label, expiresAt)\n\n-- guarded sibling --\nServer/admin/handlers_tokens.go:70 if req.ExpiresHours < 0 || req.ExpiresHours > 24*365*10 {\nServer/admin/handlers_tokens.go:71 writeErr(w, http.StatusBadRequest, \"BAD_REQUEST\", \"expires_hours must be between 0 and 87600\")",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "In tokenCreate, immediately after the `--label` check (Server/token_cli.go:90), add the lower-bound guard the HTTP path already has: `if *expires < 0 { fmt.Fprintln(os.Stderr, \"error: --expires must not be negative\"); return 2 }`. One guard in tokenCreate covers the only caller (main.go:53 -> runTokenCLI -> tokenCreate).",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0341",
|
||
"title": "A numeric API-token label can never be revoked — `token revoke` commits to the id branch on parse and never falls through to the label branch",
|
||
"file": "Server/token_cli.go",
|
||
"line": 180,
|
||
"severity": "low",
|
||
"why": "`tokenRevoke` documents its argument as `<id|label>` but dispatches purely on whether the string parses as an int64, not on whether that dispatch found anything. Labels are unvalidated free text (`--label` is only checked non-empty here and only trimmed in the admin handler), so an all-digit label is creatable. For such a token the id branch is taken, matches no row, and the function reports failure without ever trying `RevokeAPITokenByLabel`.",
|
||
"repro": "`server token create --label 2024 --user alice` mints token row id 7 with label \"2024\". `server token revoke 2024` parses 2024 as an id, calls RevokeAPIToken(ctx, 2024), which affects 0 rows (no token has id 2024), and prints `no active token matched \"2024\"` with exit 1 — the label branch is never reached. The token stays live and, per the CLI, is unrevokable by the name the operator knows it by. (Worse if a token with id 2024 does exist: an unrelated token is revoked instead.) Making the id branch fall through to the label branch when `affected == 0` fixes both halves.",
|
||
"evidence": "Server/token_cli.go:180 if id, perr := strconv.ParseInt(arg, 10, 64); perr == nil {\nServer/token_cli.go:181 affected, err = database.RevokeAPIToken(ctx, id)\nServer/token_cli.go:182 if err == nil && affected > 0 { db.WriteAudit(...) }\nServer/token_cli.go:185 } else {\nServer/token_cli.go:186 affected, err = database.RevokeAPITokenByLabel(ctx, arg)\nServer/token_cli.go:195 if affected == 0 {\nServer/token_cli.go:196 fmt.Fprintf(os.Stderr, \"no active token matched %q\\n\", arg)\nServer/token_cli.go:197 return 1\n\n-- usage text promising both --\nServer/token_cli.go:73 revoke <id|label>\nServer/token_cli.go:74 Revoke a token by numeric id or by label.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Make the id branch fall through instead of terminating. At Server/token_cli.go:180-190, after the id attempt, add: `if err == nil && affected == 0 { affected, err = database.RevokeAPITokenByLabel(ctx, arg); if err == nil && affected > 0 { db.WriteAudit(ctx, database, 0, \"api_token_revoke\", \"api_token\", 0, arg) } }`. This keeps id precedence and costs one extra query only on the miss path.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0342",
|
||
"title": "Voice-roster avatar letter and colour are derived from the username while the name rendered beside them is the nickname",
|
||
"file": "Client/src/components/ChannelSidebar.ts",
|
||
"line": 420,
|
||
"severity": "low",
|
||
"why": "The voice participant row builds its avatar initial and background colour from `user.username`, then eleven lines later resolves the label through `memberDisplayName`. Every other avatar in the app goes through `createAvatarElement`/`avatarInitial`, which prefer `displayName` (lib/avatar.ts:50-60), so a nicknamed user's voice row shows a letter that matches no other avatar of that user anywhere in the client — including the member list rendered directly above it.",
|
||
"repro": "Account `alice` sets nickname `Zoe`, then joins a voice channel. The sidebar voice row under that channel draws a circle containing `A`, coloured from the hash of \"alice\", immediately to the left of the text `Zoe`. The MemberList row for the same user (MemberList.ts:201-204 → createAvatarElement → avatarInitial) draws `Z`. Changing the nickname to something starting with a different letter never changes the voice row's letter or colour, because neither reads `member.displayName` at all.",
|
||
"evidence": "ChannelSidebar.ts:420-432\n const initial = user.username.length > 0 ? user.username.charAt(0).toUpperCase() : \"?\";\n const avatar = createElement(\"div\", { class: \"vu-avatar\" }, initial);\n avatar.style.background = pickAvatarColor(user.username);\n ...\n const member = membersStore.getState().members.get(user.userId);\n const label = (member !== undefined ? memberDisplayName(member) : user.username) || \"Unknown\";\n\nlib/avatar.ts:50-60\n export function resolveDisplayName(subject: AvatarSubject): string { ... return subject.username; }\n export function avatarInitial(subject: AvatarSubject): string { ... return resolveDisplayName(subject).charAt(0).toUpperCase() || \"?\"; }",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Resolve the name once, before the \"Unknown\" fallback, and drive all three of letter, colour and label from it: `const member = membersStore.getState().members.get(user.userId); const resolved = member !== undefined ? memberDisplayName(member) : user.username; const initial = resolved.length > 0 ? resolved.charAt(0).toUpperCase() : \"?\"; avatar.style.background = pickAvatarColor(resolved); const label = resolved || \"Unknown\";` — moving the members-store lookup above line 420. Keeping the initial derived from `resolved` rather than from `label` preserves the empty-username \"?\" assertion at channel-sidebar.test.ts:889-906.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0343",
|
||
"title": "Tray-icon click hides a minimized window instead of restoring it — `is_visible()` is true while minimized and `unminimize()` is never called",
|
||
"file": "Client/src-tauri/src/tray.rs",
|
||
"line": 67,
|
||
"severity": "low",
|
||
"why": "`toggle_window_visibility` decides show-vs-hide purely from `window.is_visible()`, but on both shipped platforms a minimized window still reports visible (tao Windows: `IsWindowVisible`, which stays TRUE for an iconified window because WS_VISIBLE is not cleared; tao GTK: `gtk_widget_get_visible`, TRUE for an iconified GtkWindow). So the branch taken for a minimized window is the *hide* branch, and neither branch ever calls `unminimize()`. The same file's sibling recovery path in `lib.rs:52-58` (the single-instance handler) gets this right — it does `unminimize(); show(); set_focus();` — which is direct in-repo evidence of the intended sequence.",
|
||
"repro": "On Windows or Linux: launch OwnCord, click the window's minimize button, then left-click the OwnCord tray icon expecting the window back. `is_visible()` returns true (minimized != hidden), so the handler takes `window.hide()` — the app disappears from the taskbar entirely instead of being restored. A second tray click now takes the else branch: `show()` maps the window again but it is still iconified (Win32 `ShowWindow(SW_SHOW)` restores it to its current, minimized state) and `set_focus()`/`SetForegroundWindow` does not deiconify, so on Windows the user gets back only a minimized taskbar button and must click that to actually see the app. Fix is to mirror lib.rs: check `is_minimized()` first, or call `unminimize()` before `show()`/`set_focus()` in the else branch.",
|
||
"evidence": "tray.rs:65-74\n fn toggle_window_visibility<R: Runtime>(app: &tauri::AppHandle<R>) {\n if let Some(window) = app.get_webview_window(\"main\") {\n if window.is_visible().unwrap_or(false) {\n let _ = window.hide();\n } else {\n let _ = window.show();\n let _ = window.set_focus();\n }\n }\n }\n\ncontrast — lib.rs:52-58 (single-instance handler, same app, same window):\n if let Some(window) = app.get_webview_window(\"main\") {\n let _ = window.unminimize();\n let _ = window.show();\n let _ = window.set_focus();\n }\n\nReachability: tauri.conf.json declares the main window with `\"decorations\": true, \"resizable\": true`, so the OS minimize button exists; bundle targets are nsis (Windows) + appimage/deb (Linux), i.e. exactly the two platforms where `is_visible()` is true while minimized. No Rust test covers tray.rs (src-tauri/tests does not exist, and tray.rs has no `mod tests`).",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "Consult is_minimized() in the shared toggle so a minimized window takes the restore arm, and mirror lib.rs's sequence there. In Client/src-tauri/src/tray.rs:65-74:\n\n fn toggle_window_visibility<R: Runtime>(app: &tauri::AppHandle<R>) {\n if let Some(window) = app.get_webview_window(\"main\") {\n let minimized = window.is_minimized().unwrap_or(false);\n if window.is_visible().unwrap_or(false) && !minimized {\n let _ = window.hide();\n } else {\n let _ = window.unminimize();\n let _ = window.show();\n let _ = window.set_focus();\n }\n }\n }\n\nOne guard in the shared function fixes both callers (tray left-click at tray.rs:54 and the Show/Hide menu item at tray.rs:78). No capability change is needed — Rust-side window calls do not go through capabilities/default.json.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0344",
|
||
"title": "ACME HTTP→HTTPS redirect hardcodes implicit port 443, but the server's documented/default HTTPS port is 8443 — every plain-HTTP visitor is 301'd to a dead port",
|
||
"file": "Server/auth/tls.go",
|
||
"line": 199,
|
||
"severity": "low",
|
||
"why": "loadACME builds the fallback redirect as `\"https://\" + cfg.Domain + r.URL.RequestURI()` with no port. config.TLSConfig carries no port field, and the HTTPS listener actually binds `:cfg.Server.Port` (Server/main.go:190 `addr := fmt.Sprintf(\":%d\", cfg.Server.Port)`), whose default and documented value is 8443 (Server/config/config.go:293 `Port: 8443`, defaultYAML line 367, docs/deployment.md:482 \"8443 | TCP | HTTPS server\"). ACME mode is documented alongside that default (docs/deployment.md:238-240), and HTTP-01 validation only needs :80, so acme + 8443 is a supported deployment. The redirect therefore sends browsers to https://<domain>:443 where nothing listens. The existing test only asserts the Location prefix (Server/auth/tls_test.go:294 `strings.HasPrefix(loc, \"https://chat.example.com/\")`), so nothing pins the port.",
|
||
"repro": "Deploy the documented ACME configuration: `server: {port: 8443}` (the default) plus `tls: {mode: \"acme\", domain: \"chat.example.com\"}`. runStartACME (Server/main.go:509) serves tlsResult.HTTPHandler on :80; the HTTPS server binds :8443. `curl -i http://chat.example.com/` returns `301 Moved Permanently` with `Location: https://chat.example.com/` — implicit port 443, where no listener exists — so following it gives connection refused. Because it is a 301, browsers cache the broken target. The ACME challenge path itself still works (autocert.Manager.HTTPHandler intercepts /.well-known/acme-challenge/ before the fallback), so the failure is silent at cert-issuance time and only bites real visitors. loadACME needs the HTTPS port (or should use r.Host's hostname plus the configured port) to build the target.",
|
||
"evidence": "Server/auth/tls.go:196-201\n\t// HTTP handler serves ACME HTTP-01 challenges on port 80 and redirects\n\t// all other traffic to HTTPS.\n\tredirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttarget := \"https://\" + cfg.Domain + r.URL.RequestURI()\n\t\thttp.Redirect(w, r, target, http.StatusMovedPermanently)\n\t})\n\nServer/main.go:190-191\n\taddr := fmt.Sprintf(\":%d\", cfg.Server.Port)\n\tsrv := &http.Server{ Addr: addr, ... }\n\nServer/config/config.go:293 Port: 8443",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Plumb the HTTPS port into loadACME rather than guessing: add a non-koanf field (e.g. `HTTPSPort int`) to config.TLSConfig set from cfg.Server.Port at the single LoadOrGenerate call site in Server/main.go, then in the redirect handler build the host with `host := cfg.Domain; if p := cfg.HTTPSPort; p != 0 && p != 443 { host = net.JoinHostPort(cfg.Domain, strconv.Itoa(p)) }` and use `\"https://\" + host + r.URL.RequestURI()`. One guard in the shared handler covers every deployment.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0345",
|
||
"title": "ownerOnlyMiddleware re-reads a role that is already in the request context and turns a transient DB read error into 403 FORBIDDEN, ejecting the Owner from backups/updates/tokens",
|
||
"file": "Server/admin/middleware.go",
|
||
"line": 130,
|
||
"severity": "low",
|
||
"why": "`adminAuthMiddleware` already resolved the principal's `*db.Role` via `auth.ResolveTokenHash` and stored it in the request context under `adminRoleKey` (line 90). `ownerOnlyMiddleware` ignores that value, issues a second `GetRoleByID` on the request context, and collapses `err != nil` into the same 403 \"role not found\" it uses for a genuinely missing role. This is the exact fail-closed-as-authorization-denial collapse that the perimeter branch 60 lines above was explicitly fixed for (it now answers 503 SERVICE_UNAVAILABLE and logs, precisely so a DB outage is not reported as a bad credential), and that `api/middleware.go:117` was fixed for. The function's own doc comment (lines 120-121) claims it \"reads the user from context ... rather than re-authenticating, avoiding redundant DB queries\" — the redundant query it claims to avoid is the one that introduces the fault.",
|
||
"repro": "Owner is signed into the admin panel. Any transient read failure on the `roles` lookup (SQLITE_BUSY / \"database is locked\" while a scheduled backup's `VACUUM INTO` runs, a disk I/O error, or a context deadline on the reader pool) hits `GetRoleByID` during a request to one of the nine owner-only routes registered in Server/admin/api.go:148-176 — `GET /admin/api/updates`, `POST /admin/api/updates/apply`, `POST /admin/api/backup`, `GET /admin/api/backups`, `DELETE /admin/api/backups/{name}`, `POST /admin/api/backups/{name}/restore`, `GET|POST /admin/api/tokens`, `DELETE /admin/api/tokens/{id}`. The perimeter middleware immediately before it already succeeded and put the correct, non-nil Owner role in the context, so the request is fully authenticated and authorized. The Owner nevertheless receives HTTP 403 `FORBIDDEN {\"code\":\"FORBIDDEN\",\"message\":\"role not found\"}` — the admin panel renders a permission-denied error telling the server Owner they lack the Owner role — and nothing is logged, unlike the perimeter path which logs the underlying error. Using the already-resolved `adminRoleKey` value (or mirroring the perimeter's 503 + slog on `err != nil`) makes the outcome correct.",
|
||
"evidence": "// middleware.go:89-93 (perimeter already stores the role)\nctx := context.WithValue(r.Context(), adminUserKey, user)\nctx = context.WithValue(ctx, adminRoleKey, role)\n\n// middleware.go:59-69 (perimeter, after the OC fix: DB error != bad token)\ndefault:\n slog.ErrorContext(r.Context(), \"admin: token resolution failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authentication service temporarily unavailable\")\n\n// middleware.go:130-134 (ownerOnlyMiddleware, unfixed sibling)\nrole, err := database.GetRoleByID(r.Context(), user.RoleID)\nif err != nil || role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n}",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Split the two outcomes in ownerOnlyMiddleware rather than switching to the context role. Reading adminRoleKey would be the cleaner design but it breaks both TestOwnerOnlyMiddleware_RoleNotFound and TestOwnerOnlyMiddleware_OwnerPassesThrough, which inject only adminUserKey. Smallest change that preserves every locked behavior, at Server/admin/middleware.go:130-134:\n\n role, err := database.GetRoleByID(r.Context(), user.RoleID)\n if err != nil {\n slog.ErrorContext(r.Context(), \"admin: owner role lookup failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authorization service temporarily unavailable\")\n return\n }\n if role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n }\n\nrole==nil still yields 403 (test at middleware_and_spawn_test.go:211 unaffected), the owner path still yields 200, and the DB fault now matches the perimeter's 503 + slog contract.",
|
||
"confidence": "medium",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-30",
|
||
"fix": {
|
||
"commit": "fb1afb8a",
|
||
"test": "Server/admin/middleware_and_spawn_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0346",
|
||
"title": "Panic logs can never carry trace_id — recoverer reads the trace ID before the tracing middleware that creates the span",
|
||
"file": "Server/api/router.go",
|
||
"line": 650,
|
||
"severity": "low",
|
||
"why": "`recoverer` is registered at router.go:286, two slots ahead of `telemetry.HTTPMiddleware()` at router.go:291, and it snapshots `telemetry.TraceIDFromContext(r.Context())` *before* calling `next.ServeHTTP`. At that moment no span exists in the request context (otelhttp is downstream), so `TraceIDFromContext` returns \"\" on every request and the `trace_id` attribute the recovery closure promises is always dropped by the `if traceID != \"\"` guard at line 666. The panic record — the one log line where trace correlation matters most — is the only one that silently loses it, while in-handler logs via logctx.go:38 get it correctly because they run inside the span.",
|
||
"repro": "Build with `-tags otel`, set telemetry.enabled=true and exporter=\"otlp\" (or \"prometheus\"), then issue a request to any REST route whose handler panics (e.g. force a nil deref in a handler). The recovered-panic slog record contains method/path/panic/stack/req_id but never a trace_id attribute, even though otelhttp created a live span for that exact request and the trace is exported. Moving `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)` (or reading the trace ID inside the deferred closure instead of before dispatch) makes the same request log the real trace ID.",
|
||
"evidence": "router.go:286-291\n\tr.Use(recoverer) // slog-routing panic recovery ...\n\tr.Use(requestLogger)\n\tr.Use(telemetry.HTTPMiddleware())\n\nrouter.go:646-667\n\t// Capture correlation IDs before dispatch ... while the\n\t// panic log still carries req_id/trace_id.\n\treqID := middleware.GetReqID(r.Context())\n\ttraceID := telemetry.TraceIDFromContext(r.Context()) // <- no span yet: always \"\"\n\tdefer func() {\n\t\tif rec := recover(); rec != nil {\n\t\t\t...\n\t\t\tif traceID != \"\" {\n\t\t\t\tattrs = append(attrs, \"trace_id\", traceID)\n\t\t\t}\n\t\t\tslog.Error(\"http handler panic recovered\", attrs...)\n\nBuild-tag dependency: telemetry_otel.go's TraceIDFromContext is the only implementation that can ever return non-empty (telemetry_default.go:26 hardcodes \"\"), and it reads trace.SpanContextFromContext(ctx), which is populated by otelhttp.NewHandler in (*otelProvider).HTTPMiddleware — mounted after recoverer.",
|
||
"status": "fixed",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "explore-2",
|
||
"suggestedFix": "In routerMiddleware (Server/api/router.go:278-292) move `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)`. With otelhttp outermost, recoverer's r.Context() already carries the live span, so the existing line 650 capture yields the real trace ID, and recoverer still recovers handler panics because it remains outside every route handler. This is one line in the shared stack rather than a change in recoverer, and it keeps the deferred closure free of context calls (the contextcheck constraint the comment cites). It does not disturb the ordering the file comment calls a security property — request-id binding, security headers and the body cap keep their relative positions.",
|
||
"confidence": "high",
|
||
"finder": "opus",
|
||
"fixed": "2026-08-30",
|
||
"fix": {
|
||
"commit": "775eba50",
|
||
"test": "Server/api/recoverer_otel_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0347",
|
||
"title": "VoiceWidget's DM call label is read from dmStore but the widget never subscribes to dmStore, so it goes stale for the whole call",
|
||
"file": "Client/src/components/VoiceWidget.ts",
|
||
"line": 255,
|
||
"severity": "low",
|
||
"why": "render() resolves a DM call's header name with `dmStore.getState().channels.find(...)` + `dmDisplayName(dm)`, but mount() registers exactly three subscriptions — voiceStore (a fixed-field selector that excludes voiceUsers), uiStore.connectionStatus and channelsStore.channels. None of them fires when dmStore changes, and a DM rename/nickname change only ever touches dmStore (`updateDmParticipant`, dm.store.ts:246) plus voiceStore.voiceUsers (excluded from the selector). The label is therefore painted once at join and can never be corrected.",
|
||
"repro": "Start a DM call with Bob; the widget header reads \"Bob\". Bob renames himself or sets a nickname: the server sends user_update, dispatcher.ts:941 calls `updateDmParticipant(bob, { username/displayName })`, dmStore updates, the DM sidebar row and the chat header both repaint (ChannelController.ts:632-637 subscribes to dmStore). The VoiceWidget header still says \"Bob\" until the call ends. Same path leaves the header on the fallback \"Voice Channel\" if the dmStore row for the call's channel arrives after the join, since no later dmStore change can trigger a re-render.",
|
||
"evidence": "const channel = channelsStore.getState().channels.get(channelId);\nconst dm = dmStore.getState().channels.find((c) => c.channelId === channelId);\nsetText(\n channelNameEl,\n dm !== undefined ? dmDisplayName(dm) : (channel?.name ?? \"Voice Channel\"),\n);\n// ... mount(): unsubs.push(voiceStore.subscribeSelector(...)), uiStore.subscribeSelector((s) => s.connectionStatus, ...), channelsStore.subscribeSelector((s) => s.channels, ...) — no dmStore.",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "In VoiceWidget.mount(), alongside the existing three subscriptions, add `unsubs.push(dmStore.subscribeSelector((s) => s.channels, () => render()));` — dmStore replaces the channels array on every participant patch, so the default identity comparison already fires only on real changes.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0348",
|
||
"title": "The sidebar \"N online\" header counts an invisible user as online, contradicting the member list right below it",
|
||
"file": "Client/src/stores/members.store.ts",
|
||
"line": 247,
|
||
"severity": "low",
|
||
"why": "`getOnlineMembers()` filters on `status !== \"offline\"`, so \"invisible\" counts as online. Every other surface deliberately groups invisible with offline — MemberList's `statusPriority` (line 129-135) and `isAwayStatus` (line 155-157) both fold invisible into offline, with a comment saying that is where the user appears \"to everybody — including, in this list, to themselves\". Only the signed-in user can ever carry \"invisible\" locally (the server maps it to offline for everyone else), so the header over-counts by exactly one whenever you go invisible.",
|
||
"repro": "Sign in with two members online (you + Bob). Header reads \"2 online\". Set your own status to Invisible via the UserBar picker: membersStore keeps your entry at status \"invisible\" (the server's self-frame), the member list moves your row into the away/offline group and greys it, but SidebarArea.ts:262's `getOnlineMembers().length` still returns 2, so the header keeps claiming \"2 online\" while the list shows one.",
|
||
"evidence": "for (const member of s.members.values()) {\n if (member.status !== \"offline\") {\n result.push(member);\n }\n}",
|
||
"status": "open",
|
||
"found": "2026-08-22",
|
||
"hunt": "general-2026-08-22-b",
|
||
"lens": "hotspot-client-tauri-client-src-components",
|
||
"suggestedFix": "Tighten the single shared selector rather than its caller — in members.store.ts getOnlineMembers, change the predicate to `if (member.status !== \"offline\" && member.status !== \"invisible\")`, matching MemberList's isAwayStatus.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0349",
|
||
"title": "voice_join relays the joiner’s own voice_state through the asynchronous hub queue while the rest of the join burst is written directly, so its position on the joiner’s socket is unordered",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 498,
|
||
"severity": "low",
|
||
"why": "voiceJoinComplete writes voice_token (voice_join.go:445), each existing participant’s voice_state (:523), the peers’ voice_e2ee_announce relays (sendVoicePeerKeys, :531) and voice_config (:546) straight into the joiner’s send queue with c.sendMsg, but the joiner’s OWN voice_state goes through h.broadcastVoiceEvent (:498), which hub_broadcast.go:95-168 enqueues on the buffered h.broadcast channel for the broadcast goroutine to fan out later. The joiner is part of that audience (channelReadAudience), so nothing orders its own sequenced voice_state against the four direct frames: it can land before voice_token, between the existing participants’ states, or after voice_config, depending on how backed up the broadcast goroutine is. The B2-1 fixture capture could not pin the join burst and had to document the order as unspecified (docs/protocol.md:927-929) and exclude that one frame from the epoch-1 transcript’s ordered comparison (protocol_epoch1_contract_test.go:69-75, 636-648). A client that treats the reply as an ordered burst — e.g. takes its own voice_state as the signal that the roster before it is complete, or that the join finished before voice_config — reads a state that is right most of the time and wrong under load.",
|
||
"repro": "cd Server && go test -tags deadlock ./ws -run TestEpoch1Fixtures -count=30 at 1fe3df79 with the voice-join journey’s own-voice_state exclusion removed (protocol_epoch1_contract_test.go:636-648): roughly 1 run in 30 records the joiner’s own voice_state after the existing participants’ states or after voice_config instead of directly after voice_token; the default build reorders less often but is not immune. Equivalently, keep the broadcast goroutine busy (a burst of chat_send into another channel from a second client) while a client sends voice_join and watch the joiner’s own voice_state trail voice_config on its socket.",
|
||
"evidence": "Server/ws/voice_join.go:445 c.sendMsg(buildVoiceToken(channelID, token, \"/livekit\", h.livekit.URL(), isKeyHolder)) // direct\nServer/ws/voice_join.go:498 h.broadcastVoiceEvent(ctx, channelID, buildVoiceState(*state)) // hub queue, joiner in audience\nServer/ws/voice_join.go:523 c.sendMsg(buildVoiceState(vs)) // direct, per existing participant\nServer/ws/voice_join.go:531 h.sendVoicePeerKeys(c, channelID) // direct\nServer/ws/voice_join.go:546 c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) // direct\nServer/ws/hub_broadcast.go:95-168 broadcastVoiceEvent -> h.broadcast <- bm (buffered channel; deliverBroadcast fans out on the hub goroutine)\ndocs/protocol.md:927-929 \"Items 1, 3 and 4 are written directly and keep that relative order; item 2 travels through the hub’s broadcast queue, so its position relative to the other three on the joiner’s own socket is not guaranteed.\"\nServer/ws/protocol_epoch1_contract_test.go:69-75, 636-648 the epoch-1 transcript deliberately records the joiner’s own voice_state out of the ordered comparison because under -tags deadlock it was observed arriving after the direct frames.",
|
||
"status": "open",
|
||
"found": "2026-08-28",
|
||
"hunt": "b2-1-fixture-capture-2026-08-28",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Write the joiner’s own voice_state to the joiner directly (c.sendMsg, in program order at :498, so the whole burst on the joiner’s socket is one goroutine’s program order) and broadcast it to everyone except the joiner — broadcastVoiceEventWithLeaver already carries an exclude-user path in hub_broadcast.go, so the fix is one call-site change plus a helper, not a new fan-out. Check the seq contract first: deliverBroadcast is where the sequenced copy is stamped and appended to the replay buffer, so the joiner’s direct copy must carry the same seq (or be documented as the unsequenced form like the relayed existing states) rather than double-stamping. Behaviour change within epoch 1 (same frame set, deterministic position): regenerate the epoch-1 fixture in the same PR (`go test ./ws -run TestEpoch1Fixtures -update`), drop the contract test’s exclusion so the order is asserted, and tighten docs/protocol.md:927-929.",
|
||
"confidence": "high",
|
||
"finder": "fable"
|
||
},
|
||
{
|
||
"id": "OC-0350",
|
||
"title": "Admin panel login has no 2FA branch — any admin with TOTP enabled is permanently locked out of /admin",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 659,
|
||
"severity": "high",
|
||
"why": "POST /api/v1/auth/login answers a TOTP-enabled account with HTTP 200 and `{\"partial_token\":…,\"requires_2fa\":true}` and NO `token`. The admin panel's sign-in handler only checks `res.ok`, then assigns `d.token` (undefined) and proceeds. There is no `requires_2fa` branch and no call to `/auth/verify-totp` anywhere in the file, so the panel can never complete a 2FA login.",
|
||
"repro": "1. Owner account enables 2FA from the desktop client (POST /users/me/totp/enable + confirm).\n2. Open https://server/admin and sign in with the correct username + password.\n3. Server returns 200 `{\"partial_token\":\"…\",\"requires_2fa\":true}`. The handler sets `state.token = undefined` and writes the literal string \"undefined\" into localStorage['admin_token'].\n4. `enterApp()` → `api('GET','/me')` sends `Authorization: Bearer undefined` → adminAuthMiddleware 401 → `handleSessionExpired()` clears the token and shows the login overlay with \"Your session expired — sign in again.\"\n5. Every retry repeats this. The admin panel is unreachable for that account, with no error explaining why.\n\nAggravating case: docs/security.md:41 advertises the `require_2fa` server setting, and Server/admin/handlers_settings.go:148 refuses to enable it until *every* user has TOTP enrolled — so following the documented hardening path locks every principal out of the admin panel at once.",
|
||
"evidence": "Server/admin/static/index.html:650-661\n document.getElementById('loginBtn').onclick=async()=>{\n ...\n try{const r=await fetch('/api/v1/auth/login',{...});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed');\n state.token=d.token;localStorage.setItem('admin_token',state.token);await enterApp();\n }catch(e){err.textContent=e.message}\n\nServer/api/auth_handler.go:363-377\n if user.TOTPSecret != nil {\n partialToken, err := partialStore.Issue(...)\n ...\n writeJSON(w, http.StatusOK, authSuccessResponse{\n PartialToken: partialToken,\n Requires2FA: true,\n })\n return\n }\n\nServer/api/auth_handler.go:67-72 Token is `json:\"token,omitempty\"` — omitted entirely on the 2FA branch.\n\n`grep -n \"verify-totp|partial_token|requires_2fa\" Server/admin/static/index.html` → no matches.",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-admin-static",
|
||
"suggestedFix": "Add the missing 2FA branch at the single shared login handler (Server/admin/static/index.html:659). After parsing `d`, before assigning the token: if `d.requires_2fa && d.partial_token`, show a code prompt and complete the login with `POST /api/v1/auth/verify-totp` sending `Authorization: Bearer <d.partial_token>` and body `{code}` (the contract handleVerifyTOTP expects, totp_handler.go:41-66), then use the `token` from that 200 response. As a one-line stopgap for the same spot, guard `if(!d.token) throw new Error(d.requires_2fa?'This account has two-factor authentication enabled; the admin panel cannot complete 2FA sign-in yet.':'Login failed')` so the panel stops writing the literal string \"undefined\" into localStorage and gives a truthful error instead of a false \"session expired\".",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0351",
|
||
"title": "voice_join tears the user out of their current call before the destination's capacity is ever checked, so clicking a full voice channel ends the call for nothing",
|
||
"file": "Server/ws/voice_join.go",
|
||
"line": 222,
|
||
"severity": "medium",
|
||
"why": "handleVoiceJoin's pre-flight (voiceJoinPrecheck, lines 84-172) refuses every condition that would make the join bounce — rate limit, bad channel_id, CONNECT_VOICE, missing channel, wrong channel type, DM block, archived, nil user, LiveKit down — but it never checks voice_max_users. voiceJoinLeaveCurrent then unconditionally runs the destructive half of the switch (h.handleVoiceLeave at line 223: DB row deleted, voice_leave broadcast to the whole READ audience, LiveKit participant removed, key-holder re-elected), and only afterwards does voiceJoinPersist (line 269) run the atomic capacity check and answer CHANNEL_FULL. The user is already out of their old channel when the refusal is sent. The sibling server-driven path knows this is wrong and guards it: handleVoiceModMoveV2 runs an explicit advisory CountChannelVoiceUsers pre-flight (Server/ws/voice_moderation.go:426-437) with the comment \"this one keeps the common case from dropping the target into a channel that is already full\", and the whole handler is introduced as \"the pre-flight: they refuse a move the re-join would only bounce, so the target is never dropped from voice for nothing.\" The self-switch path has no equivalent.",
|
||
"repro": "Voice channel B has voice_max_users = 2 and two participants. User U is live in voice channel A. U clicks B (the client deliberately never pre-blocks the click — Client/src/lib/dispatcher.ts:1291-1296: \"The server owns the limits ... the client never pre-blocks the click\"), sending voice_join{channel_id: B}. voiceJoinPrecheck passes (B exists, is a voice channel, is not archived, U holds CONNECT_VOICE). voiceJoinLeaveCurrent then calls handleVoiceLeave: U's voice_states row for A is deleted, voice_leave(A, U) is broadcast to everyone who can see A, U's LiveKit participant in A is removed, and A's E2EE key holder is re-elected. voiceJoinPersist then returns db.ErrChannelFull and the server replies CHANNEL_FULL. Client-side, dispatcher.ts:1287-1291 sees voiceStatus === \"joining\" and calls leaveVoice(true). End state: U is in no voice channel at all, their call in A is over, and rejoining A can now itself fail if A filled up meanwhile. Deterministic — no race required. Neither TestVoice_Join_ChannelFull (Server/ws/voice_handlers_test.go:996) nor TestHandleVoiceJoin_ChannelFull (Server/ws/coverage_voice_test.go:395) covers the switch case: in both, the refused joiner starts with no current voice channel, so the destructive leave never runs.",
|
||
"evidence": "// voice_join.go:221-223 — destructive leave, no capacity pre-flight above it\n\t// If user is already in a different voice channel, leave it first.\n\tif currentChID > 0 {\n\t\th.handleVoiceLeave(ctx, c)\n\n// voice_join.go:266-273 — the capacity check runs only AFTER that leave\n\t// Check channel capacity and persist to DB atomically.\n\tmaxUsers := ch.VoiceMaxUsers\n\tif maxUsers > 0 {\n\t\tif err := h.db.JoinVoiceChannelIfCapacity(ctx, c.userID, channelID, maxUsers); err != nil {\n\t\t\tif errors.Is(err, db.ErrChannelFull) {\n\t\t\t\tc.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\"))\n\t\t\t\treturn nil, false\n\n// voice_moderation.go:426-437 — the sibling path DOES pre-check\n\tif dest.VoiceMaxUsers > 0 {\n\t\tcount, cErr := d.DB.CountChannelVoiceUsers(ctx, c.ToChannelID())\n\t\t...\n\t\tif count >= dest.VoiceMaxUsers {\n\t\t\treturn Result{Error: ClientError{Code: ErrCodeChannelFull, Message: \"voice channel is full\"}}\n\t\t}\n\t}",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "ws-hub",
|
||
"suggestedFix": "Add the same advisory capacity pre-flight the sibling path already has, in voiceJoinPrecheck where `ch` is already in hand — after the Archived check and before any state mutation — scoped to the switch case so the same-channel re-join keeps answering ALREADY_JOINED: `if cur := c.getVoiceChID(); cur > 0 && cur != channelID && ch.VoiceMaxUsers > 0 { count, cErr := h.db.CountChannelVoiceUsers(ctx, channelID); if cErr != nil { c.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to check channel capacity\")); return 0, nil, false }; if count >= ch.VoiceMaxUsers { c.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\")); return 0, nil, false } }`. CountChannelVoiceUsers already exists (Server/db/voice_queries.go:329). This is advisory only — the atomic JoinVoiceChannelIfCapacity check in voiceJoinPersist stays as the authority for the racing case, exactly as in handleVoiceModMoveV2 — and the joiner's own row lives on the old channel, so it cannot be miscounted against the destination.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0352",
|
||
"title": "A key holder's reconnect re-announce is fire-and-forget with no confirmation retry, so one dropped WS frame permanently breaks room-key delivery for every participant",
|
||
"file": "Client/src/lib/livekitE2EE.ts",
|
||
"line": 401,
|
||
"severity": "medium",
|
||
"why": "reannounceForReconnect() mints a brand-new ephemeral ECDH keypair on every LiveKit auto-reconnect attempt and publishes it with a single unacknowledged `getWs()?.send(...)` (line 384). WsClient.sendRaw silently drops the frame when the WS proxy is not open (ws.ts:603-609 — `if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); ... return; }`), and nothing queues or retries it. The one bounded retry that exists — the RECONNECT_CONFIRM_MS timer — is armed only for non-key-holders (`if (!this._isKeyHolder && roomKeyAtReconnect !== null)`), so a key holder whose announce is dropped has zero recovery: no retry, no log, no signal. Its published ECDH key is now permanently the pre-reconnect (dead) one, while it wraps every future room key under the new private half. No other code path ever re-announces mid-call: setupKeyExchange only runs on a fresh join, and the server's OC-0316 resume relay (Server/ws/hub.go:672) re-publishes the client's *stale stored* key, cementing the divergence.",
|
||
"repro": "1) Client H is the elected key holder (lowest uid) in a voice channel with peers P1, P2. 2) The user's network drops. livekit-client gives up on the SFU signal socket and the session enters attemptAutoReconnect (livekitSession.ts:498); ws.ts is meanwhile in exponential backoff (getReconnectDelay, ws.ts:206 — 1s/2s/4s/8s/16s/30s, capped at 30s), so after a ~60s outage its next retry is ~30s away. 3) Network returns. attemptAutoReconnect's 3s-delayed attempt calls `await this._e2ee.reannounceForReconnect()` (livekitSession.ts:564) BEFORE `newRoom.connect()`: a fresh pair K_new is generated, `this._ecdhKeyPair = pair` is published locally, and the announce carrying K_new is passed to `getWs()?.send(...)` while `proxyOpen === false` → dropped. 4) `newRoom.connect()` succeeds (the SFU is reachable again), so the room is live with K_new. Because `this._isKeyHolder` is true, the block at line 401 never arms the confirm timer, so the announce is never repeated. 5) The WS resumes ~30s later; the server transfers and re-relays H's OLD stored key K_old to VoiceTopic (hub.go:672, `if key, sig := c.getE2EEPubKey(); key != \"\"`). P1/P2 see a duplicate announce for K_old and keep K_old in _peerPublicKeys. 6) H's next rotation (5-minute timer, or any voice_leave) calls distributeRoomKey → wrapRoomKey(K_new.privateKey, peerPub, ...). P1/P2 unwrap with unwrapRoomKey(theirPriv, K_old.publicKey, ...): the two ECDH shared secrets differ, AES-GCM authentication fails, handleOfferInner's catch logs \"failed to handle offer\" and returns. Every peer stays on the superseded room key while H encrypts with the new one — the whole call is deaf and mute in both directions, with the UI still showing \"🔒 Secured\". 7) It never heals: a *new* joiner is handed H's stale K_old by sendVoicePeerKeys (Server/ws/voice_e2ee.go:329), so its setupKeyExchange also cannot unwrap H's offer and it is ejected with e2ee_timeout after 15s. The existing test at Client/tests/unit/livekit-e2ee.test.ts:1196 deliberately stands the manager down to a non-holder (`await mgr.handleOffer(PEER_ID, \"enc\", \"iv\"); // stands us down — now a non-holder`) before asserting the retry, so the holder case is neither covered nor intended behaviour.",
|
||
"evidence": "livekitE2EE.ts:384 this.deps.getWs()?.send({ type: \"voice_e2ee_announce\", payload: reconnectAnnounce });\nlivekitE2EE.ts:394-395 // periodic rotation (OC-0007). Holders don't need this: their own key\\n // IS the current one.\nlivekitE2EE.ts:401 if (!this._isKeyHolder && roomKeyAtReconnect !== null) {\nws.ts:603-609 if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); queueMicrotask(() => notifySendFailure(id, \"OFFLINE\")); return; }",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "voice-e2ee",
|
||
"suggestedFix": "One guard in reannounceForReconnect (Client/src/lib/livekitE2EE.ts, around :368): do not adopt the freshly generated pair when the announce cannot actually be delivered. Widen the E2EE ws dep to expose the connection state (WsClient already has getState(), ws.ts:755) and, when it is not \"connected\", skip the keypair swap entirely — keep the existing `_ecdhKeyPair`, still `keyProvider.setKey(...)` the retained room key, and return. Losing per-reconnect forward secrecy on that one attempt is strictly better than publishing a key half nobody holds; the published key and the local private half then can never diverge, for holders and non-holders alike. (Equivalent, slightly larger: re-send the stored `reconnectAnnounce` from an ws.onStateChange(\"connected\") hook until it is accepted.)",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0353",
|
||
"title": "LiveKit proxy rewrites Origin to a non-canonical \"https://host:443\", which the server's own origin allowlist can never match behind the documented reverse proxy — every remote voice join 403s",
|
||
"file": "Client/src-tauri/src/livekit_proxy.rs",
|
||
"line": 136,
|
||
"severity": "medium",
|
||
"why": "rewrite_proxy_headers replaces the webview's Origin with `https://` + remote_host, and remote_host always carries an explicit port (livekitSession.ts's ensureLiveKitProxy appends `:443` when the profile host has none). The server gates every `/livekit/*` request — including the signal WS upgrade — with isOriginAllowed, which accepts only (a) an absent Origin, (b) an Origin whose host string EqualFolds r.Host, (c) the three fixed `tauri.localhost` origins, or (d) an exact allowed_origins entry. The synthesized `https://chat.example.com:443` is not a canonical origin serialization (RFC 6454 omits the default port), so it matches none of the first-party entries, and it only matches r.Host when nothing in front of the Go server normalizes the Host header. The project's own documented nginx recipe uses `proxy_set_header Host $host`, and nginx's `$host` strips the port — so r.Host becomes `chat.example.com` while Origin stays `https://chat.example.com:443`.",
|
||
"repro": "Deploy the server behind the documented nginx snippet (docs/deployment.md:285-303) on chat.example.com with the default `server.allowed_origins: []`. Save the profile as host \"chat.example.com\" in the desktop client and join a voice channel from a machine that is not the server. ensureLiveKitProxy computes remote_host=\"chat.example.com:443\"; the Rust proxy sends `Host: chat.example.com:443` and `Origin: https://chat.example.com:443`; nginx forwards `Host: chat.example.com`; isOriginAllowed compares u.Host \"chat.example.com:443\" against r.Host \"chat.example.com\", falls through the first-party list and the empty allowlist, and returns false. The LiveKit signal WS gets 403 FORBIDDEN and voice never connects, while the chat WebSocket keeps working (ws_proxy uses tokio-tungstenite, which sends no Origin header at all, so it takes the `origin == \"\"` early-accept). Note livekit_proxy.rs:598's unit test pins the rewrite's shape but nothing tests that the resulting origin is one the server accepts.",
|
||
"evidence": "Client/src-tauri/src/livekit_proxy.rs:135-137\n } else if lower.starts_with(\"origin:\") {\n modified.push_str(\"Origin: https://\");\n modified.push_str(remote_host);\n\nClient/src/lib/livekitSession.ts:766 (ensureLiveKitProxy)\n hostWithPort = this.serverHost.includes(\":\") ? this.serverHost : `${this.serverHost}:443`;\n\nServer/api/livekit_proxy.go:147-149 (isOriginAllowed)\n\tif u, err := url.Parse(origin); err == nil && u.Host != \"\" && strings.EqualFold(u.Host, r.Host) {\n\t\treturn true\n\t}\n\nServer/api/livekit_proxy.go:76-84 — the check gates every /livekit/* request, WS upgrades included.\n\ndocs/deployment.md:297\n proxy_set_header Host $host;\n\nCorroborating: Server/api/livekit_proxy_test.go:127 calls the Rust layer the client's \"Origin-stripping Rust proxy\" — the server-side allowance was written against a premise (Origin removed) that livekit_proxy.rs does not implement (Origin rewritten).",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "tauri-rust",
|
||
"suggestedFix": "Normalize the scheme-default port on both sides of the same-origin comparison in the shared server-side guard, Server/api/livekit_proxy.go:145 — e.g. compare `stripDefaultPort(u.Scheme, u.Host)` against `stripDefaultPort(schemeOf(r), r.Host)`, dropping only \":443\" for https and \":80\" for http. One guard in isOriginAllowed covers both the port-stripping and port-preserving proxies, and keeps TestIsOriginAllowed_SameHostDifferentPortDenied (:9999 vs :8443) failing as it should. Fixing it client-side by dropping :443 from the Origin alone would break the direct-on-443 case, where the rewritten Host still carries the port.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0354",
|
||
"title": "authStore.user.totp_enabled is never populated from the server, so 2FA can never be disabled from Settings and every auth_ok wipes it",
|
||
"file": "Client/src/components/settings/AccountTab.ts",
|
||
"line": 786,
|
||
"severity": "medium",
|
||
"why": "The 2FA panel's entire state is derived from `authStore.getState().user?.totp_enabled`, but nothing on any production code path ever sets that field from the server. Its only two writers are the local post-action calls in MainPage (`updateUser({ totp_enabled: true })` at :559 and `false` at :570), and the dispatcher's AUTH_OK handler replaces `authStore.user` wholesale on *every* auth_ok — fresh connect and every WS resume alike — with the `auth_ok` payload user, which `buildAuthOK` (Server/ws/serve_ready.go:27-51) does not include `totp_enabled` in. So the flag is undefined at login and is reset to undefined by the first reconnect after an in-session enable.",
|
||
"repro": "Enable 2FA on an account, then restart the desktop app (or just let the WebSocket drop and resume once). Open Settings → Account → Two-Factor Authentication. The badge reads \"Disabled\" and `buildTotpEnrollForm` is rendered; `buildTotpDisableView` is unreachable, so there is no way to turn 2FA off from the UI. Clicking Enable posts /users/me/totp/enable and the server answers 409 \"disable 2FA before re-enabling\" — an instruction the UI makes impossible to follow. The badge also actively lies about the state of a security control. Within a single session the same wipe happens on reconnect: enable 2FA (panel flips to \"Enabled\"), drop the socket, resume — the next render reads undefined again.",
|
||
"evidence": "AccountTab.ts:786 `const enabled = authStore.getState().user?.totp_enabled === true;`\nAccountTab.ts:802-806 `if (enabled) { contentArea.appendChild(buildTotpDisableView(...)); } else { contentArea.appendChild(buildTotpEnrollForm(...)); }`\ndispatcher.ts:276 `setAuth(authStore.getState().token ?? \"\", payload.user, payload.server_name, payload.motd);`\nauth.store.ts:57-65 setAuth does `authStore.setState(() => ({ token, user, serverName, motd, isAuthenticated: true }))` — a wholesale replace, no merge.\nServer/ws/serve_ready.go:29-46 auth_ok payload user = {id, username, avatar, role, display_name, about, custom_status, status} — no totp_enabled.\nClient/src/lib/types.ts:823-828 `export interface AuthResponse { token?, partial_token?, requires_2fa }` — the login response's real `totp_enabled` (Server/api/auth_handler.go:62 and :721) is not even declared client-side, and main.ts's onLogin/onRegister/onTotpSubmit only read `result.token` / `result.requires_2fa` / `result.partial_token`.\n`api.getMe()` (api.ts:321) is never called anywhere in Client/src, and its `MemberResponse` (types.ts:916-925) carries no totp_enabled either.\nServer/api/totp_handler.go:194-200 `if user.TOTPSecret != nil && *user.TOTPSecret != \"\" { 409 TOTP_ALREADY_ENABLED \"disable 2FA before re-enabling\" }`",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "flow-reconnect",
|
||
"suggestedFix": "Fix it in the one payload both the fresh-connect and resume paths share rather than in each client writer: add `\"totp_enabled\": user.TOTPSecret != nil` to the `user` map in buildAuthOK (Server/ws/serve_ready.go:29-46), declaring the field on the auth_ok user in protocol/schema.json and regenerating Server/ws/message_types.go + Client/src/lib/protocolTypes.ts via the protocol-change skill. That makes authStore.user.totp_enabled authoritative on every connect, so the existing wholesale setAuth stays correct and no client-side merge is needed.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0355",
|
||
"title": "The \"/\" quick-search hotkey swallows the keystroke inside the very inputs it focuses, so \"/\" can never be typed into the audit or log filter",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 2002,
|
||
"severity": "medium",
|
||
"why": "The global keydown handler calls e.preventDefault() for \"/\" whenever any .filter-search element exists, with no check that the event target is already a text field. On the Audit Log and Server Logs pages the search box IS that element, so the character is dropped instead of inserted. The hidden admin shell keeps its markup after logout, so the login form inherits the same swallow.",
|
||
"repro": "Open Server Logs, click the \"Filter logs...\" box and type `api/v1`. The field shows `apiv1` — every \"/\" is preventDefault-ed, so no log line can be filtered by path. Same in the Audit Log search box. Secondary: after visiting Audit Log or Server Logs, let the session expire (or sign out) — the login overlay is shown while #content still holds a .filter-search, so typing a password containing \"/\" silently drops that character and focus() targets a display:none input.",
|
||
"evidence": "index.html:2002 `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}`\nindex.html:1494 the Server Logs toolbar renders `<input class=\"filter-search\" placeholder=\"Filter logs...\" ... oninput=\"state.logSearch=this.value;renderLogLines()\">`\nindex.html:218 `.hidden{display:none!important}` — hideAll() only hides #adminShell, it never clears #content.",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-admin-static",
|
||
"suggestedFix": "Add one target guard at index.html:2002 before preventDefault: `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const t=e.target;if(t&&(t.isContentEditable||/^(input|textarea|select)$/i.test(t.tagName)))return;const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}` — this single guard fixes both the filter fields and the login form.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0356",
|
||
"title": "The Ctrl+K quick switcher is entirely unstyled — none of its five classes exist in any stylesheet, so the arrow-key selection is invisible and the result list has no scroller",
|
||
"file": "Client/src/components/QuickSwitcher.ts",
|
||
"line": 172,
|
||
"severity": "medium",
|
||
"why": "`createQuickSwitcher` builds its whole UI out of `quick-switcher`, `quick-switcher__input`, `quick-switcher__results`, `quick-switcher__item` and `quick-switcher__item--active`. `grep -n \"switcher\" Client/src/styles/*.css` returns **zero** matches across all five loaded stylesheets (tokens/base/login/app/theme-neon-glow, the complete set imported by `main.ts:3-7`), and only the backdrop (`quick-switcher-overlay`, line 166) carries an inline style. The `base.css` reset then applies: the modal is a transparent block with no background/width/padding on a 60%-black backdrop, `.quick-switcher__results` gets no `max-height`/`overflow` so a long channel list runs off the bottom of the viewport with no scroller, and — the functional break — `quick-switcher__item--active`, the class `renderResults()` moves on every ArrowUp/ArrowDown (lines 56-58, 110-126), paints nothing at all, so the keyboard highlight the widget's entire navigation model depends on is invisible.",
|
||
"repro": "Press Ctrl+K anywhere in MainPage (OverlayManagers.createQuickSwitcherManager → createQuickSwitcher). The overlay opens as unstyled text on a dark backdrop; press ArrowDown repeatedly — `activeIndex` advances and `aria-activedescendant` moves, but no row is visually highlighted, so Enter (line 129-136) navigates to a channel the user had no way to see was selected. On a server with ~30+ channels the results list also extends past the viewport bottom with no scrollbar, making the lower entries unreachable by mouse.",
|
||
"evidence": "QuickSwitcher.ts:56 class: isActive ? \"quick-switcher__item quick-switcher__item--active\" : \"quick-switcher__item\",\nQuickSwitcher.ts:172 const modal = createElement(\"div\", { class: \"quick-switcher\" });\nQuickSwitcher.ts:181 class: \"quick-switcher__input\",\nQuickSwitcher.ts:192 class: \"quick-switcher__results\",\n$ grep -n \"switcher\" Client/src/styles/*.css -> (no output)\n(compare: `.quick-switch*` — the *other* overlay — has 18 rules in app.css, and SearchOverlay's `.search-result-item--active` is defined)",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-client-src-components",
|
||
"suggestedFix": "Add the missing block to Client/src/styles/app.css (next to the existing .quick-switch-* rules) rather than to each call site — at minimum `.quick-switcher__item--active { background: var(--bg-modifier-selected); color: var(--text-normal); }` so the roving highlight paints, plus `.quick-switcher { width: 480px; max-width: 90vw; background: var(--bg-primary); border-radius: 8px; padding: 12px; }`, `.quick-switcher__input { width: 100%; padding: 8px; background: var(--bg-tertiary); color: var(--text-normal); border-radius: 4px; }`, `.quick-switcher__results { max-height: 50vh; overflow-y: auto; }` and `.quick-switcher__item { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; }`.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0357",
|
||
"title": "Message search silently returns nothing for any query containing punctuation — sanitizeFTSQuery deletes separators instead of folding them to a space",
|
||
"file": "Server/db/message_queries.go",
|
||
"line": 50,
|
||
"severity": "low",
|
||
"why": "messages_fts uses FTS5's default unicode61 tokenizer, where every non-alphanumeric rune ('_', '.', '\\'', '/', ':', '@', …) is a token separator, so \"user_id\" is indexed as the two tokens `user` and `id`. sanitizeFTSQuery keeps letters/digits/space, folds only '-' to a space, and silently DROPS every other rune — which concatenates the neighbouring tokens into one term that exists nowhere in the index. The '-' arm exists for exactly this reason (its own comment: \"Folding to a space (rather than dropping it) still matches the indexed tokens\"), but the rule was never applied to the other separators.",
|
||
"repro": "Post a message \"don't touch user_id in docs/protocol.md\". FTS indexes the tokens don, t, touch, user, id, in, docs, protocol, md. Now search (GET /api/v1/messages/search?q=user_id, or the in-app search overlay): sanitizeFTSQuery returns \"userid\", the MATCH finds no such token, and SearchMessages returns zero hits with HTTP 200. Same for \"don't\" -> \"dont\", \"docs/protocol\" -> \"docsprotocol\", \"example.com\" -> \"examplecom\". Searching \"user id\" (with a space) matches, proving the content is indexed and reachable — only the punctuated spelling the user actually copied out of the message is silently unmatchable. Expected: fold each separator to a space, exactly as '-' already is, so the query becomes the same token sequence the tokenizer produced.",
|
||
"evidence": "\tfor _, r := range q {\n\t\tswitch {\n\t\tcase unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ':\n\t\t\tsb.WriteRune(r)\n\t\tcase r == '-':\n\t\t\tsb.WriteRune(' ')\n\t\t}\n\t}\n// (no default: every other rune is dropped, joining adjacent tokens)",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "db-storage",
|
||
"suggestedFix": "In sanitizeFTSQuery, replace the special-cased `case r == '-': sb.WriteRune(' ')` with a `default: sb.WriteRune(' ')` arm, so every non-alphanumeric rune folds to a space exactly as '-' already does. Output stays within the documented charset (letters, digits, spaces), so the fuzz/unit contracts still hold, and the query becomes the same token sequence unicode61 produced when indexing.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0358",
|
||
"title": "EditMessageContent lacks the `AND deleted = 0` guard its SoftDeleteMessage / SetMessagePinned siblings have, so an edit racing a delete rewrites a tombstone and broadcasts chat_edited for a deleted message",
|
||
"file": "Server/db/message_queries.go",
|
||
"line": 179,
|
||
"severity": "low",
|
||
"why": "MessageService.EditMessage checks msg.Deleted from a read taken before the write, and db.EditMessage re-reads the row but still issues an UPDATE keyed on id alone. OC-0284 added `AND deleted = 0` to SoftDeleteMessage and SetMessagePinned (and both check RowsAffected) precisely so a message that races the writer surfaces as ErrNotFound instead of silently succeeding; the edit path was not given the same guard, so it commits content/edited_at onto an already soft-deleted row and then reports success.",
|
||
"repro": "Alice has message 100 open in the composer for editing. Interleave: (a) EditMessage reads message 100 via GetMessage — Deleted=false, passes the msg.Deleted guard and editMessageCheckAccess; (b) a moderator's chat_delete for message 100 commits (deleted=1, DecrementMentionCounts reverses its badges); (c) Alice's EditMessageContent UPDATE runs — no deleted predicate, so it rewrites the tombstone's content and stamps edited_at, then ReplaceMessageMentions re-inserts message_mentions rows for the now-deleted message that nothing will ever reverse; (d) the service returns success and the ws layer fans out chat_edited for a message every client has already tombstoned. Expected: `AND deleted = 0` plus a RowsAffected check mapping to ErrDeletedMessage, exactly as DeleteMessage and SetMessagePinned already do.",
|
||
"evidence": "messages.sql:19 -- name: EditMessageContent :one\n UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?\nmessages.sql:24 UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0;\nmessages.sql:27 UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;\nmessage_queries.go:179 updated, err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{Content: content, ID: id})",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "db-storage",
|
||
"suggestedFix": "Add `AND deleted = 0` to the EditMessageContent statement in Server/db/queries/sqlite/messages.sql (via the db-change skill so dbgen regenerates) and, in db.EditMessage, map the resulting sql.ErrNoRows to ErrNotFound/a deleted-message error rather than a bare wrap, so MessageService.EditMessage surfaces ErrDeletedMessage — one guard in the shared query, exactly as OC-0284 did for SoftDeleteMessage.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0359",
|
||
"title": "secret_store's read-back-mismatch branch deletes the keyring entry before the fallback write commits — the exact eager-purge its sibling branch was fixed to defer",
|
||
"file": "Client/src-tauri/src/secret_store.rs",
|
||
"line": 149,
|
||
"severity": "low",
|
||
"why": "set_with's write-failure arm (line 164) was fixed to defer its keyring_delete until after fallback_set has actually committed, with a comment saying an eager delete \"risks erasing the last good copy of the secret if the fallback write fails too.\" The read-back-mismatch arm 15 lines above does exactly that eager delete, then falls through to `fallback_set(account, secret)?` — whose `?` returns before any replacement copy exists. If the fallback write fails, the credential/identity key that was in the keyring is gone and nothing was written anywhere.",
|
||
"repro": "A machine whose OS credential store accepts writes but serves a stale value. User re-logs in: save_credential -> secret_store::set -> keyring_set returns Ok(()); keyring_get returns the PREVIOUS (still-valid) credential blob, which != the new secret, so the Ok(Some(_)) arm at line 139 runs keyring_delete and removes it. Execution reaches line 179; fallback_set fails (app.path().app_data_dir() unresolvable, credential_fallback.key cannot be created, or store.save() hits ENOSPC) and returns Err. Both stores are now empty: the next load_credential returns Ok(None), indistinguishable from first login, and the user's saved credential — or, via save_identity_key, the voice-E2EE identity private key every peer has TOFU-pinned — is permanently destroyed. The existing test at secret_store.rs:648 only covers this arm with a SUCCEEDING fallback, so nothing locks the current behavior; the mirror-image test for the sibling arm (line 594, set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail) has no counterpart here.",
|
||
"evidence": "Client/src-tauri/src/secret_store.rs:139-153\n Ok(Some(_)) => {\n log::error!(... \"returned a different secret than was written\" ...);\n if let Err(e) = keyring_delete(account) {\n log::warn!(\"{SERVICE}: could not remove the mismatched entry for '{account}': {e}\");\n }\n }\n...\nline 179: fallback_set(account, secret)?;\n\nContrast, same function, line 164-175:\n Err(e) => {\n ...\n // But the purge must wait until fallback_set below has actually\n // committed the replacement: deleting now, before that write is\n // known to succeed, risks erasing the last good copy of the secret\n // if the fallback write fails too.\n purge_stale_keyring_after_fallback_commits = true;\n }",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "tauri-rust",
|
||
"suggestedFix": "In the Ok(Some(_)) arm (Client/src-tauri/src/secret_store.rs:139-153), drop the inline keyring_delete and reuse the existing deferral flag: `purge_stale_keyring_after_fallback_commits = true;`. The shared purge block at lines 181-188 then runs it only after fallback_set has committed, making both degraded arms obey the same ordering invariant with no new code path and no test change.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0360",
|
||
"title": "LiveKitSession keeps a second copy of the master output-volume multiplier that setOutputVolume never updates, so the diagnostics dump always reports the app-startup value",
|
||
"file": "Client/src/lib/livekitSession.ts",
|
||
"line": 153,
|
||
"severity": "low",
|
||
"why": "The same value lives in two places: AudioElements.outputVolumeMultiplier (the one that is actually applied to participants) and LiveKitSession.outputVolumeMultiplier. setOutputVolume writes the pref and the AudioElements field only; the LiveKitSession field is initialised once from localStorage at module construction and never written again, yet it is what buildSessionDebugInfo publishes.",
|
||
"repro": "1. Launch the client with the stored `owncord:settings:outputVolume` at 100. LiveKitSession.outputVolumeMultiplier = 1.\n2. Settings -> Voice & Audio -> drag Output Volume to 40%. VoiceAudioTab calls setOutputVolume(40) -> AudioElements.outputVolumeMultiplier = 0.4 and audio really is at 40%.\n3. Settings -> Logs -> copy diagnostics (LogsTab.ts:288 getSessionDebugInfo()), or run `__owncord.lkDebug()`. The dump reports outputVolumeMultiplier: 1 while every participant is actually being played at 0.4 — the field is stale for the whole app session and only re-reads the pref on the next launch.",
|
||
"evidence": "livekitSession.ts:153 — `private outputVolumeMultiplier = loadPref<number>(\"outputVolume\", 100) / 100;` (no other assignment to this field exists in the file)\nlivekitSession.ts:1769-1774 — `return buildSessionDebugInfo({ room: this._room, currentChannelId: this._currentChannelId, outputVolumeMultiplier: this.outputVolumeMultiplier, ... })`\naudioElements.ts:248-252 —\n setOutputVolume(volume: number): void {\n const clamped = Math.max(0, Math.min(200, volume));\n savePref(\"outputVolume\", clamped);\n this.outputVolumeMultiplier = clamped / 100;\nlivekitDiagnostics.ts:171 — `outputVolumeMultiplier,` is emitted verbatim into the debug object.",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "state-desync",
|
||
"suggestedFix": "Delete the duplicate field at livekitSession.ts:153 and read the single source of truth in getSessionDebugInfo: change line 1772 to `outputVolumeMultiplier: this._audioElements.getOutputVolumeMultiplier(),` (that getter already exists at audioElements.ts:90-92). One line, no caller or signature changes — buildSessionDebugInfo's deps shape is unchanged.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0361",
|
||
"title": "Admin Users / Audit Log pagination offers a phantom empty next page at exact multiples of PAGE_SIZE",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 832,
|
||
"severity": "low",
|
||
"why": "The \"has more\" decision is `users.length < PAGE_SIZE` on a page fetched with `limit=PAGE_SIZE`, so a full page is indistinguishable from \"a full page and nothing after it\". The server API supports over-fetching (and this repo already uses the fetch-one-extra pattern correctly in `MessageService.GetMessages`, which asks for `limit+1` and derives `hasMore := len(msgs) > limit`), so the admin panel is the odd one out. Same defect at line 1433 for the audit log.",
|
||
"repro": "Server with exactly 50 users (PAGE_SIZE = 50). Open Admin -> Users: `GET /users?limit=50&offset=0` returns 50 rows, so `users.length < PAGE_SIZE` is false and the \">\" button stays enabled. Clicking it sets usersPage=2 -> `GET /users?limit=50&offset=50` returns [] -> the table renders \"No users found\" with \"Page 2\" showing. The same sequence reproduces on Admin -> Audit Log (index.html:1433) with an entry count that is an exact multiple of 50.",
|
||
"evidence": "const offset=(state.usersPage-1)*PAGE_SIZE;\nusers=await api('GET','/users?limit='+PAGE_SIZE+'&offset='+offset)\n...\nhtml+='<button class=\"page-btn\" '+(users.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.usersPage++;renderContent()\">></button>';\n// audit log sibling, line 1433:\nhtml+='<button class=\"page-btn\" '+(!entries||entries.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.auditPage++;renderContent()\">></button>';",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "ordering-boundary",
|
||
"suggestedFix": "Fetch one row past the page and derive hasMore from the overflow, matching MessageService.GetMessages. In renderUsers: `const rows=await api('GET','/users?limit='+(PAGE_SIZE+1)+'&offset='+offset); const hasMore=rows.length>PAGE_SIZE; users=rows.slice(0,PAGE_SIZE);` then line 832 becomes `(!hasMore?'disabled':'')`. Apply the identical change in renderAudit (lines 1396/1433), assigning the sliced array to state.auditCache so the \"N entries on this page\" label stays accurate. limit=51 is inside the server's 1..500 clamp, so no server change is needed.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0362",
|
||
"title": "clearCustomEmoji resets the emoji generation counter to 0, so a previous server's in-flight GET /emoji is accepted by the next server's session — and that server's own reply is then rejected as stale",
|
||
"file": "Client/src/stores/emoji.store.ts",
|
||
"line": 98,
|
||
"severity": "low",
|
||
"why": "The OC-0251 staleness guard in setCustomEmoji compares the caller's snapshotted `rev` against the store's current `rev`, but clearCustomEmoji (called from MainPage.destroy on logout/server switch) either resets `rev` to 0 via INITIAL or, when the set is already empty, early-returns and never touches `rev` at all. Either way the counter is back at 0 for the next session, which snapshots `emojiRevAtFetch = 0` too — so a reply belonging to the previous server still matches and is applied, which is precisely what clearCustomEmoji exists to prevent, and it then bumps rev to 1 so the new server's own reply is discarded as \"stale\".",
|
||
"repro": "1) Connect to server A. dispatcher's ready handler snapshots emojiRevAtFetch = 0 and issues GET /emoji to A. 2) Before that reply lands (slow/remote A), switch profiles to server B. MainPage.destroy() calls clearCustomEmoji(); the store is still empty, so line 98 early-returns and rev stays 0. 3) B's ready handler snapshots emojiRevAtFetch = 0 and issues its own GET /emoji. 4) A's late reply arrives first: setCustomEmoji(A_list, 0) sees 0 === 0, applies A's emoji and bumps rev to 1. 5) B's reply arrives: setCustomEmoji(B_list, 0) sees 0 !== 1 and is skipped. Result: for the whole B session, resolveEmoji answers from A's set — B's own shortcodes render as literal `:name:` text, and any A shortcode present in a B message renders `<img src=\"/api/v1/emoji/<A-id>/image\">` resolved against B's host, i.e. an unrelated emoji of B's. Nothing recovers until an emoji_update broadcast happens on B.",
|
||
"evidence": "emoji.store.ts:47-51 const INITIAL: EmojiState = { emoji: [], byShortcode: new Map(), rev: 0 };\nemoji.store.ts:90-93 emojiStore.setState((prev) => { if (rev !== undefined && rev !== (prev.rev ?? 0)) return prev; return { emoji: next, byShortcode, rev: (prev.rev ?? 0) + 1 }; });\nemoji.store.ts:97-99 export function clearCustomEmoji(): void { emojiStore.setState((prev) => (prev.emoji.length === 0 ? prev : INITIAL)); }\ndispatcher.ts:571-574 const emojiRevAtFetch = emojiStore.getState().rev ?? 0; api.listEmoji().then((list) => setCustomEmoji(list, emojiRevAtFetch))\nMainPage.ts:916-920 // Custom emoji belong to the server this page was connected to. ... clearCustomEmoji();",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-db",
|
||
"suggestedFix": "Make clearCustomEmoji invalidate in-flight fetches instead of resetting the counter: `emojiStore.setState((prev) => ({ emoji: [], byShortcode: new Map(), rev: (prev.rev ?? 0) + 1 }));` — unconditional (drop the `emoji.length === 0` early return), so any snapshotted rev from the previous session can never match again. One change in the shared store; no caller edits needed.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0363",
|
||
"title": "Clicking any emoji permanently deletes every custom-emoji entry from the shared recent-emoji list whenever the current server's emoji set is not loaded",
|
||
"file": "Client/src/components/EmojiPicker.ts",
|
||
"line": 540,
|
||
"severity": "low",
|
||
"why": "getRecentEmoji applies a display-time filter that drops `:shortcode:` entries which do not resolve against the *current* server's emojiStore. addRecentEmoji feeds that filtered list straight back into localStorage.setItem, so a read-side presentation filter becomes a destructive write against the single, host-unscoped `owncord:recent-emoji` key.",
|
||
"repro": "On server A (which defines custom emoji :blob:), use :blob: — recents become [\":blob:\", \"😀\"]. Switch to server B, which has no :blob:. Open the picker and click any plain unicode emoji. addRecentEmoji calls getRecentEmoji(), whose line-531 filter drops \":blob:\" because resolveEmoji returns null on B, and line 543 writes the shortened list back. Returning to server A, :blob: is gone from Recent forever. The same wipe happens on server A itself if the picker is used before the ready-time GET /emoji resolves, or after it fails (dispatcher.ts:574's .catch only logs \"Failed to load custom emoji\"), since emojiStore is empty and every stored shortcode fails to resolve. The existing test (tests/unit/emoji-picker.test.ts:330) only pins the display filter, not the write-back.",
|
||
"evidence": "EmojiPicker.ts:509 const RECENT_KEY = \"owncord:recent-emoji\";\nEmojiPicker.ts:531 .filter((e) => !(e.startsWith(\":\") && e.endsWith(\":\")) || resolveEmoji(e) !== null)\nEmojiPicker.ts:539-543\nfunction addRecentEmoji(emoji: string): void {\n const recent = getRecentEmoji().filter((e) => e !== emoji);\n recent.unshift(emoji);\n try {\n localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));\nemoji.store.ts:111 return emojiStore.getState().byShortcode.get(name) ?? null; // current server only",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-db",
|
||
"suggestedFix": "Split the read paths in Client/src/components/EmojiPicker.ts: add a `readStoredRecent()` that only parses/validates the JSON string array (no resolveEmoji filter), have `addRecentEmoji` build its list from that, and keep the `resolveEmoji` filter inside `getRecentEmoji()` for display only.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0364",
|
||
"title": "Admin Users page reports a lapsed temporary ban as \"Banned: Yes\" forever — every other surface treats the user as active",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 805,
|
||
"severity": "low",
|
||
"why": "The Users table derives its Banned column and Status dot from the raw `users.banned` column alone; nothing in the panel ever reads `ban_expires` (zero hits for ban_expires/BanExpires in that file), even though Server/db/admin_queries.go:87 maps it and Server/admin/types.go:101 ships it as `ban_expires`. Nothing on the server clears `users.banned` when a temporary ban lapses — expiry is decided lazily by auth.IsEffectivelyBanned and by the `replace(ban_expires,' ','T') <= strftime(...)` arm in ListMembers / CountUsersWithoutTOTP / notBannedClause / GetOwnerUser. So the row keeps banned=1 with a past ban_expires for life while the account is fully active (logs in, passes AuthMiddleware and the WS handshake, appears in the roster, counts for the last-admin and require_2fa guards), and the admin panel — the only surface where an operator reviews ban state — is the one place that still calls them banned.",
|
||
"repro": "1. From the desktop client, right-click a member -> Ban and pick a finite duration from the ban-duration dropdown (Client/src/components/AdminActions.ts:345; sent as ban_duration_hours by Client/src/lib/api.ts:794). The server stores banned=1 with ban_expires = now+N hours (Server/admin/handlers_users.go:169-176 -> db.BanUser).\n2. Let the ban lapse, or seed it directly: UPDATE users SET banned=1, ban_expires='2020-01-01T00:00:00Z' WHERE id=?.\n3. Log in as that user: login succeeds, they appear in every client's member list, they can be @mentioned, and they count toward the last-admin / require_2fa guards.\n4. Open the admin panel's Users page: the same account renders with a red \"Banned: Yes\" badge, the stale ban reason under it, a \"banned\" status dot instead of Online, and only an Unban action — the Ban button is hidden, so re-banning them takes two steps (Unban, then Ban).",
|
||
"evidence": "Server/admin/static/index.html:805-806,823:\n const status=u.Status||u.status||'offline';const banned=u.Banned||u.banned||false;\n const statusDot=banned?'banned':status;const statusLabel=banned?'Banned':status==='online'?'Online':'Offline';\n if(banned)html+='<button class=\"act-btn\" title=\"Unban\" onclick=\"unbanUser('+uid+')\">'+I.check+'</button>';\n else html+='<button class=\"act-btn danger\" title=\"Ban\" ...>'+I.ban+'</button>';\n\nServer/admin/types.go:99-101 (data the panel is given but ignores):\n\tBanned bool `json:\"banned\"`\n\tBanReason *string `json:\"ban_reason,omitempty\"`\n\tBanExpires *string `json:\"ban_expires,omitempty\"`\n\nServer/auth/helpers.go:73-91 (the rule every other surface applies):\n\tif u == nil || !u.Banned { return false }\n\tif u.BanExpires == nil { return true }\n\t... return time.Now().UTC().Before(t.UTC())",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-1",
|
||
"suggestedFix": "Derive the effective flag once in renderUsers (Server/admin/static/index.html:805) instead of reading the raw column: `const rawBan=u.Banned||u.banned||false; const exp=u.ban_expires||u.BanExpires||null; const banned=rawBan&&(!exp||new Date(String(exp).replace(' ','T').replace(/Z?$/,'Z')).getTime()>Date.now());` — the replace() normalises SQLite's space-separated form the same way db.notBannedClause does. That one expression fixes the badge, the status dot and the Ban/Unban button choice together. Do not move the computation into toAdminUserResponse: the API's raw `banned` is what the panel would still need to offer an Unban that clears the stale row.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0365",
|
||
"title": "GifPicker re-registers every result cell's click listener on the picker-lifetime AbortSignal on each search, retaining every discarded set of 20 GIF thumbnails",
|
||
"file": "Client/src/components/GifPicker.ts",
|
||
"line": 117,
|
||
"severity": "low",
|
||
"why": "`renderGifs` replaces the whole cell set (`clearChildren(gridArea)` then a fresh `.gp-grid`) on every load, but binds each new item's click listener to `signal` — the AbortController created once at `createGifPicker` and only aborted in `destroy()`. Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal holding a strong reference to the EventTarget, so every detached `.gp-item` (and its `<img>` with a decoded Klipy GIF) stays reachable until the picker closes. This is the identical mechanism already accepted and fixed for EmojiPicker.ts:620, SearchOverlay.ts:96, QuickSwitcher.ts:83, MemberList.ts:469 and MessageList.ts:332 — GifPicker is the one picker in that family that was not covered, and its cells are animated images rather than text.",
|
||
"repro": "Open the composer's GIF picker (MessageInput.ts:989). `loadGifs(\"\")` renders 20 trending cells, each with a click listener on `signal`. Type a query: the 300 ms debounce (GifPicker.ts:181) fires `loadGifs(\"cats\")`, `renderGifs` calls `clearChildren(gridArea)` and builds 20 brand-new cells with 20 more listeners on the same signal. Refine the search five times and the picker is holding 6 × 20 = 120 detached `<div class=\"gp-item\">` nodes plus their `<img>` GIF payloads, all rooted in `abortController.signal`'s abort-algorithm list, until `destroy()` runs when the picker is closed (MessageInput.ts:964). Take a heap snapshot after the fifth search: the detached-node count grows by 20 per search and never drops.",
|
||
"evidence": "Client/src/components/GifPicker.ts:46-47\n const abortController = new AbortController();\n const signal = abortController.signal;\n\nClient/src/components/GifPicker.ts:91-127\n function renderGifs(gifs: readonly GifResult[]): void {\n clearChildren(gridArea); // detaches the previous cell set...\n ...\n for (const gif of gifs) {\n const item = createElement(\"div\", { class: \"gp-item\", ... });\n ...\n item.addEventListener(\n \"click\",\n () => { options.onSelect(gif.fullUrl); options.onClose(); },\n { signal }, // ...but the picker-lifetime signal keeps holding it\n );\n\nClient/src/components/GifPicker.ts:206-211\n function destroy(): void {\n if (debounceTimer !== null) clearTimeout(debounceTimer);\n abortController.abort(); // the only release point",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Register one delegated click listener on gridArea at mount time (alongside enableRovingNavigation at line 78), and drop the per-item addEventListener at line 117 — mirroring EmojiPicker's data-emoji delegation: store the URL on the cell as a data attribute (e.g. createElement(\"div\", { class: \"gp-item\", ..., \"data-full-url\": gif.fullUrl })) and have the delegated handler do const cell = (e.target as Element).closest<HTMLElement>(\".gp-item\"); if (cell?.dataset.fullUrl) { options.onSelect(cell.dataset.fullUrl); options.onClose(); }.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0366",
|
||
"title": "resetBlocksStore() rewinds blockedByMeRev to 0, so a previous session's in-flight GET /blocks is accepted by the next session (and the next session's own reply is not protected)",
|
||
"file": "Client/src/stores/blocks.store.ts",
|
||
"line": 96,
|
||
"severity": "low",
|
||
"why": "`resetBlocksStore()` writes the module-level `INITIAL`, which carries `blockedByMeRev: 0`. The OC-0218 staleness guard in `setBlockedByMe` compares the caller's snapshotted revision against the store's current revision — resetting that counter to its starting value makes a token snapshotted in the *previous* server/account session match again in the *next* one, so the stale reply is applied instead of skipped. This is exactly the defect already confirmed on the twin counter in `emoji.store.ts` (`clearCustomEmoji` resetting `rev` to 0), and it defeats the cross-server isolation `clearAuth` calls `resetBlocksStore` to guarantee (auth.store.ts:77-82, 105).",
|
||
"repro": "1. User A signs into server S1. `ready` fires; dispatcher.ts:551 snapshots `blockedByMeRevAtFetch = 0` (fresh session) and issues `GET /blocks` with no AbortSignal (api.ts:686 — dispatcher passes none). A has blocked user id 7 on S1.\n2. Before the HTTP reply lands (slow link / large server), A logs out (or the session is terminated). `clearAuth()` runs `resetBlocksStore()` → state is `INITIAL`, so `blockedByMeRev` goes back to 0 and `blockedByMe` is empty.\n3. User B signs in (another server, or the same server as a different account). Its `ready` snapshots `blockedByMeRev = 0` and issues its own `GET /blocks`.\n4. S1's earlier reply now resolves: `setBlockedByMe([7], 0)`. The guard at line 58 compares 0 === 0, passes, and writes A's blocked-user ids into B's store.\n5. Result: in B's session, `dmComposerBlockReason` (line 103-106) disables the DM composer for whoever user id 7 is on this server with \"You've blocked this user. Unblock to send messages.\", and MemberList's context menu (MemberList.ts:294) offers \"Unblock\" for a user B never blocked. Because `setBlockedByMe` does not bump the revision, B's own correct reply is *also* accepted at rev 0 — so whichever of the two HTTP responses lands last wins, and if the stale one loses the race the wrong list persists until the next `ready` or block toggle.",
|
||
"evidence": "blocks.store.ts:38-42 const INITIAL: BlocksState = { blockedByMe: new Set(), blockedByThem: new Set(), blockedByMeRev: 0 };\nblocks.store.ts:56-61 export function setBlockedByMe(userIds, rev?) { blocksStore.setState((prev) => { if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev; return { ...prev, blockedByMe: new Set(userIds) }; }); }\nblocks.store.ts:95-97 export function resetBlocksStore(): void { blocksStore.setState(() => INITIAL); }\nauth.store.ts:105 resetBlocksStore();\ndispatcher.ts:551-554 const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0;\n api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch))\napi.ts:686 listBlocks(signal?: AbortSignal) // dispatcher passes no signal, so the fetch is never aborted on logout",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Make the revision monotonic across resets instead of rewinding it — one guard in the shared function: `export function resetBlocksStore(): void { blocksStore.setState((prev) => ({ ...INITIAL, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 })); }`. Every snapshot taken before the reset then fails the equality at line 58, so no pre-logout reply can be applied, while a post-reset ready-time snapshot still matches.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0367",
|
||
"title": "Create Role prefills a position that is already taken, so every role created after the first is refused",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 1310,
|
||
"severity": "low",
|
||
"why": "The create modal always prefills position = myPosition()-1 and saveRole always sends the field, so the server's auto-placement path (which walks down to the highest free slot) is unreachable. CreateRole rejects an explicitly requested position that is occupied, and the slot directly below the actor is occupied by the previous role the admin created.",
|
||
"repro": "Sign in as Owner (position 100). Roles → Create Role → name \"Helper\" → Save: created at position 99. Roles → Create Role again → name \"Greeter\": the Position field is prefilled 99 again, Save returns 400 \"position 99 is already used by another role\". Every subsequent create fails on its own default until the admin manually types a free number; the server's free-slot fallback never runs because the panel never omits position.",
|
||
"evidence": "index.html:1310 `const position=role?role.position:Math.max(0,myPosition()-1);`\nindex.html:1360 `position:parseInt(document.getElementById('rolePos').value,10)||0,`\nServer/service/role.go (CreateRole): `if in.Position != nil { ... if taken[position] { return nil, fmt.Errorf(\"%w: position %d is already used by another role\", ErrBadRequest, position) } } else { for position > 0 && taken[position] { position-- } ... }`",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-admin-static",
|
||
"suggestedFix": "Mirror the server's walk-down in the prefill at index.html:1310: `let position;if(role)position=role.position;else{const taken={};(state.roleList||[]).forEach(r=>{taken[r.position]=true});position=Math.max(0,myPosition()-1);while(position>0&&taken[position])position--;}` (equivalently, omit `position` from the create body so role.go's auto-placement branch runs).",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0368",
|
||
"title": "UserProfilePopup's hand-rolled focus trap lets Tab/Shift+Tab walk out of the aria-modal dialog",
|
||
"file": "Client/src/components/UserProfilePopup.ts",
|
||
"line": 378,
|
||
"severity": "low",
|
||
"why": "The popup re-implements the focus trap instead of using @lib/a11y's trapFocus, and its copy is missing the two guards that helper documents as required: it never treats `document.activeElement === popup` (the dialog container, which is exactly what mount() focuses at line 341) as an edge of the cycle, and it returns without `e.preventDefault()` when the dialog holds no focusable control (line 373). Either case falls through to the browser's native tab order, so focus leaves a dialog that declares `aria-modal=\"true\"` and lands on controls hidden behind a full-screen `.upp-overlay` (position:fixed; inset:0; z-index:200).",
|
||
"repro": "Case A (any member): click a row in the member list; the popup mounts and focus sits on the popup container. Press Shift+Tab as the first keystroke. activeElement is the popup, which is neither `first` nor `last`, so no branch fires, nothing is prevented, and the browser moves focus backwards to the last tabbable element of the page *behind* the overlay (composer, sidebar buttons) — visually covered and unclickable, with no way to tab back in. Case B (own row): clicking your own member row builds a popup with zero buttons (MemberList.ts:257-260 omits onMessage when isSelf, and never passes onCall), so `focusable.length === 0` and plain Tab escapes the dialog the same way.",
|
||
"evidence": "line 341: popup.focus(); // activeElement is now the popup container itself\nline 373: if (focusable.length === 0) return; // no preventDefault -> native Tab escapes\nline 378: if (e.shiftKey && document.activeElement === first) {\nline 381: } else if (!e.shiftKey && document.activeElement === last) {\n// neither branch matches while activeElement === popup\n// contrast Client/src/lib/a11y.ts:74-88, which preventDefaults the empty case and\n// wraps when `active === container`.",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Delete the hand-rolled listener at UserProfilePopup.ts:363-384 and call the shared helper instead: `import { trapFocus } from \"@lib/a11y\";` then `trapFocus(popup, signal);` — it preventDefaults the empty-dialog case and treats `active === container` as an edge that wraps.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0369",
|
||
"title": "Any self role/profile update wipes the custom-status text the user is typing in the user-bar status picker, and silently discards it",
|
||
"file": "Client/src/components/UserBar.ts",
|
||
"line": 285,
|
||
"severity": "low",
|
||
"why": "The `authStore` → `s.user` subscriber unconditionally pushes the server's stored custom status back into the picker's input with no \"is the user editing it\" guard. `StatusPicker.setCustomStatus` both overwrites `customInputEl.value` and resets `lastCommittedCustom`, so the in-progress text is not just visually replaced — the later blur/Enter commit compares against the freshly-reset watermark, short-circuits, and never sends what was typed.",
|
||
"repro": "Open the user bar's status dropdown and type \"on vacation\" into the custom-status input; do not press Enter yet. Have an admin change your role — dispatcher.ts:892 runs `updateUser({ role: payload.role })` on a self MEMBER_UPDATE, which replaces `authStore.user` with a new object (auth.store.ts:127-130). The selector `(s) => s.user` fires, `statusPicker.setCustomStatus(serverCustomStatus() ?? \"\")` runs, the input now reads the old server value and `lastCommittedCustom` equals it. Click away: `commit()` in StatusPicker.ts:178-183 computes `text === lastCommittedCustom` and returns early, so \"on vacation\" is never saved or sent. The identical clobber happens on a self USER_UPDATE (dispatcher.ts:947 `setAuth({...currentUser, ...})`).",
|
||
"evidence": " disposable.onStoreChange(\n authStore,\n (s) => s.user,\n () => {\n updateFromState();\n statusPicker?.setCustomStatus(serverCustomStatus() ?? \"\"); // no focus/dirty guard\n },\n );\n\n// StatusPicker.ts\n function setCustomStatus(text: string): void {\n lastCommittedCustom = text;\n if (customInputEl !== null) customInputEl.value = text;\n }\n const commit = (): void => {\n const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);\n if (text === lastCommittedCustom) return; // <-- typed text now silently dropped",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-client-src-components",
|
||
"suggestedFix": "Guard the push in the UserBar subscriber (one place, not per caller): skip the overwrite when the user is mid-edit, e.g. expose the input's focus state (or accept a `force` flag) and in UserBar.ts:283-286 call `statusPicker?.setCustomStatus(...)` only when the custom-status input is not `document.activeElement`. Equivalently, inside `StatusPicker.setCustomStatus` return early if `customInputEl !== null && document.activeElement === customInputEl`, leaving `lastCommittedCustom` untouched so the pending commit still fires on blur.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0370",
|
||
"title": "Arrow-key navigation in the mention/emoji autocomplete never scrolls the active row into view, so rows past the 8th are highlighted off-screen",
|
||
"file": "Client/src/components/inline-autocomplete.ts",
|
||
"line": 167,
|
||
"severity": "low",
|
||
"why": "handleKeydown moves `activeIndex` and calls render(), which only repaints the `ma-item--active` class and re-aims `aria-activedescendant`. Nothing ever calls `scrollIntoView` on the active row, but the list is a fixed-height scroller (`.mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }`, app.css:2838) while the widget renders up to 10 rows of ~29-30px each (~290-300px). aria-activedescendant does not scroll the container, so the highlighted row can sit entirely outside the visible box and Enter inserts something the user cannot see.",
|
||
"repro": "In a server with ≥10 members whose names share a prefix, type `@user` in the composer. filterMentionSuggestions returns MAX_MENTION_SUGGESTIONS = 10 rows; `.ma-list` clips at 240px so only ~8 are visible. Press ArrowDown nine times: activeIndex reaches 8 then 9, render() marks those rows `ma-item--active`, but the list scrollTop is still 0, so no row appears highlighted anywhere on screen. Pressing Enter inserts the 10th suggestion the user never saw. The same happens immediately on a single ArrowUp from index 0, which wraps to index 9 (line 172) — the popup looks like nothing is selected. Identical for the emoji popup (10 rows at 30px each with `.ea-preview` 18px + 12px padding).",
|
||
"evidence": " case \"ArrowDown\":\n e.preventDefault();\n activeIndex = (activeIndex + 1) % suggestions.length;\n render(); // render() has no scrollIntoView / scrollTop write\n return true;\n case \"ArrowUp\":\n activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;\n/* app.css:2838 */ .mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "One change in the shared widget: at the end of `render()` in Client/src/components/inline-autocomplete.ts, after the aria-activedescendant block, add `if (suggestions.length > 0) (list.children[activeIndex] as HTMLElement | undefined)?.scrollIntoView({ block: \"nearest\" });`. That covers both the mention and emoji popups and both arrow directions.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0371",
|
||
"title": "`btn-modal-cancel` is defined in no stylesheet — every modal's Cancel button (and the NSFW gate's \"Go Back\") renders as bare unstyled text beside a styled Save pill",
|
||
"file": "Client/src/components/CreateChannelModal.ts",
|
||
"line": 134,
|
||
"severity": "low",
|
||
"why": "Four components give their secondary action the class `btn-modal-cancel`, but no CSS file in the repo defines `.btn-modal-cancel`. The styled sibling that clearly belongs to it, `.btn-cancel` (login.css:989 — padding, muted colour, hover underline), has zero users. With base.css:34's global `button { border:none; background:none; color:inherit; }` reset the Cancel control ends up with no padding, no background, no border and no hover state, flush against the `.btn-modal-save` accent pill in the same `.modal-footer` flex row.",
|
||
"repro": "Open the channel context menu → \"Create Channel\". The footer holds `<button class=\"btn-modal-cancel\">Cancel</button>` next to `<button class=\"btn-modal-save\">Create Channel</button>`. `grep -rn 'btn-modal-cancel' --include=*.css .` returns nothing, so only base.css's `button` reset applies: Cancel paints as inherit-coloured text with zero padding (a click target the height of one text line), while Save is a padded accent-filled button. Same on EditChannelModal.ts:332, DeleteChannelModal.ts:70 and NsfwGate.ts:74 (\"Go Back\" next to the \"Continue\" save button). The intended rule exists but is spelled `.btn-cancel`, which nothing uses.",
|
||
"evidence": "CreateChannelModal.ts:132-136\n const cancelBtn = createElement(\n \"button\",\n { class: \"btn-modal-cancel\", type: \"button\" },\n \"Cancel\",\n );\nlogin.css:989 .btn-cancel { padding: 8px 16px; ... } /* zero users */\nlogin.css:999 .btn-modal-save { padding: 8px 20px; background: var(--accent); ... }",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Rename the orphaned selector: change `.btn-cancel` / `.btn-cancel:hover` at Client/src/styles/login.css:987 and :995 to `.btn-modal-cancel` / `.btn-modal-cancel:hover`. One CSS edit fixes all four call sites; do not touch the components.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0372",
|
||
"title": "Quick-switch server overlay has no max-height and no scroller, so with enough saved profiles rows and the \"Add new server\" button are clipped off-screen and unreachable",
|
||
"file": "Client/src/components/QuickSwitchOverlay.ts",
|
||
"line": 52,
|
||
"severity": "low",
|
||
"why": "The overlay's modal is `.quick-switch-modal`, which sets `overflow: hidden` with no `max-height`, and `.quick-switch-list` sets neither. Its parent `.quick-switch-backdrop` is `position: fixed; inset: 0; display: flex; align-items: center`, so once the row list makes the modal taller than the viewport the box overflows the fixed backdrop symmetrically above and below the fold. Nothing scrolls — a fixed-position backdrop creates no scrollable overflow and the modal clips its own children — so those rows can never be reached. Every other modal in the app gets this right (`.modal { max-height: 80vh; overflow-y: auto; }`, login.css:945).",
|
||
"repro": "Save 14 server profiles, then click the disconnect/switch button in the UserBar. createQuickSwitchOverlay renders one `.quick-switch-item` per profile plus the `add-new` row; each is 36px icon + 10px×2 padding + 1px×2 border = 58px, on top of a ~70px header and ~40px footer. On a 800px-tall window the modal is ~940px and, being flex-centred in a viewport-sized fixed backdrop, extends ~70px above y=0 and ~70px below the bottom. The first profile row and the \"Add new server\" row are outside the viewport, the modal's `overflow:hidden` shows no scrollbar, and the page does not scroll — so the user cannot add a server or switch to the clipped profiles without deleting profiles first.",
|
||
"evidence": "QuickSwitchOverlay.ts:52 const modal = createElement(\"div\", { class: \"quick-switch-modal\" });\napp.css:5253 .quick-switch-backdrop { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }\napp.css:5262 .quick-switch-modal { width: 380px; max-width: 90vw; overflow: hidden; } /* no max-height */\napp.css:5285 .quick-switch-list { padding: 0 12px 8px; } /* no overflow-y */\nlogin.css:945 .modal { width: 440px; max-height: 80vh; overflow-y: auto; } /* every other modal */",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "In Client/src/styles/app.css, give `.quick-switch-modal` (line 5262) `max-height: 85vh; display: flex; flex-direction: column;` and give `.quick-switch-list` (line 5285) `overflow-y: auto;` (plus `min-height: 0`), so the row list scrolls while the header and footer stay pinned. No TS change needed.",
|
||
"confidence": "medium",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0373",
|
||
"title": "Audit action filter cannot be cleared once its option leaves the page: dropdown reads \"All Actions\" while the filter is still applied",
|
||
"file": "Server/admin/static/index.html",
|
||
"line": 1417,
|
||
"severity": "low",
|
||
"why": "The <select> options are rebuilt from the actions present on the *currently fetched page*, but state.auditActionFilter is global and survives paging. When the filtered action does not occur on the new page, no option carries `selected`, so the browser falls back to the first option (\"All Actions\") while the filter is still in force — and because the element's DOM value is already \"all\", picking \"All Actions\" fires no change event, so the filter can no longer be cleared from the control.",
|
||
"repro": "1. Audit Log, page 1 (50 rows) contains at least one `channel_delete`. Select \"channel_delete\" in the dropdown.\n2. Click \">\" to page 2, whose 50 rows contain no `channel_delete`.\n3. renderAudit emits only the \"All Actions\" option (no `selected` anywhere), so the dropdown displays \"All Actions\" — but state.auditActionFilter is still 'channel_delete', so `filtered` is empty and the table reads \"No matching entries\" beside 50 fetched rows.\n4. Open the dropdown and choose \"All Actions\": the element's value is already \"all\", so no `change` event fires and state.auditActionFilter is never reset. The page stays blank until the admin selects some other action first and then re-selects \"All Actions\".",
|
||
"evidence": "Server/admin/static/index.html:1400\n const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean))].sort(); // current page only\nServer/admin/static/index.html:1415-1418\n html+='<select class=\"filter-select\" onchange=\"state.auditActionFilter=this.value;refilterAudit()\">';\n html+='<option value=\"all\" '+(state.auditActionFilter==='all'?'selected':'')+'>All Actions</option>';\n actionTypes.forEach(t=>{html+='<option value=\"'+esc(t)+'\" '+(state.auditActionFilter===t?'selected':'')+'>'+esc(t)+'</option>'});\nServer/admin/static/index.html:1403-1405 — the row filter still reads the stale state:\n if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;\nstate.auditActionFilter is declared once at line 315 and is never reset by paging (`state.auditPage++;renderContent()` at line 1432).",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "hotspot-server-admin-static",
|
||
"suggestedFix": "Make the option set always contain the active filter so the control and the state cannot diverge, in the one place that builds it (index.html:1400): `const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean).concat(state.auditActionFilter!=='all'?[state.auditActionFilter]:[]))].sort();`. The stale filter then still renders as a `selected` option, the dropdown tells the truth, and choosing \"All Actions\" is a real value change that fires `change`.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0374",
|
||
"title": "ReorderRoles compacts positions to a gapless N..1 block, permanently breaking role creation for every non-owner role manager",
|
||
"file": "Server/service/role.go",
|
||
"line": 459,
|
||
"severity": "low",
|
||
"why": "ReorderRoles normalizes the manageable roles to positions N, N-1, … 1 with no gaps and never 0, while CreateRole's default placement (role.go:249-254) only accepts a strictly-positive unoccupied slot below the actor. After a single reorder there is no such slot for any actor below the owner, so role creation fails permanently — and the error it returns tells the admin to \"reorder existing roles first\", which re-compacts to the same dense block and cannot help.",
|
||
"repro": "Default install: Owner 100, Admin 80, Moderator 60, Member 40. (1) As the Owner, click one reorder arrow on the admin Roles page (Server/admin/static/index.html:1291 moveRole → PATCH /roles/reorder). ReorderRoles writes Admin=3, Moderator=2, Member=1; Owner stays at 100. (2) Sign in as the Admin (now position 3, holds MANAGE_ROLES) and POST /roles with no `position` field. taken={100,3,2,1}; the loop at role.go:249 steps 2 → 1 → 0 and the call returns ErrBadRequest \"no free position below your rank — reorder existing roles first\". (3) Reorder as the Admin: its one manageable role goes back to position 1, so step 2 fails identically — forever. The Moderator at position 2 is in the same state. Through the panel it is worse: the Create Role modal prefills Math.max(0, myPosition()-1) (index.html:1310), which post-compaction is always an occupied slot, so the explicit-position path returns \"position N is already used by another role\" and the omitted-position path returns the message above; UpdateRole's position change (role.go:321-331) is dead for the same reason.",
|
||
"evidence": "role.go:457-460\n\tpositions := make(map[int64]int, len(orderedIDs))\n\tfor i, id := range orderedIDs {\n\t\tpositions[id] = len(orderedIDs) - i\n\t}\n\nrole.go:248-254\n\t} else {\n\t\tfor position > 0 && taken[position] {\n\t\t\tposition--\n\t\t}\n\t\tif position <= 0 {\n\t\t\treturn nil, fmt.Errorf(\"%w: no free position below your rank — reorder existing roles first\", ErrBadRequest)\n\t\t}\n\nServer/service/role_test.go:505 TestReorderRoles_NormalizesPositions already locks the compaction (Admin→4, Helper→3, Moderator→2, Member→1); no test covers CreateRole after a reorder.",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "Preserve gaps in the single shared normalizer instead of guarding each caller: in ReorderRoles (Server/service/role.go:457-460) space the slots under the actor, e.g. stride := actor.Position / (len(orderedIDs) + 1) (>=1) and positions[id] = (len(orderedIDs) - i) * stride. That keeps every position unique, strictly below actor.Position, and in the same order, while leaving free slots for CreateRole's default placement. TestReorderRoles_NormalizesPositions must be updated to assert ordering/uniqueness/below-actor rather than the literal 4,3,2,1 values.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0375",
|
||
"title": "The server's own voice_state echo strips \"(You)\" off your local self-view tile in the video grid",
|
||
"file": "Client/src/pages/MainPage.ts",
|
||
"line": 815,
|
||
"severity": "low",
|
||
"why": "The mid-call relabel loop added for OC-0227 iterates the whole voice roster, which includes the current user, and calls `videoGrid.setLabel(uid, remoteTileLabel(uid, false))`. The local self-view tile is registered under `tileId === currentUserId` (VideoModeController.ts:184) with the label `` `${myName} (You)` ``, and `remoteTileLabel` never appends \"(You)\". So the loop overwrites the self tile's label with the bare display name the moment the server echoes back `voice_state` with `camera: true`. Two writers of one tile label disagree, and the last writer is the wrong one — your own tile becomes indistinguishable from a remote participant's.",
|
||
"repro": "1. Join a voice channel and turn your camera on. `setLocalCamera(true)` (screenShare.ts:228) bumps the voice-store signature; the subscriber runs with `voiceUsers[channel][self].camera` still false (no server echo yet), so the relabel loop skips self and `checkVideoMode()` adds the self tile labelled \"Alice (You)\". 2. The client sends `voice_camera`; the server broadcasts `voice_state` for you with `camera: true`. 3. `updateVoiceState` writes `camera: true` onto the self entry in `voiceUsers`, the subscriber re-runs, and the loop now takes the `if (u.camera)` branch for `uid === currentUserId`: `prevTileLabels.get(self)` is undefined ≠ \"Alice\", so it calls `videoGrid.setLabel(self, \"Alice\")`. 4. Your own self-view tile now reads plain \"Alice\" for the rest of the call, with nothing marking it as yours. (Same path turns the local screenshare tile's \"Your Screen\" fallback into \"User <id> (Screen)\" when no member record supplies a display name.)",
|
||
"evidence": "// MainPage.ts:808-821 — no `uid === currentUserId` skip\nfor (const [uid, u] of users) {\n ...\n if (u.camera) {\n const label = remoteTileLabel(uid, false); // returns `name`, never `name (You)`\n if (prevTileLabels.get(uid) !== label) {\n prevTileLabels.set(uid, label);\n videoGrid?.setLabel(uid, label); // uid === currentUserId hits the SELF tile\n }\n }\n\n// VideoModeController.ts:180-190 — the self tile is keyed by currentUserId\nif (voice.localCamera) {\n if (!localTileAdded) {\n const localStream = getLocalCameraStream();\n if (localStream !== null) {\n videoGrid.addStream(currentUserId, myName ? `${myName} (You)` : \"You\", localStream, { isSelf: true, ... });\n localTileAdded = true;\n\n// voice.store.ts:231-241 — updateVoiceState puts SELF into voiceUsers with camera from the payload\nnextUsers.set(payload.user_id, { userId: payload.user_id, username: payload.username, ..., camera: payload.camera, ... });",
|
||
"status": "open",
|
||
"found": "2026-08-29",
|
||
"hunt": "general-2026-08-29",
|
||
"lens": "explore-3",
|
||
"suggestedFix": "In the loop at Client/src/pages/MainPage.ts:808-829, leave the signature accumulation alone but skip the local user for relabeling: hoist `const selfId = getCurrentUserId();` and wrap both setLabel blocks in `if (uid !== selfId) { ... }` — VideoModeController is the sole writer of the self tiles' \"(You)\" / \"Your Screen\" labels.",
|
||
"confidence": "high",
|
||
"finder": "opus"
|
||
},
|
||
{
|
||
"id": "OC-0376",
|
||
"title": "Register commits the account and burns the invite, then answers 500 when the session insert fails",
|
||
"file": "Server/api/auth_handler.go",
|
||
"line": 201,
|
||
"severity": "low",
|
||
"why": "handleRegister hashes the password first so a hashing failure cannot burn an invite (the comment at line 153 states that intent), and CreateUserWithInvite consumes the invite and creates the user in one transaction. The session insert that follows is outside that transaction: when CreateSession fails the handler returns 500 \"failed to create session\", but the user row and the invite use are already committed. The caller sees a failed registration; retrying gets 400 \"invalid invite or credentials\" (username taken, invite exhausted) while a login with the same password succeeds. Same shape on the verify-totp path (OC-0378).",
|
||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_RegisterPolicyAndFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, registers with a valid invite, and pins today's outcome: 500, user row present, invite use_count 1.",
|
||
"evidence": "Server/api/auth_handler.go:166 uid, err := database.CreateUserWithInvite(...) // commits user + invite use\nServer/api/auth_handler.go:201 if _, err := database.CreateSession(...); err != nil { // 500 after the commit\nServer/api/auth_handler.go:153 // Hash password before consuming the invite so that a hashing failure\n // does not burn a valid invite code.",
|
||
"suggestedFix": "Either answer 201 without a token when the session insert fails after the account commit (the account exists; the client logs in), or move the session insert into the CreateUserWithInvite transaction so registration is atomic. Belongs to the AuthService in B3-2/B3-9, not to the handler.",
|
||
"status": "fixed",
|
||
"found": "2026-08-29",
|
||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||
"lens": "characterization",
|
||
"confidence": "high",
|
||
"finder": "claude",
|
||
"fixed": "2026-08-30",
|
||
"fix": {
|
||
"commit": "85d86dc7",
|
||
"test": "Server/api/auth_characterization_test.go, Server/db/coverage_boost_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0377",
|
||
"title": "verify-totp maps a database error while loading the challenged user to 401, indistinguishable from an expired challenge",
|
||
"file": "Server/api/totp_handler.go",
|
||
"line": 141,
|
||
"severity": "low",
|
||
"why": "totpChallengeSecret folds `err != nil` from GetUserByID into the same 401 \"invalid or expired two-factor challenge\" that a missing user or missing secret gets. A transient database fault during the second factor therefore reads as a bad challenge: the client drops the partial token and asks the user to log in again, and the attempt has already been recorded against the per-user totp_fail cap by the limiter.Allow call above it. Every sibling path in the slice maps a non-sentinel database error to 5xx (login 500, AuthMiddleware 503) precisely so an outage is not mistaken for a credential failure.",
|
||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/user_lookup_fails' ./api/ — the row renames the users table after the challenge is issued and pins today's 401.",
|
||
"evidence": "Server/api/totp_handler.go:140 user, err := database.GetUserByID(r.Context(), challengeUserID)\nServer/api/totp_handler.go:141 if err != nil || user == nil || user.TOTPSecret == nil {\nServer/api/totp_handler.go:142 writeJSON(w, http.StatusUnauthorized, errorResponse{ ... \"invalid or expired two-factor challenge\" })\nServer/api/auth_handler.go:488-497 the login sibling: a non-nil error is a genuine DB failure -> 500 \"login temporarily unavailable\"",
|
||
"suggestedFix": "Split the condition: `err != nil` -> 500 INTERNAL_ERROR (\"two-factor verification temporarily unavailable\") without RegisterFailure and with the limiter reservation undone or not made; keep 401 for `user == nil || user.TOTPSecret == nil`. Fix in B3-9 after B3-2 lands, and flip the characterization row with it.",
|
||
"status": "fixed",
|
||
"found": "2026-08-29",
|
||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||
"lens": "characterization",
|
||
"confidence": "high",
|
||
"finder": "claude",
|
||
"fixed": "2026-08-30",
|
||
"fix": {
|
||
"commit": "f7015809",
|
||
"test": "Server/api/auth_characterization_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0378",
|
||
"title": "verify-totp consumes the partial challenge before the session insert, so a store failure forces the user back to the password step",
|
||
"file": "Server/api/totp_handler.go",
|
||
"line": 107,
|
||
"severity": "low",
|
||
"why": "handleVerifyTOTP calls partialStore.Consume before issueSession. When CreateSession fails the handler answers 500 \"failed to create session\", but the challenge is already gone (and the code is marked used by VerifyTOTPCodeOnce), so the only way forward is a fresh POST /login with the password. A verified second factor is discarded because of a persistence hiccup that has nothing to do with the credential. Same shape as OC-0376 on the register path.",
|
||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, verifies a valid code, pins the 500, drops the trigger and pins that the same partial token is now refused with 401.",
|
||
"evidence": "Server/api/totp_handler.go:107 if _, ok := partialStore.Consume(partialToken); !ok { // challenge gone here\nServer/api/totp_handler.go:115 token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) // fails after it",
|
||
"suggestedFix": "Keep the claim atomic and first: Consume the challenge before issuing the session (as today), then on CreateSession failure re-issue or restore the challenge for the same user/device/IP so the verified second factor is not discarded. The restore must also keep the accepted verification usable: VerifyTOTPCodeOnce has already recorded (user, code) in UsedTOTPCodeStore for 90 s, so an immediate retry with the authenticator's still-current code would be refused as a replay - either carry the verified state on the restored challenge (retry issues the session without a new code) or roll back that MarkUsed claim together with the challenge. Do NOT issue the session before Consume: two concurrent requests holding the same partial token can pass Lookup with different valid codes from the +/-1 step window (the used-code store keys on (user, code), not the token), both would create sessions, and the losing Consume would leave an unreturned bearer session in the database; if the order must change, the loser has to revoke the session it created. Belongs to the AuthService in B3-2/B3-9.",
|
||
"status": "fixed",
|
||
"found": "2026-08-29",
|
||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||
"lens": "characterization",
|
||
"confidence": "high",
|
||
"finder": "claude",
|
||
"fixed": "2026-08-30",
|
||
"fix": {
|
||
"commit": "be37d7ee",
|
||
"test": "Server/api/auth_characterization_test.go, Server/auth/totp_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
},
|
||
{
|
||
"id": "OC-0379",
|
||
"title": "ownerOnlyMiddleware still re-read the role adminAuthMiddleware had already resolved — OC-0345's fix kept the redundant query and the 503 branch that existed only to serve it",
|
||
"file": "Server/admin/middleware.go",
|
||
"line": 130,
|
||
"severity": "low",
|
||
"why": "OC-0345's title named two defects: the redundant GetRoleByID and the transient-fault-as-403 collapse. Its fix (PR #1454) repaired only the error mapping — deliberately, per its own suggestedFix, to avoid touching the two tests that injected only adminUserKey — so the owner gate still issued a second role read on every owner-only request and carried a private 503 path whose only job was that read's failures. The register's closure evidence for OC-0345 ('Reuse the authenticated context…') was half-met, and the function's doc comment claimed to avoid the redundant query it performed.",
|
||
"repro": "Verified at dev 7abdd941 by the 2026-08-31 post-merge audit: middleware.go:130 ran database.GetRoleByID inside ownerOnlyMiddleware while adminAuthMiddleware had stored the same principal's *db.Role under adminRoleKey (:90) and requirePerm (:104) already consumed it query-free. The nine owner-only routes in Server/admin/api.go paid the extra read; a roles-table fault on it produced the gate's own 503 although the perimeter had just proven the database healthy on the same request.",
|
||
"evidence": "RED first: TestOwnerOnlyMiddleware_NoSecondRoleLookup (role in context, roles table renamed away) failed 503 against the old middleware — the second lookup, observed. GREEN after: the gate consumes adminRoleKey (missing role fails closed as 401, exactly requirePerm's contract; position below Owner stays 403), the query and its 503 branch are deleted, and the signature drops *db.DB at all nine call sites, so reintroducing a lookup is a compile-visible change. Revert-proof: restoring the old middleware body fails the build at api.go:150.",
|
||
"status": "fixed",
|
||
"found": "2026-08-31",
|
||
"hunt": "postmerge-audit-2026-08-31",
|
||
"lens": "audit",
|
||
"confidence": "high",
|
||
"finder": "claude",
|
||
"fixed": "2026-08-31",
|
||
"fix": {
|
||
"commit": "bcdc0ef3",
|
||
"test": "Server/admin/middleware_and_spawn_test.go",
|
||
"revertProof": "pass"
|
||
}
|
||
}
|
||
]
|
||
}
|