mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277) * fix(voice): 1 defect(s) (OC-0278) * fix(client): 1 defect(s) (OC-0280) refreshDmSidebar() rebuilds the entire DM sidebar subtree on every dmStore.channels change - which includes presence flips and new messages, not just DM list changes. The "Find a conversation" filter text and input focus live only in that destroyed subtree, so they were silently wiped mid-typing. Capture and restore both across the destroy+recreate cycle. * fix(ws): 1 defect(s) (OC-0285) * fix(client): 1 defect(s) (OC-0286) * fix(client): 1 defect(s) (OC-0288) Consume the legacy unscoped mute key after migrating it onto the first host, so a brand-new host with no scoped key of its own no longer reads through to the same legacy list and inherits another server's mutes. * fix(voice): 1 defect(s) (OC-0290) * fix(db): 1 defect(s) (OC-0293) DecrementMentionCounts reversed mention_count bumps that were never applied: message_mentions stores every resolved mention id including the author's blockers, while applyMentionCounts excludes blockers before incrementing. Deleting a blocked author's message therefore wiped an unrelated, genuine mention badge on the same read_states row. Mirror the block exclusion in the decrement UPDATE. * fix(db): 1 defect(s) (OC-0294) DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards. * fix(client): 1 defect(s) (OC-0295) MemberList rebuilt every row on any non-presence-only membersStore change and on every roles_update, but registered each row's click/contextmenu listeners on the component-lifetime disposable.signal, which only aborts at destroy(). Discarded rows therefore stayed reachable (and their listeners live) for the component's whole lifetime. Route per-row listeners through a per-render AbortController that is aborted and replaced at the top of every render, and aborted again in destroy(). * fix(identity): 1 defect(s) (OC-0297) UpdateProfile's post-commit re-read of the user row could fail for reasons unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion) and was reported as ErrInternal even though UpdateUserProfile had already committed. Callers that treat any UpdateProfile error as proof the write never landed — handleUploadAvatar deletes the file it just stored — would delete a file the committed avatar column now points at, permanently breaking the avatar with no user_update broadcast. Since UpdateUserProfile only writes username/avatar/display_name/about, merge those four onto the pre-write snapshot to reconstruct the committed row without needing the re-read to succeed, and log the read failure. * fix(ws): 2 defect(s) (OC-0298, OC-0299) - OC-0298: applyConnectStatus stamped c.user.Status even when the UpdateUserStatus write failed, so auth_ok and the presence broadcast claimed a status users.status disagreed with, and buildReady's ListMembers read never self-corrected for the session. - OC-0299: refreshUserSnapshot silently fell back to roleName "member" when the new role lookup failed, pinning the session to a fabricated role on the wire. It now fails closed like the sibling lookups in upgradeAndAuth and handleFreshConnect. * fix(client): 1 defect(s) (OC-0300) * fix(client): 1 defect(s) (OC-0301) * fix(ws): 1 defect(s) (OC-0302) * fix(api): 1 defect(s) (OC-0305) handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route. * fix(client): 2 defect(s) (OC-0306, OC-0308) * fix(client): 1 defect(s) (OC-0307) QuickSwitcher registered a per-row click listener against the overlay-lifetime AbortSignal, but renderResults() rebuilds every row on each keystroke, arrow key, and store refresh. Discarded rows kept their listeners alive until the overlay closed. Replaced with one delegated click listener on the stable results container, keyed off the data-channelid each row already carries. * fix(client): 1 defect(s) (OC-0310) * fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292) Reap a soft-deleted message's attachment files, count lapsed temporary bans as active users in the require_2fa enrollment gate, and only apply the 2FA-enrollment precondition when require_2fa itself is being enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * test(api): sync apiTestSchema with the user_blocks migration DeleteAccount's mention-count reversal joins user_blocks; the api package's hand-rolled schema fixture predates migration 012. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296) Decouple the E2EE identity-mismatch modal and right-click popovers from the sidebar's per-render abort signal, and let global drag listeners survive a mid-drag re-render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(voice): 2 defect(s) (OC-0283, OC-0287) Retire a departed peer's E2EE key unconditionally on leave, and surface a failed microphone unmute instead of reporting an unmuted state the room never saw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309) Guard the DM call button against redialing the channel already joined, resolve the incoming-call banner's caller through the nickname-aware display name, and keep the DM profile sidebar subscribed to live member/status updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * style(client): prettier-format the dm-store test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 1 defect(s) (OC-0284) Make message soft-delete a compare-and-set so a repeated chat_delete cannot reverse mention counts twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 2 defect(s) (OC-0276, OC-0304) Re-sync a resumed connection's voice E2EE peer keys in registerNow (announce frames are unsequenced and cannot be replayed), and apply the live-connection presence rule to every DM payload DMService builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): record the 2026-08-21 hunt findings as fixed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): independent revert-proof pass for OC-0276..OC-0310 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * refactor(service): extract DeleteMessage authorization into a helper Keeps DeleteMessage under the cyclop complexity ceiling after the OC-0284 guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+950
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.
|
||||
|
||||
**0 open** · 0 blocked · 271 fixed · 3 declined · 0 refuted · 1 duplicate
|
||||
**0 open** · 0 blocked · 306 fixed · 3 declined · 0 refuted · 1 duplicate
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -7033,6 +7033,955 @@ queries/sqlite/messages.sql:51-56 — unread excludes deleted rows, mentions is
|
||||
|
||||
**Fixed:** `ce252b0` · test `Server/service/mentions_test.go` · revert-proof self-reported
|
||||
|
||||
### OC-0276 — high — 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
|
||||
|
||||
`Server/ws/voice_e2ee.go:271` · found 2026-08-22 · hunt `general-2026-08-21` · lens `voice-e2ee`
|
||||
|
||||
`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.
|
||||
1. 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)`.
|
||||
2. 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`.
|
||||
3. 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`.
|
||||
4. A's `voiceStore.voiceUsers` now lists J; A's `E2EEManager._peerPublicKeys` does not.
|
||||
5. 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.
|
||||
6. J can neither decrypt nor be decrypted for the rest of the call, while every client's VoiceWidget still shows "🔒 Secured".
|
||||
|
||||
Same 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
|
||||
func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) {
|
||||
h.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID)
|
||||
}
|
||||
(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(...)`)
|
||||
|
||||
Server/ws/voice_join.go:527-529 — the sole re-relay, reachable only from voiceJoinComplete:
|
||||
if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != "" {
|
||||
c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))
|
||||
}
|
||||
|
||||
Server/ws/hub.go:559 — registerNow discards everything queued for the replaced socket: `old.closeSend()`
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `201e2bc` · test `Server/ws/oc_0276_voice_e2ee_resync_test.go` · revert-proof pass
|
||||
|
||||
### OC-0277 — high — 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
|
||||
|
||||
`Client/tauri-client/src/lib/noise-suppression.ts:264` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
`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/tauri-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`).
|
||||
|
||||
What 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.
|
||||
|
||||
Resulting 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
|
||||
async init(opts: AudioProcessorOptions): Promise<void> {
|
||||
...
|
||||
const ctx = opts.audioContext; // <- never cached
|
||||
if (supportsAudioWorklet()) {
|
||||
try { pipeline = await createWorkletPipeline(opts.track, ctx); return; }
|
||||
catch (err) { log.warn("AudioWorklet failed, falling back to ScriptProcessorNode", err); }
|
||||
}
|
||||
pipeline = await createScriptProcessorPipeline(opts.track, ctx);
|
||||
},
|
||||
async restart(opts: AudioProcessorOptions): Promise<void> {
|
||||
if (pipeline !== null) { pipeline.destroy(); pipeline = null; } // destroy first
|
||||
await this.init(opts); // then rebuild from opts.audioContext
|
||||
},
|
||||
|
||||
node_modules/livekit-client/dist/livekit-client.esm.mjs:20414 (the ONLY `processor.restart` call site in the bundle)
|
||||
yield this.processor.restart({
|
||||
track: newTrack,
|
||||
kind: this.kind,
|
||||
element: this.processorElement,
|
||||
localTrack: this
|
||||
}); // no audioContext field
|
||||
|
||||
contrast with setProcessor(), esm.mjs:20750-20755, which does pass it:
|
||||
const processorOptions = { kind, track, element: processorElement, audioContext: _this3.audioContext, localTrack: _this3 };
|
||||
|
||||
esm.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.
|
||||
|
||||
**Suggested fix:** 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.)
|
||||
|
||||
**Fixed:** `15d65e1` · test `Client/tauri-client/tests/unit/noise-suppression-restart.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0278 — medium — 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
|
||||
|
||||
`Server/ws/voice_moderation.go:443` · found 2026-08-22 · hunt `general-2026-08-21` · lens `ws-hub`
|
||||
|
||||
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).
|
||||
2. 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).
|
||||
3. 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.
|
||||
4. A moderator un-mutes U in B (voice_mod_mute muted=false -> voice_states.server_muted=0).
|
||||
5. 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.
|
||||
6. voiceJoinLeaveCurrent sees currentChID==0, takePendingModFlags() returns (true,false), and voiceJoinRestoreModFlags calls SetVoiceServerMute(..., true).
|
||||
Result: 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
|
||||
stashPendingModFlags(d.Mod, c.TargetID(), state.ServerMuted, state.ServerDeafened)
|
||||
if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) {
|
||||
// No live connection on this node ... or the target left the checked
|
||||
// channel while this handler was deciding, in which case the move must
|
||||
// not follow them.
|
||||
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}}
|
||||
}
|
||||
|
||||
voice_moderation.go:551-557 — the false return is reachable whenever the target switched channels concurrently:
|
||||
func (h *Hub) DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool {
|
||||
c := h.GetClient(userID); if c == nil { return false }
|
||||
return h.handleVoiceLeaveIfStillIn(ctx, c, channelID) // false when c.voiceChID != channelID
|
||||
}
|
||||
|
||||
The 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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `66681d1` · test `Server/ws/voice_moderation_test.go` · revert-proof pass
|
||||
|
||||
### OC-0279 — medium — Deleting a message strands its uploaded attachment files on disk forever — the only reaper requires message_id IS NULL
|
||||
|
||||
`Server/db/message_queries.go:203` · found 2026-08-22 · hunt `general-2026-08-21` · lens `db-storage`
|
||||
|
||||
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`.
|
||||
2. Send a message with that attachment id → `LinkAttachmentsToMessage` sets `message_id = <msgID>`.
|
||||
3. 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>`.
|
||||
4. 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.
|
||||
5. 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)
|
||||
Server/db/message_queries.go:263-266 purge: `UPDATE messages SET deleted = 1 WHERE id IN (...)` — same, no unlink
|
||||
Server/db/account.go:74-76 `UPDATE messages SET deleted = 1, content = '' WHERE user_id = ?` — same, no unlink
|
||||
Server/db/queries/sqlite/attachments.sql:22-28 `DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? ... RETURNING stored_as;` (the ONLY reaper)
|
||||
Server/main.go:610-620 the only caller of `fileStorage.Delete` for attachments
|
||||
Server/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"
|
||||
Contrast — 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.
|
||||
|
||||
**Suggested fix:** 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).
|
||||
|
||||
**Fixed:** `140633a` · test `Server/db/attachment_orphan_softdelete_test.go` · revert-proof pass
|
||||
|
||||
### OC-0280 — medium — Any DM-store change destroys and recreates the whole DM sidebar, wiping the "Find a conversation" filter and stealing keyboard focus mid-typing
|
||||
|
||||
`Client/tauri-client/src/pages/main-page/SidebarArea.ts:664` · found 2026-08-22 · hunt `general-2026-08-21` · lens `client-state`
|
||||
|
||||
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
|
||||
function refreshDmSidebar(): void {
|
||||
if (activeSidebarContent !== null) { activeSidebarContent.destroy?.(); }
|
||||
clearChildren(contentSlot);
|
||||
const freshDm = buildDmSidebar();
|
||||
freshDm.mount(freshSlot);
|
||||
...
|
||||
}
|
||||
SidebarArea.ts:684-690
|
||||
const unsubDmStore = dmStore.subscribeSelector((s) => s.channels, () => { refreshDmSidebar(); });
|
||||
|
||||
DmSidebar.ts:310-314 const searchInput = createElement("input", { class: "dm-search", placeholder: "Find a conversation" });
|
||||
DmSidebar.ts:334-353 searchInput.addEventListener("input", () => { const q = searchInput.value.trim().toLowerCase(); items.forEach(...) }, { signal: ac.signal });
|
||||
|
||||
dm.store.ts:236-252 updateDmParticipant -> returns `{ channels }` (new array, patched objects) for any DM partner presence/profile change
|
||||
dm.store.ts:189-195 clearDmUnread -> always maps to a new object for the matching channel, so the selector always fires
|
||||
|
||||
**Suggested fix:** 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.)
|
||||
|
||||
**Fixed:** `4da361a` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0281 — medium — 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)
|
||||
|
||||
`Client/tauri-client/src/components/ChannelSidebar.ts:497` · found 2026-08-22 · hunt `general-2026-08-21` · lens `lifecycle`
|
||||
|
||||
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();
|
||||
ChannelSidebar.ts:823 currentRenderAc.signal, // -> renderCategoryGroup -> renderChannelItem -> renderVoiceChannelItem(signal)
|
||||
ChannelSidebar.ts:497 void openIdentityMismatchModal(user.userId, user.username || "Unknown", signal);
|
||||
ChannelSidebar.ts:126 if (signal.aborted) return; // after the async fingerprint compute
|
||||
ChannelSidebar.ts:152 signal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `0e3435a` · test `Client/tauri-client/tests/unit/channel-sidebar.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0282 — medium — Sidebar right-click popovers (channel context menu, voice-user volume/moderation menu) close themselves on any unrelated sidebar re-render
|
||||
|
||||
`Client/tauri-client/src/components/ChannelSidebar.ts:610` · found 2026-08-22 · hunt `general-2026-08-21` · lens `lifecycle`
|
||||
|
||||
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);
|
||||
ChannelSidebar.ts:517 showUserVolumeMenu(user.userId, user.username || "Unknown", e.clientX, e.clientY, signal, buildVoiceModOptions(...));
|
||||
context-menu.ts:188 signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
|
||||
volume-menu.ts:136-142 signal.addEventListener("abort", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal });
|
||||
ChannelSidebar.ts:795 renderAc?.abort(); // fires both bridges on every render
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `0e3435a` · test `Client/tauri-client/tests/unit/channel-sidebar.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0283 — medium — OC-0239's `stillInRoster` parameter is true for every genuine departure, so the departed-peer key retirement (OC-0020) never runs in production
|
||||
|
||||
`Client/tauri-client/src/lib/livekitE2EE.ts:1298` · found 2026-08-22 · hunt `general-2026-08-21` · lens `flow-voice`
|
||||
|
||||
`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)`
|
||||
livekitE2EE.ts:1298 `if (departingKey && !stillInRoster && !channelUsers?.has(userId)) {`
|
||||
livekitE2EE.ts:1299 ` this.retirePeerKey(userId, await exportPublicKey(departingKey));`
|
||||
dispatcher.ts:1062 `const stillInRoster =`
|
||||
dispatcher.ts:1063 ` voiceStore.getState().voiceUsers.get(payload.channel_id)?.has(payload.user_id) ?? false;`
|
||||
dispatcher.ts:1064 `removeVoiceUser(payload);`
|
||||
dispatcher.ts:1080 `void handleParticipantLeft(payload.user_id, stillInRoster);`
|
||||
voice.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
|
||||
livekitE2EE.ts:836 `if (!isDuplicate) { this._peerPublicKeys.set(userId, peerKey); ... }` // no retire on first-sight re-announce after a leave
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `3767be1` · test `Client/tauri-client/tests/unit/dispatcher.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0284 — medium — chat_delete is idempotent-but-not-guarded, so a repeated delete decrements mention_count again and wipes an unrelated, genuinely-unread mention badge
|
||||
|
||||
`Server/service/message_crud.go:477` · found 2026-08-22 · hunt `general-2026-08-21` · lens `flow-message`
|
||||
|
||||
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 }`).
|
||||
Server/service/message_crud.go:463-465 — `if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { ... }`
|
||||
Server/service/message_crud.go:477 — `if mcErr := s.st.DecrementMentionCounts(context.WithoutCancel(ctx), msg.ChannelID, []int64{msgID}); mcErr != nil {`
|
||||
Server/db/message_queries.go:191-207 — db.DeleteMessage checks ownership only, then `d.q.SoftDeleteMessage(ctx, id)` and returns nil.
|
||||
Server/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).
|
||||
Server/db/queries/sqlite/messages.sql:6-9 — GetMessage has no `deleted = 0` filter, so the tombstone is returned as a normal row.
|
||||
Server/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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `d08c7e0` · test `Server/service/mentions_test.go` · revert-proof pass
|
||||
|
||||
### OC-0285 — medium — kickClient never stops readPump, so a banned / revoked WS principal keeps executing fully authorized commands after the server decides to cut it off
|
||||
|
||||
`Server/ws/hub_sweep.go:57` · found 2026-08-22 · hunt `general-2026-08-21` · lens `flow-session`
|
||||
|
||||
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":
|
||||
func (h *Hub) kickClient(c *Client) {
|
||||
h.mu.Lock(); ... delete(h.clients, c.userID); h.mu.Unlock()
|
||||
c.closeSend()
|
||||
h.pubsub.UnsubscribeAll(c)
|
||||
} // no conn.Close, no cancel, no flag readPump reads
|
||||
|
||||
serve_pumps.go:203-211 —
|
||||
for {
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil { lastReadErr = err; return }
|
||||
c.touch()
|
||||
hub.handleMessage(c, msg) // no isSendClosed()/registration guard
|
||||
}
|
||||
|
||||
handlers.go:112-143 —
|
||||
c.msgCount++
|
||||
shouldCheck := c.msgCount >= SessionCheckInterval
|
||||
if shouldCheck { c.msgCount = 0 } // reset BEFORE the kick below
|
||||
...
|
||||
if result == nil || auth.IsSessionExpired(result.ExpiresAt) { h.kickClient(c); return true }
|
||||
...
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
|
||||
h.kickClient(c); return true
|
||||
|
||||
serve_pumps.go:15-23 — every drained frame is written under writeTimeout (serve.go:22 = 10 * time.Second) before writePumpDrainAndClose reaches conn.Close.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `51b3144` · test `Server/ws/handlers_test.go` · revert-proof pass
|
||||
|
||||
### OC-0286 — medium — MessageList registers every message row's listeners on the component-lifetime AbortSignal, so each rebuild permanently retains a full window of detached rows
|
||||
|
||||
`Client/tauri-client/src/components/MessageList.ts:332` · found 2026-08-22 · hunt `general-2026-08-21` · lens `hotspot-client-tauri-client-src-components`
|
||||
|
||||
`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();
|
||||
MessageList.ts:332 return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal);
|
||||
MessageList.ts:563-569
|
||||
releaseTrackedMedia();
|
||||
clearChildren(contentContainer);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = start; i < end; i++) {
|
||||
fragment.appendChild(renderVirtualItem(virtualItems[i]!));
|
||||
}
|
||||
contentContainer.appendChild(fragment);
|
||||
(only uses of `signal` in the file are lines 332, 895, 904, 916, 958 — no per-render controller exists)
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `3bc52ea` · test `Client/tauri-client/tests/unit/message-list-row-listener-leak.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0287 — medium — 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
|
||||
|
||||
`Client/tauri-client/src/lib/livekitSession.ts:1640` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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):
|
||||
} else {
|
||||
if (isMicPolicyGated()) { ...; return; }
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await room.localParticipant.setMicrophoneEnabled(true); // <-- no catch, no setListenOnly(true)
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
|
||||
setMuted (livekitSession.ts:1597-1598):
|
||||
setLocalMuted(muted);
|
||||
this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e));
|
||||
|
||||
VoiceCallbacks.ts:76-78 sends the wire frame unconditionally:
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
|
||||
VoiceWidget.ts:307-309 — the only recovery affordance is gated on listenOnly, which is still false:
|
||||
if (grantMicBtn) { grantMicBtn.style.display = voice.listenOnly ? "block" : "none"; }
|
||||
|
||||
Contrast restoreLocalVoiceState (livekitSession.ts:925-931), which does it right:
|
||||
} catch (micErr) {
|
||||
setListenOnly(true);
|
||||
... this.onErrorCallback?.("Microphone permission denied — joined in listen-only mode");
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `3767be1` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0288 — medium — Pre-scoping mute list is copied into every server the user connects to, silently muting unrelated channels on each new host
|
||||
|
||||
`Client/tauri-client/src/lib/channel-mutes.ts:98` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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:
|
||||
const scopedKey = mutedKey();
|
||||
if (currentHost === null || keyExists(scopedKey)) {
|
||||
cache = parseMutedIds(loadPref<unknown[]>(scopedKey, []));
|
||||
return cache;
|
||||
}
|
||||
// Miss at the scoped key: read through to the pre-scoping legacy key once
|
||||
if (keyExists(MUTED_KEY)) {
|
||||
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
|
||||
writeMuted(legacy); // <-- writes server A's ids under THIS host's key
|
||||
return legacy;
|
||||
}
|
||||
|
||||
Nothing 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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `27f42c1` · test `Client/tauri-client/tests/unit/channel-mutes.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0289 — medium — 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
|
||||
|
||||
`Client/tauri-client/src/pages/MainPage.ts:297` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-2`
|
||||
|
||||
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/tauri-client/src/pages/MainPage.ts:297-310
|
||||
function startCall(): void {
|
||||
const active = getActiveChannel();
|
||||
if (active === null || active.type !== "dm") return;
|
||||
if (uiStore.getState().connectionStatus !== "connected") { showToast("Not connected", "error"); return; }
|
||||
createSidebarVoiceCallbacks(ws).onVoiceJoin(active.id);
|
||||
ws.send({ type: "call_ring", payload: { channel_id: active.id } });
|
||||
|
||||
Server/ws/voice_join.go:186-190
|
||||
if currentChID == channelID {
|
||||
c.sendMsg(buildErrorMsg(ErrCodeAlreadyJoined, "already in this voice channel"))
|
||||
|
||||
Client/tauri-client/src/lib/dispatcher.ts:1355 — the catch-all: showToast(payload.message || "Server error", "error");
|
||||
|
||||
**Suggested fix:** Add the guard once in the shared function rather than in startCall: at Client/tauri-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.
|
||||
|
||||
**Fixed:** `f4a60a6` · test `Client/tauri-client/tests/unit/voice-callbacks.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0290 — medium — 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
|
||||
|
||||
`Server/ws/handlers_voice.go:46` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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
|
||||
func handleVoiceLeaveV2(_ context.Context, cmd Command, _ ClientInfo, deps any) Result {
|
||||
d := deps.(VoiceDeps)
|
||||
ratKey := auth.Key("voice_leave", cmd.UserID())
|
||||
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) {
|
||||
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice leave attempts"}}
|
||||
}
|
||||
return Result{LeaveVoice: true}
|
||||
}
|
||||
|
||||
Server/ws/handlers.go:85-100 (the error path only runs the leave when the handler asked for it)
|
||||
if result.Error != nil {
|
||||
... c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID)) ...
|
||||
if result.LeaveVoice { h.handleVoiceLeave(c.ctx, c) }
|
||||
return
|
||||
}
|
||||
|
||||
Server/ws/voice_join.go:25-26
|
||||
voiceLeaveRateLimit = 5
|
||||
voiceLeaveWindow = time.Second
|
||||
|
||||
Server/ws/voice_join.go:189-193 (rejoin of the same channel is refused)
|
||||
if currentChID == channelID {
|
||||
c.sendMsg(buildErrorMsg(ErrCodeAlreadyJoined, "already in this voice channel"))
|
||||
return false, false, false
|
||||
}
|
||||
|
||||
Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:62-68 and 190-193 (teardown happens first, answer never inspected)
|
||||
voiceSessionLeave(false);
|
||||
leaveVoiceChannel();
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
|
||||
Client/tauri-client/src/lib/dispatcher.ts:1298-1354 (rollback branch is gated on voiceStatus === "joining"; everything else just toasts)
|
||||
if (voiceStore.getState().voiceStatus === "joining") { ... }
|
||||
...
|
||||
showToast(payload.message || "Server error", "error");
|
||||
|
||||
**Suggested fix:** Make the refusal idempotent-safe by mirroring handleVoiceTokenRefreshV2's refusal — one line in the shared handler, Server/ws/handlers_voice.go:47:
|
||||
|
||||
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice leave attempts"}, LeaveVoice: true}
|
||||
|
||||
handlers.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.
|
||||
|
||||
**Fixed:** `82c202d` · test `Server/ws/handler_v2_migration_test.go` · revert-proof pass
|
||||
|
||||
### OC-0291 — medium — require_2fa's "all users enrolled" gate ignores lapsed temporary bans, permanently locking those users out of their own account
|
||||
|
||||
`Server/admin/handlers_settings.go:133` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-3`
|
||||
|
||||
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).
|
||||
2. Wait for the hour to pass. Nothing clears banned; the only unban path is an explicit admin action (users.sql:46).
|
||||
3. 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.
|
||||
4. 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.
|
||||
5. 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
|
||||
-- name: CountUsersWithoutTOTP :one
|
||||
SELECT COUNT(*) FROM users WHERE banned = 0 AND totp_secret IS NULL;
|
||||
|
||||
Server/admin/handlers_settings.go:133-139
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
...
|
||||
if count > 0 {
|
||||
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
|
||||
}
|
||||
|
||||
Server/auth/helpers.go:73-90 — IsEffectivelyBanned returns false once ban_expires is in the past, even though users.banned is still 1.
|
||||
|
||||
Server/api/auth_handler.go:345-385 — login passes the IsEffectivelyBanned check, then:
|
||||
if require2FA { 403 "two-factor authentication must be enabled on this account before login" }
|
||||
|
||||
Contrast: Server/db/queries/sqlite/users.sql:58 (ListMembers) and apitokens.sql:57 both include the lapsed-ban arm this query lacks.
|
||||
|
||||
**Suggested fix:** 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).
|
||||
|
||||
**Fixed:** `140633a` · test `Server/db/count_users_without_totp_test.go` · revert-proof pass
|
||||
|
||||
### OC-0292 — medium — Every settings PATCH is gated on the 2FA-enrollment precondition even when require_2fa is not being changed, wedging the whole Settings page
|
||||
|
||||
`Server/admin/handlers_settings.go:52` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-3`
|
||||
|
||||
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).
|
||||
2. Admin PATCHes /admin/api/settings {"registration_open":"false","require_2fa":"true"} → 200, require_2fa = '1'.
|
||||
3. Admin unbans B (PATCH /admin/api/users/{B} {"banned":false}) → banned = 0, totp_secret still NULL, so CountUsersWithoutTOTP now returns 1.
|
||||
4. 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.
|
||||
5. 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:
|
||||
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()); return
|
||||
}
|
||||
|
||||
Server/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:
|
||||
targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa")
|
||||
if !targetRequire2FA { return nil }
|
||||
...
|
||||
if count > 0 { return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled") }
|
||||
|
||||
Server/admin/handlers_settings.go:143-150 — targetBoolSetting's DB fallback for a key not present in the payload.
|
||||
|
||||
No 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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `140633a` · test `Server/admin/api_test.go` · revert-proof pass
|
||||
|
||||
### OC-0293 — low — DecrementMentionCounts reverses mentions that were never counted, wiping a blocker's genuine mention badge
|
||||
|
||||
`Server/db/mention_queries.go:280` · found 2026-08-22 · hunt `general-2026-08-21` · lens `db-storage`
|
||||
|
||||
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).
|
||||
2. 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.
|
||||
3. 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.
|
||||
4. Alice deletes her own message m2. `MessageService.DeleteMessage` calls `DecrementMentionCounts(#general, [m2])`.
|
||||
5. 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.
|
||||
6. 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
|
||||
`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 = ?)`
|
||||
Server/service/mentions.go:213-220 (increment side excludes blockers):
|
||||
`blockers, err := s.st.ListBlockersOf(ctx, authorID)` ... `for _, b := range blockers { delete(recipients, b) }`
|
||||
Server/service/mentions.go:202-206 (increment side also excludes non-readers): `if _, ok := readerIDs[uid]; ok { recipients[uid] = struct{}{} }`
|
||||
Server/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."
|
||||
No test covers DecrementMentionCounts (no `_test.go` in Server/ mentions it).
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `582dff6` · test `Server/db/mention_queries_test.go` · revert-proof pass
|
||||
|
||||
### OC-0294 — low — Account deletion soft-deletes every message the user wrote but never reverses their mention counts, leaving permanent phantom badges
|
||||
|
||||
`Server/db/account.go:75` · found 2026-08-22 · hunt `general-2026-08-21` · lens `db-storage`
|
||||
|
||||
`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.
|
||||
2. 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).
|
||||
3. Bob connects. `GetChannelUnreadCounts` reports #general as `unread = 0` (the only message is deleted) but `mentions = 1`.
|
||||
4. 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
|
||||
`if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE messages SET deleted = 1, content = '' WHERE user_id = ?", userID,
|
||||
); err != nil { return fmt.Errorf("DeleteAccount messages: %w", err) }`
|
||||
— no DecrementMentionCounts anywhere in DeleteAccount / deleteAccountCloseDMChannels.
|
||||
Contrast, Server/service/message_crud.go:477-479 (single delete):
|
||||
`if mcErr := s.st.DecrementMentionCounts(context.WithoutCancel(ctx), msg.ChannelID, []int64{msgID}); ...`
|
||||
and Server/service/message_purge.go:84-86 (bulk purge): same call for the whole id set.
|
||||
Server/db/message_queries.go:629-634 shows unread is live-computed (`m.deleted = 0`) while `mentions` is read straight out of read_states.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `6b42aeb` · test `Server/db/account_test.go` · revert-proof pass
|
||||
|
||||
### OC-0295 — low — MemberList re-registers per-row click/contextmenu listeners on the component-lifetime AbortSignal on every rebuild, permanently retaining every discarded row set
|
||||
|
||||
`Client/tauri-client/src/components/MemberList.ts:469` · found 2026-08-22 · hunt `general-2026-08-21` · lens `client-state`
|
||||
|
||||
`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 });
|
||||
MemberList.ts:269 item.addEventListener("contextmenu", (e) => { ... }, { signal });
|
||||
MemberList.ts:331 function renderList(root, opts, signal, rowsByUserId) { clearChildren(root); rowsByUserId.clear(); ... }
|
||||
MemberList.ts:469/479/495 renderList(root, opts, disposable.signal, rowsByUserId); // disposable.signal aborts only in destroy()
|
||||
|
||||
vs. the already-fixed sibling:
|
||||
ChannelSidebar.ts:758 let renderAc: AbortController | null = null; // "aborted and replaced at the top of every renderChannels() call"
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `7e767fe` · test `Client/tauri-client/tests/unit/member-list.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0296 — low — 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
|
||||
|
||||
`Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts:42` · found 2026-08-22 · hunt `general-2026-08-21` · lens `lifecycle`
|
||||
|
||||
`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; } }
|
||||
drag-reorder.ts:93 owner.addEventListener("abort", () => releaseOwner(owner), { once: true });
|
||||
drag-reorder.ts:246 owner: signal, // signal === currentRenderAc.signal
|
||||
ChannelSidebar.ts:612 attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
ChannelSidebar.ts:795 renderAc?.abort();
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `0e3435a` · test `Client/tauri-client/tests/unit/drag-reorder.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0297 — low — 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
|
||||
|
||||
`Server/api/profile_handler.go:613` · found 2026-08-22 · hunt `general-2026-08-21` · lens `error-paths`
|
||||
|
||||
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
|
||||
updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{
|
||||
Avatar: &avatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
// The column never moved, so the file and its row are orphans.
|
||||
if delErr := store.Delete(fileID); delErr != nil { ... }
|
||||
writeServiceError(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Server/service/user.go:224-239
|
||||
if err := s.st.UpdateUserProfile(ctx, userID, username, avatar, displayName, about); err != nil { ... } // <-- commits
|
||||
user, err := s.st.GetUserByID(context.WithoutCancel(ctx), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to fetch updated user: %v", ErrInternal, err) // <-- reached AFTER the commit
|
||||
}
|
||||
|
||||
Server/db/dbgen/attachments.sql.go:42-50 (the sweep that would otherwise reclaim the row)
|
||||
DELETE FROM attachments
|
||||
WHERE message_id IS NULL AND uploaded_at < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar = '/api/v1/files/' || attachments.id)
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `ec6e357` · test `Server/service/user_postcommit_readerror_test.go` · revert-proof pass
|
||||
|
||||
### OC-0298 — low — applyConnectStatus swallows the UpdateUserStatus failure but still stamps and broadcasts the new status, leaving users.status permanently disagreeing with the live roster
|
||||
|
||||
`Server/ws/serve.go:715` · found 2026-08-22 · hunt `general-2026-08-21` · lens `error-paths`
|
||||
|
||||
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
|
||||
func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {
|
||||
status := db.ConnectStatus(c.user.Status)
|
||||
if updateErr := database.UpdateUserStatus(ctx, c.userID, status); updateErr != nil {
|
||||
slog.Warn("ws UpdateUserStatus", "err", updateErr) // swallowed
|
||||
}
|
||||
c.user.Status = status
|
||||
}
|
||||
|
||||
Server/ws/serve_ready.go:65-76 (only downgrades, never upgrades)
|
||||
if !connected[m.ID] {
|
||||
m.Status = db.StatusOffline
|
||||
m.CustomStatus = nil
|
||||
}
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `e436acd` · test `Server/ws/oc_0298_apply_connect_status_test.go` · revert-proof pass
|
||||
|
||||
### OC-0299 — low — refreshUserSnapshot silently substitutes role name "member" on a role lookup failure — the exact fail-open that upgradeAndAuth 230 lines above was fixed to reject
|
||||
|
||||
`Server/ws/serve.go:374` · found 2026-08-22 · hunt `general-2026-08-21` · lens `error-paths`
|
||||
|
||||
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/tauri-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
|
||||
if user.RoleID != c.user.RoleID {
|
||||
roleName := "member"
|
||||
if role, roleErr := database.GetRoleByID(ctx, user.RoleID); roleErr == nil && role != nil {
|
||||
roleName = strings.ToLower(role.Name)
|
||||
}
|
||||
c.roleName = roleName
|
||||
}
|
||||
|
||||
compare Server/ws/serve.go:145-151 (the fail-closed sibling)
|
||||
role, roleErr := database.GetRoleByID(r.Context(), user.RoleID)
|
||||
if roleErr != nil || role == nil {
|
||||
slog.Error("ws: role lookup failed during handshake, closing connection", ...)
|
||||
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
|
||||
return nil, 0, fmt.Errorf(...)
|
||||
}
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `e436acd` · test `Server/ws/oc_0299_refresh_snapshot_role_test.go` · revert-proof pass
|
||||
|
||||
### OC-0300 — low — Ctrl+I on a double-clicked bold word downgrades it to italic — the outer-unwrap check matches one asterisk of a `**` pair
|
||||
|
||||
`Client/tauri-client/src/components/MessageInput.ts:110` · found 2026-08-22 · hunt `general-2026-08-21` · lens `ordering-boundary`
|
||||
|
||||
`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) {
|
||||
return {
|
||||
value: value.slice(0, start - len) + selected + value.slice(end + len),
|
||||
selectionStart: start - len,
|
||||
selectionEnd: start - len + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `31f73e3` · test `Client/tauri-client/tests/unit/message-input.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0301 — low — updateDmLastMessagePreview writes lastMessageId with no monotonicity guard, so it can regress the watermark updateDmLastMessage's replay guard depends on
|
||||
|
||||
`Client/tauri-client/src/stores/dm.store.ts:180` · found 2026-08-22 · hunt `general-2026-08-21` · lens `ordering-boundary`
|
||||
|
||||
`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):
|
||||
const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;
|
||||
...
|
||||
unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,
|
||||
|
||||
// updateDmLastMessagePreview (unguarded, same field):
|
||||
channels: [
|
||||
{ ...updated, lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp },
|
||||
...rest,
|
||||
],
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `770c849` · test `Client/tauri-client/tests/unit/dm-store.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0302 — low — registerNow's client-replacement transfer still misses pendingModServerMuted/pendingModServerDeafened, so a WS blip during a voice_mod_move silently lifts the moderator's mute
|
||||
|
||||
`Server/ws/hub.go:532` · found 2026-08-22 · hunt `general-2026-08-21` · lens `flow-reconnect`
|
||||
|
||||
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.
|
||||
|
||||
**Suggested fix:** 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):
|
||||
|
||||
if pm, pd := old.takePendingModFlags(); pm || pd {
|
||||
c.setPendingModFlags(pm, pd)
|
||||
}
|
||||
|
||||
Put 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.
|
||||
|
||||
**Fixed:** `956271f` · test `Server/ws/oc_0302_pending_mod_flags_transfer_test.go` · revert-proof pass
|
||||
|
||||
### OC-0303 — low — Incoming-call banner prints the caller's raw username, ignoring the nickname every other identity surface shows
|
||||
|
||||
`Client/tauri-client/src/pages/MainPage.ts:606` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-2`
|
||||
|
||||
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/tauri-client/src/lib/call-ring.ts:22-26
|
||||
export interface RingState { readonly channelId: number; readonly fromUserId: number; readonly fromUsername: string; }
|
||||
|
||||
Client/tauri-client/src/pages/MainPage.ts:603-607
|
||||
channelId: payload.channel_id,
|
||||
fromUserId: payload.from_user,
|
||||
fromUsername: payload.username,
|
||||
|
||||
Client/tauri-client/src/components/IncomingCallBanner.ts:83
|
||||
setText(title, `${state.fromUsername} is calling`);
|
||||
|
||||
Compare Client/tauri-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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `f4a60a6` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0304 — low — dm_channel_open (and POST /dms) report a DM partner's stale saved idle/dnd status, contradicting the member list, which shows them offline
|
||||
|
||||
`Server/api/dm_handler.go:169` · found 2026-08-22 · hunt `general-2026-08-21` · lens `hotspot-server-ws`
|
||||
|
||||
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)
|
||||
```go
|
||||
Status: db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID),
|
||||
```
|
||||
Same 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.
|
||||
The 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.
|
||||
Client 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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `201e2bc` · test `Server/service/dm_test.go; Server/api/dm_handler_presence_test.go` · revert-proof pass
|
||||
|
||||
### OC-0305 — low — Diagnostics endpoint reports the reverse proxy's address as the client address, ignoring the trusted_proxies config its own rate limiter uses
|
||||
|
||||
`Server/api/diagnostics_handler.go:46` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-3`
|
||||
|
||||
`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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
clientAddr := clientIP(r) // <- RemoteAddr only; cfg.Server.TrustedProxies unused
|
||||
...
|
||||
Client: clientDiag{
|
||||
RemoteAddr: clientAddr,
|
||||
IsPrivateNet: isPrivateIP(clientAddr),
|
||||
},
|
||||
|
||||
// middleware.go:258 — clientIP is documented as the no-proxy-trust variant
|
||||
func clientIP(r *http.Request) string { return clientIPWithProxies(r, nil) }
|
||||
|
||||
// router.go:155-159 — the very same route's limiter DOES honour the proxies
|
||||
r.With(AuthMiddleware(database), RequirePermission(permissions.Administrator),
|
||||
RateLimitMiddleware(limiter, "diag:", 5, time.Minute, cfg.Server.TrustedProxies)).
|
||||
Get("/api/v1/diagnostics/connectivity", handleDiagnosticsConnectivity(cfg, ver, hub))
|
||||
|
||||
**Suggested fix:** 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).
|
||||
|
||||
**Fixed:** `ab4b1ed` · test `Server/api/diagnostics_handler_test.go` · revert-proof pass
|
||||
|
||||
### OC-0306 — low — EmojiPicker re-registers every emoji cell's click listener on the picker-lifetime AbortSignal on each search keystroke, permanently retaining every discarded cell set
|
||||
|
||||
`Client/tauri-client/src/components/EmojiPicker.ts:620` · found 2026-08-22 · hunt `general-2026-08-21` · lens `hotspot-client-tauri-client-src-components`
|
||||
|
||||
`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();
|
||||
const signal = abortController.signal; // picker-lifetime, aborts only in destroy()
|
||||
...
|
||||
function buildEmojiSpan(emoji: string): HTMLSpanElement {
|
||||
const span = createElement("span", { class: "ep-emoji", ... });
|
||||
const image = buildCustomEmojiNode(emoji);
|
||||
...
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal }); // <-- line 620
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderAllCategories(categories: readonly EmojiCategory[]): void {
|
||||
clearChildren(scrollArea); // old cells detached, listeners still held by `signal`
|
||||
... grid.appendChild(buildEmojiSpan(emoji)); ...
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
searchQuery = searchInput.value.trim();
|
||||
renderAllCategories(getAllCategories()); // full rebuild per keystroke
|
||||
}, { signal });
|
||||
|
||||
**Suggested fix:** 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 });`
|
||||
|
||||
**Fixed:** `f468bab` · test `Client/tauri-client/tests/unit/emoji-picker.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0307 — low — QuickSwitcher re-registers every result row's click listener on the overlay-lifetime AbortSignal on each keystroke and each arrow key
|
||||
|
||||
`Client/tauri-client/src/components/QuickSwitcher.ts:83` · found 2026-08-22 · hunt `general-2026-08-21` · lens `hotspot-client-tauri-client-src-components`
|
||||
|
||||
`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();
|
||||
const signal = ac.signal; // overlay-lifetime, aborted only in destroy()
|
||||
...
|
||||
function renderResults(): void {
|
||||
clearChildren(resultsDiv); // previous rows detached, listeners still held
|
||||
for (let i = 0; i < filteredChannels.length; i++) {
|
||||
const item = createElement("div", { ... });
|
||||
...
|
||||
item.addEventListener("click", () => { options.onSelectChannel(ch.id); options.onClose(); }, { signal }); // <-- line 83
|
||||
resultsDiv.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") { ... renderResults(); return; } // line 124 — full rebuild per keypress
|
||||
if (e.key === "ArrowUp") { ... renderResults(); return; } // line 133
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `1158506` · test `Client/tauri-client/tests/unit/quick-switcher.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0308 — low — 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)
|
||||
|
||||
`Client/tauri-client/src/components/EmojiPicker.ts:508` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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)
|
||||
L583 `emoji: options.customEmoji.map((e) => `:${e.shortcode}:`)` // Server category rows are shortcode tokens
|
||||
L596-599 `function handleEmojiClick(emoji: string): void { addRecentEmoji(emoji); options.onSelect(emoji); }` // no discrimination between unicode and custom
|
||||
L613 `const image = buildCustomEmojiNode(emoji); if (image !== null) {...} else { setText(span, emoji); }`
|
||||
custom-emoji.ts:111-114 `buildCustomEmojiNode` returns null when `resolveEmoji` misses → the cell renders the literal text.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `f468bab` · test `Client/tauri-client/tests/unit/emoji-picker.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0309 — low — 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
|
||||
|
||||
`Client/tauri-client/src/components/DmProfileSidebar.ts:190` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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;`
|
||||
L288 `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.
|
||||
MainPage.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).
|
||||
Contrast 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.
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `f4a60a6` · test `Client/tauri-client/tests/unit/main-page.test.ts` · revert-proof pass
|
||||
|
||||
### OC-0310 — low — 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
|
||||
|
||||
`Client/tauri-client/src/components/UserBar.ts:170` · found 2026-08-22 · hunt `general-2026-08-21` · lens `explore-1`
|
||||
|
||||
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(),
|
||||
currentCustomStatus: loadCustomStatus(), // localStorage, not authStore.getState().user?.custom_status
|
||||
UserBar.ts:194-202 onUserStatusChange((status) => { statusPicker?.setStatus(status); ... }) // status only — no custom-status sync anywhere
|
||||
StatusPicker.ts:78 let lastCommittedCustom = options.currentCustomStatus ?? "";
|
||||
StatusPicker.ts:184-188 const text = input.value.trim()...; if (text === lastCommittedCustom) return; // "" === "" short-circuits
|
||||
StatusPicker.ts:307-310 function setCustomStatus(text) { lastCommittedCustom = text; ... } // exported, unit-tested, called from nowhere (grep: only StatusPicker.ts)
|
||||
userStatus.ts:98-101 loadCustomStatus() -> loadPref(CUSTOM_STATUS_PREF_KEY, "") -> localStorage "owncord:settings:customStatus" (preferences.ts:12,20) — one global key, no host/account scope
|
||||
auth.store.ts:86-116 clearAuth() resets voice/messages/channels/blocks/sidebarMode/NSFW acks — never the settings prefs
|
||||
Server/ws/serve_ready.go:39 "custom_status": user.CustomStatus, // in every auth_ok
|
||||
Server/service/channel.go:218 if customStatus == nil { return storedCustomStatus, nil } // a plain status change never clears it server-side
|
||||
|
||||
**Suggested fix:** 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.
|
||||
|
||||
**Fixed:** `d0791c4` · test `Client/tauri-client/tests/unit/status-picker-userbar.test.ts` · revert-proof pass
|
||||
|
||||
## Declined
|
||||
|
||||
### OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"nextId": 276,
|
||||
"nextId": 311,
|
||||
"findings": [
|
||||
{
|
||||
"id": "OC-0001",
|
||||
@@ -6520,6 +6520,811 @@
|
||||
"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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-client/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/tauri-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/tauri-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/tauri-client/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/tauri-client/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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-client/src/lib/call-ring.ts:22-26\nexport interface RingState { readonly channelId: number; readonly fromUserId: number; readonly fromUsername: string; }\n\nClient/tauri-client/src/pages/MainPage.ts:603-607\n channelId: payload.channel_id,\n fromUserId: payload.from_user,\n fromUsername: payload.username,\n\nClient/tauri-client/src/components/IncomingCallBanner.ts:83\n setText(title, `${state.fromUsername} is calling`);\n\nCompare Client/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-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/tauri-client/tests/unit/status-picker-userbar.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function closeIdentityModal(): void {
|
||||
async function openIdentityMismatchModal(
|
||||
userId: number,
|
||||
username: string,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
closeIdentityModal();
|
||||
// Compute the newly-delivered key's fingerprint so the user can verify it
|
||||
@@ -117,8 +117,14 @@ async function openIdentityMismatchModal(
|
||||
log.warn("E2EE: could not compute changed-key fingerprint for re-pin modal", err);
|
||||
}
|
||||
}
|
||||
// The sidebar (or a newer open) may have superseded us during the async compute.
|
||||
if (signal.aborted) return;
|
||||
// The SIDEBAR (or a newer open) may have superseded us during the async
|
||||
// compute — but NOT a mere re-render: `lifetimeSignal` is the sidebar's own
|
||||
// factory-lifetime signal (aborted only in destroy()), not the per-render
|
||||
// one that renderChannels() replaces on every redraw (OC-0281). Binding this
|
||||
// check to the render signal made an unrelated re-render landing mid-compute
|
||||
// (a message in another channel, a peer toggling mute) turn the click into a
|
||||
// silent no-op.
|
||||
if (lifetimeSignal.aborted) return;
|
||||
closeIdentityModal();
|
||||
const modal = createIdentityMismatchModal({
|
||||
username,
|
||||
@@ -148,8 +154,10 @@ async function openIdentityMismatchModal(
|
||||
});
|
||||
modal.mount(document.body);
|
||||
activeIdentityModal = modal;
|
||||
// Close if the owning sidebar is destroyed while the modal is still open.
|
||||
signal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
// Close if the owning sidebar is destroyed while the modal is still open —
|
||||
// NOT on a re-render, which is why this is `lifetimeSignal` and not the
|
||||
// render-scoped signal (OC-0281).
|
||||
lifetimeSignal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
}
|
||||
|
||||
export interface ChannelReorderData {
|
||||
@@ -332,6 +340,7 @@ function buildVoiceModOptions(
|
||||
function renderVoiceChannelItem(
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
@@ -494,7 +503,16 @@ function renderVoiceChannelItem(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
void openIdentityMismatchModal(user.userId, user.username || "Unknown", signal);
|
||||
// lifetimeSignal (not the per-render `signal`): the modal must
|
||||
// survive an unrelated re-render, and must not be silently
|
||||
// skipped by one landing during the async fingerprint compute
|
||||
// (OC-0281). The click listener itself stays on the per-render
|
||||
// `signal` so it dies with this row (OC-0229).
|
||||
void openIdentityMismatchModal(
|
||||
user.userId,
|
||||
user.username || "Unknown",
|
||||
lifetimeSignal,
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -514,7 +532,10 @@ function renderVoiceChannelItem(
|
||||
user.username || "Unknown",
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
signal,
|
||||
// lifetimeSignal (not the per-render `signal`): the menu is
|
||||
// mounted on document.body, independent of this row's render,
|
||||
// and must not be torn down by an unrelated re-render (OC-0282).
|
||||
lifetimeSignal,
|
||||
buildVoiceModOptions(channel.id, user, onVoiceModerate),
|
||||
);
|
||||
},
|
||||
@@ -583,6 +604,7 @@ function renderChannelItem(
|
||||
channel: Channel,
|
||||
isActive: boolean,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onEditChannel?: (channel: Channel) => void,
|
||||
@@ -599,6 +621,7 @@ function renderChannelItem(
|
||||
el = renderVoiceChannelItem(
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onWatchStream,
|
||||
@@ -607,9 +630,25 @@ function renderChannelItem(
|
||||
} else {
|
||||
el = renderTextChannelItem(channel, isActive, signal);
|
||||
}
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);
|
||||
attachChannelContextMenu(
|
||||
el,
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onEditChannel,
|
||||
onDeleteChannel,
|
||||
onPurgeChannel,
|
||||
);
|
||||
if (containerEl !== undefined && channels !== undefined) {
|
||||
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
channel,
|
||||
containerEl,
|
||||
channels,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onReorderChannel,
|
||||
);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
@@ -619,6 +658,7 @@ function renderCategoryGroup(
|
||||
channels: readonly Channel[],
|
||||
activeChannelId: number | null,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onCreateChannel?: (category: string) => void,
|
||||
@@ -689,6 +729,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -713,6 +754,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -821,6 +863,11 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
channels,
|
||||
state.activeChannelId,
|
||||
currentRenderAc.signal,
|
||||
// Sidebar-lifetime signal (aborted only in destroy()) for anything
|
||||
// that owns DOM mounted outside this render's rows -- a menu or
|
||||
// modal on document.body must not be torn down by an unrelated
|
||||
// re-render (OC-0281, OC-0282).
|
||||
ac.signal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onCreateChannel,
|
||||
|
||||
@@ -50,6 +50,18 @@ export interface DmProfileSidebarOptions {
|
||||
|
||||
export type DmProfileSidebarComponent = MountableComponent & {
|
||||
readonly isOpen: () => boolean;
|
||||
/**
|
||||
* Repaint the name, avatar initial and status (dot + label, both the
|
||||
* avatar-corner one and the inline one) from a fresher `DmProfileData`,
|
||||
* in place -- without rebuilding the panel and losing the note textarea's
|
||||
* focus/selection. The panel itself has no subscription to any store (it
|
||||
* is intentionally presentational); the owner is expected to call this
|
||||
* when the underlying user's presence or identity changes while the panel
|
||||
* stays open, mirroring how ChannelController keeps the DM chat header
|
||||
* live across the same events (see ChannelController.ts's refreshDmHeader).
|
||||
* A no-op before mount() or after destroy().
|
||||
*/
|
||||
readonly update: (user: DmProfileData) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -124,11 +136,21 @@ export function createDmProfileSidebar(
|
||||
): DmProfileSidebarComponent {
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const { user, onClose, host = "" } = options;
|
||||
const { onClose, host = "" } = options;
|
||||
let user = options.user;
|
||||
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let open = false;
|
||||
|
||||
// Live-updatable node refs, populated on mount() and cleared on destroy()
|
||||
// -- see the `update()` doc comment on DmProfileSidebarComponent for why
|
||||
// these are repainted in place instead of the whole panel being rebuilt.
|
||||
let nameNode: HTMLDivElement | null = null;
|
||||
let avatarLetterNode: HTMLSpanElement | null = null;
|
||||
let statusDotNode: HTMLDivElement | null = null;
|
||||
let statusDotInlineNode: HTMLSpanElement | null = null;
|
||||
let statusTextNode: HTMLSpanElement | null = null;
|
||||
|
||||
function isOpen(): boolean {
|
||||
return open;
|
||||
}
|
||||
@@ -158,6 +180,7 @@ export function createDmProfileSidebar(
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = avatarInitial(user);
|
||||
const letter = createElement("span", {}, initial);
|
||||
avatarLetterNode = letter;
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (isRenderableAvatar(user.avatar)) {
|
||||
@@ -189,6 +212,7 @@ export function createDmProfileSidebar(
|
||||
statusDot.style.border = "3px solid var(--bg-secondary, #111214)";
|
||||
statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDot.title = STATUS_LABELS[user.status] ?? "Offline";
|
||||
statusDotNode = statusDot;
|
||||
wrapper.appendChild(statusDot);
|
||||
|
||||
return wrapper;
|
||||
@@ -266,6 +290,7 @@ export function createDmProfileSidebar(
|
||||
nameEl.style.color = "var(--text-primary, #f2f3f5)";
|
||||
nameEl.style.marginBottom = "4px";
|
||||
setText(nameEl, resolveDisplayName(user));
|
||||
nameNode = nameEl;
|
||||
|
||||
// Status line
|
||||
const statusLine = createElement("div", {
|
||||
@@ -286,8 +311,10 @@ export function createDmProfileSidebar(
|
||||
statusDotInline.style.borderRadius = "50%";
|
||||
statusDotInline.style.display = "inline-block";
|
||||
statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDotInlineNode = statusDotInline;
|
||||
|
||||
const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline");
|
||||
statusTextNode = statusText;
|
||||
appendChildren(statusLine, statusDotInline, statusText);
|
||||
|
||||
appendChildren(content, nameEl, statusLine);
|
||||
@@ -413,7 +440,41 @@ export function createDmProfileSidebar(
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
nameNode = null;
|
||||
avatarLetterNode = null;
|
||||
statusDotNode = null;
|
||||
statusDotInlineNode = null;
|
||||
statusTextNode = null;
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen };
|
||||
function update(nextUser: DmProfileData): void {
|
||||
user = nextUser;
|
||||
// Not mounted (or already torn down) -- nothing to repaint. mount() will
|
||||
// paint the fresh `user` from scratch if it is called afterwards.
|
||||
if (panel === null) return;
|
||||
|
||||
if (nameNode !== null) setText(nameNode, resolveDisplayName(user));
|
||||
|
||||
const color = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
const label = STATUS_LABELS[user.status] ?? "Offline";
|
||||
|
||||
if (statusDotNode !== null) {
|
||||
statusDotNode.style.background = color;
|
||||
statusDotNode.title = label;
|
||||
}
|
||||
if (statusDotInlineNode !== null) {
|
||||
statusDotInlineNode.style.background = color;
|
||||
}
|
||||
if (statusTextNode !== null) setText(statusTextNode, label);
|
||||
|
||||
// Only repaint the fallback letter if it is still showing -- once the
|
||||
// fetched avatar image swaps in, buildAvatar() removes the letter node
|
||||
// from the DOM (see above), and a stale identity's initial no longer
|
||||
// matters (or exists) to update.
|
||||
if (avatarLetterNode !== null && avatarLetterNode.isConnected) {
|
||||
setText(avatarLetterNode, avatarInitial(user));
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen, update };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
|
||||
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
|
||||
import { resolveEmoji } from "@stores/emoji.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -517,7 +518,19 @@ function getRecentEmoji(): string[] {
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((e): e is string => typeof e === "string").slice(0, MAX_RECENT);
|
||||
return (
|
||||
parsed
|
||||
.filter((e): e is string => typeof e === "string")
|
||||
// A `:shortcode:`-shaped entry is only meaningful when it still
|
||||
// resolves on *this* server — the recent list is global (unscoped by
|
||||
// host), so a custom emoji clicked on one server would otherwise leak
|
||||
// as dead literal text into every other server's picker, and a
|
||||
// deleted emoji would do the same on its own server forever after.
|
||||
// Plain unicode entries (no colons) are never shortcode-shaped and
|
||||
// pass through untouched.
|
||||
.filter((e) => !(e.startsWith(":") && e.endsWith(":")) || resolveEmoji(e) !== null)
|
||||
.slice(0, MAX_RECENT)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -571,6 +584,27 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
root.appendChild(scrollArea);
|
||||
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
|
||||
|
||||
// Single delegated listener for the whole grid, registered once at mount
|
||||
// time. renderAllCategories() discards and rebuilds every cell on each
|
||||
// search keystroke (~250 cells per render); a listener bound directly to
|
||||
// each cell would register (and, since it lives on the picker-lifetime
|
||||
// `signal`, never release) one abort algorithm per discarded cell for the
|
||||
// rest of the picker's life — the same pattern SearchOverlay.ts's
|
||||
// handleResultsClick already fixes for its rows.
|
||||
scrollArea.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const cell = target.closest<HTMLElement>(".ep-emoji");
|
||||
if (cell === null) return;
|
||||
const emoji = cell.dataset.emoji;
|
||||
if (emoji === undefined) return;
|
||||
handleEmojiClick(emoji);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Build categories with recent + custom
|
||||
function getAllCategories(): readonly EmojiCategory[] {
|
||||
const recent = getRecentEmoji();
|
||||
@@ -606,6 +640,9 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
// Mirrors the title (the character or :shortcode: token) — e2e specs
|
||||
// select cells by title, so the accessible name must never diverge.
|
||||
"aria-label": emoji,
|
||||
// Read by the delegated click handler on scrollArea (see mount-time
|
||||
// listener above) instead of a per-cell listener.
|
||||
"data-emoji": emoji,
|
||||
});
|
||||
// A `:shortcode:` entry shows its image; everything else is the character
|
||||
// itself. An unresolvable shortcode falls back to the text, which is what
|
||||
@@ -617,7 +654,6 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
} else {
|
||||
setText(span, emoji);
|
||||
}
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
@@ -462,11 +462,30 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
/** Rendered rows by user id \u2014 lets presence-only updates patch in place. */
|
||||
const rowsByUserId = new Map<number, HTMLDivElement>();
|
||||
let prevMembers: ReadonlyMap<number, Member> = new Map();
|
||||
// renderList() rebuilds every row from scratch on every non-presence-only
|
||||
// membersStore change and on every roles_update. Per-row listeners (click,
|
||||
// contextmenu) must NOT be registered on the component-lifetime
|
||||
// `disposable.signal`, which only aborts once, at destroy() --
|
||||
// addEventListener({ signal }) keeps a detached row alive via that signal's
|
||||
// own retained "abort" listener list until it fires, so every rebuild would
|
||||
// otherwise leak one full set of detached rows (OC-0295), exactly the
|
||||
// defect already fixed in ChannelSidebar (renderAc, OC-0229) and
|
||||
// MessageList (OC-0286). renderAc is aborted and replaced at the top of
|
||||
// every render, so only the CURRENT render's rows stay reachable.
|
||||
let renderAc: AbortController | null = null;
|
||||
|
||||
function render(): void {
|
||||
if (root === null) return;
|
||||
renderAc?.abort();
|
||||
const currentRenderAc = new AbortController();
|
||||
renderAc = currentRenderAc;
|
||||
renderList(root, opts, currentRenderAc.signal, rowsByUserId);
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
|
||||
prevMembers = membersStore.getState().members;
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
|
||||
disposable.onStoreChange<MembersState, ReadonlyMap<number, Member>>(
|
||||
membersStore,
|
||||
@@ -476,7 +495,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
if (isPresenceOnlyChange(prevMembers, members)) {
|
||||
patchPresence(prevMembers, members, rowsByUserId);
|
||||
} else {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
}
|
||||
}
|
||||
prevMembers = members;
|
||||
@@ -491,9 +510,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
channelsStore,
|
||||
(s) => s.roles,
|
||||
() => {
|
||||
if (root !== null) {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
}
|
||||
render();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -505,6 +522,8 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
closeActivePopup();
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
disposable.destroy();
|
||||
renderAc?.abort();
|
||||
renderAc = null;
|
||||
rowsByUserId.clear();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
|
||||
@@ -108,11 +108,37 @@ export function wrapWithMarker(
|
||||
};
|
||||
}
|
||||
if (value.slice(start - len, start) === marker && value.slice(end, end + len) === marker) {
|
||||
return {
|
||||
value: value.slice(0, start - len) + selected + value.slice(end + len),
|
||||
selectionStart: start - len,
|
||||
selectionEnd: start - len + selected.length,
|
||||
// The characters immediately outside the selection match this marker,
|
||||
// but matching alone doesn't prove they *are* this marker rather than
|
||||
// the edge of a longer run of the same repeated character — e.g. the
|
||||
// single "*" bordering a double-clicked word inside "**bold**" matches
|
||||
// the italic marker "*", but it's really one half of a "**" bold pair.
|
||||
// Compare the full contiguous run of the marker's character against
|
||||
// exactly one marker-width: a run that's a whole marker-width *longer*
|
||||
// means the true neighbour is a bigger marker, so unwrapping here would
|
||||
// tear it apart. Fall through to wrapping (adding this marker as an
|
||||
// extra layer) instead.
|
||||
const markerChar = marker[0];
|
||||
const runLength = (index: number, step: -1 | 1): number => {
|
||||
let i = index;
|
||||
let count = 0;
|
||||
while (value[i] === markerChar) {
|
||||
count++;
|
||||
i += step;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const leftRun = runLength(start - 1, -1);
|
||||
const rightRun = runLength(end, 1);
|
||||
const leftIsLongerMarker = leftRun - len === len;
|
||||
const rightIsLongerMarker = rightRun - len === len;
|
||||
if (!leftIsLongerMarker && !rightIsLongerMarker) {
|
||||
return {
|
||||
value: value.slice(0, start - len) + selected + value.slice(end + len),
|
||||
selectionStart: start - len,
|
||||
selectionEnd: start - len + selected.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -243,6 +243,23 @@ export type MessageListComponent = MountableComponent & {
|
||||
export function createMessageList(options: MessageListOptions): MessageListComponent {
|
||||
const ac = new AbortController();
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
/**
|
||||
* Scopes the *current* rendered window's row listeners (react/reply/pin/
|
||||
* edit/delete/copy-link, reply-ref, reaction chips, ...). `renderWindow`
|
||||
* aborts and replaces this before every full rebuild, so a discarded row's
|
||||
* listeners are dropped immediately instead of accumulating on `ac` for
|
||||
* the whole component lifetime — every row used to register against
|
||||
* `ac.signal` directly, and nothing aborted a stale render's registrations
|
||||
* short of `destroy()`, retaining a full window of detached rows (and
|
||||
* everything they reference: videos, images, embeds, tooltips) per rebuild
|
||||
* (OC-0286). Mirrors ChannelSidebar's `renderAc` (OC-0229) and
|
||||
* SettingsOverlay's `renderAC`.
|
||||
*/
|
||||
let rowAc: AbortController | null = null;
|
||||
/** Signal handed to row renderers — combines `ac.signal` (component
|
||||
* lifetime) with `rowAc.signal` (current window) so either one aborts a
|
||||
* row's listeners. Starts as plain `ac.signal` before the first render. */
|
||||
let rowSignal: AbortSignal = ac.signal;
|
||||
/** Non-scrolling frame around the scroller; what is actually appended to
|
||||
* the parent. The floating controls anchor to this box — an absolutely
|
||||
* positioned box whose containing block is the scroller itself sits in
|
||||
@@ -329,7 +346,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
function renderVirtualItem(item: VirtualItem): HTMLElement {
|
||||
if (item.kind === "divider") return renderDayDivider(item.timestamp);
|
||||
if (item.kind === "new-divider") return renderNewDivider();
|
||||
return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal);
|
||||
return renderMessage(item.message, item.isGrouped, allMessages, options, rowSignal);
|
||||
}
|
||||
|
||||
function itemKey(index: number): string {
|
||||
@@ -488,6 +505,19 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort the previous window's row-scoped listeners and start a fresh
|
||||
* signal for the rows about to replace them. Must run before every
|
||||
* `clearChildren(contentContainer)` that discards rendered rows, so a
|
||||
* stale row can never outlive the render that replaced it (OC-0286) — the
|
||||
* incremental append fast path (tryAppendMessages) deliberately does NOT
|
||||
* call this, since it appends to rows that stay live until the next
|
||||
* rebuild and must keep using the current window's signal. */
|
||||
function beginRowRender(): void {
|
||||
rowAc?.abort();
|
||||
rowAc = new AbortController();
|
||||
rowSignal = AbortSignal.any([ac.signal, rowAc.signal]);
|
||||
}
|
||||
|
||||
let renderWindowCount = 0;
|
||||
let renderWindowResetTimer = 0;
|
||||
|
||||
@@ -500,6 +530,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
if (virtualItems.length === 0) {
|
||||
releaseTrackedMedia();
|
||||
beginRowRender();
|
||||
clearChildren(contentContainer);
|
||||
// With no rows, the region shows the fetch state: an in-region loading
|
||||
// placeholder, an inline error + Retry, or the welcome/empty state once
|
||||
@@ -561,6 +592,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
// Rebuild content
|
||||
releaseTrackedMedia();
|
||||
beginRowRender();
|
||||
clearChildren(contentContainer);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = start; i < end; i++) {
|
||||
@@ -1011,6 +1043,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
resizeObserver = null;
|
||||
}
|
||||
ac.abort();
|
||||
// rowSignal (AbortSignal.any([ac.signal, rowAc.signal])) already aborts
|
||||
// as soon as ac does, but abort + drop the reference too so a stray
|
||||
// beginRowRender() after destroy (there shouldn't be one) can't resurrect
|
||||
// a live-looking controller.
|
||||
rowAc?.abort();
|
||||
rowAc = null;
|
||||
if (scrollRafId !== 0) {
|
||||
cancelAnimationFrame(scrollRafId);
|
||||
scrollRafId = 0;
|
||||
|
||||
@@ -80,15 +80,6 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
|
||||
appendChildren(item, ...parts);
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
options.onSelectChannel(ch.id);
|
||||
options.onClose();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
resultsDiv.appendChild(item);
|
||||
}
|
||||
|
||||
@@ -222,6 +213,25 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
root.addEventListener("click", handleBackdropClick, { signal });
|
||||
document.addEventListener("keydown", handleGlobalKeydown, { signal });
|
||||
|
||||
// Delegated row click — renderResults() rebuilds every row from scratch
|
||||
// on each keystroke, arrow key, and store refresh, so a per-row listener
|
||||
// registered against this overlay-lifetime `signal` would never be freed
|
||||
// until the overlay closes (OC-0307). One listener on the (stable)
|
||||
// container instead, keyed off the data-channelid each row already
|
||||
// carries.
|
||||
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 },
|
||||
);
|
||||
|
||||
// Subscribe to store changes
|
||||
unsubscribe = channelsStore.subscribeSelector((s) => s.channels, refreshFromStore);
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ export interface UserBarOptions {
|
||||
readonly presenceSender?: PresenceSender | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's custom status as the server told it to us, or `null`
|
||||
* when no user is loaded yet (before the first `ready`/auth_ok). `null` is
|
||||
* the only case callers should fall back to the localStorage pref for —
|
||||
* once a user is loaded, its `custom_status` (even "") is authoritative and
|
||||
* must not be second-guessed against a value that may belong to a different
|
||||
* account or server (OC-0310).
|
||||
*/
|
||||
function serverCustomStatus(): string | null {
|
||||
const user = authStore.getState().user;
|
||||
return user !== null ? (user.custom_status ?? "") : null;
|
||||
}
|
||||
|
||||
/** Status labels for the line under the username. */
|
||||
const STATUS_TEXT: Readonly<Record<UserStatus, string>> = {
|
||||
online: "Online",
|
||||
@@ -167,7 +180,15 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
// Start from the stored selection, not a hardcoded "online" — otherwise
|
||||
// this picker and the settings Account tab show different statuses.
|
||||
currentStatus: loadUserStatus(),
|
||||
currentCustomStatus: loadCustomStatus(),
|
||||
// The server's own auth_ok.user.custom_status is authoritative (OC-0310):
|
||||
// the localStorage pref is only a same-window fallback for before the
|
||||
// first `ready` arrives. Once authStore has a user, its custom_status —
|
||||
// even null/"" meaning "no status set" — wins outright; falling through
|
||||
// to the pref there (via `??`) would let a stale value from a previous
|
||||
// account/server on this machine leak back in exactly when the server
|
||||
// says there is nothing to show, which is the one case that must render
|
||||
// empty for the picker's clear-it flow to work at all.
|
||||
currentCustomStatus: serverCustomStatus() ?? loadCustomStatus(),
|
||||
onStatusChange: (status: UserStatus) => {
|
||||
saveUserStatus(status);
|
||||
updateFromState();
|
||||
@@ -250,11 +271,19 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
// Initial render
|
||||
updateFromState();
|
||||
|
||||
// Subscribe to auth changes
|
||||
// Subscribe to auth changes. Also reflects a custom_status that arrives
|
||||
// (or changes) through authStore — a later auth_ok, or the settings
|
||||
// Account tab's own presence send being echoed back — into the picker,
|
||||
// using the same setCustomStatus() the seed above trusts (OC-0310). The
|
||||
// null-safe read happens inside the callback so both "no user yet" and
|
||||
// "user with no custom status" collapse to the same "" default.
|
||||
disposable.onStoreChange(
|
||||
authStore,
|
||||
(s) => s.user,
|
||||
() => updateFromState(),
|
||||
() => {
|
||||
updateFromState();
|
||||
statusPicker?.setCustomStatus(serverCustomStatus() ?? "");
|
||||
},
|
||||
);
|
||||
|
||||
container.appendChild(root);
|
||||
|
||||
@@ -21,11 +21,22 @@ import { appendPurgeSection } from "@components/purge-prompt";
|
||||
/** Bubbles from a channel row when its mute is toggled. */
|
||||
export const CHANNEL_MUTE_CHANGED = "owncord:channel-mute-changed";
|
||||
|
||||
/** Attach a right-click context menu to a channel element for edit/delete/purge. */
|
||||
/**
|
||||
* Attach a right-click context menu to a channel element for edit/delete/purge.
|
||||
*
|
||||
* `signal` governs only the row-level `contextmenu` listener below, so it
|
||||
* dies with the row that created it on the next re-render (OC-0229).
|
||||
* `lifetimeSignal` is the sidebar's own factory-lifetime signal (aborted only
|
||||
* on sidebar destroy) and owns everything INSIDE the opened menu instead --
|
||||
* the menu is mounted on document.body, independent of the row's render, and
|
||||
* must not be torn down (or have its item clicks silently detached) by an
|
||||
* unrelated re-render (OC-0282).
|
||||
*/
|
||||
export function attachChannelContextMenu(
|
||||
el: HTMLElement,
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onEdit?: (channel: Channel) => void,
|
||||
onDelete?: (channel: Channel) => void,
|
||||
onPurge?: (channel: Channel, count: number) => Promise<void>,
|
||||
@@ -85,7 +96,7 @@ export function attachChannelContextMenu(
|
||||
closeMenu();
|
||||
markChannelRead(channel.id);
|
||||
},
|
||||
{ signal },
|
||||
{ signal: lifetimeSignal },
|
||||
);
|
||||
}
|
||||
menu.appendChild(markItem);
|
||||
@@ -117,7 +128,7 @@ export function attachChannelContextMenu(
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
{ signal: lifetimeSignal },
|
||||
);
|
||||
menu.appendChild(muteItem);
|
||||
}
|
||||
@@ -138,7 +149,7 @@ export function attachChannelContextMenu(
|
||||
closeMenu();
|
||||
onEdit(channel);
|
||||
},
|
||||
{ signal },
|
||||
{ signal: lifetimeSignal },
|
||||
);
|
||||
menu.appendChild(editItem);
|
||||
}
|
||||
@@ -158,7 +169,7 @@ export function attachChannelContextMenu(
|
||||
closeMenu();
|
||||
onDelete(channel);
|
||||
},
|
||||
{ signal },
|
||||
{ signal: lifetimeSignal },
|
||||
);
|
||||
menu.appendChild(deleteItem);
|
||||
}
|
||||
@@ -169,7 +180,7 @@ export function attachChannelContextMenu(
|
||||
dangerItemClass: "context-menu-item danger",
|
||||
separatorClass: showEdit || showDelete ? "context-menu-sep" : "",
|
||||
onPurge: (count) => onPurge(channel, count),
|
||||
signal,
|
||||
signal: lifetimeSignal,
|
||||
onDone: () => closeMenu(),
|
||||
});
|
||||
}
|
||||
@@ -185,7 +196,10 @@ export function attachChannelContextMenu(
|
||||
// Tie this bridge listener's own lifetime to menuAc so it does not
|
||||
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
|
||||
// already fires far more often than the sidebar's own teardown.
|
||||
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
|
||||
// lifetimeSignal (not the per-render `signal`): the menu is mounted on
|
||||
// document.body, independent of the row that opened it, so an unrelated
|
||||
// re-render must not close it (OC-0282).
|
||||
lifetimeSignal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
|
||||
// Defer so this click event doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
if (menuAc.signal.aborted) return;
|
||||
|
||||
@@ -226,13 +226,22 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */
|
||||
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only).
|
||||
* `signal` is per-render — it is aborted and replaced on every sidebar
|
||||
* re-render, so it scopes only this row's own mousedown/mousemove/mouseup
|
||||
* listeners (OC-0229: a stale row must not outlive the render that replaced
|
||||
* it). `lifetimeSignal` is the sidebar's lifetime controller and is what
|
||||
* owns the shared document-level drag listeners (see the module comment on
|
||||
* `listenerOwners`): a mid-drag re-render must not tear those down or
|
||||
* cancel the in-flight drag, or `retargetDetachedDrag` never gets a chance
|
||||
* to run (OC-0296). */
|
||||
export function attachDragHandlers(
|
||||
el: HTMLElement,
|
||||
channel: Channel,
|
||||
containerEl: HTMLElement,
|
||||
channels: readonly Channel[],
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
|
||||
): void {
|
||||
if (onReorderChannel === undefined) {
|
||||
@@ -246,7 +255,7 @@ export function attachDragHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
ensureGlobalDragListeners(signal);
|
||||
ensureGlobalDragListeners(lifetimeSignal);
|
||||
|
||||
el.classList.add("channel-draggable");
|
||||
el.dataset.dragChannelId = String(channel.id);
|
||||
@@ -293,7 +302,7 @@ export function attachDragHandlers(
|
||||
containerEl,
|
||||
channels,
|
||||
onReorder: onReorderChannel,
|
||||
owner: signal,
|
||||
owner: lifetimeSignal,
|
||||
};
|
||||
el.classList.add("dragging");
|
||||
document.body.classList.add("channel-reordering");
|
||||
|
||||
@@ -21,12 +21,19 @@ export interface VoiceModMenuOptions {
|
||||
readonly onDisconnect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lifetimeSignal` should be the sidebar's own factory-lifetime signal
|
||||
* (aborted only on sidebar destroy), NOT a per-render signal that gets
|
||||
* replaced on every redraw — this menu is mounted on document.body,
|
||||
* independent of any one render, and must not be torn down by an unrelated
|
||||
* re-render (OC-0282).
|
||||
*/
|
||||
export function showUserVolumeMenu(
|
||||
userId: number,
|
||||
username: string,
|
||||
x: number,
|
||||
y: number,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
mod?: VoiceModMenuOptions,
|
||||
): void {
|
||||
// Remove any existing context menus and abort their dismiss controllers
|
||||
@@ -131,9 +138,9 @@ export function showUserVolumeMenu(
|
||||
// Also clean up if the parent component is destroyed. Tied to dismissAc's
|
||||
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
|
||||
// listener is torn down with the menu itself — otherwise it never runs
|
||||
// (the parent signal is long-lived) and every right-click permanently
|
||||
// (the lifetime signal is long-lived) and every right-click permanently
|
||||
// accumulates one closure retaining a detached .user-vol-menu subtree.
|
||||
signal.addEventListener(
|
||||
lifetimeSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
menu.remove();
|
||||
|
||||
@@ -95,9 +95,18 @@ function readMuted(): ReadonlySet<number> {
|
||||
// and persist the result under the scoped key so the read-through isn't
|
||||
// repeated. A different host with its OWN explicit (even empty) mute list
|
||||
// is not touched by this — it never reaches this branch.
|
||||
//
|
||||
// The legacy key is then consumed (removed) so this migration can only
|
||||
// ever apply to the FIRST host connected to post-upgrade. Channel ids are
|
||||
// per-server autoincrement integers, so leaving the legacy key in place
|
||||
// would let every subsequent brand-new host also miss its own scoped key,
|
||||
// read through to the same legacy list, and inherit server A's mutes as
|
||||
// its own (OC-0288) — every host after that falls through to `new Set()`
|
||||
// instead.
|
||||
if (keyExists(MUTED_KEY)) {
|
||||
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
|
||||
writeMuted(legacy);
|
||||
localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
|
||||
@@ -1050,17 +1050,14 @@ export function wireDispatcher(
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_LEAVE, (payload) => {
|
||||
// OC-0239: snapshot the roster BEFORE removeVoiceUser() below mutates
|
||||
// it. handleParticipantLeft's own stale-leave guard (OC-0213, "is this
|
||||
// peer still listed as present?") reads the SAME store — if it read
|
||||
// that after removeVoiceUser() already deleted the user, the check
|
||||
// would always see them as absent and could never tell a stale,
|
||||
// superseded-rejoin leave from a genuine departure, permanently
|
||||
// retiring a peer who never actually left. Pass this pre-mutation
|
||||
// snapshot through explicitly instead of relying on the post-mutation
|
||||
// store state.
|
||||
const stillInRoster =
|
||||
voiceStore.getState().voiceUsers.get(payload.channel_id)?.has(payload.user_id) ?? false;
|
||||
// OC-0283: removeVoiceUser() below always mutates the roster before
|
||||
// handleParticipantLeft below runs, and a pre-mutation snapshot (the
|
||||
// OC-0239 attempt this replaced) reads "still present" on every
|
||||
// genuine departure too — a departing peer is only ever removed from
|
||||
// the roster by this very handler, so any roster read taken here, at
|
||||
// any point relative to that mutation, cannot tell a genuine departure
|
||||
// apart from the OC-0213 stale-leave case. Don't try; see
|
||||
// E2EEManager.handleParticipantLeft for the current defense.
|
||||
removeVoiceUser(payload);
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
const isSelf = payload.user_id === currentUserId;
|
||||
@@ -1077,7 +1074,7 @@ export function wireDispatcher(
|
||||
// (when applicable) tear down the media session — both through one lazy
|
||||
// import so the two effects cannot land in different ticks.
|
||||
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
|
||||
void handleParticipantLeft(payload.user_id, stillInRoster);
|
||||
void handleParticipantLeft(payload.user_id);
|
||||
if (shouldTeardownSession) void leaveVoice(false);
|
||||
});
|
||||
// Clear local voice state only for the same channel-match case as the
|
||||
|
||||
@@ -1242,16 +1242,8 @@ export class E2EEManager {
|
||||
* Key holder election: the participant with the lowest user ID among remaining
|
||||
* participants is elected. This is deterministic and does not depend on Map
|
||||
* insertion order (which is not guaranteed to match server join order).
|
||||
*
|
||||
* @param stillInRoster - (OC-0239) True when the caller knows, from a
|
||||
* roster snapshot taken BEFORE its own store mutation, that this user
|
||||
* was still present in the channel when the leave was received — the
|
||||
* signal that this is a stale, superseded-rejoin leave rather than a
|
||||
* genuine departure (OC-0213). Defaults to false so callers with no
|
||||
* better information fall back to this method's own (post-mutation)
|
||||
* roster read.
|
||||
*/
|
||||
async handleParticipantLeft(userId: number, stillInRoster = false): Promise<void> {
|
||||
async handleParticipantLeft(userId: number): Promise<void> {
|
||||
const departingKey = this._peerPublicKeys.get(userId);
|
||||
const hadPeerKey = departingKey !== undefined;
|
||||
this._peerPublicKeys.delete(userId);
|
||||
@@ -1287,15 +1279,25 @@ export class E2EEManager {
|
||||
// still correctly excludes them from any resulting rotation) so nothing
|
||||
// regresses for a genuine departure.
|
||||
//
|
||||
// `channelUsers` alone can no longer tell this apart in production
|
||||
// (OC-0239): the only real VOICE_LEAVE caller (dispatcher.ts) deletes the
|
||||
// user from the voice-user roster BEFORE calling this method, so
|
||||
// `channelUsers?.has(userId)` is always false by the time we get here —
|
||||
// the store mutation always wins the race this check was meant to
|
||||
// detect. `stillInRoster` lets a caller pass a snapshot taken BEFORE its
|
||||
// own roster mutation instead; it is OR'd with the live read so a direct
|
||||
// caller relying on the store alone (as tests do) still works unchanged.
|
||||
if (departingKey && !stillInRoster && !channelUsers?.has(userId)) {
|
||||
// `channelUsers` is a POST-mutation read: the only real VOICE_LEAVE
|
||||
// caller (dispatcher.ts) deletes the user from the voice-user roster
|
||||
// BEFORE calling this method, so `channelUsers?.has(userId)` is always
|
||||
// false by the time we get here in production — this guard only ever
|
||||
// protects the OC-0213 case for a caller that reads the roster itself
|
||||
// without that prior mutation (as tests do). OC-0239 tried to plug that
|
||||
// production gap by having the caller pass a PRE-mutation roster
|
||||
// snapshot (`stillInRoster`) instead, but that snapshot cannot tell the
|
||||
// two cases apart either (OC-0283): the roster still lists a departing
|
||||
// peer as present right up until this very event is what removes them,
|
||||
// so the snapshot reads "still present" on every genuine departure too,
|
||||
// not just the stale-leave case it was meant to isolate. Gating on it
|
||||
// made retirement never run in production, silently killing this replay
|
||||
// defense — worse than the gap it was meant to close. A real fix for the
|
||||
// OC-0213 race needs a discriminator the roster does not carry (e.g. the
|
||||
// server stamping a join/epoch id on voice_leave so a stale leave for a
|
||||
// superseded join can be dropped outright), not a roster read taken at
|
||||
// any point during this call.
|
||||
if (departingKey && !channelUsers?.has(userId)) {
|
||||
this.retirePeerKey(userId, await exportPublicKey(departingKey));
|
||||
}
|
||||
|
||||
|
||||
@@ -1478,8 +1478,8 @@ export class LiveKitSession {
|
||||
* Handle a participant leaving the voice channel (key-holder election and
|
||||
* membership-forward-secrecy rekey). See E2EEManager.handleParticipantLeft.
|
||||
*/
|
||||
async handleParticipantLeft(userId: number, stillInRoster = false): Promise<void> {
|
||||
return this._e2ee.handleParticipantLeft(userId, stillInRoster);
|
||||
async handleParticipantLeft(userId: number): Promise<void> {
|
||||
return this._e2ee.handleParticipantLeft(userId);
|
||||
}
|
||||
|
||||
/** Retry microphone permission after being in listen-only mode. */
|
||||
@@ -1636,11 +1636,28 @@ export class LiveKitSession {
|
||||
log.debug("Skipping mic re-publish — still gated (mute/deafen/server-mute/PTT)");
|
||||
return;
|
||||
}
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
log.debug("Mic re-published (unmuted)");
|
||||
// Re-enable mic — this re-publishes the track to the SFU. Every caller
|
||||
// (setMuted/setDeafened's unmute branches, ptt.ts, roomEventHandlers)
|
||||
// fires this forgetfully with only a `.catch(e => log.warn(...))`, so a
|
||||
// rejection here (permission revoked, device unplugged) must not
|
||||
// propagate silently: without recovery, setLocalMuted(false) and the
|
||||
// outbound voice_mute{muted:false} frame have already gone out by the
|
||||
// time this runs, leaving the client reporting itself unmuted to the
|
||||
// server and every peer while publishing no audio at all (OC-0287).
|
||||
// Fall back into listen-only + muted so the state matches reality and
|
||||
// the existing "Grant Microphone" affordance (gated on listenOnly)
|
||||
// reappears as the recovery path.
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
log.debug("Mic re-published (unmuted)");
|
||||
} catch (err) {
|
||||
setListenOnly(true);
|
||||
setLocalMuted(true);
|
||||
log.warn("Mic re-publish failed — falling back to listen-only/muted", err);
|
||||
this.onErrorCallback?.("Microphone unavailable — you are muted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,13 +235,25 @@ async function createScriptProcessorPipeline(
|
||||
*/
|
||||
export function createRNNoiseProcessor(): TrackProcessor<Track.Kind.Audio, AudioProcessorOptions> {
|
||||
let pipeline: ProcessingPipeline | null = null;
|
||||
// Cached across init()/restart() calls: livekit-client's ONLY
|
||||
// processor.restart() call site (LocalTrack.setMediaStreamTrack(), reached
|
||||
// via restartTrack() on a device switch, mic replug, or an AEC/NS/AGC
|
||||
// constraint toggle) sends `{track, kind, element, localTrack}` with no
|
||||
// `audioContext` — only setProcessor() supplies one. Without this cache,
|
||||
// restart() would try to build a new pipeline from `undefined` and reject,
|
||||
// leaving `pipeline` null and the mic silently unpublished.
|
||||
let cachedCtx: AudioContext | null = null;
|
||||
|
||||
return {
|
||||
name: "rnnoise",
|
||||
|
||||
async init(opts: AudioProcessorOptions): Promise<void> {
|
||||
log.debug("RNNoise processor init", { audioWorkletSupported: supportsAudioWorklet() });
|
||||
const ctx = opts.audioContext;
|
||||
const ctx = opts.audioContext ?? cachedCtx;
|
||||
if (ctx == null) {
|
||||
throw new Error("RNNoise processor: no AudioContext available");
|
||||
}
|
||||
cachedCtx = ctx;
|
||||
|
||||
if (supportsAudioWorklet()) {
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,7 @@ import { createChannelController } from "./main-page/ChannelController";
|
||||
import type { ChannelController } from "./main-page/ChannelController";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
import { createDmProfileSidebar } from "@components/DmProfileSidebar";
|
||||
import type { DmProfileSidebarComponent } from "@components/DmProfileSidebar";
|
||||
import type { DmProfileData, DmProfileSidebarComponent } from "@components/DmProfileSidebar";
|
||||
import { createIncomingCallBanner } from "@components/IncomingCallBanner";
|
||||
import type { IncomingCallBannerComponent } from "@components/IncomingCallBanner";
|
||||
import { createRingController } from "@lib/call-ring";
|
||||
@@ -170,6 +170,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// DM profile sidebar (right panel, toggled via DM header click)
|
||||
let dmProfileSidebar: DmProfileSidebarComponent | null = null;
|
||||
let dmProfileSlot: HTMLDivElement | null = null;
|
||||
/** Tears down the store subscriptions that keep an open profile panel's
|
||||
* status/name live (see toggleDmProfile) -- null while the panel is
|
||||
* closed. Always cleared alongside dmProfileSidebar itself. */
|
||||
let dmProfileUnsub: (() => void) | null = null;
|
||||
|
||||
// DM calls: the banner draws a ring, the controller owns its lifetime.
|
||||
let callBanner: IncomingCallBannerComponent | null = null;
|
||||
@@ -233,14 +237,55 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
return channelName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the profile panel's data from the live stores for a 1:1 DM channel.
|
||||
* Null for a group (no single "recipient" -- see the caller) or a channel
|
||||
* that is no longer a DM. Shared by the initial open and by the live
|
||||
* refresh below so the two can never disagree on how a status/name is
|
||||
* derived.
|
||||
*/
|
||||
function buildDmProfileUser(channelId: number): DmProfileData | null {
|
||||
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
// A group has no single "recipient" — dm.store.ts documents .recipient as
|
||||
// just the first of .participants for a group, with group-correct code
|
||||
// expected to read .participants instead. A 1:1 profile panel built from
|
||||
// it would present one arbitrary member's identity as the conversation.
|
||||
if (dmChannel === undefined || dmChannel.isGroup) return null;
|
||||
|
||||
const recipient = dmChannel.recipient;
|
||||
// Prefer membersStore's status, like the chat header's refreshDmHeader
|
||||
// does (ChannelController.ts) -- falling back to dmStore's own copy keeps
|
||||
// this correct even for a DM partner who isn't a guild member.
|
||||
const rawStatus = membersStore.getState().members.get(recipient.id)?.status ?? recipient.status;
|
||||
const status =
|
||||
rawStatus === "online" ||
|
||||
rawStatus === "idle" ||
|
||||
rawStatus === "dnd" ||
|
||||
rawStatus === "offline"
|
||||
? rawStatus
|
||||
: ("offline" as const);
|
||||
|
||||
return {
|
||||
id: recipient.id,
|
||||
username: recipient.username,
|
||||
// The DM header this panel opens from renders through dmDisplayName,
|
||||
// which prefers the nickname -- drop it here and the panel shows a
|
||||
// different identity from the header the reader just clicked.
|
||||
displayName: recipient.displayName ?? null,
|
||||
avatar: recipient.avatar || null,
|
||||
status,
|
||||
about: null,
|
||||
joinDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle the DM profile sidebar open/closed for the current DM partner. */
|
||||
function toggleDmProfile(): void {
|
||||
if (dmProfileSlot === null) return;
|
||||
|
||||
// If already open, close it
|
||||
if (dmProfileSidebar !== null) {
|
||||
dmProfileSidebar.destroy?.();
|
||||
dmProfileSidebar = null;
|
||||
closeDmProfile();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,42 +293,42 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
const active = getActiveChannel();
|
||||
if (active === null || active.type !== "dm") return;
|
||||
|
||||
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === active.id);
|
||||
// A group has no single "recipient" — dm.store.ts documents .recipient as
|
||||
// just the first of .participants for a group, with group-correct code
|
||||
// expected to read .participants instead. A 1:1 profile panel built from
|
||||
// it would present one arbitrary member's identity as the conversation.
|
||||
if (dmChannel === undefined || dmChannel.isGroup) return;
|
||||
|
||||
const recipient = dmChannel.recipient;
|
||||
const status =
|
||||
recipient.status === "online" ||
|
||||
recipient.status === "idle" ||
|
||||
recipient.status === "dnd" ||
|
||||
recipient.status === "offline"
|
||||
? recipient.status
|
||||
: ("offline" as const);
|
||||
const channelId = active.id;
|
||||
const profileUser = buildDmProfileUser(channelId);
|
||||
if (profileUser === null) return;
|
||||
|
||||
dmProfileSidebar = createDmProfileSidebar({
|
||||
user: {
|
||||
id: recipient.id,
|
||||
username: recipient.username,
|
||||
// The DM header this panel opens from renders through dmDisplayName,
|
||||
// which prefers the nickname -- drop it here and the panel shows a
|
||||
// different identity from the header the reader just clicked.
|
||||
displayName: recipient.displayName ?? null,
|
||||
avatar: recipient.avatar || null,
|
||||
status,
|
||||
about: null,
|
||||
joinDate: null,
|
||||
},
|
||||
user: profileUser,
|
||||
host: apiConfig.host ?? "",
|
||||
onClose: () => {
|
||||
dmProfileSidebar?.destroy?.();
|
||||
dmProfileSidebar = null;
|
||||
closeDmProfile();
|
||||
},
|
||||
});
|
||||
dmProfileSidebar.mount(dmProfileSlot);
|
||||
|
||||
// Keep the panel's status and name live across presence/rename/nickname
|
||||
// changes for as long as it stays open on this DM -- otherwise it is
|
||||
// painted once from this open-time snapshot and never updated until
|
||||
// re-mounted, leaving it disagreeing with the chat header it was opened
|
||||
// from (which ChannelController.ts:621-638 already keeps live the same
|
||||
// way). Torn down in closeDmProfile.
|
||||
const recipientId = profileUser.id;
|
||||
const refresh = (): void => {
|
||||
const next = buildDmProfileUser(channelId);
|
||||
if (next !== null) dmProfileSidebar?.update(next);
|
||||
};
|
||||
const unsubMembers = membersStore.subscribeSelector(
|
||||
(s) => s.members.get(recipientId)?.status,
|
||||
refresh,
|
||||
);
|
||||
const unsubDm = dmStore.subscribeSelector(
|
||||
(s) => s.channels.find((c) => c.channelId === channelId),
|
||||
refresh,
|
||||
);
|
||||
dmProfileUnsub = () => {
|
||||
unsubMembers();
|
||||
unsubDm();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,6 +356,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
/** Close the DM profile sidebar if open. */
|
||||
function closeDmProfile(): void {
|
||||
if (dmProfileUnsub !== null) {
|
||||
dmProfileUnsub();
|
||||
dmProfileUnsub = null;
|
||||
}
|
||||
if (dmProfileSidebar !== null) {
|
||||
dmProfileSidebar.destroy?.();
|
||||
dmProfileSidebar = null;
|
||||
@@ -600,10 +649,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// A call in the DM you are already sitting in still rings: the
|
||||
// channel being open does not mean the app has focus, and Discord
|
||||
// rings there too.
|
||||
// Resolve through the members store, same as every other identity
|
||||
// surface (voice roster, member list, DM header) -- the raw wire
|
||||
// username is only a fallback for a caller this client hasn't
|
||||
// seen yet (OC-0303).
|
||||
const caller = membersStore.getState().members.get(payload.from_user);
|
||||
ringCtrl?.incoming({
|
||||
channelId: payload.channel_id,
|
||||
fromUserId: payload.from_user,
|
||||
fromUsername: payload.username,
|
||||
fromUsername: caller !== undefined ? memberDisplayName(caller) : payload.username,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("call_incoming handler error", err);
|
||||
|
||||
@@ -660,8 +660,19 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
* entire DM sidebar on every store change. For a small number of DMs this
|
||||
* is acceptable, but should be optimized to diff/patch individual DM items
|
||||
* once the DM list grows or store updates become more frequent.
|
||||
*
|
||||
* dmStore.channels changes far more often than "the DM list changed" —
|
||||
* a DM partner's presence flip or a new message rebuilds it too — so the
|
||||
* "Find a conversation" filter text and input focus (state that lives
|
||||
* only in the destroyed subtree) are captured here and restored onto
|
||||
* the freshly-mounted input rather than silently dropped (OC-0280).
|
||||
*/
|
||||
function refreshDmSidebar(): void {
|
||||
const oldSearchInput = contentSlot.querySelector<HTMLInputElement>(".dm-search");
|
||||
const savedQuery = oldSearchInput?.value ?? "";
|
||||
const hadFocus = oldSearchInput !== null && document.activeElement === oldSearchInput;
|
||||
const savedCaret = oldSearchInput?.selectionStart ?? null;
|
||||
|
||||
if (activeSidebarContent !== null) {
|
||||
activeSidebarContent.destroy?.();
|
||||
}
|
||||
@@ -673,6 +684,20 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
freshDm.mount(freshSlot);
|
||||
activeSidebarContent = freshDm;
|
||||
contentSlot.appendChild(freshSlot);
|
||||
|
||||
const newSearchInput = freshSlot.querySelector<HTMLInputElement>(".dm-search");
|
||||
if (newSearchInput !== null) {
|
||||
if (savedQuery !== "") {
|
||||
newSearchInput.value = savedQuery;
|
||||
newSearchInput.dispatchEvent(new Event("input"));
|
||||
}
|
||||
if (hadFocus) {
|
||||
newSearchInput.focus();
|
||||
if (savedCaret !== null) {
|
||||
newSearchInput.setSelectionRange(savedCaret, savedCaret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshDmSidebarRef = refreshDmSidebar;
|
||||
|
||||
@@ -181,6 +181,13 @@ export function createSidebarVoiceCallbacks(ws: WsClient): SidebarVoiceCallbacks
|
||||
return {
|
||||
onVoiceJoin: (channelId: number) => {
|
||||
if (!socketLive()) return;
|
||||
// Already there: a same-channel re-join (e.g. a DM "Start a call"
|
||||
// redial while the caller is still in the call) buys nothing and the
|
||||
// server refuses it with ALREADY_JOINED, which the dispatcher's
|
||||
// catch-all turns into a user-facing error toast (OC-0289). Callers
|
||||
// that used to hand-check this (ChannelSidebar's item click / stream
|
||||
// watch) stay correct since the guard is idempotent with theirs.
|
||||
if (voiceStore.getState().currentChannelId === channelId) return;
|
||||
log.info("Joining voice channel", { channelId });
|
||||
joinVoiceChannel(channelId);
|
||||
ws.send({ type: "voice_join", payload: { channel_id: channelId } });
|
||||
|
||||
@@ -164,7 +164,16 @@ export function updateDmLastMessage(
|
||||
|
||||
/** Update last message preview for a DM channel without incrementing unread count.
|
||||
* Used for own messages and messages in the currently focused DM.
|
||||
* Moves the channel to the top of the list so active conversations stay visible. */
|
||||
* Moves the channel to the top of the list so active conversations stay visible.
|
||||
*
|
||||
* OC-0301: guarded by the same message-id monotonicity check as its sibling
|
||||
* `updateDmLastMessage`. A queued chat_message frame can be redelivered for
|
||||
* an id already behind the channel's current `lastMessageId` (e.g. a `ready`
|
||||
* snapshot lands mid-burst and advances the watermark past a message that
|
||||
* is still queued behind it) — applying it here would roll the watermark
|
||||
* backwards, which `updateDmLastMessage`'s own replay guard on the *next*
|
||||
* frame in the burst depends on being monotonic, and would also regress the
|
||||
* visible preview text/timestamp and wrongly re-sort the channel to the top. */
|
||||
export function updateDmLastMessagePreview(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
@@ -174,6 +183,7 @@ export function updateDmLastMessagePreview(
|
||||
dmStore.setState((prev) => {
|
||||
const updated = prev.channels.find((c) => c.channelId === channelId);
|
||||
if (updated === undefined) return prev;
|
||||
if (updated.lastMessageId !== null && messageId <= updated.lastMessageId) return prev;
|
||||
const rest = prev.channels.filter((c) => c.channelId !== channelId);
|
||||
return {
|
||||
channels: [
|
||||
|
||||
@@ -49,7 +49,11 @@ afterEach(() => {
|
||||
function openMenu(ch: Channel): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
container.appendChild(el);
|
||||
attachChannelContextMenu(el, ch, ac.signal);
|
||||
// Same signal for both the row-level listener and the menu's own lifetime
|
||||
// here — this suite doesn't exercise the render-vs-sidebar distinction
|
||||
// (see ChannelSidebar.test.ts's OC-0282 coverage for that), and `ac.abort()`
|
||||
// below still needs to close the menu.
|
||||
attachChannelContextMenu(el, ch, ac.signal, ac.signal);
|
||||
el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 4, clientY: 4 }));
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,24 @@ describe("channel mutes — host scoping", () => {
|
||||
setChannelMutesHost("b.example.com");
|
||||
expect(isChannelMuted(7)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not leak the legacy mute list into a brand-new host that has never had a scoped key", () => {
|
||||
// Regression for OC-0288: the legacy read-through fired for ANY host
|
||||
// missing a scoped key, not just the first host migrated, and it never
|
||||
// consumed the legacy key. So server A migrates correctly, but server B
|
||||
// (first connection ever, no scoped key yet) reads through to the same
|
||||
// legacy list and persists server A's mutes as its own — even though the
|
||||
// user never muted anything on B and channel ids don't even correspond
|
||||
// across servers.
|
||||
localStorage.setItem(KEY, JSON.stringify([7]));
|
||||
|
||||
setChannelMutesHost("a.example.com");
|
||||
expect(isChannelMuted(7)).toBe(true); // migration onto A is correct
|
||||
|
||||
setChannelMutesHost("b.example.com");
|
||||
expect(isChannelMuted(7)).toBe(false);
|
||||
expect(localStorage.getItem(`${STORAGE_PREFIX}mutedChannels:b.example.com`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel mutes — notification gating", () => {
|
||||
|
||||
@@ -2238,6 +2238,105 @@ describe("ChannelSidebar voice identity badge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Identity-mismatch modal must not be bound to the per-render signal (OC-0281) ──
|
||||
//
|
||||
// openIdentityMismatchModal used to receive renderChannels()'s render-scoped
|
||||
// AbortSignal (aborted and replaced on EVERY re-render), not the sidebar's own
|
||||
// lifetime signal. That means any unrelated re-render -- a message landing in
|
||||
// another channel, a voice peer toggling mute, the WS flipping to
|
||||
// reconnecting -- tears the open re-pin prompt down, and a re-render landing
|
||||
// during the async fingerprint compute makes the click a silent no-op.
|
||||
describe("ChannelSidebar identity-mismatch modal lifetime (OC-0281)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let sidebar: ReturnType<typeof createChannelSidebar>;
|
||||
|
||||
const VOICE_CH = 3; // "voice-lobby" in testChannels
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
setChannels(testChannels);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sidebar.destroy?.();
|
||||
container.remove();
|
||||
document.querySelectorAll(".modal-overlay").forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
function badgeFor(userId: number): HTMLElement | null {
|
||||
return container.querySelector(`.voice-user-item[data-voice-uid="${userId}"] .vu-verify`);
|
||||
}
|
||||
|
||||
it("keeps the mismatch modal open across a sidebar re-render unrelated to the modal", async () => {
|
||||
addVoiceUser(VOICE_CH, 10, "Alice");
|
||||
setPeerVerif(10, "mismatch", null);
|
||||
sidebar.mount(container);
|
||||
|
||||
(badgeFor(10) as HTMLElement).click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector(".modal-overlay")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Unrelated re-render, the same shape as a message landing in another
|
||||
// channel (dispatcher -> incrementUnread -> new channels Map ->
|
||||
// renderChannels()). Nothing about the open modal should care.
|
||||
setChannels(testChannels);
|
||||
channelsStore.flush();
|
||||
|
||||
expect(document.body.querySelector(".modal-overlay")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still opens the mismatch modal when an unrelated re-render lands during the fingerprint compute", async () => {
|
||||
addVoiceUser(VOICE_CH, 10, "Alice");
|
||||
membersStore.setState((prev) => {
|
||||
const members = new Map(prev.members);
|
||||
members.set(10, {
|
||||
id: 10,
|
||||
username: "Alice",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online",
|
||||
identityPublicKey: "alice-published-key-b64",
|
||||
});
|
||||
return { ...prev, members };
|
||||
});
|
||||
setPeerVerif(10, "mismatch", null);
|
||||
sidebar.mount(container);
|
||||
|
||||
// Hold the fingerprint compute open so a re-render can land mid-flight.
|
||||
let resolveFingerprint: (v: string) => void = () => {};
|
||||
(computeKeyFingerprint as any).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFingerprint = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
(badgeFor(10) as HTMLElement).click();
|
||||
|
||||
// Let the click's async handler actually reach the (held-open) fingerprint
|
||||
// compute before landing the re-render, so the re-render provably lands
|
||||
// mid-flight rather than before the compute even started.
|
||||
await vi.waitFor(() => {
|
||||
expect(computeKeyFingerprint).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Unrelated re-render lands while the click's async fingerprint compute
|
||||
// is still in flight.
|
||||
setChannels(testChannels);
|
||||
channelsStore.flush();
|
||||
|
||||
resolveFingerprint("FEED FACE 1234 5678");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector(".modal-overlay")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel feature flags in the sidebar ──
|
||||
//
|
||||
// nsfw and voice_max_users reach the sidebar through the channel store, so
|
||||
@@ -2515,3 +2614,95 @@ describe("ChannelSidebar row listeners across re-renders (OC-0229)", () => {
|
||||
expect(document.querySelector(".channel-ctx-menu")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sidebar popovers must not close on an unrelated re-render (OC-0282) ──
|
||||
//
|
||||
// The channel context menu and the voice-user volume/moderation menu bridge
|
||||
// their "close if the sidebar goes away" listener onto renderChannels()'s
|
||||
// render-scoped AbortSignal, not the sidebar's own lifetime signal. Since
|
||||
// renderChannels() aborts and replaces that signal on EVERY re-render, an
|
||||
// open popover is torn down by events that have nothing to do with it -- a
|
||||
// message in another channel, a peer toggling their mic, a category
|
||||
// collapsing.
|
||||
describe("ChannelSidebar popovers survive unrelated re-renders (OC-0282)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let sidebar: ReturnType<typeof createChannelSidebar>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sidebar.destroy?.();
|
||||
container.remove();
|
||||
document.querySelectorAll(".channel-ctx-menu, .user-vol-menu").forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it("keeps the channel context menu open across a re-render unrelated to the menu", () => {
|
||||
setChannels(testChannels);
|
||||
sidebar.mount(container);
|
||||
|
||||
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
|
||||
channelEl.dispatchEvent(
|
||||
new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }),
|
||||
);
|
||||
expect(document.querySelector('[data-testid="channel-context-menu"]')).not.toBeNull();
|
||||
|
||||
// Unrelated re-render, the same shape as a message landing in another
|
||||
// channel (dispatcher -> incrementUnread -> new channels Map ->
|
||||
// renderChannels()). The open menu has nothing to do with this.
|
||||
setChannels(testChannels);
|
||||
channelsStore.flush();
|
||||
|
||||
expect(document.querySelector('[data-testid="channel-context-menu"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the voice-user volume menu open when an unrelated peer's voice state changes", () => {
|
||||
authStore.setState(() => ({
|
||||
token: "tok",
|
||||
user: { id: 99, username: "Me", avatar: null, role: "member" },
|
||||
serverName: "Test Server",
|
||||
motd: null,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
setChannels(testChannels);
|
||||
updateVoiceState({
|
||||
channel_id: 3,
|
||||
user_id: 80,
|
||||
username: "OtherUser",
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
const voiceRow = container.querySelector(
|
||||
'.voice-user-item[data-voice-uid="80"]',
|
||||
) as HTMLElement;
|
||||
voiceRow.dispatchEvent(
|
||||
new MouseEvent("contextmenu", { bubbles: true, clientX: 150, clientY: 250 }),
|
||||
);
|
||||
expect(document.querySelector(".user-vol-menu")).not.toBeNull();
|
||||
|
||||
// Unrelated re-render: a DIFFERENT peer joins/toggles state in the same
|
||||
// channel, firing the voice store's structural-signature subscription.
|
||||
updateVoiceState({
|
||||
channel_id: 3,
|
||||
user_id: 81,
|
||||
username: "AnotherUser",
|
||||
muted: true,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
});
|
||||
voiceStore.flush();
|
||||
|
||||
expect(document.querySelector(".user-vol-menu")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2673,6 +2673,62 @@ describe("WS Dispatcher", () => {
|
||||
expect(voiceStore.getState().currentChannelId).toBe(7);
|
||||
});
|
||||
|
||||
// OC-0283: a departing peer's roster entry is only ever removed by THIS
|
||||
// same voice_leave handler (removeVoiceUser below), so a roster read taken
|
||||
// at any point relative to that mutation — before or after — sees the
|
||||
// peer as "still present" on a genuine departure exactly as it would for
|
||||
// the OC-0213 stale-leave case. handleParticipantLeft must not be handed a
|
||||
// flag computed that way: E2EEManager.handleParticipantLeft used to accept
|
||||
// a `stillInRoster` second argument (OC-0239) that this dispatcher always
|
||||
// computed as true from a pre-mutation snapshot, permanently disabling the
|
||||
// OC-0020 departed-peer key retirement in production. Assert the call
|
||||
// shape directly so a future re-introduction of such a flag is caught here.
|
||||
it("[OC-0283] does not pass a stale-leave roster snapshot to handleParticipantLeft on a genuine departure", async () => {
|
||||
vi.mocked(mockHandleParticipantLeft).mockClear();
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 5, username: "me", avatar: null, role: "member" },
|
||||
}));
|
||||
// Peer 7 is present in the roster right up until their own leave, just
|
||||
// as a real join (voice_state) would have left them — nothing removes
|
||||
// them from voiceUsers before this voice_leave is processed.
|
||||
voiceStore.setState((prev) => ({
|
||||
...prev,
|
||||
currentChannelId: 3,
|
||||
voiceUsers: new Map([
|
||||
[
|
||||
3,
|
||||
new Map([
|
||||
[
|
||||
7,
|
||||
{
|
||||
userId: 7,
|
||||
username: "peer",
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
serverMuted: false,
|
||||
serverDeafened: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
}));
|
||||
|
||||
mock.dispatch("voice_leave", {
|
||||
channel_id: 3,
|
||||
user_id: 7,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Exactly one argument: no roster-derived flag riding along that could
|
||||
// suppress the departed peer's key retirement.
|
||||
expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("mirrors a moderator mute/deafen into the local flags and honors it", async () => {
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -282,6 +282,47 @@ describe("DmProfileSidebar", () => {
|
||||
localStorage.removeItem("owncord:dm-note:a.example.com:5");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OC-0309: the panel has no store subscription of its own (by design --
|
||||
// it's presentational) and is only ever painted once at mount from an
|
||||
// open-time snapshot. update() is the mechanism its owner (MainPage) uses
|
||||
// to repaint it in place when the underlying user's status/name changes
|
||||
// while it stays open, so the panel doesn't disagree with the chat header
|
||||
// it was opened from indefinitely.
|
||||
// -------------------------------------------------------------------------
|
||||
it("repaints name, avatar initial and status (dot + label) in place via update()", () => {
|
||||
const user = makeUser({ id: 7, username: "bob", displayName: null, status: "online" });
|
||||
const sidebar = createDmProfileSidebar(makeOptions({ user }));
|
||||
sidebar.mount(container);
|
||||
|
||||
expect(container.querySelector('[data-testid="dps-username"]')!.textContent).toBe("bob");
|
||||
expect(container.querySelector('[data-testid="dps-status"]')!.textContent).toContain("Online");
|
||||
expect(container.querySelector('[data-testid="dps-avatar"]')!.textContent).toContain("B");
|
||||
|
||||
sidebar.update({ ...user, displayName: "Bobby", status: "offline" });
|
||||
|
||||
expect(container.querySelector('[data-testid="dps-username"]')!.textContent).toBe("Bobby");
|
||||
expect(container.querySelector('[data-testid="dps-status"]')!.textContent).toContain("Offline");
|
||||
expect(container.querySelector('[data-testid="dps-avatar"]')!.textContent).toContain("B");
|
||||
|
||||
// Repainted in place -- the panel element itself was never rebuilt.
|
||||
expect(container.querySelectorAll('[data-testid="dm-profile-sidebar"]')).toHaveLength(1);
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("does not throw and is a no-op when update() is called before mount or after destroy", () => {
|
||||
const user = makeUser({ id: 8 });
|
||||
const sidebar = createDmProfileSidebar(makeOptions({ user }));
|
||||
|
||||
expect(() => sidebar.update({ ...user, status: "dnd" })).not.toThrow();
|
||||
|
||||
sidebar.mount(container);
|
||||
sidebar.destroy?.();
|
||||
|
||||
expect(() => sidebar.update({ ...user, status: "idle" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to the legacy unscoped key to migrate a note saved before host-scoping", () => {
|
||||
const user = makeUser({ id: 42 });
|
||||
localStorage.setItem("owncord:dm-note:42", "Pre-existing note");
|
||||
|
||||
@@ -408,6 +408,43 @@ describe("dmStore", () => {
|
||||
const after = dmStore.getState();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
// OC-0301: lastMessageId must stay monotonic — updateDmLastMessage's
|
||||
// replay guard (messageId <= lastMessageId) depends on it. A queued
|
||||
// chat_message frame for an id below the current watermark (e.g. a
|
||||
// burst draining after `ready` already advanced lastMessageId past it)
|
||||
// must not roll the watermark backwards, or the next frame in the same
|
||||
// burst slips past the sibling's guard and double-counts.
|
||||
it("does not regress lastMessageId when the incoming id is behind the current watermark", () => {
|
||||
setDmChannels([
|
||||
makeDm({
|
||||
channelId: 5,
|
||||
lastMessageId: 500,
|
||||
lastMessage: "peer message",
|
||||
lastMessageAt: "2026-03-28T16:00:00Z",
|
||||
unreadCount: 3,
|
||||
}),
|
||||
]);
|
||||
updateDmLastMessagePreview(
|
||||
5,
|
||||
495,
|
||||
"own message replayed after ready",
|
||||
"2026-03-28T15:00:00Z",
|
||||
);
|
||||
const ch = dmStore.getState().channels[0]!;
|
||||
expect(ch.lastMessageId).toBe(500);
|
||||
expect(ch.lastMessage).toBe("peer message");
|
||||
expect(ch.lastMessageAt).toBe("2026-03-28T16:00:00Z");
|
||||
expect(ch.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it("still updates when the incoming id is ahead of the current watermark", () => {
|
||||
setDmChannels([makeDm({ channelId: 5, lastMessageId: 100 })]);
|
||||
updateDmLastMessagePreview(5, 101, "newer own message", "2026-03-28T17:01:00Z");
|
||||
const ch = dmStore.getState().channels[0]!;
|
||||
expect(ch.lastMessageId).toBe(101);
|
||||
expect(ch.lastMessage).toBe("newer own message");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateDmLastMessage — channel reordering ──────────
|
||||
|
||||
@@ -113,7 +113,7 @@ function buildRig(channels: Channel[]): Rig {
|
||||
const el = document.createElement("div");
|
||||
container.appendChild(el);
|
||||
stubRowRect(el, idx);
|
||||
attachDragHandlers(el, ch, container, channels, abort.signal, onReorder);
|
||||
attachDragHandlers(el, ch, container, channels, abort.signal, abort.signal, onReorder);
|
||||
items.set(ch.id, el);
|
||||
});
|
||||
|
||||
@@ -136,7 +136,15 @@ function rebuildContainer(
|
||||
const el = document.createElement("div");
|
||||
container.appendChild(el);
|
||||
stubRowRect(el, idx);
|
||||
attachDragHandlers(el, ch, container, channels, rig.abort.signal, rig.onReorder);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
ch,
|
||||
container,
|
||||
channels,
|
||||
rig.abort.signal,
|
||||
rig.abort.signal,
|
||||
rig.onReorder,
|
||||
);
|
||||
items.set(ch.id, el);
|
||||
});
|
||||
|
||||
@@ -248,7 +256,8 @@ describe("attachDragHandlers permission gate", () => {
|
||||
const el = document.createElement("div");
|
||||
container.appendChild(el);
|
||||
|
||||
attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], new AbortController().signal);
|
||||
const noopAc = new AbortController();
|
||||
attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], noopAc.signal, noopAc.signal);
|
||||
|
||||
expect(el.classList.contains("channel-draggable")).toBe(false);
|
||||
expect(el.dataset.dragChannelId).toBeUndefined();
|
||||
@@ -615,6 +624,78 @@ describe("mid-drag sidebar re-render", () => {
|
||||
expect(rig.onReorder).not.toHaveBeenCalled();
|
||||
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
||||
});
|
||||
|
||||
// OC-0296: ChannelSidebar.renderChannels() aborts and replaces a fresh,
|
||||
// per-render AbortController on every store-driven re-render, while the
|
||||
// sidebar's own lifetime controller (`ac`) is untouched — the two are never
|
||||
// the same signal. `rebuildContainer` above re-attaches every row under one
|
||||
// constant `rig.abort.signal` across "renders", which models the sidebar's
|
||||
// *lifetime* signal, not this per-render one, so it cannot catch a bug that
|
||||
// only shows up when the render-scoped signal itself is what gets aborted
|
||||
// and replaced mid-drag.
|
||||
it("survives a mid-drag re-render that aborts only the render-scoped signal, leaving the sidebar's own lifetime signal untouched", () => {
|
||||
signIn("owner");
|
||||
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
|
||||
setStoreChannels(channels);
|
||||
|
||||
// The sidebar's own lifetime controller (ChannelSidebar's `ac`) — never
|
||||
// aborted by a re-render, only by the sidebar's own destroy().
|
||||
const lifetimeAc = new AbortController();
|
||||
rigAborts.push(lifetimeAc);
|
||||
|
||||
let renderAc = new AbortController();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const items = new Map<number, HTMLElement>();
|
||||
const onReorder = vi.fn();
|
||||
channels.forEach((ch, idx) => {
|
||||
const el = document.createElement("div");
|
||||
container.appendChild(el);
|
||||
stubRowRect(el, idx);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
ch,
|
||||
container,
|
||||
channels,
|
||||
renderAc.signal,
|
||||
lifetimeAc.signal,
|
||||
onReorder,
|
||||
);
|
||||
items.set(ch.id, el);
|
||||
});
|
||||
|
||||
const source = items.get(1)!;
|
||||
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
||||
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
||||
expect(source.classList.contains("dragging")).toBe(true);
|
||||
|
||||
// A store-driven re-render: renderChannels() aborts ONLY the previous
|
||||
// render's controller (never the sidebar's lifetime one) and attaches
|
||||
// fresh rows under a brand-new render controller.
|
||||
renderAc.abort();
|
||||
renderAc = new AbortController();
|
||||
container.remove();
|
||||
const live = document.createElement("div");
|
||||
live.className = "category-channels-container";
|
||||
document.body.appendChild(live);
|
||||
const liveItems = new Map<number, HTMLElement>();
|
||||
channels.forEach((ch, idx) => {
|
||||
const el = document.createElement("div");
|
||||
live.appendChild(el);
|
||||
stubRowRect(el, idx);
|
||||
attachDragHandlers(el, ch, live, channels, renderAc.signal, lifetimeAc.signal, onReorder);
|
||||
liveItems.set(ch.id, el);
|
||||
});
|
||||
|
||||
// Release over the bottom half of row 1 (ch2): ch1 lands after ch2.
|
||||
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));
|
||||
|
||||
expect(onReorder).toHaveBeenCalledTimes(1);
|
||||
const reorders = onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
||||
expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
|
||||
expect(liveItems.get(1)?.classList.contains("dragging")).toBe(false);
|
||||
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listener lifecycle ─────────────────────────────────────────────────────
|
||||
@@ -634,7 +715,15 @@ describe("global listener ownership", () => {
|
||||
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
||||
for (const [id, el] of rig.items) {
|
||||
const ch = rig.channels.find((c) => c.id === id)!;
|
||||
attachDragHandlers(el, ch, rig.container, rig.channels, rig.abort.signal, rig.onReorder);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
ch,
|
||||
rig.container,
|
||||
rig.channels,
|
||||
rig.abort.signal,
|
||||
rig.abort.signal,
|
||||
rig.onReorder,
|
||||
);
|
||||
}
|
||||
|
||||
rig.abort.abort();
|
||||
|
||||
@@ -286,4 +286,64 @@ describe("EmojiPicker", () => {
|
||||
expect(onSelect).toHaveBeenCalledWith(firstEmoji.getAttribute("title"));
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
// OC-0306: every cell's click listener used to be registered directly on
|
||||
// the span against the picker-lifetime AbortSignal (aborted only in
|
||||
// destroy()). renderAllCategories() discards and rebuilds the whole grid
|
||||
// on every search keystroke, so a discarded span's own listener was never
|
||||
// released — it stayed live (and reachable via the signal's abort-listener
|
||||
// list) for the rest of the picker's life. A fixed, delegated listener on
|
||||
// the container means a stale span dispatched at directly must no longer
|
||||
// reach the handler once a rebuild has discarded it.
|
||||
it("does not leave a discarded cell's click listener live after a search rebuild", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { picker } = makePicker({ onSelect });
|
||||
const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement;
|
||||
expect(firstEmoji).not.toBeNull();
|
||||
|
||||
// Sanity: the live cell's listener does fire.
|
||||
firstEmoji.click();
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
onSelect.mockClear();
|
||||
|
||||
// Any search keystroke fully discards and rebuilds the cell set, even
|
||||
// when the same emoji still matches — renderAllCategories always calls
|
||||
// clearChildren() first.
|
||||
const input = picker.element.querySelector(".ep-search") as HTMLInputElement;
|
||||
input.value = "fire";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
expect(picker.element.contains(firstEmoji)).toBe(false);
|
||||
|
||||
// Dispatching directly on the stale, detached span must not still reach
|
||||
// the handler it closed over.
|
||||
firstEmoji.dispatchEvent(new MouseEvent("click"));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
// OC-0308: Recent is fed any selection, including `:shortcode:` tokens
|
||||
// that are only meaningful on the server that defined them (or that have
|
||||
// since been deleted). An entry that can no longer resolve must not be
|
||||
// offered back out of Recent — clicking it would insert (or re-react
|
||||
// with) dead literal text.
|
||||
it("drops unresolvable :shortcode: entries from Recent", () => {
|
||||
localStorage.setItem("owncord:recent-emoji", JSON.stringify([":blobwave:", "😀"]));
|
||||
clearCustomEmoji();
|
||||
emojiStore.flush();
|
||||
|
||||
const { picker } = makePicker();
|
||||
const recentLabel = Array.from(picker.element.querySelectorAll(".ep-category-label")).find(
|
||||
(l) => l.textContent === "Recent",
|
||||
);
|
||||
expect(recentLabel).not.toBeUndefined();
|
||||
const grid = recentLabel!.nextElementSibling as HTMLElement;
|
||||
const cellTitles = Array.from(grid.querySelectorAll(".ep-emoji")).map((c) =>
|
||||
c.getAttribute("title"),
|
||||
);
|
||||
|
||||
expect(cellTitles).not.toContain(":blobwave:");
|
||||
expect(cellTitles).toContain("😀");
|
||||
picker.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1761,7 +1761,19 @@ describe("E2EEManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("[OC-0239] a stale voice_leave must not retire a peer's key when the caller's pre-mutation roster snapshot still listed them as present", async () => {
|
||||
// OC-0239 tried to have the caller pass a pre-mutation "was this peer
|
||||
// still in the roster?" snapshot into handleParticipantLeft so a stale,
|
||||
// superseded-rejoin voice_leave (OC-0213) would not retire a peer's live
|
||||
// key. OC-0283 found that snapshot is true on every genuine departure too
|
||||
// — a departing peer's roster entry is only ever removed by the very
|
||||
// voice_leave event being handled, so a read taken relative to it, before
|
||||
// or after the mutation, cannot distinguish the two cases. Threading that
|
||||
// flag through therefore disabled the OC-0020 retirement defense in
|
||||
// production outright. The parameter is gone; this test now pins the
|
||||
// corrected behavior: a genuine departure retires the key even though the
|
||||
// roster looked "still present" right up until this event, matching what
|
||||
// production's real (post-mutation) roster read looks like.
|
||||
it("[OC-0283] retires a departed peer's key even though the roster listed them as present right up until this event", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1); // holder in channel 1
|
||||
@@ -1776,29 +1788,22 @@ describe("E2EEManager", () => {
|
||||
const KEY = "b2xk";
|
||||
|
||||
try {
|
||||
// The peer's rejoin announce (carrying a fresh key) arrives first — same
|
||||
// OC-0213 reordering.
|
||||
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
||||
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
||||
|
||||
// Reproduce dispatcher.ts's real VOICE_LEAVE ordering: removeVoiceUser()
|
||||
// deletes the peer from the roster BEFORE handleParticipantLeft runs, so
|
||||
// by the time this fires the local roster no longer lists them — the
|
||||
// OC-0213 guard's own `channelUsers?.has(userId)` read is unreachable in
|
||||
// production. The caller must instead pass a snapshot taken BEFORE that
|
||||
// mutation, which is what `stillInRoster` carries here.
|
||||
// by the time this fires the local roster no longer lists them.
|
||||
mockVoiceState.voiceUsers.set(1, new Map()); // peer already removed from roster
|
||||
await mgr.handleParticipantLeft(PEER_ID, /* stillInRoster */ true);
|
||||
await mgr.handleParticipantLeft(PEER_ID);
|
||||
|
||||
// Removed from the live peer map (unchanged behavior)...
|
||||
// Removed from the live peer map...
|
||||
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
||||
|
||||
// ...but the key must not be permanently retired: the peer's own,
|
||||
// still-valid re-announce of the SAME key must be accepted again, not
|
||||
// rejected as a replay of a "retired" key — otherwise the peer is
|
||||
// stranded, un-re-announceable, for the rest of the call.
|
||||
// ...AND retired: a replay of the peer's old, still-validly-signed
|
||||
// announce must now be rejected, not accepted as if they never left.
|
||||
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
||||
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY}` });
|
||||
expect(mgr.peerPublicKeys.get(PEER_ID)).toBeUndefined();
|
||||
} finally {
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async () => ({ type: "public" }) as unknown as CryptoKey,
|
||||
|
||||
@@ -1713,6 +1713,34 @@ describe("LiveKitSession", () => {
|
||||
mockVoiceState.localServerMuted = false;
|
||||
}
|
||||
});
|
||||
|
||||
// OC-0287: applyMicMuteState's re-enable branch used to await
|
||||
// setMicrophoneEnabled(true) with no try/catch. Every caller (setMuted,
|
||||
// setDeafened, ptt.ts) fires it forgetfully with only a `.catch(e =>
|
||||
// log.warn(...))`, so a rejection (mic revoked/unplugged) never reached
|
||||
// setListenOnly/setLocalMuted or onErrorCallback — yet setLocalMuted(false)
|
||||
// and the outbound voice_mute{muted:false} frame had already gone out.
|
||||
// The user was left reporting "unmuted" everywhere while publishing no
|
||||
// audio, with no error and no Grant-Microphone affordance (that button is
|
||||
// gated on listenOnly, which stayed false). This pins the fix: a failed
|
||||
// re-enable must flip back into listen-only + muted and surface an error.
|
||||
it("unmuting when the mic cannot be acquired enters listen-only, re-mutes, and reports an error (OC-0287)", async () => {
|
||||
const errorCb = vi.fn();
|
||||
session.setOnError(errorCb);
|
||||
const domErr = new DOMException("Permission denied", "NotAllowedError");
|
||||
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(domErr);
|
||||
|
||||
session.setMuted(false);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Must not silently swallow the failure: listen-only mode has to come
|
||||
// back on (this is what reveals the existing Grant Microphone button)
|
||||
// and the local mute flag has to reflect that no audio is publishing.
|
||||
expect(setListenOnly).toHaveBeenCalledWith(true);
|
||||
expect(setLocalMuted).toHaveBeenCalledWith(true);
|
||||
expect(errorCb).toHaveBeenCalled();
|
||||
expect(errorCb.mock.calls[0]?.[0]).toEqual(expect.stringMatching(/microphone/i));
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDeafened (with active room)", () => {
|
||||
|
||||
@@ -187,7 +187,7 @@ import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/c
|
||||
import { authStore } from "../../src/stores/auth.store";
|
||||
import { uiStore } from "../../src/stores/ui.store";
|
||||
import { voiceStore, updateVoiceUserProfile } from "../../src/stores/voice.store";
|
||||
import { dmStore } from "../../src/stores/dm.store";
|
||||
import { dmStore, updateDmParticipant } from "../../src/stores/dm.store";
|
||||
import { membersStore, updateMemberProfile } from "../../src/stores/members.store";
|
||||
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
|
||||
import type { ApiClient } from "../../src/lib/api";
|
||||
@@ -663,6 +663,52 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
|
||||
expect(banner.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("shows the caller's nickname on the incoming-call banner, not the raw username (OC-0303)", () => {
|
||||
const ws = fakeWs();
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map([
|
||||
[
|
||||
10,
|
||||
{
|
||||
id: 10,
|
||||
username: "alice_1998",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online" as const,
|
||||
displayName: "Ali",
|
||||
},
|
||||
],
|
||||
]),
|
||||
typingUsers: new Map(),
|
||||
roleRevision: 0,
|
||||
}));
|
||||
|
||||
page = createMainPage({ ws, api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
// Alice (10) has set a nickname of "Ali" -- every other identity surface
|
||||
// (voice roster, member list, DM header) resolves through it, so the
|
||||
// ring must too instead of showing the raw handle.
|
||||
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice_1998" });
|
||||
|
||||
const title = document.querySelector('[data-testid="incoming-call-title"]') as HTMLElement;
|
||||
expect(title.textContent).toBe("Ali is calling");
|
||||
});
|
||||
|
||||
it("falls back to the raw username on the incoming-call banner when the caller isn't in the members store (OC-0303)", () => {
|
||||
const ws = fakeWs();
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
||||
|
||||
page = createMainPage({ ws, api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
ws.emit("call_incoming", { channel_id: 50, from_user: 999, username: "stranger" });
|
||||
|
||||
const title = document.querySelector('[data-testid="incoming-call-title"]') as HTMLElement;
|
||||
expect(title.textContent).toBe("stranger is calling");
|
||||
});
|
||||
|
||||
it("does not cancel an incoming ring when a fellow group-DM callee declines, only when the actual ringer does (OC-0114)", () => {
|
||||
const ws = fakeWs();
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
||||
@@ -852,6 +898,53 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
|
||||
localStorage.removeItem("owncord:dm-note:5");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the open DM profile panel's status and name live, like the chat header does (OC-0309)", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(70, dmChannel(70, "dm-bob"));
|
||||
return { ...prev, channels: ch, activeChannelId: 70 };
|
||||
});
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
{
|
||||
channelId: 70,
|
||||
recipient: { id: 7, username: "bob", avatar: "", status: "online" },
|
||||
participants: [{ id: 7, username: "bob", avatar: "", status: "online" }],
|
||||
name: "bob",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
||||
chatAreaOpts.onToggleDmProfile();
|
||||
|
||||
const slot = capturedChatAreaRef.current!.dmProfileSlot;
|
||||
const statusEl = slot.querySelector('[data-testid="dps-status"]') as HTMLElement;
|
||||
const nameEl = slot.querySelector('[data-testid="dps-username"]') as HTMLElement;
|
||||
expect(statusEl.textContent).toContain("Online");
|
||||
expect(nameEl.textContent).toBe("bob");
|
||||
|
||||
// Bob goes offline and sets a nickname while the panel stays open, via
|
||||
// the same updateDmParticipant call dispatcher.ts's PRESENCE and
|
||||
// USER_UPDATE handlers make. The chat header this panel was opened from
|
||||
// already stays live across this (ChannelController.ts:621-638) — the
|
||||
// profile panel beside it must not be left showing the opposite.
|
||||
updateDmParticipant(7, { status: "offline", displayName: "Bobby" });
|
||||
dmStore.flush();
|
||||
|
||||
expect(statusEl.textContent).toContain("Offline");
|
||||
expect(nameEl.textContent).toBe("Bobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MainPage — presence", () => {
|
||||
|
||||
@@ -611,6 +611,36 @@ describe("MemberList", () => {
|
||||
expect(headerTexts.find((t) => t?.includes("ADMIN"))).toContain("3");
|
||||
});
|
||||
|
||||
// OC-0295: renderList -> appendGroup -> createMemberItem hands every row's
|
||||
// click/contextmenu listeners to the component-lifetime `disposable.signal`,
|
||||
// which only aborts in destroy(). A full rebuild (any non-presence-only
|
||||
// membersStore change, e.g. a role change) discards the old rows' DOM but
|
||||
// never aborts their listeners, so a stale, detached row's listener keeps
|
||||
// firing — and keeps the row (and everything it closed over) reachable —
|
||||
// for the component's entire lifetime, exactly the defect already fixed in
|
||||
// ChannelSidebar (renderAc, OC-0229) and MessageList (OC-0286).
|
||||
it("aborts a discarded row's listeners on a full rebuild instead of retaining them for the component's lifetime (OC-0295)", () => {
|
||||
setTestMembers(testMembers);
|
||||
memberList.mount(container);
|
||||
|
||||
const eveRowBefore = container.querySelector('[data-testid="member-5"]') as HTMLDivElement;
|
||||
expect(eveRowBefore).not.toBeNull();
|
||||
|
||||
// Structural change (role change) -> renderList rebuild, discarding the
|
||||
// old row in favor of a freshly rendered one.
|
||||
updateMemberRole(5, "admin");
|
||||
membersStore.flush();
|
||||
expect(container.contains(eveRowBefore)).toBe(false);
|
||||
|
||||
// The row element is detached, but nothing detaches its listener from the
|
||||
// underlying signal until destroy() -- clicking the stale, discarded row
|
||||
// must not still reach the handler it closed over.
|
||||
eveRowBefore.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, clientX: 10, clientY: 10 }),
|
||||
);
|
||||
expect(document.querySelector('[data-testid="user-profile-popup"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("re-renders when store updates to a different member set", () => {
|
||||
setTestMembers(testMembers);
|
||||
memberList.mount(container);
|
||||
|
||||
@@ -1415,5 +1415,14 @@ describe("MessageInput", () => {
|
||||
const result = wrapWithMarker("**bold**", 0, 8, "*");
|
||||
expect(result.value).toBe("***bold***");
|
||||
});
|
||||
|
||||
it("wraps rather than downgrades bold text when italicizing a double-clicked word", () => {
|
||||
// Selecting only the word (as a double-click would), not the "**"
|
||||
// markers themselves: start/end land just inside the bold pair, so
|
||||
// the single "*" immediately outside each edge belongs to a "**"
|
||||
// pair rather than being a matching "*" marker of its own.
|
||||
const result = wrapWithMarker("**bold**", 2, 6, "*");
|
||||
expect(result.value).toBe("***bold***");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// jsdom does not provide ResizeObserver — stub it so MessageList can mount.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
/* noop */
|
||||
}
|
||||
unobserve(): void {
|
||||
/* noop */
|
||||
}
|
||||
disconnect(): void {
|
||||
/* noop */
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
import { createMessageList } from "@components/MessageList";
|
||||
import type { MessageListOptions } from "@components/MessageList";
|
||||
import { messagesStore } from "@stores/messages.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
|
||||
function resetStores(): void {
|
||||
messagesStore.setState(() => ({
|
||||
messagesByChannel: new Map(),
|
||||
pendingSends: new Map(),
|
||||
loadedChannels: new Set(),
|
||||
hasMore: new Map(),
|
||||
historyLoadState: new Map(),
|
||||
detachedChannels: new Set(),
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<Message> & { id: number }): Message {
|
||||
return {
|
||||
channelId: 1,
|
||||
user: { id: 1, username: "Alice", avatar: null },
|
||||
content: `Message ${overrides.id}`,
|
||||
replyTo: null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
pinned: false,
|
||||
editedAt: null,
|
||||
deleted: false,
|
||||
timestamp: "2024-01-15T12:00:00Z",
|
||||
status: "sent",
|
||||
correlationId: null,
|
||||
errorCode: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setMessages(channelId: number, messages: Message[]): void {
|
||||
messagesStore.setState((prev) => {
|
||||
const next = new Map(prev.messagesByChannel);
|
||||
next.set(channelId, messages);
|
||||
return { ...prev, messagesByChannel: next };
|
||||
});
|
||||
}
|
||||
|
||||
// OC-0286: renderVirtualItem hands every row's listeners to the
|
||||
// component-lifetime `ac.signal`, which only aborts in destroy(). A full
|
||||
// renderWindow rebuild discards the old rows' DOM but never aborts their
|
||||
// listeners, so each rebuild permanently retains a full window's worth of
|
||||
// detached rows (and everything they reference) via the signal's abort-
|
||||
// listener list. This is only observable indirectly in a unit test: a
|
||||
// discarded row's button listener must stop firing once the render that
|
||||
// owned it has been replaced, exactly as it already does for ChannelSidebar
|
||||
// (renderAc, OC-0229) and SettingsOverlay (renderAC).
|
||||
describe("MessageList row listener lifecycle (OC-0286)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let msgList: ReturnType<typeof createMessageList>;
|
||||
let options: MessageListOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
options = {
|
||||
channelId: 1,
|
||||
channelName: "general",
|
||||
currentUserId: 1,
|
||||
onScrollTop: vi.fn(),
|
||||
onReplyClick: vi.fn(),
|
||||
onEditClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onReactionClick: vi.fn(),
|
||||
onPinClick: vi.fn(),
|
||||
};
|
||||
msgList = createMessageList(options);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
msgList.destroy?.();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("aborts a discarded row's listeners on a full rebuild instead of retaining them for the component's lifetime", () => {
|
||||
setMessages(1, [makeMessage({ id: 2, content: "hello" })]);
|
||||
msgList.mount(container);
|
||||
|
||||
const reactBtn = container.querySelector(
|
||||
'[data-testid="msg-react-2"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(reactBtn).not.toBeNull();
|
||||
|
||||
// Sanity: the row is live, so its listener does fire.
|
||||
reactBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(options.onReactionClick).toHaveBeenCalledTimes(1);
|
||||
(options.onReactionClick as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Prepend an older message — NOT a suffix extension, so the list takes
|
||||
// the full-rebuild path (renderWindow REBUILD) that discards the
|
||||
// currently rendered rows and replaces them with freshly rendered ones.
|
||||
setMessages(1, [
|
||||
makeMessage({ id: 1, content: "older", timestamp: "2024-01-15T11:00:00Z" }),
|
||||
makeMessage({ id: 2, content: "hello" }),
|
||||
]);
|
||||
messagesStore.flush();
|
||||
|
||||
// The button element is now detached from the document, but nothing
|
||||
// detaches its listener from the underlying signal until destroy() —
|
||||
// dispatching a click on the stale, discarded row must not still reach
|
||||
// the handler it closed over.
|
||||
reactBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(options.onReactionClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not abort a still-rendered row's listeners across an incremental append", () => {
|
||||
const first = makeMessage({ id: 1, content: "hello" });
|
||||
setMessages(1, [first]);
|
||||
msgList.mount(container);
|
||||
|
||||
const reactBtn = container.querySelector(
|
||||
'[data-testid="msg-react-1"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(reactBtn).not.toBeNull();
|
||||
|
||||
// Suffix extension (tail append fast path) — the existing row is kept in
|
||||
// the DOM as-is, so its listener must still be live.
|
||||
setMessages(1, [
|
||||
first,
|
||||
makeMessage({ id: 2, content: "follow-up", timestamp: "2024-01-15T12:01:00Z" }),
|
||||
]);
|
||||
messagesStore.flush();
|
||||
|
||||
expect(container.contains(reactBtn!)).toBe(true);
|
||||
reactBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(options.onReactionClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// OC-0277: RNNoise processor's restart() destroyed the live pipeline and
|
||||
// then rebuilt it from opts.audioContext, which livekit-client's ONLY
|
||||
// processor.restart() call site (LocalTrack.setMediaStreamTrack(), reached
|
||||
// from restartTrack() on a device switch / mic replug / echo-cancellation
|
||||
// toggle) never sends:
|
||||
//
|
||||
// this.processor.restart({ track: newTrack, kind: this.kind, element: this.processorElement, localTrack: this })
|
||||
//
|
||||
// (contrast with setProcessor(), which does pass audioContext). Because the
|
||||
// old pipeline was torn down before rebuilding, a restart with no cached
|
||||
// AudioContext left `pipeline` null and the mic permanently silent until the
|
||||
// user left and rejoined the voice channel.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@lib/logger", () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// The ScriptProcessorNode fallback path is what jsdom actually exercises
|
||||
// here (AudioWorkletNode/AudioContext are not defined in jsdom, so
|
||||
// supportsAudioWorklet() is false) — mock the WASM module it depends on.
|
||||
vi.mock("@jitsi/rnnoise-wasm", () => ({
|
||||
createRNNWasmModule: vi.fn(() => ({
|
||||
ready: Promise.resolve(),
|
||||
_rnnoise_create: vi.fn(() => 1),
|
||||
_rnnoise_destroy: vi.fn(),
|
||||
_rnnoise_process_frame: vi.fn(),
|
||||
_malloc: vi.fn(() => 0),
|
||||
_free: vi.fn(),
|
||||
HEAPF32: new Float32Array(4096),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { createRNNoiseProcessor } from "../../src/lib/noise-suppression";
|
||||
import type { AudioProcessorOptions } from "livekit-client";
|
||||
|
||||
function makeFakeAudioContext() {
|
||||
const sourceNode = { connect: vi.fn(), disconnect: vi.fn() };
|
||||
const destTrack = { id: "dest-track", kind: "audio" } as unknown as MediaStreamTrack;
|
||||
const destNode = {
|
||||
stream: { getAudioTracks: () => [destTrack] },
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
const processorNode = {
|
||||
onaudioprocess: null as unknown,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
return {
|
||||
createMediaStreamSource: vi.fn().mockReturnValue(sourceNode),
|
||||
createMediaStreamDestination: vi.fn().mockReturnValue(destNode),
|
||||
createScriptProcessor: vi.fn().mockReturnValue(processorNode),
|
||||
} as unknown as AudioContext;
|
||||
}
|
||||
|
||||
describe("createRNNoiseProcessor restart (OC-0277)", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"MediaStream",
|
||||
vi.fn().mockImplementation((tracks: unknown[]) => ({ tracks })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps producing a processed track across a restart() call that omits audioContext", async () => {
|
||||
const processor = createRNNoiseProcessor();
|
||||
const micTrack = { id: "mic-track", kind: "audio" } as unknown as MediaStreamTrack;
|
||||
const audioContext = makeFakeAudioContext();
|
||||
|
||||
await processor.init({
|
||||
track: micTrack,
|
||||
audioContext,
|
||||
kind: "audio",
|
||||
} as unknown as AudioProcessorOptions);
|
||||
|
||||
expect(processor.processedTrack).toBeDefined();
|
||||
|
||||
// Mirrors livekit-client's real restart() call shape exactly — see
|
||||
// node_modules/livekit-client/dist/livekit-client.esm.mjs,
|
||||
// LocalTrack.setMediaStreamTrack(): no `audioContext` field.
|
||||
const newMicTrack = { id: "new-mic-track", kind: "audio" } as unknown as MediaStreamTrack;
|
||||
await expect(
|
||||
processor.restart({
|
||||
track: newMicTrack,
|
||||
kind: "audio",
|
||||
} as unknown as AudioProcessorOptions),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(processor.processedTrack).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -366,6 +366,29 @@ describe("QuickSwitcher", () => {
|
||||
expect(input.hasAttribute("aria-activedescendant")).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts a discarded row's click listener on a full rebuild instead of retaining it for the overlay's lifetime (OC-0307)", () => {
|
||||
switcher.mount(container);
|
||||
const staleFirstItem = container.querySelector(".quick-switcher__item") as HTMLDivElement;
|
||||
|
||||
// ArrowDown does not change the result set, but renderResults() still
|
||||
// tears down every row and rebuilds it from scratch just to move the
|
||||
// highlight. The pre-rebuild row is now detached from the DOM.
|
||||
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
expect(container.contains(staleFirstItem)).toBe(false);
|
||||
|
||||
// If the discarded row's listener is still registered against the
|
||||
// overlay-lifetime signal (instead of being torn down with the row),
|
||||
// dispatching a click directly on the stale node still reaches it.
|
||||
staleFirstItem.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onSelectChannel).not.toHaveBeenCalled();
|
||||
|
||||
// A live row must still work after the rebuild.
|
||||
const liveItem = container.querySelector(".quick-switcher__item") as HTMLDivElement;
|
||||
liveItem.click();
|
||||
expect(onSelectChannel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("moves focus into the dialog on mount and restores it on destroy", () => {
|
||||
const opener = document.createElement("button");
|
||||
document.body.appendChild(opener);
|
||||
|
||||
@@ -923,6 +923,85 @@ describe("SidebarArea", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DM search preservation across refresh (OC-0280)
|
||||
//
|
||||
// refreshDmSidebar() destroys and recreates the whole DM sidebar subtree on
|
||||
// every dmStore.channels change (presence flips, new messages, unread
|
||||
// clears — not just "the DM list changed"). Real DmSidebar keeps the
|
||||
// "Find a conversation" filter text and focus only in its own destroyed
|
||||
// DOM, so a naive rebuild wipes both mid-typing. This mock stands in for
|
||||
// the real component closely enough to pin that: a `.dm-search` input that
|
||||
// SidebarArea can read/restore across the destroy+recreate cycle.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("DM search preservation across refresh (OC-0280)", () => {
|
||||
function mockDmSidebarWithSearchInput(): void {
|
||||
(createDmSidebar as MockedFn).mockImplementation(() => {
|
||||
let root: HTMLDivElement | null = null;
|
||||
return {
|
||||
mount: vi.fn((mountContainer: HTMLElement) => {
|
||||
root = document.createElement("div");
|
||||
const input = document.createElement("input");
|
||||
input.className = "dm-search";
|
||||
input.placeholder = "Find a conversation";
|
||||
root.appendChild(input);
|
||||
mountContainer.appendChild(root);
|
||||
}),
|
||||
destroy: vi.fn(() => {
|
||||
root?.remove();
|
||||
root = null;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the search filter text after a DM store change destroys/recreates the sidebar", () => {
|
||||
uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" }));
|
||||
mockDmSidebarWithSearchInput();
|
||||
|
||||
const result = createSidebarArea(defaultOpts());
|
||||
container.appendChild(result.sidebarWrapper);
|
||||
|
||||
const searchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
||||
expect(searchInput).not.toBeNull();
|
||||
searchInput.value = "ali";
|
||||
|
||||
// A DM partner's presence flip / new message rebuilds dmStore.channels
|
||||
// even though the user typed nothing and did not touch the DM list.
|
||||
addDmChannel(makeDm({ channelId: 100 }));
|
||||
dmStore.flush();
|
||||
|
||||
const newSearchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
||||
expect(newSearchInput).not.toBeNull();
|
||||
expect(newSearchInput.value).toBe("ali");
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
|
||||
it("keeps keyboard focus on the search input after a DM store change", () => {
|
||||
uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" }));
|
||||
mockDmSidebarWithSearchInput();
|
||||
|
||||
const result = createSidebarArea(defaultOpts());
|
||||
container.appendChild(result.sidebarWrapper);
|
||||
|
||||
const searchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
||||
searchInput.focus();
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
|
||||
addDmChannel(makeDm({ channelId: 100 }));
|
||||
dmStore.flush();
|
||||
|
||||
const newSearchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
||||
expect(newSearchInput).not.toBeNull();
|
||||
expect(newSearchInput).not.toBe(searchInput);
|
||||
expect(document.activeElement).toBe(newSearchInput);
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Member picker modal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -8,15 +8,27 @@ import { authStore } from "@stores/auth.store";
|
||||
import { uiStore, setConnectionStatus } from "@stores/ui.store";
|
||||
|
||||
import { createUserBar } from "@components/UserBar";
|
||||
import { loadUserStatus, saveUserStatus } from "@lib/userStatus";
|
||||
import { loadUserStatus, saveUserStatus, saveCustomStatus } from "@lib/userStatus";
|
||||
import { createPresenceSender } from "@lib/presence";
|
||||
import { createPresenceLimiter } from "@lib/rate-limiter";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
|
||||
function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void {
|
||||
function setAuthState(
|
||||
user: { username: string; custom_status?: string | null } | null,
|
||||
isAuthenticated: boolean,
|
||||
): void {
|
||||
authStore.setState(() => ({
|
||||
token: isAuthenticated ? "tok" : null,
|
||||
user: user !== null ? { id: 1, username: user.username, avatar: null, role: "member" } : null,
|
||||
user:
|
||||
user !== null
|
||||
? {
|
||||
id: 1,
|
||||
username: user.username,
|
||||
avatar: null,
|
||||
role: "member",
|
||||
custom_status: user.custom_status,
|
||||
}
|
||||
: null,
|
||||
serverName: "TestServer",
|
||||
motd: null,
|
||||
isAuthenticated,
|
||||
@@ -234,6 +246,55 @@ describe("StatusPicker wired to UserBar", () => {
|
||||
expect((checks[2] as HTMLElement).style.display).toBe("");
|
||||
});
|
||||
|
||||
// OC-0310: the custom-status input must be seeded from the server's
|
||||
// authoritative auth_ok.user.custom_status, not from the unscoped
|
||||
// localStorage pref — otherwise a fresh install (or a different account on
|
||||
// the same machine) shows an empty/wrong input while the server still
|
||||
// holds a real custom status, and the picker's own equality guard
|
||||
// (StatusPicker.ts commit(): `text === lastCommittedCustom`) makes that
|
||||
// status permanently unclearable.
|
||||
it("seeds the custom-status input from the server's custom_status, not the local pref", () => {
|
||||
// The local pref disagrees with the server — e.g. a previous account on
|
||||
// this machine, or a stale write from before a clean reinstall.
|
||||
saveCustomStatus("stale local value");
|
||||
setAuthState({ username: "alice", custom_status: "In a meeting" }, true);
|
||||
const ws = createMockWs("connected");
|
||||
comp = createUserBar({ ws });
|
||||
comp.mount(container);
|
||||
|
||||
const dot = container.querySelector(".status-picker-dot") as HTMLElement;
|
||||
dot.click();
|
||||
const input = container.querySelector(
|
||||
"[data-testid='custom-status-input']",
|
||||
) as HTMLInputElement;
|
||||
expect(input.value).toBe("In a meeting");
|
||||
});
|
||||
|
||||
// Companion to "follows a status change made elsewhere" above, but for the
|
||||
// custom-status text: a value that arrives on the auth store after mount
|
||||
// (e.g. a later auth_ok on reconnect) must reach the already-open picker,
|
||||
// or the input goes stale until the whole bar remounts.
|
||||
it("follows a custom-status change delivered through the auth store", async () => {
|
||||
setAuthState({ username: "alice", custom_status: "" }, true);
|
||||
const ws = createMockWs("connected");
|
||||
comp = createUserBar({ ws });
|
||||
comp.mount(container);
|
||||
|
||||
const dot = container.querySelector(".status-picker-dot") as HTMLElement;
|
||||
dot.click();
|
||||
|
||||
authStore.setState((s) => ({
|
||||
...s,
|
||||
user: s.user ? { ...s.user, custom_status: "Brewing coffee" } : s.user,
|
||||
}));
|
||||
authStore.flush();
|
||||
|
||||
const input = container.querySelector(
|
||||
"[data-testid='custom-status-input']",
|
||||
) as HTMLInputElement;
|
||||
expect(input.value).toBe("Brewing coffee");
|
||||
});
|
||||
|
||||
it("status picker is disabled without a ws send path even when connected", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
comp = createUserBar({});
|
||||
|
||||
@@ -340,6 +340,7 @@ describe("createSidebarVoiceCallbacks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "connected" });
|
||||
mockVoiceStoreGetState.mockReturnValue(makeVoiceState({ currentChannelId: null }));
|
||||
});
|
||||
|
||||
it("onVoiceJoin sends voice_join and updates store", () => {
|
||||
@@ -355,6 +356,22 @@ describe("createSidebarVoiceCallbacks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("onVoiceJoin is a no-op when already in the requested voice channel (OC-0289)", () => {
|
||||
// A redial into a live call (e.g. DM "Start a call" clicked again to
|
||||
// nudge a callee who hasn't answered) must not re-send voice_join for the
|
||||
// channel this client already occupies -- the server refuses a
|
||||
// same-channel re-join with ALREADY_JOINED, which the dispatcher's
|
||||
// catch-all turns into a user-facing error toast.
|
||||
mockVoiceStoreGetState.mockReturnValue(makeVoiceState({ currentChannelId: 42 }));
|
||||
const ws = makeWs();
|
||||
const cbs = createSidebarVoiceCallbacks(ws);
|
||||
|
||||
cbs.onVoiceJoin(42);
|
||||
|
||||
expect(mockJoinVoiceChannel).not.toHaveBeenCalled();
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onVoiceLeave sends voice_leave and cleans up", () => {
|
||||
const ws = makeWs();
|
||||
const cbs = createSidebarVoiceCallbacks(ws);
|
||||
|
||||
@@ -1122,6 +1122,55 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate verifies
|
||||
// that the 2FA-enrollment precondition only applies to requests that actually
|
||||
// change require_2fa. Once require_2fa is already on, a later PATCH that
|
||||
// leaves it untouched (e.g. only motd) must not be rejected just because some
|
||||
// user without TOTP now exists (targetBoolSetting falls back to the stored
|
||||
// value, which is still "true", so validateRequire2FAUpdate must not
|
||||
// re-run the enrollment count for a key nobody asked to change).
|
||||
func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Enroll the admin so the initial require_2fa enable succeeds.
|
||||
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil {
|
||||
t.Fatalf("enroll admin user: %v", err)
|
||||
}
|
||||
|
||||
enableBody := map[string]string{
|
||||
"registration_open": "false",
|
||||
"require_2fa": "true",
|
||||
}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token, enableBody)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("enabling require_2fa: status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// A user without TOTP now exists (e.g. created after the gate closed, or
|
||||
// unbanned once a temporary ban lapsed) — CountUsersWithoutTOTP is now > 0.
|
||||
if _, err := database.CreateUser(context.Background(), "no-totp-user", "hash", 3); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// An unrelated settings PATCH that never mentions require_2fa must still
|
||||
// succeed and actually write the change.
|
||||
motdBody := map[string]string{"motd": "Back online"}
|
||||
w = doRequest(t, handler, http.MethodPatch, "/settings", token, motdBody)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
val, err := database.GetSetting(context.Background(), "motd")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting: %v", err)
|
||||
}
|
||||
if val != "Back online" {
|
||||
t.Errorf("motd = %q, want %q (unrelated PATCH must not be blocked by the require_2fa enrollment gate)", val, "Back online")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fix 2.1: Sensitive field redaction ──────────────────────────────────────
|
||||
|
||||
// TestAdminAPI_ListUsers_NoPasswordHash verifies that GET /users does not
|
||||
|
||||
@@ -130,6 +130,16 @@ func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[
|
||||
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
|
||||
}
|
||||
|
||||
// The enrollment count only matters when this request is actually turning
|
||||
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
|
||||
// name, backup settings, ...) inherits require_2fa's *current* value via
|
||||
// targetBoolSetting's DB fallback and gets rejected by a precondition
|
||||
// about a value it never touches — wedging the whole settings page once
|
||||
// any non-banned user without TOTP exists.
|
||||
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate 2FA enrollment")
|
||||
|
||||
@@ -42,8 +42,9 @@ func handleDiagnosticsConnectivity(
|
||||
ver string,
|
||||
hub *ws.Hub,
|
||||
) http.HandlerFunc {
|
||||
proxyNets := parseCIDRList(cfg.Server.TrustedProxies) // OC-0305: parse once at construction
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
clientAddr := clientIP(r)
|
||||
clientAddr := clientIPWithProxies(r, proxyNets)
|
||||
|
||||
lkHealthy := false
|
||||
if ok, _ := hub.LiveKitHealthCheck(r.Context()); ok {
|
||||
|
||||
@@ -83,6 +83,68 @@ func TestDiagnosticsConnectivity_ReturnsData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiagnosticsConnectivity_HonoursTrustedProxies reproduces OC-0305: behind
|
||||
// a configured trusted reverse proxy, the diagnostics endpoint must report the
|
||||
// real client address from X-Forwarded-For, not the proxy's own RemoteAddr —
|
||||
// matching the same route's RateLimitMiddleware, which already honours
|
||||
// cfg.Server.TrustedProxies.
|
||||
func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("db.Migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Name: "Test Server",
|
||||
Port: 8443,
|
||||
TrustedProxies: []string{"127.0.0.1/32"},
|
||||
},
|
||||
}
|
||||
|
||||
handler, _, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
uid, _ := database.CreateUser(context.Background(), "diagproxyuser", "$2a$12$fake", 1)
|
||||
token := "diagtest-proxy-token"
|
||||
hash := auth.HashToken(token)
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
||||
VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`,
|
||||
uid, hash,
|
||||
); err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
req.RemoteAddr = "127.0.0.1:9999" // the trusted reverse proxy's own hop
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
client, _ := resp["client"].(map[string]any)
|
||||
if client["remote_addr"] != "203.0.113.9" {
|
||||
t.Errorf("client.remote_addr = %v, want 203.0.113.9 (the real client behind the trusted proxy)", client["remote_addr"])
|
||||
}
|
||||
if isPrivate, _ := client["is_private_network"].(bool); isPrivate {
|
||||
t.Errorf("client.is_private_network = true, want false for public client 203.0.113.9")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) {
|
||||
router, _, _ := setupDiagnosticsRouter(t)
|
||||
|
||||
|
||||
@@ -162,11 +162,16 @@ func handleCreateDM(svc *service.Services, broadcaster DMBroadcaster) http.Handl
|
||||
if result.Recipient.DisplayName != nil {
|
||||
displayName = *result.Recipient.DisplayName
|
||||
}
|
||||
// PresentableStatus applies the "no live connection is offline,
|
||||
// whatever the row says" half of the rule ws/serve_ready.go's
|
||||
// presentableMembers documents — StatusForViewer alone only
|
||||
// collapses invisible to offline and would otherwise ship a
|
||||
// disconnected recipient's saved idle/dnd verbatim (OC-0304).
|
||||
dmUser := db.DMUser{
|
||||
ID: result.Recipient.ID,
|
||||
Username: result.Recipient.Username,
|
||||
Avatar: avatarStr,
|
||||
Status: db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID),
|
||||
Status: svc.DMs.PresentableStatus(result.Recipient.ID, db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID)),
|
||||
DisplayName: displayName,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/api"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// OC-0304: POST /dms must apply the same "no live connection is offline,
|
||||
// whatever users.status stores" rule ws/serve_ready.go's
|
||||
// presentableMembers/presentableDMChannels apply to the ready payload and
|
||||
// members list. MarkUserDisconnected deliberately keeps a chosen idle/dnd
|
||||
// status across a disconnect (so a reconnect can honour it), so a
|
||||
// signed-out recipient's saved "dnd" must not leak into the DM sidebar as a
|
||||
// live presence dot — contradicting the member list right next to it, which
|
||||
// would correctly show the same user offline.
|
||||
func TestCreateDM_RecipientStatus_OfflineWhenDisconnected(t *testing.T) {
|
||||
database := newDMTestDB(t)
|
||||
broadcaster := &mockBroadcaster{}
|
||||
|
||||
r := chi.NewRouter()
|
||||
svc := service.New(database, auth.NewRateLimiter())
|
||||
api.MountDMRoutes(r, database, svc, broadcaster)
|
||||
|
||||
tokenAlice := dmCreateToken(t, database, "presence_alice", 4)
|
||||
_ = dmCreateToken(t, database, "presence_bob", 4)
|
||||
bob, err := database.GetUserByUsername(context.Background(), "presence_bob")
|
||||
if err != nil || bob == nil {
|
||||
t.Fatalf("lookup bob: %v", err)
|
||||
}
|
||||
|
||||
// Bob chose "Do Not Disturb" and then signed out: MarkUserDisconnected
|
||||
// only ever rewrites the "online" status, so the saved row keeps "dnd".
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`UPDATE users SET status = 'dnd' WHERE id = ?`, bob.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set bob status: %v", err)
|
||||
}
|
||||
|
||||
// Nobody currently holds a live connection — mirrors a hub with bob's
|
||||
// session gone.
|
||||
svc.DMs.SetOnlineChecker(func(userID int64) bool { return false })
|
||||
|
||||
rr := dmPost(t, r, "/api/v1/dms", tokenAlice, map[string]any{
|
||||
"recipient_id": bob.ID,
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateDM: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
recipient, ok := resp["recipient"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("recipient missing from response: %v", resp)
|
||||
}
|
||||
if got := recipient["status"]; got != db.StatusOffline {
|
||||
t.Errorf("recipient.status = %v, want %q (bob has no live connection, so his saved 'dnd' must not leak into the DM sidebar)",
|
||||
got, db.StatusOffline)
|
||||
}
|
||||
}
|
||||
@@ -1351,4 +1351,12 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (blocker_id, blocked_id),
|
||||
CHECK (blocker_id != blocked_id)
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -69,6 +69,47 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reverse the read_states.mention_count bumps this user's own messages
|
||||
// made, before soft-deleting them below. DeleteMessage and PurgeMessages
|
||||
// already do this on every other message-removal path via
|
||||
// DecrementMentionCounts (OC-0275); DeleteAccount must too (OC-0294), or a
|
||||
// mention badge from a message that no longer exists survives forever —
|
||||
// the live unread count excludes deleted rows, but mention_count is a
|
||||
// stored counter nothing else ever zeroes. This runs inline in the
|
||||
// existing transaction rather than calling DecrementMentionCounts, which
|
||||
// opens its own writer transaction and would contend with this one.
|
||||
//
|
||||
// The subquery mirrors DecrementMentionCounts' own guard: only messages
|
||||
// still undeleted (deleted = 1 flips below), past the recipient's
|
||||
// last_message_id (a reader who has since marked the channel read is left
|
||||
// alone), and excluding mentions to a user who has blocked the departing
|
||||
// author — applyMentionCounts (service/mentions.go) never counted those in
|
||||
// the first place (OC-0293), so reversing them would wipe out a genuine,
|
||||
// unrelated badge sitting on the same read_states row. MAX(0, …) keeps the
|
||||
// result monotonic.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`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
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE b.blocker_id = read_states.user_id
|
||||
AND b.blocked_id = m.user_id
|
||||
)
|
||||
))
|
||||
WHERE mention_count > 0`,
|
||||
userID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("DeleteAccount mention counts: %w", err)
|
||||
}
|
||||
|
||||
// Soft-delete messages: mark as deleted and clear content so the rows
|
||||
// remain for conversation continuity but contain no personal data.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
|
||||
@@ -209,6 +209,82 @@ func TestDeleteAccount_SoftDeletesMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteAccount_ReversesMentionCounts locks OC-0294: DeleteAccount
|
||||
// soft-deletes every message the departing user wrote, but unlike
|
||||
// DeleteMessage and PurgeMessages (OC-0275) it never calls
|
||||
// DecrementMentionCounts. The live unread count excludes deleted rows, but
|
||||
// read_states.mention_count is a stored counter -- so a mention badge from a
|
||||
// message that no longer exists must be reversed here too, or it survives
|
||||
// forever on a channel with nothing unread.
|
||||
func TestDeleteAccount_ReversesMentionCounts(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
ctx := context.Background()
|
||||
alice := seedUser(t, database, "alice-mention")
|
||||
bob := seedUser(t, database, "bob-mention")
|
||||
chID := seedChannel(t, database, "general-mention")
|
||||
|
||||
msg, err := database.CreateMessageWithMentions(ctx, chID, alice, "hi @bob", nil, []int64{bob}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessageWithMentions: %v", err)
|
||||
}
|
||||
if err := database.IncrementMentionCounts(ctx, chID, msg.ID, []int64{bob}); err != nil {
|
||||
t.Fatalf("IncrementMentionCounts: %v", err)
|
||||
}
|
||||
if n, _ := database.GetMentionCount(ctx, bob, chID); n != 1 {
|
||||
t.Fatalf("precondition: bob mention_count = %d, want 1", n)
|
||||
}
|
||||
|
||||
if err := database.DeleteAccount(ctx, alice); err != nil {
|
||||
t.Fatalf("DeleteAccount: %v", err)
|
||||
}
|
||||
|
||||
if n, _ := database.GetMentionCount(ctx, bob, chID); n != 0 {
|
||||
t.Errorf("bob mention_count = %d after alice's account deletion, want 0 (phantom badge on a channel with nothing unread)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteAccount_MentionCounts_PreservesOthersContribution is the control
|
||||
// for the fix above: it must reverse only the departing user's own mentions,
|
||||
// not blanket-zero the recipient's mention_count. Bob has two independent
|
||||
// mention badges in the same channel; deleting alice's account must leave
|
||||
// carol's genuine, unrelated badge standing.
|
||||
func TestDeleteAccount_MentionCounts_PreservesOthersContribution(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
ctx := context.Background()
|
||||
alice := seedUser(t, database, "alice-mention2")
|
||||
carol := seedUser(t, database, "carol-mention2")
|
||||
bob := seedUser(t, database, "bob-mention2")
|
||||
chID := seedChannel(t, database, "general-mention2")
|
||||
|
||||
msgAlice, err := database.CreateMessageWithMentions(ctx, chID, alice, "hi @bob", nil, []int64{bob}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessageWithMentions(alice): %v", err)
|
||||
}
|
||||
if err := database.IncrementMentionCounts(ctx, chID, msgAlice.ID, []int64{bob}); err != nil {
|
||||
t.Fatalf("IncrementMentionCounts(alice): %v", err)
|
||||
}
|
||||
|
||||
msgCarol, err := database.CreateMessageWithMentions(ctx, chID, carol, "hey @bob too", nil, []int64{bob}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessageWithMentions(carol): %v", err)
|
||||
}
|
||||
if err := database.IncrementMentionCounts(ctx, chID, msgCarol.ID, []int64{bob}); err != nil {
|
||||
t.Fatalf("IncrementMentionCounts(carol): %v", err)
|
||||
}
|
||||
|
||||
if n, _ := database.GetMentionCount(ctx, bob, chID); n != 2 {
|
||||
t.Fatalf("precondition: bob mention_count = %d, want 2", n)
|
||||
}
|
||||
|
||||
if err := database.DeleteAccount(ctx, alice); err != nil {
|
||||
t.Fatalf("DeleteAccount: %v", err)
|
||||
}
|
||||
|
||||
if n, _ := database.GetMentionCount(ctx, bob, chID); n != 1 {
|
||||
t.Errorf("bob mention_count = %d after alice's account deletion, want 1 (carol's genuine mention must survive)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccount_NonexistentUser(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
|
||||
|
||||
@@ -336,8 +336,9 @@ func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CountUsersWithoutTOTP returns the number of non-banned users that do not
|
||||
// currently have a confirmed TOTP secret.
|
||||
// CountUsersWithoutTOTP returns the number of users who are not effectively
|
||||
// banned (unbanned, or serving a temporary ban whose ban_expires has already
|
||||
// lapsed) and do not currently have a confirmed TOTP secret.
|
||||
func (d *DB) CountUsersWithoutTOTP(ctx context.Context) (int, error) {
|
||||
count, err := d.q.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OC-0279: soft-deleting a message (the only kind of delete that exists —
|
||||
// messages.deleted=1, row survives) leaves attachments.message_id pointing
|
||||
// at the now-deleted message. DeleteOrphanedAttachments' predicate is
|
||||
// `message_id IS NULL`, so such a row never matches: the attachment is
|
||||
// simultaneously unservable (serveFileResolve 404s once the message is
|
||||
// deleted) and unreclaimable (no sweep ever deletes the row or the file on
|
||||
// disk). This test pins that the orphan sweep must reclaim attachments whose
|
||||
// owning message has been soft-deleted.
|
||||
func TestDeleteOrphanedAttachments_ReclaimsAttachmentOfSoftDeletedMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openMigratedMemory(t)
|
||||
|
||||
userID := seedUser(t, database, "softdelete-uploader")
|
||||
chID := seedChannel(t, database, "softdelete-channel")
|
||||
|
||||
if err := database.CreateAttachment(
|
||||
ctx, "att-softdel-1", userID, "file.txt", "stored-softdel.txt", "text/plain", 100, nil, nil,
|
||||
); err != nil {
|
||||
t.Fatalf("CreateAttachment: %v", err)
|
||||
}
|
||||
msgID, err := database.CreateMessage(ctx, chID, userID, "with attachment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage: %v", err)
|
||||
}
|
||||
if n, err := database.LinkAttachmentsToMessage(ctx, msgID, userID, []string{"att-softdel-1"}); err != nil || n != 1 {
|
||||
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
// Soft-delete the message the attachment is linked to. Under the current
|
||||
// schema this ONLY sets messages.deleted=1 -- it does not touch the
|
||||
// attachments row, so attachments.message_id is still set afterward.
|
||||
if err := database.DeleteMessage(ctx, msgID, userID, false); err != nil {
|
||||
t.Fatalf("DeleteMessage: %v", err)
|
||||
}
|
||||
|
||||
// A generous future cutoff -- if the row is ever eligible at all, this
|
||||
// cutoff catches it. today's implementation never will, because
|
||||
// message_id IS NULL is false for this row.
|
||||
files, err := database.DeleteOrphanedAttachments(ctx, time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
||||
}
|
||||
if len(files) != 1 || files[0] != "stored-softdel.txt" {
|
||||
t.Fatalf("orphan sweep returned %v, want exactly [stored-softdel.txt] -- the attachment "+
|
||||
"of a soft-deleted message must be reclaimed", files)
|
||||
}
|
||||
|
||||
if att, err := database.GetAttachmentByID(ctx, "att-softdel-1"); err != nil || att != nil {
|
||||
t.Fatalf("sweep should have removed the row after reclaiming the file (att=%v err=%v)", att, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Guard rail on the same fix: an attachment linked to a message that is
|
||||
// still live (not deleted) must NOT be reclaimed by the sweep, no matter how
|
||||
// old uploaded_at is. Without this, a predicate that over-widens (e.g.
|
||||
// dropping the deleted check entirely) would start eating live attachments.
|
||||
func TestDeleteOrphanedAttachments_DoesNotReclaimAttachmentOfLiveMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openMigratedMemory(t)
|
||||
|
||||
userID := seedUser(t, database, "live-uploader")
|
||||
chID := seedChannel(t, database, "live-channel")
|
||||
|
||||
if err := database.CreateAttachment(
|
||||
ctx, "att-live-1", userID, "file.txt", "stored-live.txt", "text/plain", 100, nil, nil,
|
||||
); err != nil {
|
||||
t.Fatalf("CreateAttachment: %v", err)
|
||||
}
|
||||
msgID, err := database.CreateMessage(ctx, chID, userID, "with attachment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage: %v", err)
|
||||
}
|
||||
if n, err := database.LinkAttachmentsToMessage(ctx, msgID, userID, []string{"att-live-1"}); err != nil || n != 1 {
|
||||
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
files, err := database.DeleteOrphanedAttachments(ctx, time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Fatalf("orphan sweep reclaimed %v, want none -- the message is still live", files)
|
||||
}
|
||||
if att, err := database.GetAttachmentByID(ctx, "att-live-1"); err != nil || att == nil {
|
||||
t.Fatalf("attachment row of a live message must survive the sweep (att=%v err=%v)", att, err)
|
||||
}
|
||||
}
|
||||
@@ -184,10 +184,18 @@ func (d *DB) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (ma
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteOrphanedAttachments atomically removes attachment records where
|
||||
// message_id IS NULL and uploaded_at is older than the given cutoff. Live
|
||||
// avatars are excluded by the query itself. Returns the stored_as filenames
|
||||
// of deleted records so the caller can remove files.
|
||||
// DeleteOrphanedAttachments atomically removes attachment records that are
|
||||
// either unlinked (message_id IS NULL) or linked to a message that has since
|
||||
// been soft-deleted (messages.deleted = 1), and whose uploaded_at is older
|
||||
// than the given cutoff. Live avatars are excluded by the query itself.
|
||||
// Returns the stored_as filenames of deleted records so the caller can
|
||||
// remove files.
|
||||
//
|
||||
// OC-0279: a message delete is a soft delete -- the messages row survives
|
||||
// with deleted=1 -- so its attachments never go through message_id IS NULL
|
||||
// on their own. The second half of the query's WHERE clause is what lets
|
||||
// this sweep reclaim those files too, since serveFileResolve already treats
|
||||
// them as unservable once the owning message is deleted.
|
||||
//
|
||||
// The cutoff is a time.Time, not a string, because uploaded_at is stored in
|
||||
// SQLite's own 'YYYY-MM-DD HH:MM:SS' shape and the comparison is bytewise:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A temporary ban that has already lapsed must not hide a TOTP-less user from
|
||||
// CountUsersWithoutTOTP: auth.IsEffectivelyBanned treats a lapsed ban_expires
|
||||
// as "not banned", so a user in this state can still log in. If the count
|
||||
// query excludes them (banned = 0 filter, ignoring ban_expires), enabling
|
||||
// require_2fa while they exist silently locks them out with no recovery path
|
||||
// (see handlers_settings.go's validateRequire2FAUpdate). The query must mirror
|
||||
// the lapsed-ban arm already used by ListMembers (users.sql) and the API token
|
||||
// not-banned clause (apitokens.sql).
|
||||
func TestCountUsersWithoutTOTP_LapsedTempBanStillCounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := newTokenTestDB(t)
|
||||
|
||||
// A user with no TOTP secret and a ban that expired an hour ago.
|
||||
lapsedID := seedTokenUser(t, database, "lapsed-ban-no-totp", 3)
|
||||
lapsedExpires := time.Now().UTC().Add(-time.Hour).Format("2006-01-02T15:04:05Z")
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`UPDATE users SET banned = 1, ban_expires = ? WHERE id = ?`, lapsedExpires, lapsedID); err != nil {
|
||||
t.Fatalf("seed lapsed ban: %v", err)
|
||||
}
|
||||
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUsersWithoutTOTP: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("CountUsersWithoutTOTP = %d, want 1 (lapsed-ban user without TOTP must still be counted since they can still log in)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// A user with an active (not yet expired) ban is genuinely unable to log in,
|
||||
// so they must stay excluded from the count — enabling require_2fa should not
|
||||
// be blocked by someone who cannot authenticate anyway.
|
||||
func TestCountUsersWithoutTOTP_ActiveTempBanExcluded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := newTokenTestDB(t)
|
||||
|
||||
activeID := seedTokenUser(t, database, "active-ban-no-totp", 3)
|
||||
activeExpires := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z")
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`UPDATE users SET banned = 1, ban_expires = ? WHERE id = ?`, activeExpires, activeID); err != nil {
|
||||
t.Fatalf("seed active ban: %v", err)
|
||||
}
|
||||
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUsersWithoutTOTP: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("CountUsersWithoutTOTP = %d, want 0 (actively banned user without TOTP cannot log in and must not block require_2fa)", count)
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,13 @@ func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentPara
|
||||
|
||||
const deleteOrphanedAttachments = `-- name: DeleteOrphanedAttachments :many
|
||||
DELETE FROM attachments
|
||||
WHERE message_id IS NULL
|
||||
AND uploaded_at < ?
|
||||
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
|
||||
)
|
||||
@@ -53,6 +58,17 @@ RETURNING stored_as
|
||||
// users.avatar URL is what keeps them alive and authorizes serving them
|
||||
// (migration 027). Excluding them here is what stops the sweep from destroying
|
||||
// every avatar in the instance. idx_users_avatar makes the lookup cheap.
|
||||
//
|
||||
// OC-0279: a message delete is a soft delete (messages.deleted=1, the row
|
||||
// survives), so an attachment linked to a deleted message keeps a non-NULL
|
||||
// message_id forever -- message_id IS NULL alone never matches it, so its
|
||||
// file was permanently unreclaimable even though serveFileResolve already
|
||||
// 404s it once the owning message is deleted. The second EXISTS below
|
||||
// catches that case by joining back to messages.deleted instead. Matching on
|
||||
// messages.deleted=1 here (rather than unlinking message_id in the delete
|
||||
// path) is deliberate: unlinking would move the row onto the "unlinked
|
||||
// attachment" access branch in serveFileAuthorize and make it downloadable
|
||||
// again by the uploader.
|
||||
func (q *Queries) DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, deleteOrphanedAttachments, uploadedAt)
|
||||
if err != nil {
|
||||
|
||||
@@ -267,13 +267,12 @@ func (q *Queries) SetMessagePinned(ctx context.Context, arg SetMessagePinnedPara
|
||||
return q.db.ExecContext(ctx, setMessagePinned, arg.Pinned, arg.ID)
|
||||
}
|
||||
|
||||
const softDeleteMessage = `-- name: SoftDeleteMessage :exec
|
||||
UPDATE messages SET deleted = 1 WHERE id = ?
|
||||
const softDeleteMessage = `-- name: SoftDeleteMessage :execresult
|
||||
UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0
|
||||
`
|
||||
|
||||
func (q *Queries) SoftDeleteMessage(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, softDeleteMessage, id)
|
||||
return err
|
||||
func (q *Queries) SoftDeleteMessage(ctx context.Context, id int64) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, softDeleteMessage, id)
|
||||
}
|
||||
|
||||
const updateReadState = `-- name: UpdateReadState :exec
|
||||
|
||||
@@ -40,6 +40,13 @@ type Querier interface {
|
||||
// everyone exactly while some user's avatar points at it. Covered by the
|
||||
// partial index on users(avatar) added in migration 027.
|
||||
CountUsersWithAvatar(ctx context.Context, avatar *string) (int64, error)
|
||||
// A lapsed temporary ban must not hide a TOTP-less user from this count: the
|
||||
// ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause /
|
||||
// ListMembers above), which treats an elapsed ban as "not banned" and lets
|
||||
// the user log in. Without it, require_2fa can be enabled while such a user
|
||||
// still exists, and their next login is refused forever with no recovery
|
||||
// path. The replace() normalises the space-separator form of ban_expires to
|
||||
// 'T' before comparing, because ' ' sorts below 'T'.
|
||||
CountUsersWithoutTOTP(ctx context.Context) (int64, error)
|
||||
CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (sql.Result, error)
|
||||
CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error
|
||||
@@ -63,6 +70,17 @@ type Querier interface {
|
||||
// users.avatar URL is what keeps them alive and authorizes serving them
|
||||
// (migration 027). Excluding them here is what stops the sweep from destroying
|
||||
// every avatar in the instance. idx_users_avatar makes the lookup cheap.
|
||||
//
|
||||
// OC-0279: a message delete is a soft delete (messages.deleted=1, the row
|
||||
// survives), so an attachment linked to a deleted message keeps a non-NULL
|
||||
// message_id forever -- message_id IS NULL alone never matches it, so its
|
||||
// file was permanently unreclaimable even though serveFileResolve already
|
||||
// 404s it once the owning message is deleted. The second EXISTS below
|
||||
// catches that case by joining back to messages.deleted instead. Matching on
|
||||
// messages.deleted=1 here (rather than unlinking message_id in the delete
|
||||
// path) is deliberate: unlinking would move the row onto the "unlinked
|
||||
// attachment" access branch in serveFileAuthorize and make it downloadable
|
||||
// again by the uploader.
|
||||
DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error)
|
||||
DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (sql.Result, error)
|
||||
DeleteRole(ctx context.Context, id int64) error
|
||||
@@ -254,7 +272,7 @@ type Querier interface {
|
||||
SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (sql.Result, error)
|
||||
SetRolePosition(ctx context.Context, arg SetRolePositionParams) error
|
||||
SetSetting(ctx context.Context, arg SetSettingParams) error
|
||||
SoftDeleteMessage(ctx context.Context, id int64) error
|
||||
SoftDeleteMessage(ctx context.Context, id int64) (sql.Result, error)
|
||||
TouchAPIToken(ctx context.Context, tokenHash string) error
|
||||
TouchSession(ctx context.Context, token string) error
|
||||
UnbanUser(ctx context.Context, id int64) error
|
||||
|
||||
@@ -37,9 +37,20 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
const countUsersWithoutTOTP = `-- name: CountUsersWithoutTOTP :one
|
||||
SELECT COUNT(*) FROM users WHERE banned = 0 AND totp_secret IS NULL
|
||||
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
|
||||
`
|
||||
|
||||
// A lapsed temporary ban must not hide a TOTP-less user from this count: the
|
||||
// ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause /
|
||||
// ListMembers above), which treats an elapsed ban as "not banned" and lets
|
||||
// the user log in. Without it, require_2fa can be enabled while such a user
|
||||
// still exists, and their next login is refused forever with no recovery
|
||||
// path. The replace() normalises the space-separator form of ban_expires to
|
||||
// 'T' before comparing, because ' ' sorts below 'T'.
|
||||
func (q *Queries) CountUsersWithoutTOTP(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countUsersWithoutTOTP)
|
||||
var count int64
|
||||
|
||||
@@ -260,6 +260,18 @@ func (d *DB) IncrementMentionCounts(ctx context.Context, channelID, msgID int64,
|
||||
// holds resolved @user mentions, and the everyone/here fan-out is filtered by
|
||||
// presence at send time (OC-0223) rather than persisted — so this reverses
|
||||
// only stored direct mentions. That is a smaller, but never wrong, correction.
|
||||
//
|
||||
// message_mentions stores every resolved mention id, blockers of the author
|
||||
// included (insertMentionRows deliberately does not filter — the fan-out,
|
||||
// not storage, is what excludes them). But the increment side
|
||||
// (applyMentionCounts in service/mentions.go) deletes the author's blockers
|
||||
// from the recipient set before ever calling IncrementMentionCounts, so a
|
||||
// blocker's read_states row was never bumped for this message. The NOT
|
||||
// EXISTS below mirrors that same exclusion here (OC-0293): without it, a
|
||||
// blocker who happens to have an unrelated, genuine mention badge on this
|
||||
// same channel — from some other message — has that real badge wiped out
|
||||
// when the blocked author's message is deleted, because the UPDATE cannot
|
||||
// otherwise tell "never counted" apart from "counted, now reversing".
|
||||
func (d *DB) DecrementMentionCounts(ctx context.Context, channelID int64, msgIDs []int64) error {
|
||||
if len(msgIDs) == 0 {
|
||||
return nil
|
||||
@@ -277,8 +289,13 @@ func (d *DB) DecrementMentionCounts(ctx context.Context, channelID int64, msgIDs
|
||||
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 = ?)`,
|
||||
channelID, msgID, msgID,
|
||||
AND user_id IN (SELECT mentioned_user_id FROM message_mentions WHERE message_id = ?)
|
||||
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 = ?)
|
||||
)`,
|
||||
channelID, msgID, msgID, msgID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("DecrementMentionCounts: %w", err)
|
||||
}
|
||||
|
||||
@@ -263,6 +263,56 @@ func TestIncrementMentionCounts_StillAppliesWhenReaderIsBehind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecrementMentionCounts_SkipsNeverCountedBlockedMention locks OC-0293:
|
||||
// applyMentionCounts (service/mentions.go) excludes the author's blockers from
|
||||
// the recipient set before calling IncrementMentionCounts, but
|
||||
// insertMentionRows stores every resolved mention id regardless of blocks.
|
||||
// DecrementMentionCounts must mirror that same exclusion, or deleting a
|
||||
// message from a blocked author reverses a mention_count bump that message
|
||||
// never made -- wiping out an unrelated, genuine mention badge sitting on the
|
||||
// same read_states row.
|
||||
func TestDecrementMentionCounts_SkipsNeverCountedBlockedMention(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
seedMentionFixture(t, database)
|
||||
ctx := context.Background()
|
||||
|
||||
// Bob (2) blocks Alice (1).
|
||||
if err := database.BlockUser(ctx, 2, 1); err != nil {
|
||||
t.Fatalf("BlockUser: %v", err)
|
||||
}
|
||||
|
||||
// Carol's earlier message gives Bob a genuine, unrelated mention badge.
|
||||
earlierMsg, err := database.CreateMessage(ctx, 1, 3, "hey @bob standup?", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage(earlier): %v", err)
|
||||
}
|
||||
if err := database.IncrementMentionCounts(ctx, 1, earlierMsg, []int64{2}); err != nil {
|
||||
t.Fatalf("IncrementMentionCounts: %v", err)
|
||||
}
|
||||
if n, _ := database.GetMentionCount(ctx, 2, 1); n != 1 {
|
||||
t.Fatalf("precondition: bob mention_count = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Alice, blocked by Bob, posts a message mentioning him. Storage still
|
||||
// records the row (fan-out, not storage, excludes blockers), but the
|
||||
// service layer never calls IncrementMentionCounts for Bob because he
|
||||
// blocked Alice -- so no increment ever landed for this message.
|
||||
blockedMsg, err := database.CreateMessageWithMentions(ctx, 1, 1, "hi @bob", nil, []int64{2}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessageWithMentions: %v", err)
|
||||
}
|
||||
|
||||
// Alice deletes her message. DecrementMentionCounts must not touch Bob's
|
||||
// mention_count: that message never contributed to it.
|
||||
if err := database.DecrementMentionCounts(ctx, 1, []int64{blockedMsg.ID}); err != nil {
|
||||
t.Fatalf("DecrementMentionCounts: %v", err)
|
||||
}
|
||||
|
||||
if n, _ := database.GetMentionCount(ctx, 2, 1); n != 1 {
|
||||
t.Errorf("bob mention_count = %d after deleting a blocked-author's message, want 1 (Carol's genuine badge must survive)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserIDsByUsernames_CaseInsensitive(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
seedMentionFixture(t, database)
|
||||
|
||||
@@ -188,6 +188,13 @@ func (d *DB) EditMessage(ctx context.Context, id, userID int64, content string)
|
||||
|
||||
// DeleteMessage performs a soft delete (sets deleted=1) on the message.
|
||||
// The calling user must be the message owner or ismod must be true.
|
||||
//
|
||||
// OC-0284: the UPDATE is guarded with `AND deleted = 0` (mirroring
|
||||
// SetMessagePinned) and RowsAffected is checked, so a message already
|
||||
// soft-deleted — by a prior call, or by one that raced this one to the
|
||||
// writer — reports ErrNotFound instead of silently succeeding a second time.
|
||||
// A caller-visible no-op here is what let a repeated delete run the
|
||||
// mention_count reversal twice upstream in MessageService.DeleteMessage.
|
||||
func (d *DB) DeleteMessage(ctx context.Context, id, userID int64, ismod bool) error {
|
||||
msg, err := d.GetMessage(ctx, id)
|
||||
if err != nil {
|
||||
@@ -200,9 +207,13 @@ func (d *DB) DeleteMessage(ctx context.Context, id, userID int64, ismod bool) er
|
||||
return fmt.Errorf("DeleteMessage: user %d does not own message %d: %w", userID, id, ErrForbidden)
|
||||
}
|
||||
|
||||
if err := d.q.SoftDeleteMessage(ctx, id); err != nil {
|
||||
res, err := d.q.SoftDeleteMessage(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteMessage: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("DeleteMessage: message %d: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,25 @@ WHERE a.id = ?;
|
||||
-- users.avatar URL is what keeps them alive and authorizes serving them
|
||||
-- (migration 027). Excluding them here is what stops the sweep from destroying
|
||||
-- every avatar in the instance. idx_users_avatar makes the lookup cheap.
|
||||
--
|
||||
-- OC-0279: a message delete is a soft delete (messages.deleted=1, the row
|
||||
-- survives), so an attachment linked to a deleted message keeps a non-NULL
|
||||
-- message_id forever -- message_id IS NULL alone never matches it, so its
|
||||
-- file was permanently unreclaimable even though serveFileResolve already
|
||||
-- 404s it once the owning message is deleted. The second EXISTS below
|
||||
-- catches that case by joining back to messages.deleted instead. Matching on
|
||||
-- messages.deleted=1 here (rather than unlinking message_id in the delete
|
||||
-- path) is deliberate: unlinking would move the row onto the "unlinked
|
||||
-- attachment" access branch in serveFileAuthorize and make it downloadable
|
||||
-- again by the uploader.
|
||||
DELETE FROM attachments
|
||||
WHERE message_id IS NULL
|
||||
AND uploaded_at < ?
|
||||
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
|
||||
)
|
||||
|
||||
@@ -20,8 +20,8 @@ UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?
|
||||
RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp,
|
||||
mentions_everyone;
|
||||
|
||||
-- name: SoftDeleteMessage :exec
|
||||
UPDATE messages SET deleted = 1 WHERE id = ?;
|
||||
-- name: SoftDeleteMessage :execresult
|
||||
UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0;
|
||||
|
||||
-- name: SetMessagePinned :execresult
|
||||
UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;
|
||||
|
||||
@@ -61,5 +61,16 @@ ORDER BY u.username ASC;
|
||||
-- name: CountUsers :one
|
||||
SELECT COUNT(*) FROM users;
|
||||
|
||||
-- A lapsed temporary ban must not hide a TOTP-less user from this count: the
|
||||
-- ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause /
|
||||
-- ListMembers above), which treats an elapsed ban as "not banned" and lets
|
||||
-- the user log in. Without it, require_2fa can be enabled while such a user
|
||||
-- still exists, and their next login is refused forever with no recovery
|
||||
-- path. The replace() normalises the space-separator form of ban_expires to
|
||||
-- 'T' before comparing, because ' ' sorts below 'T'.
|
||||
-- name: CountUsersWithoutTOTP :one
|
||||
SELECT COUNT(*) FROM users WHERE banned = 0 AND totp_secret IS NULL;
|
||||
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;
|
||||
|
||||
+79
-1
@@ -14,6 +14,21 @@ import (
|
||||
// DMService handles direct message channel operations.
|
||||
type DMService struct {
|
||||
st Store
|
||||
// online reports whether userID currently holds a live WebSocket
|
||||
// connection. It is wired by the ws layer (Hub.IsUserConnected) after
|
||||
// both are constructed, mirroring MessageService.online (see its doc
|
||||
// comment in message.go) — so every DM payload this service builds can
|
||||
// apply the same "no live connection is offline, whatever users.status
|
||||
// stores" rule ws/serve_ready.go's presentableMembers/
|
||||
// presentableDMChannels apply to the ready payload and members list.
|
||||
// users.status keeps a *chosen* idle/dnd/invisible across a disconnect by
|
||||
// design (MarkUserDisconnected only ever rewrites "online" ->
|
||||
// "offline"), so without this a signed-out user's last chosen status
|
||||
// leaks into the DM sidebar as if they were still connected. nil (the
|
||||
// zero value, e.g. in tests and any caller with no hub) means "no
|
||||
// live-connection information available" and applies no extra
|
||||
// narrowing, preserving prior behavior.
|
||||
online func(userID int64) bool
|
||||
}
|
||||
|
||||
// NewDMService creates a DMService.
|
||||
@@ -21,6 +36,54 @@ func NewDMService(st Store) *DMService {
|
||||
return &DMService{st: st}
|
||||
}
|
||||
|
||||
// SetOnlineChecker wires the live-connection predicate every DM payload this
|
||||
// service builds consults in addition to users.status. Passing nil clears
|
||||
// it. Safe to call once at startup (the ws layer, after constructing both
|
||||
// the Hub and the Services) or from a test.
|
||||
func (s *DMService) SetOnlineChecker(online func(userID int64) bool) {
|
||||
s.online = online
|
||||
}
|
||||
|
||||
// PresentableStatus narrows status to db.StatusOffline when subjectID holds
|
||||
// no live connection, whatever status says — the second half of the rule
|
||||
// ws/serve_ready.go's presentableMembers documents: users.status keeps a
|
||||
// *chosen* idle/dnd/invisible across a disconnect (MarkUserDisconnected only
|
||||
// ever rewrites "online" -> "offline") so the next connect can honour it,
|
||||
// which means every read path must apply this narrowing itself rather than
|
||||
// trusting the stored value alone. Exported so a caller that hand-builds a
|
||||
// db.DMUser outside this package (POST /dms) applies the identical rule
|
||||
// DMSummaryFor/ListDMs/CreateGroupDM apply via presentableDMChannelInfo
|
||||
// below. A checker that was never wired (SetOnlineChecker not called, e.g.
|
||||
// in most tests) leaves status untouched.
|
||||
func (s *DMService) PresentableStatus(subjectID int64, status string) string {
|
||||
if s.online != nil && !s.online(subjectID) {
|
||||
return db.StatusOffline
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// presentableDMUser applies PresentableStatus to one DM participant.
|
||||
func (s *DMService) presentableDMUser(u db.DMUser) db.DMUser {
|
||||
u.Status = s.PresentableStatus(u.ID, u.Status)
|
||||
return u
|
||||
}
|
||||
|
||||
// presentableDMChannelInfo applies PresentableStatus to every participant of
|
||||
// a DM payload — Recipient (the legacy single-recipient field) and every
|
||||
// entry of Recipients, since a 1:1 DM's Recipient is a copy of
|
||||
// Recipients[0], not a shared reference. Mirrors Hub.presentableDMChannels
|
||||
// (ws/serve_ready.go) at the service layer so every REST response and push
|
||||
// event built from a db.DMChannelInfo need not duplicate the rule itself.
|
||||
func (s *DMService) presentableDMChannelInfo(info db.DMChannelInfo) db.DMChannelInfo {
|
||||
if info.Recipient.ID != 0 {
|
||||
info.Recipient = s.presentableDMUser(info.Recipient)
|
||||
}
|
||||
for i := range info.Recipients {
|
||||
info.Recipients[i] = s.presentableDMUser(info.Recipients[i])
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// CreateDMResult holds the result of creating or fetching a DM channel.
|
||||
type CreateDMResult struct {
|
||||
Channel *db.Channel
|
||||
@@ -91,6 +154,12 @@ func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelIn
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to list DMs: %v", ErrInternal, err)
|
||||
}
|
||||
// GetUserDMChannels only applies db.StatusForViewer (invisible ->
|
||||
// offline); apply the "no live connection" half too, see
|
||||
// presentableDMChannelInfo.
|
||||
for i := range dms {
|
||||
dms[i] = s.presentableDMChannelInfo(dms[i])
|
||||
}
|
||||
return dms, nil
|
||||
}
|
||||
|
||||
@@ -289,6 +358,11 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID
|
||||
slog.Error("DMService.CreateGroupDM: failed to read participants after commit", "err", err, "channel_id", ch.ID)
|
||||
participants = nil
|
||||
}
|
||||
// See presentableDMChannelInfo: a participant with no live connection
|
||||
// must read as offline, whatever users.status stored for them.
|
||||
for i := range participants {
|
||||
participants[i] = s.presentableDMUser(participants[i])
|
||||
}
|
||||
|
||||
return &CreateGroupDMResult{
|
||||
Channel: ch,
|
||||
@@ -363,7 +437,11 @@ func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64)
|
||||
if err != nil {
|
||||
return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err)
|
||||
}
|
||||
return db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID), nil
|
||||
// See presentableDMChannelInfo: this is the single place broadcastDMOpen
|
||||
// (group create/rename/leave refresh) and PATCH /dms/{id}'s response
|
||||
// build their payload from, so applying the "no live connection" rule
|
||||
// here covers every push of a DM's membership.
|
||||
return s.presentableDMChannelInfo(db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID)), nil
|
||||
}
|
||||
|
||||
// SharedOneToOneDM returns the id of the 1:1 DM channel the two users share,
|
||||
|
||||
@@ -186,3 +186,99 @@ func TestDMService_CreateGroupDM_SurvivesCancelledPostCommitRead(t *testing.T) {
|
||||
t.Fatalf("persisted participant rows = %d, want 3", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OC-0304: disconnected recipients must read as offline ────────────────
|
||||
//
|
||||
// users.status keeps a *chosen* idle/dnd/invisible across a disconnect
|
||||
// (MarkUserDisconnected only ever rewrites "online" -> "offline") so a
|
||||
// reconnect can honour it. ws/serve_ready.go's presentableMembers documents
|
||||
// the resulting obligation on every read path: "a member with no live
|
||||
// connection is offline, whatever the row says." DMSummaryFor, ListDMs and
|
||||
// CreateGroupDM are the service-layer choke points every DM payload in this
|
||||
// package is built from, so each must apply that rule once SetOnlineChecker
|
||||
// is wired — otherwise a signed-out user's last chosen status leaks into the
|
||||
// DM sidebar as a live presence dot, contradicting the member list right
|
||||
// next to it.
|
||||
|
||||
func TestDMService_DMSummaryFor_RecipientOfflineWhenDisconnected(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
|
||||
seedUser(t, database, &db.User{ID: 2, Username: "bob", Status: db.StatusDND})
|
||||
|
||||
svc := NewDMService(database)
|
||||
created, err := svc.CreateDM(context.Background(), 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("setup CreateDM: %v", err)
|
||||
}
|
||||
|
||||
// Bob chose "dnd" and then signed out — nobody holds a live connection.
|
||||
svc.SetOnlineChecker(func(userID int64) bool { return false })
|
||||
|
||||
summary, err := svc.DMSummaryFor(context.Background(), 1, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DMSummaryFor: %v", err)
|
||||
}
|
||||
if summary.Recipient.Status != db.StatusOffline {
|
||||
t.Errorf("Recipient.Status = %q, want %q (bob has no live connection, so his saved %q must not leak through)",
|
||||
summary.Recipient.Status, db.StatusOffline, db.StatusDND)
|
||||
}
|
||||
if len(summary.Recipients) != 1 || summary.Recipients[0].Status != db.StatusOffline {
|
||||
t.Errorf("Recipients = %+v, want a single offline entry", summary.Recipients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMService_ListDMs_RecipientOfflineWhenDisconnected(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
|
||||
seedUser(t, database, &db.User{ID: 2, Username: "bob", Status: db.StatusDND})
|
||||
|
||||
svc := NewDMService(database)
|
||||
if _, err := svc.CreateDM(context.Background(), 1, 2); err != nil {
|
||||
t.Fatalf("setup CreateDM: %v", err)
|
||||
}
|
||||
|
||||
svc.SetOnlineChecker(func(userID int64) bool { return false })
|
||||
|
||||
dms, err := svc.ListDMs(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDMs: %v", err)
|
||||
}
|
||||
if len(dms) != 1 {
|
||||
t.Fatalf("ListDMs: got %d channels, want 1", len(dms))
|
||||
}
|
||||
if dms[0].Recipient.Status != db.StatusOffline {
|
||||
t.Errorf("Recipient.Status = %q, want %q (bob has no live connection, so his saved %q must not leak through)",
|
||||
dms[0].Recipient.Status, db.StatusOffline, db.StatusDND)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMService_CreateGroupDM_ParticipantOfflineWhenDisconnected(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
|
||||
seedUser(t, database, &db.User{ID: 2, Username: "bob", Status: db.StatusDND})
|
||||
seedUser(t, database, &db.User{ID: 3, Username: "carol"})
|
||||
|
||||
svc := NewDMService(database)
|
||||
svc.SetOnlineChecker(func(userID int64) bool { return userID != 2 })
|
||||
|
||||
result, err := svc.CreateGroupDM(context.Background(), 1, []int64{2, 3}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroupDM: %v", err)
|
||||
}
|
||||
|
||||
var bobStatus string
|
||||
found := false
|
||||
for _, p := range result.Participants {
|
||||
if p.ID == 2 {
|
||||
bobStatus = p.Status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("bob missing from Participants: %+v", result.Participants)
|
||||
}
|
||||
if bobStatus != db.StatusOffline {
|
||||
t.Errorf("bob's Status = %q, want %q (bob has no live connection, so his saved %q must not leak through)",
|
||||
bobStatus, db.StatusOffline, db.StatusDND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -546,6 +547,50 @@ func TestDeleteMessage_ClearsMentionCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMessage_RepeatedDeleteDoesNotDecrementMentionCountTwice is
|
||||
// OC-0284: DeleteMessage has no `msg.Deleted` guard and every layer beneath
|
||||
// it is silently idempotent (SoftDeleteMessage is a bare UPDATE with no
|
||||
// `deleted = 0` filter), so a second chat_delete for the same message id
|
||||
// succeeds and runs DecrementMentionCounts a second time. That statement has
|
||||
// no per-message idempotence — it just decrements every recipient row whose
|
||||
// last_message_id < msgID and mention_count > 0 — so the second run eats a
|
||||
// mention raised by a *different, still-live* message instead of being
|
||||
// rejected as a no-op.
|
||||
func TestDeleteMessage_RepeatedDeleteDoesNotDecrementMentionCountTwice(t *testing.T) {
|
||||
svc, _, database := newMentionFixture(t)
|
||||
|
||||
m1 := sendAs(t, svc, 1, "@bob first")
|
||||
m2 := sendAs(t, svc, 1, "@bob second")
|
||||
if got := mentionCount(t, database, 2); got != 2 {
|
||||
t.Fatalf("setup: bob mention_count = %d, want 2", got)
|
||||
}
|
||||
|
||||
if _, err := svc.DeleteMessage(context.Background(), 1, m1.MessageID); err != nil {
|
||||
t.Fatalf("first DeleteMessage: %v", err)
|
||||
}
|
||||
if got := mentionCount(t, database, 2); got != 1 {
|
||||
t.Fatalf("after first delete, bob mention_count = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Repeating the delete for the same (already-deleted) message must be
|
||||
// rejected rather than silently re-running the mention reversal — m2 is
|
||||
// still live and unread, so its mention must survive.
|
||||
if _, err := svc.DeleteMessage(context.Background(), 1, m1.MessageID); !errors.Is(err, ErrDeletedMessage) {
|
||||
t.Fatalf("repeated DeleteMessage: err = %v, want ErrDeletedMessage", err)
|
||||
}
|
||||
if got := mentionCount(t, database, 2); got != 1 {
|
||||
t.Errorf("after repeated delete of m1, bob mention_count = %d, want 1 (m2's mention must survive)", got)
|
||||
}
|
||||
|
||||
// m2's mention must still be reversible by its own (first) delete.
|
||||
if _, err := svc.DeleteMessage(context.Background(), 1, m2.MessageID); err != nil {
|
||||
t.Fatalf("DeleteMessage(m2): %v", err)
|
||||
}
|
||||
if got := mentionCount(t, database, 2); got != 0 {
|
||||
t.Errorf("after deleting m2, bob mention_count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeMessages_ClearsMentionCounts is the bulk-delete sibling of
|
||||
// TestDeleteMessage_ClearsMentionCount (OC-0275): PurgeMessages must reverse
|
||||
// the mention_count increments of every message it purges, the same way a
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -404,6 +405,32 @@ func (s *MessageService) editMessageCheckAccess(ctx context.Context, userID, cha
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAuthz decides whether userID may delete msg and whether the delete
|
||||
// runs as a moderation action (isMod).
|
||||
func (s *MessageService) deleteAuthz(ctx context.Context, userID int64, msg *db.Message, isDM bool) (bool, error) {
|
||||
if isDM {
|
||||
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
return false, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
// Require READ_MESSAGES alongside MANAGE_MESSAGES (and alongside
|
||||
// SEND_MESSAGES on the author path) so a role explicitly denied access to
|
||||
// a channel cannot delete messages in it. Mirrors handleReaction and
|
||||
// checkSendPermission, which both require ReadMessages for non-DM channels.
|
||||
isMsgOwner := msg.UserID == userID
|
||||
canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages)
|
||||
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.SendMessages))
|
||||
if !canDelete {
|
||||
return false, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
// db.DeleteMessage skips the ownership check when ismod is true, so the
|
||||
// moderation flag must reuse the decision made above rather than
|
||||
// re-checking MANAGE_MESSAGES without READ_MESSAGES.
|
||||
return canManage, nil
|
||||
}
|
||||
|
||||
// DeleteMessage validates and soft-deletes a message.
|
||||
func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) {
|
||||
// Rate limit.
|
||||
@@ -420,6 +447,15 @@ func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64)
|
||||
if err != nil || msg == nil {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
// OC-0284: GetMessage returns tombstones (so callers can broadcast the
|
||||
// deletion event), and every layer beneath a plain re-delete is silently
|
||||
// idempotent. Without this guard a second chat_delete for the same
|
||||
// message reaches DecrementMentionCounts below a second time, which has
|
||||
// no per-message idempotence of its own and eats a mention raised by a
|
||||
// different, still-live message. Mirrors EditMessage's msg.Deleted guard.
|
||||
if msg.Deleted {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrDeletedMessage)
|
||||
}
|
||||
|
||||
// Fail closed, mirroring EditMessage: a lookup failure must not fall
|
||||
// through to the non-DM permission branch (skipping the DM-participant
|
||||
@@ -437,30 +473,23 @@ func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var isMod bool
|
||||
if isDM {
|
||||
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
} else {
|
||||
// Require READ_MESSAGES alongside MANAGE_MESSAGES (and alongside
|
||||
// SEND_MESSAGES on the author path) so a role explicitly denied access to
|
||||
// a channel cannot delete messages in it. Mirrors handleReaction and
|
||||
// checkSendPermission, which both require ReadMessages for non-DM channels.
|
||||
isMsgOwner := msg.UserID == userID
|
||||
canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages)
|
||||
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.SendMessages))
|
||||
if !canDelete {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
// db.DeleteMessage skips the ownership check when ismod is true, so the
|
||||
// moderation flag must reuse the decision made above rather than
|
||||
// re-checking MANAGE_MESSAGES without READ_MESSAGES.
|
||||
isMod = canManage
|
||||
isMod, err := s.deleteAuthz(ctx, userID, msg, isDM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil {
|
||||
// db.DeleteMessage's UPDATE now excludes already-deleted rows (OC-0284),
|
||||
// so a message that raced this request to the writer between the
|
||||
// msg.Deleted check above and this write surfaces here as
|
||||
// db.ErrNotFound. Map it to ErrDeletedMessage rather than the generic
|
||||
// ErrForbidden below so the caller sees the same taxonomy as the
|
||||
// sequential-repeat guard above, and so this request never reaches the
|
||||
// DecrementMentionCounts call past this point for a delete that did
|
||||
// not actually happen.
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrDeletedMessage)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -235,7 +235,26 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro
|
||||
// stale profile indefinitely even though the DB already has the new one.
|
||||
user, err := s.st.GetUserByID(context.WithoutCancel(ctx), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to fetch updated user: %v", ErrInternal, err)
|
||||
// OC-0297: the write above already committed, so this re-read failing
|
||||
// (SQLITE_BUSY, an I/O error, pool exhaustion — anything short of the
|
||||
// context cancellation the comment above already covers) must not be
|
||||
// reported as ErrInternal. A caller that treats any UpdateProfile
|
||||
// error as "the write never landed" — handleUploadAvatar deletes the
|
||||
// file it just stored on that assumption — would otherwise delete a
|
||||
// file the committed avatar column now points at, permanently
|
||||
// breaking it with no user_update ever broadcast. UpdateUserProfile
|
||||
// only ever writes username/avatar/display_name/about, so merging
|
||||
// those four onto the pre-write snapshot (current) reconstructs the
|
||||
// row that is now actually in the database without needing the read
|
||||
// to succeed.
|
||||
slog.Error("UpdateProfile post-commit re-read failed; returning locally merged row",
|
||||
"user_id", userID, "error", err)
|
||||
merged := *current
|
||||
merged.Username = username
|
||||
merged.Avatar = avatar
|
||||
merged.DisplayName = displayName
|
||||
merged.About = about
|
||||
user = &merged
|
||||
}
|
||||
// Audit rows must survive a request canceled after the write committed.
|
||||
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// OC-0297: UpdateProfile's post-commit re-read (the GetUserByID call after
|
||||
// UpdateUserProfile commits) can fail for reasons that have nothing to do
|
||||
// with context cancellation — SQLITE_BUSY, an I/O error, pool exhaustion —
|
||||
// and UpdateProfile currently reports ErrInternal in that case even though
|
||||
// the write already committed. handleUploadAvatar treats any UpdateProfile
|
||||
// error as proof "the column never moved" and deletes the file it just
|
||||
// stored, but here the column DID move: the avatar row now points at a file
|
||||
// that request just deleted, permanently breaking the avatar with no
|
||||
// user_update broadcast to tell anyone. handleUpdateProfile (PATCH
|
||||
// /users/me) has the same exposure: it reports 500 to a client whose rename
|
||||
// actually committed, and never broadcasts it.
|
||||
//
|
||||
// failGetUserByIDAfterStore wraps a real *db.DB and fails GetUserByID from
|
||||
// its Nth call onward, regardless of the context passed in — unlike
|
||||
// cancelOnCommitStore (user_postcommit_ctx_test.go), which fails only on an
|
||||
// already-canceled context. This pins the read failure itself as the
|
||||
// trigger, not cancellation.
|
||||
type failGetUserByIDAfterStore struct {
|
||||
*db.DB
|
||||
failFromCall int // GetUserByID calls at or after this 1-indexed count fail
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *failGetUserByIDAfterStore) GetUserByID(ctx context.Context, id int64) (*db.User, error) {
|
||||
f.calls++
|
||||
if f.calls >= f.failFromCall {
|
||||
return nil, errors.New("database is locked")
|
||||
}
|
||||
return f.DB.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_SurvivesPostCommitReReadError(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "alice", PasswordHash: "h"})
|
||||
|
||||
// Call 1: the pre-write GetUserByID inside UpdateProfile (must succeed so
|
||||
// the merge has a base row). Call 2: the post-commit re-read — this is
|
||||
// the one that fails, simulating a transient DB error that has nothing
|
||||
// to do with request-context cancellation.
|
||||
store := &failGetUserByIDAfterStore{DB: database, failFromCall: 2}
|
||||
svc := NewUserService(store)
|
||||
|
||||
avatarURL := "/api/v1/files/new-avatar-id"
|
||||
u, err := svc.UpdateProfile(context.Background(), 1, ProfilePatch{Avatar: &avatarURL})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProfile returned %v after UpdateUserProfile committed; "+
|
||||
"the write already landed (avatar column now points at the newly "+
|
||||
"uploaded file), so this must still succeed and report the new row "+
|
||||
"— otherwise the caller (handleUploadAvatar) treats the commit as if "+
|
||||
"it never happened and deletes the file the row now points at", err)
|
||||
}
|
||||
if u == nil || u.Avatar == nil || *u.Avatar != avatarURL {
|
||||
t.Fatalf("UpdateProfile returned user %+v, want avatar %q", u, avatarURL)
|
||||
}
|
||||
// Fields UpdateUserProfile never touches must survive the merge intact.
|
||||
if u.Username != "alice" {
|
||||
t.Fatalf("UpdateProfile merged row lost username: got %q, want %q", u.Username, "alice")
|
||||
}
|
||||
|
||||
// The write itself committed regardless — confirm the DB agrees.
|
||||
stored, err := database.GetUserByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
if stored.Avatar == nil || *stored.Avatar != avatarURL {
|
||||
t.Fatalf("stored avatar = %v, want %q", stored.Avatar, avatarURL)
|
||||
}
|
||||
}
|
||||
@@ -305,6 +305,16 @@ func GetClientVoiceJoinTokenForTest(c *Client) string {
|
||||
return c.voiceJoinToken
|
||||
}
|
||||
|
||||
// PeekClientPendingModFlagsForTest reads the moderator-stash flags
|
||||
// (pendingModServerMuted/pendingModServerDeafened) without consuming them,
|
||||
// unlike takePendingModFlags. Lets a test assert what a handler left behind
|
||||
// without also clearing it out from under a later assertion.
|
||||
func PeekClientPendingModFlagsForTest(c *Client) (serverMuted, serverDeafened bool) {
|
||||
c.voiceMu.Lock()
|
||||
defer c.voiceMu.Unlock()
|
||||
return c.pendingModServerMuted, c.pendingModServerDeafened
|
||||
}
|
||||
|
||||
// ExpireSettingsCacheForTest forces the settings cache to appear stale so that
|
||||
// the next call to getCachedSettings triggers a DB refresh.
|
||||
func (h *Hub) ExpireSettingsCacheForTest() {
|
||||
|
||||
@@ -89,6 +89,40 @@ func TestHandleVoiceLeaveV2_RateLimited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A rate-limited voice_leave must still signal LeaveVoice so handlers.go's
|
||||
// error path (handlers.go:109) runs the hub's handleVoiceLeave and reconciles
|
||||
// server-side voice membership with a client that already tore its local
|
||||
// voice session down before sending the frame. Without this, a throttled
|
||||
// leave leaves c.voiceChID and the voice_states row pointing at the old
|
||||
// channel forever, and a later voice_join for that same channel is refused
|
||||
// with ALREADY_JOINED. handleVoiceLeave is a documented no-op when the
|
||||
// client is not in voice, so this is safe even when the throttled attempt
|
||||
// was spurious.
|
||||
func TestHandleVoiceLeaveV2_RateLimited_StillSignalsLeave(t *testing.T) {
|
||||
deps := VoiceDeps{Limiter: auth.NewRateLimiter()}
|
||||
cmd := VoiceLeaveCmd{userID: 1}
|
||||
info := ClientInfo{UserID: 1}
|
||||
|
||||
// voiceLeaveRateLimit (5) per voiceLeaveWindow (1s) — the 6th is rejected.
|
||||
var sawRateLimited bool
|
||||
for range voiceLeaveRateLimit + 1 {
|
||||
res := handleVoiceLeaveV2(context.Background(), cmd, info, deps)
|
||||
if res.Error != nil {
|
||||
ce, ok := res.Error.(ClientError)
|
||||
if !ok || ce.Code != ErrCodeRateLimited {
|
||||
t.Fatalf("expected rate-limit ClientError, got %v", res.Error)
|
||||
}
|
||||
sawRateLimited = true
|
||||
if !res.LeaveVoice {
|
||||
t.Error("rate-limited voice_leave must still set LeaveVoice=true so the client's already-torn-down voice session is reconciled server-side (handlers.go runs handleVoiceLeave only when LeaveVoice is set on an error result)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRateLimited {
|
||||
t.Fatal("expected voice_leave to be rate limited after the burst")
|
||||
}
|
||||
}
|
||||
|
||||
// ── chat_command V2 ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestChatCommandConstructor_Errors(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,19 @@ func (h *Hub) HandleVoiceLeaveForTest(c *Client) {
|
||||
|
||||
// handleMessage parses the envelope and dispatches to the appropriate handler.
|
||||
func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
// kickClient (hub_sweep.go and the ban/expiry paths below) removes c from
|
||||
// the hub and closes its send channels, but never touches the underlying
|
||||
// connection or signals readPump — readPump keeps calling handleMessage
|
||||
// for every frame it reads until the write side eventually times out and
|
||||
// closes the conn (OC-0285). isSendClosed is the same "this client has
|
||||
// been cut off" flag Subscribe already treats as canonical (pubsub.go)
|
||||
// and that closeSend sets synchronously before kickClient returns, so
|
||||
// checking it here — before the session recheck even runs — drops every
|
||||
// frame a kicked/banned/expired client's connection still has buffered,
|
||||
// regardless of which goroutine did the kicking.
|
||||
if c.isSendClosed() {
|
||||
return
|
||||
}
|
||||
if h.handleMessageSessionRecheck(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1912,3 +1912,71 @@ func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) {
|
||||
t.Error("banned user was not kicked after session check")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMessage_KickedClient_FrameNotDispatched pins OC-0285.
|
||||
//
|
||||
// kickClient (hub_sweep.go) only removes the client from the hub map and
|
||||
// closes its send channels — it never touches the underlying connection or
|
||||
// signals readPump. readPump (serve_pumps.go) loops on conn.Read and hands
|
||||
// every frame it reads to hub.handleMessage with no check that the client
|
||||
// was just kicked, so any frame already in flight (pipelined by the peer, or
|
||||
// sitting in the kernel receive buffer) is still dispatched with full
|
||||
// authority after the kick decision was made — including, for chat_send, a
|
||||
// DB write and a broadcast to everyone else in the channel. This reproduces
|
||||
// that window directly: kick the client via the same ban-triggered path
|
||||
// handleMessageSessionRecheck uses, then feed one more chat_send exactly as
|
||||
// readPump would, and confirm it does not get executed.
|
||||
func TestHandleMessage_KickedClient_FrameNotDispatched(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "kicked-user1")
|
||||
chID := seedTestChannel(t, database, "kicked-chan1")
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
hash := auth.HashToken(token)
|
||||
if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
|
||||
// Ban the user, then drive msgCount to exactly SessionCheckInterval so the
|
||||
// next call is the one where handleMessageSessionRecheck discovers the
|
||||
// ban and kicks. Each of these warm-up calls is a normal chat_send, which
|
||||
// dispatches and persists like any pre-kick traffic.
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`UPDATE users SET banned=1, ban_reason='test ban', ban_expires=NULL WHERE id=?`,
|
||||
user.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("ban user: %v", err)
|
||||
}
|
||||
for i := range ws.SessionCheckInterval {
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("warmup %d", i)))
|
||||
}
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Fatal("banned user was not kicked after crossing the session-check threshold")
|
||||
}
|
||||
|
||||
// The bug: nothing stops readPump (simulated here by calling
|
||||
// HandleMessageForTest directly on the same, now-kicked client) from
|
||||
// still handing frames to handleMessage. msgCount was just reset to 0 by
|
||||
// the kicking call, so this frame is far from the next recheck and, on
|
||||
// the buggy code, sails straight through to the chat_send handler.
|
||||
const postKickContent = "post-kick-should-not-persist"
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, postKickContent))
|
||||
|
||||
msgs, err := database.GetMessages(context.Background(), chID, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.Content == postKickContent {
|
||||
t.Fatalf("chat_send from a kicked client was persisted (id=%d): handleMessage dispatched a frame after kickClient had already removed the client and closed its send channels", m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,14 @@ func handleVoiceLeaveV2(_ context.Context, cmd Command, _ ClientInfo, deps any)
|
||||
d := deps.(VoiceDeps)
|
||||
ratKey := auth.Key("voice_leave", cmd.UserID())
|
||||
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) {
|
||||
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice leave attempts"}}
|
||||
// LeaveVoice stays set even on this refusal: a client that already
|
||||
// tore its local voice session down before sending the (throttled)
|
||||
// frame must not be left stuck with server-side state pointing at a
|
||||
// channel it believes it has left. handlers.go's error path runs
|
||||
// handleVoiceLeave whenever LeaveVoice is set, and handleVoiceLeave is
|
||||
// a documented no-op when the client isn't actually in voice, so a
|
||||
// burst of spurious refusals costs nothing.
|
||||
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice leave attempts"}, LeaveVoice: true}
|
||||
}
|
||||
return Result{LeaveVoice: true}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,11 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *
|
||||
// from one who is actually still connected — the same live-connection
|
||||
// rule presentableMembers applies to the members array.
|
||||
svc.Messages.SetOnlineChecker(h.IsUserConnected)
|
||||
// So every DM payload DMService builds (GET/POST /dms, POST
|
||||
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
|
||||
// applies the same live-connection rule instead of only the ready
|
||||
// payload's presentableDMChannels doing so (OC-0304).
|
||||
svc.DMs.SetOnlineChecker(h.IsUserConnected)
|
||||
}
|
||||
|
||||
registerChatHandlers(reg, chatDeps)
|
||||
@@ -498,6 +503,20 @@ func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
|
||||
oldE2EEKey, oldE2EESig := old.getE2EEPubKey()
|
||||
oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()
|
||||
replacedVoiceChID = oldVoiceChID
|
||||
// A moderator-imposed mute/deafen stashed by voice_mod_move
|
||||
// (setPendingModFlags) lives ONLY on the old *Client between the
|
||||
// target's eviction (which deletes the voice_states row that state
|
||||
// normally lives in) and the target's own re-join, which consumes it
|
||||
// via takePendingModFlags (voice_join.go). Any client replacement —
|
||||
// reconnect or full resync alike — must carry it to the new *Client
|
||||
// or it is silently destroyed and the mute is lost (OC-0302).
|
||||
// Unlike the voice-state transfer below, this has none of the
|
||||
// voiceJoinCompleted supersession concerns, so it is not gated on
|
||||
// c.lastSeq > 0: take-and-clear leaves nothing behind for old to
|
||||
// double-serve, and a stash nobody set is always (false, false).
|
||||
if pendingMuted, pendingDeafened := old.takePendingModFlags(); pendingMuted || pendingDeafened {
|
||||
c.setPendingModFlags(pendingMuted, pendingDeafened)
|
||||
}
|
||||
if c.lastSeq > 0 {
|
||||
// Network reconnect — preserve voice state so the user stays
|
||||
// in voice during brief WS drops.
|
||||
@@ -627,6 +646,24 @@ func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
|
||||
if replacedVoiceChID != 0 {
|
||||
h.updateKeyHolder(replacedVoiceChID)
|
||||
}
|
||||
|
||||
// Re-sync this connection's local E2EE peer-key map now that it is
|
||||
// reachable (OC-0276). voice_e2ee_announce is delivered as an
|
||||
// unsequenced pub/sub frame (sendToVoiceChannelExcept, voice_e2ee.go),
|
||||
// bypassing deliverBroadcast/h.replayBuf entirely — so on a network
|
||||
// reconnect (the transfer above), neither reconnect replay tier can ever
|
||||
// redeliver a peer's key, or a mid-call key rotation, that was announced
|
||||
// while this socket was down. voiceJoinComplete's relay
|
||||
// (voice_join.go) only runs on a brand-new voice_join, never here, so
|
||||
// without this call a resumed connection's peer-key map would silently
|
||||
// and permanently desync from its (correctly replayed) voice roster.
|
||||
// c.getVoiceChID() reflects the transfer above, so this covers a
|
||||
// resumed connection as well as a client pre-set into a voice channel
|
||||
// (e.g. NewTestClientWithChannel); it is a no-op whenever c is not
|
||||
// currently in a voice channel, which is the common case (fresh login).
|
||||
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
|
||||
h.sendVoicePeerKeys(c, voiceChID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unregisterNow(c *Client) bool {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package ws
|
||||
|
||||
// oc_0276_voice_e2ee_resync_test.go — regression test for finding OC-0276.
|
||||
//
|
||||
// voice_e2ee_announce is delivered as an unsequenced pub/sub frame:
|
||||
// sendToVoiceChannelExcept (voice_e2ee.go) publishes straight onto the
|
||||
// VoiceTopic via h.pubsub.Publish, bypassing h.broadcast/deliverBroadcast
|
||||
// entirely. It therefore never gets a seq, is never pushed to h.replayBuf,
|
||||
// and is never persisted — so neither reconnect replay tier (buffer or DB)
|
||||
// can ever redeliver one that was queued for a socket that was down when it
|
||||
// was sent. voiceJoinComplete (voice_join.go) is the ONLY other place the
|
||||
// server relays a peer's stored ECDH public key, and that runs solely on a
|
||||
// brand-new voice_join, never on a resume.
|
||||
//
|
||||
// Concretely: a client whose WebSocket blips and resumes (registerNow
|
||||
// transfers its still-completed voice join onto the new connection, see
|
||||
// OC-0270) never recovers a peer's key — or a mid-call key rotation — that
|
||||
// went out while the socket was down, permanently desyncing its local
|
||||
// peer-key map from the room roster even though its voice roster (which IS
|
||||
// sequenced and replayed) stays correct.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
func TestRegisterNow_ResyncsPeerE2EEKeyOnResume(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
|
||||
const (
|
||||
chanID = int64(500)
|
||||
peerID = int64(1)
|
||||
userID = int64(2) // the resuming client
|
||||
)
|
||||
|
||||
// Peer is already connected and in the voice channel, with a stored ECDH
|
||||
// public key from an earlier voice_e2ee_announce (as if it announced, or
|
||||
// re-announced on a LiveKit reconnect, while userID's socket was down).
|
||||
peerSend := make(chan []byte, 8)
|
||||
peer := &Client{userID: peerID, send: peerSend, sendHigh: peerSend, sendLow: peerSend}
|
||||
peer.voiceChID = chanID
|
||||
peer.e2eePubKey = "peer-pub-key-b64"
|
||||
hub.mu.Lock()
|
||||
hub.clients[peerID] = peer
|
||||
hub.mu.Unlock()
|
||||
|
||||
// userID's PREVIOUS connection, still registered, with a completed voice
|
||||
// join for the same channel (voiceJoinCompleted=true) — this is exactly
|
||||
// what registerNow transfers onto a resuming connection per OC-0270.
|
||||
oldSend := make(chan []byte, 8)
|
||||
old := &Client{userID: userID, send: oldSend, sendHigh: oldSend, sendLow: oldSend}
|
||||
old.voiceChID = chanID
|
||||
old.voiceJoinToken = "join-token"
|
||||
old.voiceJoinCompleted = true
|
||||
hub.mu.Lock()
|
||||
hub.clients[userID] = old
|
||||
hub.mu.Unlock()
|
||||
|
||||
// The resuming connection: lastSeq > 0 marks this as a network reconnect
|
||||
// (registerNow only transfers voice state on this path) rather than a
|
||||
// fresh login.
|
||||
newSend := make(chan []byte, 8)
|
||||
newC := &Client{userID: userID, send: newSend, sendHigh: newSend, sendLow: newSend, lastSeq: 1}
|
||||
|
||||
hub.registerNow(newC, nil)
|
||||
|
||||
if got := newC.getVoiceChID(); got != chanID {
|
||||
t.Fatalf("precondition failed: resumed client's voice state was not transferred, got channel %d want %d", got, chanID)
|
||||
}
|
||||
|
||||
close(newSend)
|
||||
var gotAnnounce bool
|
||||
for msg := range newSend {
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal message sent to resumed client: %v (raw=%s)", err, msg)
|
||||
}
|
||||
if env.Type == MsgTypeVoiceE2EEAnnounceBC && env.Payload.UserID == peerID && env.Payload.PublicKey == peer.e2eePubKey {
|
||||
gotAnnounce = true
|
||||
}
|
||||
}
|
||||
if !gotAnnounce {
|
||||
t.Error("resumed client never received the peer's stored ECDH public key (voice_e2ee_announce) on reconnect — " +
|
||||
"OC-0276: the server's only relay of an existing participant's key runs on a fresh voice_join, never on a resume")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ws
|
||||
|
||||
// oc_0298_apply_connect_status_test.go — regression test for OC-0298.
|
||||
//
|
||||
// applyConnectStatus (serve.go) is supposed to be the single place that
|
||||
// settles the status a reconnecting/freshly-connecting session comes online
|
||||
// as: it writes db.ConnectStatus(saved) to users.status and then caches that
|
||||
// same value on c.user.Status, which announceConnectPresence broadcasts and
|
||||
// which buildAuthOK reports back to the connecting client itself.
|
||||
//
|
||||
// When the DB write fails, the old code logged and swallowed the error but
|
||||
// still unconditionally stamped c.user.Status to the new value — so auth_ok
|
||||
// and the presence broadcast both claim a status that was never persisted.
|
||||
// Every later ready payload (built via ListMembers, which reads users.status)
|
||||
// disagrees with what this connected client is telling everyone else about
|
||||
// itself, and nothing ever corrects it because presentableMembers only ever
|
||||
// downgrades a connected user's status to offline, never upgrades one.
|
||||
//
|
||||
// The fix: only stamp c.user.Status when the write actually succeeded, so a
|
||||
// failure leaves the in-memory value equal to whatever is actually in
|
||||
// users.status.
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
func TestApplyConnectStatus_DoesNotStampStatusWhenDBWriteFails(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := database.CreateUser(ctx, "connect-status-user", "hash", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
// Establish a known persisted status distinct from what ConnectStatus
|
||||
// would compute from it, so a wrongly-stamped c.user.Status is
|
||||
// unambiguous. MarkUserDisconnected is what actually leaves a session at
|
||||
// "offline" going into a reconnect, so use that instead of a raw status
|
||||
// write to keep the precondition realistic.
|
||||
if err := database.UpdateUserStatus(ctx, userID, db.StatusOnline); err != nil {
|
||||
t.Fatalf("UpdateUserStatus(online): %v", err)
|
||||
}
|
||||
if err := database.MarkUserDisconnected(ctx, userID); err != nil {
|
||||
t.Fatalf("MarkUserDisconnected: %v", err)
|
||||
}
|
||||
pre, err := database.GetUserByID(ctx, userID)
|
||||
if err != nil || pre == nil {
|
||||
t.Fatalf("GetUserByID (precondition): %v", err)
|
||||
}
|
||||
if pre.Status != db.StatusOffline {
|
||||
t.Fatalf("precondition: expected status=offline after MarkUserDisconnected, got %q", pre.Status)
|
||||
}
|
||||
|
||||
c := newClient(nil, nil, pre, "tokenhash", 0, ctx)
|
||||
|
||||
// A canceled context makes the UpdateUserStatus write fail deterministically
|
||||
// (database/sql refuses to start an exec against an already-canceled
|
||||
// context) without needing a mock DB — applyConnectStatus takes a
|
||||
// concrete *db.DB, not an interface.
|
||||
failCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
applyConnectStatus(failCtx, database, c)
|
||||
|
||||
if c.user.Status != db.StatusOffline {
|
||||
t.Fatalf("c.user.Status = %q after a failed UpdateUserStatus, want unchanged %q — "+
|
||||
"applyConnectStatus must not stamp a new status that was never persisted "+
|
||||
"(auth_ok and the presence broadcast would otherwise claim a value "+
|
||||
"users.status disagrees with)",
|
||||
c.user.Status, db.StatusOffline)
|
||||
}
|
||||
|
||||
post, err := database.GetUserByID(ctx, userID)
|
||||
if err != nil || post == nil {
|
||||
t.Fatalf("GetUserByID (postcondition): %v", err)
|
||||
}
|
||||
if post.Status != db.StatusOffline {
|
||||
t.Fatalf("persisted status after failed applyConnectStatus = %q, want unchanged %q", post.Status, db.StatusOffline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ws
|
||||
|
||||
// oc_0299_refresh_snapshot_role_test.go — regression test for OC-0299.
|
||||
//
|
||||
// refreshUserSnapshot (serve.go) re-reads a handshaking client's user row and,
|
||||
// when the role changed since the auth-time snapshot, looks up the new role's
|
||||
// name to cache on c.roleName. c.roleName is authoritative on the wire —
|
||||
// auth_ok's "role" field, member_join, and every chat_message carry it — so a
|
||||
// role-lookup failure here must fail the whole handshake closed, exactly like
|
||||
// the sibling lookup in upgradeAndAuth (serve.go, "role lookup failed during
|
||||
// handshake") and the one in handleFreshConnect ("role lookup failed,
|
||||
// disconnecting").
|
||||
//
|
||||
// The old code instead defaulted c.roleName to "member" and returned nil,
|
||||
// silently pinning the session to a fabricated role for its whole lifetime.
|
||||
//
|
||||
// This test forces the "role lookup did not resolve" branch by pointing
|
||||
// role_id at a role row that does not exist (bypassing the FK constraint via
|
||||
// a connection-scoped pragma toggle, since the in-memory test DB is a single
|
||||
// connection) — the same GetRoleByID(id) -> (nil, nil) outcome a transient
|
||||
// lookup failure produces in the buggy code's `roleErr == nil && role != nil`
|
||||
// check.
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
// missingRoleID names no row in the roles table.
|
||||
const missingRoleID = int64(999999)
|
||||
|
||||
func TestRefreshUserSnapshot_FailsClosedWhenNewRoleLookupFails(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uid, err := database.CreateUser(ctx, "refresh-role-fail-user", "hash", 4) // 4 = Member, seeded by migration
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
c := newClient(hub, nil, user, "", 0, ctx)
|
||||
c.roleName = "member"
|
||||
|
||||
// Point role_id at a nonexistent role, bypassing the FK constraint that
|
||||
// would otherwise reject this — the point is to exercise
|
||||
// GetRoleByID(missingRoleID) returning (nil, nil), the same shape a
|
||||
// transient lookup failure produces in refreshUserSnapshot.
|
||||
if _, err := database.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil {
|
||||
t.Fatalf("disable foreign_keys: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `UPDATE users SET role_id = ? WHERE id = ?`, missingRoleID, uid); err != nil {
|
||||
t.Fatalf("reassign role to missing role: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("re-enable foreign_keys: %v", err)
|
||||
}
|
||||
|
||||
if role, roleErr := database.GetRoleByID(ctx, missingRoleID); roleErr != nil || role != nil {
|
||||
t.Fatalf("precondition: GetRoleByID(missingRoleID) = (%v, %v), want (nil, nil)", role, roleErr)
|
||||
}
|
||||
|
||||
err = hub.refreshUserSnapshot(ctx, database, c)
|
||||
if err == nil {
|
||||
t.Fatalf("refreshUserSnapshot returned nil error with an unresolvable new role — "+
|
||||
"it must fail closed like upgradeAndAuth's and handleFreshConnect's role lookups, "+
|
||||
"not silently pin the session to roleName=%q", c.roleName)
|
||||
}
|
||||
if c.roleName != "member" {
|
||||
// Not the real bug assertion (an error return is), but documents that
|
||||
// the fabricated default must not have been left in place either.
|
||||
t.Logf("c.roleName after failed refresh = %q", c.roleName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ws
|
||||
|
||||
import "testing"
|
||||
|
||||
// oc_0302_pending_mod_flags_transfer_test.go — regression test for OC-0302.
|
||||
//
|
||||
// voice_mod_move stashes a moderator-imposed mute/deafen on the target's
|
||||
// live *Client (setPendingModFlags) immediately before evicting them from
|
||||
// their current voice channel, because that eviction deletes the
|
||||
// voice_states row those flags normally live in. The stash is meant to
|
||||
// survive until the target's own subsequent voice_join reads it back via
|
||||
// takePendingModFlags (voice_join.go:218) since there is no voice_states row
|
||||
// left for it to be restored from.
|
||||
//
|
||||
// If the target's WebSocket drops and reconnects in that window, registerNow
|
||||
// builds a brand new *Client for the resumed connection. Its
|
||||
// client-replacement transfer block copies voice state, join token,
|
||||
// announced E2EE key, and focused channel from the replaced *Client onto the
|
||||
// new one — but not pendingModServerMuted/pendingModServerDeafened. The
|
||||
// stash was the ONLY place that state lived, so a plain reconnect silently
|
||||
// destroys it: the target's subsequent voice_join finds both flags false and
|
||||
// restores no mute/deafen at all.
|
||||
func TestRegisterNow_ReconnectTransfersPendingModFlags(t *testing.T) {
|
||||
h := newEmitTestHub()
|
||||
|
||||
old := NewTestClient(h, 1, make(chan []byte, 8))
|
||||
h.clients[1] = old
|
||||
// Mirrors voice_moderation.go:443 (handleVoiceModMoveV2 stashing the
|
||||
// target's server_muted/server_deafened state onto their live *Client
|
||||
// right before DisconnectFromVoiceInChannel deletes the voice_states row
|
||||
// those flags normally live in).
|
||||
old.setPendingModFlags(true, false)
|
||||
|
||||
replacement := NewTestClient(h, 1, make(chan []byte, 8))
|
||||
replacement.lastSeq = 1 // network reconnect, not a fresh connect
|
||||
h.registerNow(replacement, nil)
|
||||
|
||||
gotMuted, gotDeafened := replacement.takePendingModFlags()
|
||||
if !gotMuted || gotDeafened {
|
||||
t.Fatalf("registerNow did not transfer pending mod flags across reconnect: "+
|
||||
"replacement.takePendingModFlags() = (%v, %v), want (true, false) "+
|
||||
"(the moderator's mute must survive a WS blip during voice_mod_move)",
|
||||
gotMuted, gotDeafened)
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -371,11 +371,16 @@ func (h *Hub) refreshUserSnapshot(ctx context.Context, database *db.DB, c *Clien
|
||||
return fmt.Errorf("refreshUserSnapshot: user %d is banned", c.userID)
|
||||
}
|
||||
if user.RoleID != c.user.RoleID {
|
||||
roleName := "member"
|
||||
if role, roleErr := database.GetRoleByID(ctx, user.RoleID); roleErr == nil && role != nil {
|
||||
roleName = strings.ToLower(role.Name)
|
||||
// Fail closed like the sibling lookups in upgradeAndAuth and
|
||||
// handleFreshConnect: c.roleName is authoritative on the wire
|
||||
// (auth_ok, member_join, every chat_message), so a lookup failure
|
||||
// must not silently substitute "member" and pin the session to a
|
||||
// fabricated role (OC-0299).
|
||||
role, roleErr := database.GetRoleByID(ctx, user.RoleID)
|
||||
if roleErr != nil || role == nil {
|
||||
return fmt.Errorf("refreshUserSnapshot: role lookup failed for user %d role %d: %w", c.userID, user.RoleID, roleErr)
|
||||
}
|
||||
c.roleName = roleName
|
||||
c.roleName = strings.ToLower(role.Name)
|
||||
}
|
||||
c.user = user
|
||||
return nil
|
||||
@@ -714,6 +719,13 @@ func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {
|
||||
status := db.ConnectStatus(c.user.Status)
|
||||
if updateErr := database.UpdateUserStatus(ctx, c.userID, status); updateErr != nil {
|
||||
slog.Warn("ws UpdateUserStatus", "err", updateErr)
|
||||
// Do not stamp c.user.Status on a failed write: it would make the
|
||||
// auth_ok reply and the presence broadcast below both claim a value
|
||||
// that users.status disagrees with, and buildReady's ListMembers read
|
||||
// of users.status (via presentableMembers, which only ever downgrades
|
||||
// a connected user to offline, never upgrades one) would then never
|
||||
// self-correct for the rest of this session (OC-0298).
|
||||
return
|
||||
}
|
||||
c.user.Status = status
|
||||
}
|
||||
|
||||
@@ -292,3 +292,44 @@ func (h *Hub) GetClientE2EEPubKeyForTest(userID int64) string {
|
||||
key, _ := h.getClientE2EEPubKey(userID)
|
||||
return key
|
||||
}
|
||||
|
||||
// voiceChannelPeerUserIDs returns the user IDs of every currently connected
|
||||
// client in channelID, excluding excludeUserID. Callers must not already
|
||||
// hold h.mu (this takes h.mu.RLock itself).
|
||||
func (h *Hub) voiceChannelPeerUserIDs(channelID, excludeUserID int64) []int64 {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
var ids []int64
|
||||
for uid, c := range h.clients {
|
||||
if uid != excludeUserID && c.getVoiceChID() == channelID {
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// sendVoicePeerKeys sends c the current stored ECDH public key (and its F3
|
||||
// TOFU identity signature) for every other client currently in channelID.
|
||||
//
|
||||
// This is the server's only re-sync path for a client's local peer-key map,
|
||||
// factored out of voiceJoinComplete's per-participant relay loop
|
||||
// (voice_join.go) so both a brand-new voice_join AND a WS reconnect/resume
|
||||
// can use it. voice_e2ee_announce itself is delivered as an unsequenced
|
||||
// pub/sub frame (sendToVoiceChannelExcept -> h.pubsub.Publish, bypassing
|
||||
// deliverBroadcast entirely) — it never gets a seq, is never pushed to
|
||||
// h.replayBuf, and is never persisted, so neither reconnect replay tier
|
||||
// (buffer or DB) can ever redeliver one that was queued for a socket that
|
||||
// was down when it was sent. Before this existed, a client that blipped and
|
||||
// resumed permanently lost every peer key (or mid-call key rotation)
|
||||
// announced while its socket was down — its voice roster (voice_state,
|
||||
// which IS sequenced and replayed) stayed correct while its E2EE peer-key
|
||||
// map silently desynced from it (OC-0276). See registerNow
|
||||
// (hub.go), which calls this for every WS (re)registration that leaves the
|
||||
// client in a voice channel.
|
||||
func (h *Hub) sendVoicePeerKeys(c *Client, channelID int64) {
|
||||
for _, uid := range h.voiceChannelPeerUserIDs(channelID, c.userID) {
|
||||
if pubKey, sig := h.getClientE2EEPubKey(uid); pubKey != "" {
|
||||
c.sendMsg(buildVoiceE2EEAnnounce(uid, pubKey, sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -499,16 +499,16 @@ func (h *Hub) voiceJoinComplete(ctx context.Context, c *Client, ch *db.Channel,
|
||||
|
||||
// Send existing channel voice states to the joiner.
|
||||
//
|
||||
// OC-0172: this read is the ONLY place the server ever relays an existing
|
||||
// participant's stored ECDH public key (voice_e2ee_announce) to a joiner
|
||||
// — mid-call peers never counter-announce, they only answer an offer. A
|
||||
// swallowed error here used to just `return`, leaving the joiner's own
|
||||
// voice_state already broadcast to everyone (above) but the joiner
|
||||
// itself blind to who else is in the channel and unable to complete the
|
||||
// E2EE key exchange: it times out ~15s later with no explanation. Treat
|
||||
// this the same as every other post-commit failure in this handler
|
||||
// (rollbackVoiceJoin + an error frame), broadcasting the compensating
|
||||
// voice_leave for the voice_state that already went out.
|
||||
// OC-0172: this is the ONLY place a brand-new voice_join relays an
|
||||
// existing participant's stored ECDH public key (voice_e2ee_announce) to
|
||||
// a joiner — mid-call peers never counter-announce, they only answer an
|
||||
// offer. A swallowed error here used to just `return`, leaving the
|
||||
// joiner's own voice_state already broadcast to everyone (above) but the
|
||||
// joiner itself blind to who else is in the channel and unable to
|
||||
// complete the E2EE key exchange: it times out ~15s later with no
|
||||
// explanation. Treat this the same as every other post-commit failure in
|
||||
// this handler (rollbackVoiceJoin + an error frame), broadcasting the
|
||||
// compensating voice_leave for the voice_state that already went out.
|
||||
existing, err := h.db.GetChannelVoiceStates(ctx, channelID)
|
||||
if err != nil {
|
||||
slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err)
|
||||
@@ -521,13 +521,14 @@ func (h *Hub) voiceJoinComplete(ctx context.Context, c *Client, ch *db.Channel,
|
||||
continue
|
||||
}
|
||||
c.sendMsg(buildVoiceState(vs))
|
||||
// Send existing participant's ECDH public key (and its identity
|
||||
// signature, F3 TOFU) so the joiner can participate in the
|
||||
// client-side E2EE key exchange.
|
||||
if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != "" {
|
||||
c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))
|
||||
}
|
||||
}
|
||||
// Send every other current participant's ECDH public key (and its
|
||||
// identity signature, F3 TOFU) so the joiner can complete the E2EE key
|
||||
// exchange. Factored into sendVoicePeerKeys (voice_e2ee.go) so the WS
|
||||
// resume path (registerNow, hub.go) can reuse the exact same relay for a
|
||||
// reconnecting client — voice_e2ee_announce itself is an unsequenced
|
||||
// pub/sub frame that no reconnect replay tier can ever recover (OC-0276).
|
||||
h.sendVoicePeerKeys(c, channelID)
|
||||
|
||||
// Send voice_config to the joiner.
|
||||
quality := "medium"
|
||||
|
||||
@@ -187,6 +187,20 @@ func stashPendingModFlags(mod VoiceModerator, targetID int64, serverMuted, serve
|
||||
}
|
||||
}
|
||||
|
||||
// clearPendingModFlags unconditionally wipes any stash left by
|
||||
// stashPendingModFlags. Unlike stashPendingModFlags it cannot early-return on
|
||||
// "both false" — false/false IS the clear — so it calls the setter directly.
|
||||
// Used when a move that stashed flags in anticipation of an eviction turns
|
||||
// out not to have evicted anyone (OC-0278): without this, a refused move
|
||||
// leaves an unbound, unexpiring stash that the target's next unrelated
|
||||
// voice_join (taken with no live row to read the real flags from) would
|
||||
// re-apply as a server mute/deafen nobody currently ordered.
|
||||
func clearPendingModFlags(mod VoiceModerator, targetID int64) {
|
||||
if setter, ok := mod.(voicePendingModFlagsSetter); ok {
|
||||
setter.SetPendingVoiceModFlags(targetID, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVoiceModMuteV2 processes a voice_mod_mute command. The DB row is the
|
||||
// authority for the UI; the SFU mute is what makes it more than cosmetic, so a
|
||||
// LiveKit failure is logged but does not fail the action — the persisted
|
||||
@@ -445,7 +459,12 @@ func handleVoiceModMoveV2(ctx context.Context, cmd Command, info ClientInfo, dep
|
||||
// No live connection on this node — the voice_states row is a ghost the
|
||||
// sweeper owns, and there is nobody to send voice_moved to — or the
|
||||
// target left the checked channel while this handler was deciding, in
|
||||
// which case the move must not follow them.
|
||||
// which case the move must not follow them. Either way the eviction
|
||||
// that would have justified the stash above never happened, so undo
|
||||
// it (OC-0278): left in place, it has no expiry and no binding to
|
||||
// this move, and the target's next unrelated voice_join would consume
|
||||
// it as if a moderator had just muted them.
|
||||
clearPendingModFlags(d.Mod, c.TargetID())
|
||||
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}}
|
||||
}
|
||||
d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))
|
||||
|
||||
@@ -721,6 +721,54 @@ func TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceMod_Move_RefusedMoveClearsPendingModStash locks OC-0278:
|
||||
// handleVoiceModMoveV2 stashes the target's server_muted/server_deafened onto
|
||||
// their live connection (stashPendingModFlags) BEFORE the eviction that is
|
||||
// supposed to justify it. When the target switched channels concurrently —
|
||||
// staged here the same way TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel
|
||||
// stages it, since the interleaving itself cannot be forced from a test — the
|
||||
// scoped eviction (DisconnectFromVoiceInChannel) refuses and the move errors
|
||||
// out, but nothing undoes the stash. The residue has no expiry and no binding
|
||||
// to this move: the target's next voice_join taken with no live row
|
||||
// (currentChID == 0) re-applies a server mute nobody currently ordered,
|
||||
// silently reverting whatever happened to server_muted in the meantime.
|
||||
//
|
||||
// A correct refusal must leave no residue: the stash should only survive a
|
||||
// move that actually evicted the target.
|
||||
func TestVoiceMod_Move_RefusedMoveClearsPendingModStash(t *testing.T) {
|
||||
hub, database := newVoiceModHub(t)
|
||||
chanA := seedVoiceChan(t, database, "vc-move-stash-a")
|
||||
chanB := seedVoiceChan(t, database, "vc-move-stash-b") // where target "concurrently" switched to
|
||||
chanC := seedVoiceChan(t, database, "vc-move-stash-c") // intended destination of the move
|
||||
actor := seedVoiceUserWithRole(t, database, "admin-move-stash", 2)
|
||||
target := seedVoiceUserWithRole(t, database, "member-move-stash", 4)
|
||||
|
||||
targetClient, _ := joinVoice(t, hub, target, chanA)
|
||||
if _, err := database.SetVoiceServerMute(context.Background(), target.ID, chanA, true); err != nil {
|
||||
t.Fatalf("SetVoiceServerMute: %v", err)
|
||||
}
|
||||
// Stage the concurrent switch: the DB row (what voiceModTarget authorizes
|
||||
// against) still names chanA, but the client's live connection — the only
|
||||
// thing the eviction below reads — already names chanB.
|
||||
ws.SetClientVoiceStateForTest(targetClient, chanB, "join-token-b")
|
||||
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, actor, chanA, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
|
||||
hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, chanC))
|
||||
|
||||
if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" {
|
||||
t.Fatalf("error code = %q, want VOICE_ERROR (move must refuse when the target left the authorized channel)", code)
|
||||
}
|
||||
if gotMuted, gotDeafened := ws.PeekClientPendingModFlagsForTest(targetClient); gotMuted || gotDeafened {
|
||||
t.Errorf("pending mod stash after a refused move = (muted=%v, deafened=%v), want (false, false): "+
|
||||
"a refused move must leave no residue for an unrelated later join to re-apply",
|
||||
gotMuted, gotDeafened)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── self-service controls under a server mute ───────────────────────────────
|
||||
|
||||
func TestVoiceMute_SelfUnmuteWhileServerMuted_Refused(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user