fix: resolve the six blocked batch-4 ledger findings (#1393)

* fix(ws): resolve an empty READ audience for a channel whose row is gone

channelReadAudience already failed closed on a GetChannel error; a
deleted channel returns (nil, nil) and fell through to the role scan.
Return nobody for a missing row too — voice teardown callers union the
room's participants and the leaver back in, so their signals still land.

Test locks both halves: the non-participant hears nothing, the leaver
still gets voice_leave. (OC-0090)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): re-elect the key holder in CleanupVoiceForChannel

Every other voice-removal path re-elects (finishVoiceLeave, the LiveKit
webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates); the
channel delete/archive path did not, so a torn-down channel's
voiceKeyHolders entry lived for the process lifetime. One updateKeyHolder
call at the end of the teardown deletes it. (OC-0012)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): implement BroadcastMemberUnban so unban reaches connected clients

The admin unban path reaches the hub through an optional-capability type
assertion that *ws.Hub never satisfied, so it always missed silently and
clients connected during a ban kept the user missing from their member
store. Implement the mirror of BroadcastMemberBan: fan out the same
member_join a fresh connect sends (clients already map it to addMember),
reporting offline since the unbanned user cannot be connected. A
compile-time assertion in admin pins the wiring so the assertion can
never silently miss again. (OC-0058)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): exclude the requester's own flag from the video-cap stream count

EnableCameraIfUnderLimit and EnableScreenshareIfUnderLimit counted every
stream in the channel including the very flag the UPDATE sets, so a user
whose server-side flag was already 1 (client lost track and retried) was
refused at the cap against their own stream, with no path out. Subtract
the outer row's own bit from the correlated count: re-enable becomes
idempotent while the requester's other stream and everyone else's still
count. sqlc layer regenerated. (OC-0081)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ledger): resolve the six blocked batch-4 findings

Four fixed in this branch (OC-0012, OC-0058, OC-0081, OC-0090), each
with an independent revert-proof pass. Two were already fixed on main by
later sibling fixes and are recorded as such: OC-0086 by the OC-0017
pre-delete re-check (#1374), OC-0101 by the OC-0206 early watermark bump
(#1375). The ledger holds zero open and zero blocked findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-19 18:28:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8cf019c03f
commit c86d803a18
13 changed files with 560 additions and 168 deletions
+139 -129
View File
@@ -2,135 +2,7 @@
Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`. Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.
**0 open** · 6 blocked · 180 fixed · 4 declined · 0 refuted · 1 duplicate **0 open** · 0 blocked · 186 fixed · 4 declined · 0 refuted · 1 duplicate
## Blocked — fix attempted, revert-proof failed
### OC-0012 — low — CleanupVoiceForChannel never clears voiceKeyHolders
`Server/ws/hub_sweep.go:290` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election`
Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.
**Repro:** Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.
**Evidence:** Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102
### OC-0058 — low — Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster
`Server/admin/handlers_users.go:169` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync`
The ban path calls hub.BroadcastMemberBan(id) directly (a method *ws.Hub really has), but the unban path routes through a type assertion to memberUnbanBroadcaster, and BroadcastMemberUnban exists nowhere on *ws.Hub (grep finds it only in this file and admin/handlers_users_broadcast_test.go). The assertion always misses, so the DB (users.banned=0, the user is back in ListMembers) and every already-connected client's membersStore — which hard-deleted the row on member_ban — permanently disagree.
**Repro:** Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {"banned": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.
**Evidence:** case *req.Banned && hub != nil:
hub.BroadcastMemberBan(id) // real method on *ws.Hub
case !*req.Banned && hub != nil:
if mub, ok := hub.(memberUnbanBroadcaster); ok {
mub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false
}
**Suggested fix:** Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.
### OC-0081 — low — voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable
`Server/db/queries/sqlite/voice.sql:92` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from "no voice_states row for this channel", and handleVoiceCameraV2 maps both to VIDEO_LIMIT "maximum N video streams reached".
**Repro:** Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/tauri-client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT "maximum 1 video streams reached" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.
**Evidence:** Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.
**Suggested fix:** Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.
### OC-0086 — low — sweepStaleVoiceStates classifies ghost rows from a snapshot and then deletes them without re-checking, so an in-flight voice_join is ejected from the SFU while the hub still believes the user is in the room
`Server/ws/hub_sweep.go:220` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
The ghost loop decides staleness under one h.mu.RLock (lines 202-218) and then acts on that decision in a second, much longer loop that performs a DB delete, a channelReadAudience resolution, a broadcast and a LiveKit RemoveParticipant (5s timeout) per entry. The LeaveVoiceChannelIfMatch guard only protects against the user having moved to a *different* row — it matches the very row the in-flight join just created, because joined_at is identical. The revocation loop in the same function (lines 182-187) applies exactly the opposite discipline via handleVoiceLeaveIfStillIn, with a comment explaining why ("a voice_join to a still-permitted channel may have committed while it ran"); that reasoning was never carried into the ghost loop below it.
**Repro:** Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the `stale` loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.
1. The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.
2. At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to `stale`.
3. X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.
4. Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.
5. Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).
Outcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.
**Evidence:** hub_sweep.go:202-249
h.mu.RLock()
var stale []struct{ userID, channelID int64; joinedAt string }
for _, vs := range allStates {
c, ok := h.clients[vs.UserID]
if !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock
stale = append(stale, ...)
}
}
h.mu.RUnlock()
for _, s := range stale { // <-- acted on here, seconds later, no re-check
deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)
...
h.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))
h.updateKeyHolder(s.channelID)
if h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }
}
contrast, same function, hub_sweep.go:181-187
// The permission check is a DB round-trip; a voice_join to a
// still-permitted channel may have committed while it ran. The
// eviction is conditional on the client still being in the checked
// channel — never on whatever channel it is in by now.
if !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }
the window being raced, voice_join.go:196-222
if err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed
state, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here
...
c.setVoiceState(channelID, state.JoinedAt) // only now non-zero
**Suggested fix:** In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.
### OC-0090 — low — channelReadAudience falls through to a server-wide role scan when the channel row is gone, leaking a private group-DM's voice_leave to every connected member
`Server/ws/hub_broadcast.go:149` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
The DM-audience branch is guarded on `ch != nil && ch.Type == "dm"`. A deleted channel yields `ch == nil` (db.GetChannel returns (nil, nil) on sql.ErrNoRows), so control drops into the generic role scan, which — for an id whose channel row and ON DELETE CASCADE'd channel_overrides no longer exist — resolves to "every connected user whose base role holds READ_MESSAGES". The read error above is deliberately failed closed; the missing-row case is not.
**Repro:** Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the `ch.Type == "dm"` branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {"type":"voice_leave","payload":{"channel_id":G,"user_id":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.
**Evidence:** ch, err := h.db.GetChannel(ctx, channelID)
if err != nil {
slog.Error("ws: channelReadAudience GetChannel failed, denying", ...)
return []int64{}
}
if ch != nil && ch.Type == "dm" { // <- nil (deleted) channel skips the DM audience entirely
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
...
return audience
}
...
for _, uid := range userIDs {
if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {
audience = append(audience, uid)
}
}
**Suggested fix:** In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == "dm"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.
### OC-0101 — medium — Channel-visibility watermark is bumped after the fan-out loop, so a client reconnecting during the loop replays past the change and never converges
`Server/ws/hub_broadcast.go:365` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `ws-hub`
RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via `defer h.bumpVisibilityWatermark()` at hub_broadcast.go:497.
**Repro:** Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.
**Evidence:** hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: "Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.
**Suggested fix:** Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the `defer` on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.
## Fixed ## Fixed
@@ -242,6 +114,18 @@ The signed announce message is domain || userId || ephemeralPubRaw with no chann
**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass **Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass
### OC-0012 — low — CleanupVoiceForChannel never clears voiceKeyHolders
`Server/ws/hub_sweep.go:290` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election`
Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.
**Repro:** Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.
**Evidence:** Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102
**Fixed:** `6f3485e7` · test `TestCleanupVoiceForChannel_ClearsKeyHolder` · revert-proof pass
### OC-0013 — high — REST DM events never bump the visibility watermark — *ws.Hub does not implement dmVisibilityMarker ### OC-0013 — high — REST DM events never bump the visibility watermark — *ws.Hub does not implement dmVisibilityMarker
`Server/api/dm_handler.go:45` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync` `Server/api/dm_handler.go:45` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync`
@@ -1300,6 +1184,25 @@ showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-cont
**Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/context-menu.test.ts` · revert-proof self-reported **Fixed:** `c3837fa` · test `Client/tauri-client/tests/unit/context-menu.test.ts` · revert-proof self-reported
### OC-0058 — low — Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster
`Server/admin/handlers_users.go:169` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync`
The ban path calls hub.BroadcastMemberBan(id) directly (a method *ws.Hub really has), but the unban path routes through a type assertion to memberUnbanBroadcaster, and BroadcastMemberUnban exists nowhere on *ws.Hub (grep finds it only in this file and admin/handlers_users_broadcast_test.go). The assertion always misses, so the DB (users.banned=0, the user is back in ListMembers) and every already-connected client's membersStore — which hard-deleted the row on member_ban — permanently disagree.
**Repro:** Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {"banned": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.
**Evidence:** case *req.Banned && hub != nil:
hub.BroadcastMemberBan(id) // real method on *ws.Hub
case !*req.Banned && hub != nil:
if mub, ok := hub.(memberUnbanBroadcaster); ok {
mub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false
}
**Suggested fix:** Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.
**Fixed:** `d2289560` · test `TestBroadcastMemberUnban_FansOutMemberJoin` · revert-proof pass
### OC-0059 — low — Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to ### OC-0059 — low — Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to
`Client/tauri-client/src/pages/main-page/ChannelController.ts:450` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync` `Client/tauri-client/src/pages/main-page/ChannelController.ts:450` · found 2026-08-12 · hunt `general-2026-08-12` · lens `state-desync`
@@ -1783,6 +1686,20 @@ roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForRecon
**Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass **Fixed:** `7be9ccd2` · test `Client/tauri-client/tests/unit/livekit-session.test.ts` · revert-proof pass
### OC-0081 — low — voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable
`Server/db/queries/sqlite/voice.sql:92` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from "no voice_states row for this channel", and handleVoiceCameraV2 maps both to VIDEO_LIMIT "maximum N video streams reached".
**Repro:** Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/tauri-client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT "maximum 1 video streams reached" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.
**Evidence:** Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.
**Suggested fix:** Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.
**Fixed:** `6a5a3a7c` · test `TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap` · revert-proof pass
### OC-0082 — low — Pinning a soft-deleted message returns HTTP 500: SetMessagePinned leaks db.ErrNotFound unwrapped, and lacks the deleted-message guard its siblings have ### OC-0082 — low — Pinning a soft-deleted message returns HTTP 500: SetMessagePinned leaks db.ErrNotFound unwrapped, and lacks the deleted-message guard its siblings have
`Server/service/message_query.go:227` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service` `Server/service/message_query.go:227` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service`
@@ -1877,6 +1794,56 @@ setAudioVolumeHost(apiConfig.host ?? null);
**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass **Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/sidebar-area.test.ts` · revert-proof pass
### OC-0086 — low — sweepStaleVoiceStates classifies ghost rows from a snapshot and then deletes them without re-checking, so an in-flight voice_join is ejected from the SFU while the hub still believes the user is in the room
`Server/ws/hub_sweep.go:220` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
The ghost loop decides staleness under one h.mu.RLock (lines 202-218) and then acts on that decision in a second, much longer loop that performs a DB delete, a channelReadAudience resolution, a broadcast and a LiveKit RemoveParticipant (5s timeout) per entry. The LeaveVoiceChannelIfMatch guard only protects against the user having moved to a *different* row — it matches the very row the in-flight join just created, because joined_at is identical. The revocation loop in the same function (lines 182-187) applies exactly the opposite discipline via handleVoiceLeaveIfStillIn, with a comment explaining why ("a voice_join to a still-permitted channel may have committed while it ran"); that reasoning was never carried into the ghost loop below it.
**Repro:** Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the `stale` loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.
1. The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.
2. At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to `stale`.
3. X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.
4. Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.
5. Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).
Outcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.
**Evidence:** hub_sweep.go:202-249
h.mu.RLock()
var stale []struct{ userID, channelID int64; joinedAt string }
for _, vs := range allStates {
c, ok := h.clients[vs.UserID]
if !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock
stale = append(stale, ...)
}
}
h.mu.RUnlock()
for _, s := range stale { // <-- acted on here, seconds later, no re-check
deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)
...
h.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))
h.updateKeyHolder(s.channelID)
if h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }
}
contrast, same function, hub_sweep.go:181-187
// The permission check is a DB round-trip; a voice_join to a
// still-permitted channel may have committed while it ran. The
// eviction is conditional on the client still being in the checked
// channel — never on whatever channel it is in by now.
if !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }
the window being raced, voice_join.go:196-222
if err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed
state, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here
...
c.setVoiceState(channelID, state.JoinedAt) // only now non-zero
**Suggested fix:** In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.
**Fixed:** `b8b7a2a1` · test `TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow` · revert-proof covered-by-OC-0017
### OC-0087 — low — Global search silently drops every DM hit when the DM-id lookup fails, and reports success ### OC-0087 — low — Global search silently drops every DM hit when the DM-id lookup fails, and reports success
`Server/service/message_perms.go:44` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service` `Server/service/message_perms.go:44` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service`
@@ -1955,6 +1922,35 @@ tauri = 2.11.5 (src-tauri/Cargo.lock:4809-4810), which carries the replace-not-a
**Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/tauri-conf-webview2-args.test.ts` · revert-proof pass **Fixed:** `8787b906` · test `Client/tauri-client/tests/unit/tauri-conf-webview2-args.test.ts` · revert-proof pass
### OC-0090 — low — channelReadAudience falls through to a server-wide role scan when the channel row is gone, leaking a private group-DM's voice_leave to every connected member
`Server/ws/hub_broadcast.go:149` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-ws`
The DM-audience branch is guarded on `ch != nil && ch.Type == "dm"`. A deleted channel yields `ch == nil` (db.GetChannel returns (nil, nil) on sql.ErrNoRows), so control drops into the generic role scan, which — for an id whose channel row and ON DELETE CASCADE'd channel_overrides no longer exist — resolves to "every connected user whose base role holds READ_MESSAGES". The read error above is deliberately failed closed; the missing-row case is not.
**Repro:** Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the `ch.Type == "dm"` branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {"type":"voice_leave","payload":{"channel_id":G,"user_id":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.
**Evidence:** ch, err := h.db.GetChannel(ctx, channelID)
if err != nil {
slog.Error("ws: channelReadAudience GetChannel failed, denying", ...)
return []int64{}
}
if ch != nil && ch.Type == "dm" { // <- nil (deleted) channel skips the DM audience entirely
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
...
return audience
}
...
for _, uid := range userIDs {
if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {
audience = append(audience, uid)
}
}
**Suggested fix:** In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == "dm"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.
**Fixed:** `b22ad84d` · test `TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant` · revert-proof pass
### OC-0091 — low — chat_command is the only client message type registered without a rate limiter, and each frame runs a WASM plugin invocation ### OC-0091 — low — chat_command is the only client message type registered without a rate limiter, and each frame runs a WASM plugin invocation
`Server/ws/hub.go:155` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service` `Server/ws/hub.go:155` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service`
@@ -2177,6 +2173,20 @@ service/user.go:107-110 // "a plain sanitizer.Sanitize call would persist and d
**Fixed:** `8579cb5d` · test `Server/api/profile_handler_test.go` · revert-proof pass **Fixed:** `8579cb5d` · test `Server/api/profile_handler_test.go` · revert-proof pass
### OC-0101 — medium — Channel-visibility watermark is bumped after the fan-out loop, so a client reconnecting during the loop replays past the change and never converges
`Server/ws/hub_broadcast.go:365` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `ws-hub`
RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via `defer h.bumpVisibilityWatermark()` at hub_broadcast.go:497.
**Repro:** Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.
**Evidence:** hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: "Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.
**Suggested fix:** Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the `defer` on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.
**Fixed:** `ea0430c5` · test `TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady` · revert-proof covered-by-OC-0206
### OC-0102 — medium — Avatar upload rewrites the username from a pre-lock snapshot, silently reverting a concurrent rename ### OC-0102 — medium — Avatar upload rewrites the username from a pre-lock snapshot, silently reverting a concurrent rename
`Server/api/profile_handler.go:575` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `api-authz` `Server/api/profile_handler.go:575` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `api-authz`
+49 -25
View File
@@ -231,13 +231,17 @@
"why": "Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.", "why": "Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.",
"repro": "Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.", "repro": "Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.",
"evidence": "Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102", "evidence": "Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102",
"status": "blocked", "status": "fixed",
"found": "2026-08-09", "found": "2026-08-09",
"hunt": "voice-e2ee-2026-08-09", "hunt": "voice-e2ee-2026-08-09",
"lens": "keyholder-election", "lens": "keyholder-election",
"fix": null, "fix": {
"rationale": "cross-cluster edit: shares Server/db/voice_queries.go with Server/db/queries/sqlite/voice.sql - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "commit": "6f3485e7",
"blockedDate": "2026-08-14" "test": "TestCleanupVoiceForChannel_ClearsKeyHolder",
"revertProof": "pass"
},
"fixed": "2026-08-19",
"note": "CleanupVoiceForChannel now re-elects via updateKeyHolder"
}, },
{ {
"id": "OC-0013", "id": "OC-0013",
@@ -1386,16 +1390,20 @@
"repro": "Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {\"banned\": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.", "repro": "Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {\"banned\": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.",
"evidence": "case *req.Banned && hub != nil:\n\thub.BroadcastMemberBan(id) // real method on *ws.Hub\ncase !*req.Banned && hub != nil:\n\tif mub, ok := hub.(memberUnbanBroadcaster); ok {\n\t\tmub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false\n\t}", "evidence": "case *req.Banned && hub != nil:\n\thub.BroadcastMemberBan(id) // real method on *ws.Hub\ncase !*req.Banned && hub != nil:\n\tif mub, ok := hub.(memberUnbanBroadcaster); ok {\n\t\tmub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false\n\t}",
"suggestedFix": "Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.", "suggestedFix": "Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.",
"status": "blocked", "status": "fixed",
"found": "2026-08-12", "found": "2026-08-12",
"hunt": "general-2026-08-12", "hunt": "general-2026-08-12",
"lens": "state-desync", "lens": "state-desync",
"finder": "opus", "finder": "opus",
"round": 2, "round": 2,
"confidence": "high", "confidence": "high",
"fix": null, "fix": {
"rationale": "cross-cluster edit: shares Server/ws/hub_broadcast.go with the hub_broadcast cluster - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "commit": "d2289560",
"blockedDate": "2026-08-14" "test": "TestBroadcastMemberUnban_FansOutMemberJoin",
"revertProof": "pass"
},
"fixed": "2026-08-19",
"note": "BroadcastMemberUnban implemented on *ws.Hub; compile-time wiring assertion added in admin"
}, },
{ {
"id": "OC-0059", "id": "OC-0059",
@@ -1965,16 +1973,20 @@
"repro": "Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/tauri-client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT \"maximum 1 video streams reached\" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.", "repro": "Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/tauri-client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT \"maximum 1 video streams reached\" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.",
"evidence": "Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.", "evidence": "Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.",
"suggestedFix": "Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.", "suggestedFix": "Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.",
"status": "blocked", "status": "fixed",
"found": "2026-08-12", "found": "2026-08-12",
"hunt": "general-2026-08-12", "hunt": "general-2026-08-12",
"lens": "hotspot-server-ws", "lens": "hotspot-server-ws",
"finder": "opus", "finder": "opus",
"round": 6, "round": 6,
"confidence": "high", "confidence": "high",
"fix": null, "fix": {
"rationale": "cross-cluster edit: shares Server/db/voice_queries.go with Server/ws/hub_sweep.go - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "commit": "6a5a3a7c",
"blockedDate": "2026-08-14" "test": "TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap",
"revertProof": "pass"
},
"fixed": "2026-08-19",
"note": "own-flag exclusion in both Enable*IfUnderLimit gates; sqlc regenerated"
}, },
{ {
"id": "OC-0082", "id": "OC-0082",
@@ -2088,16 +2100,20 @@
"repro": "Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the `stale` loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.\n1. The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.\n2. At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to `stale`.\n3. X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.\n4. Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.\n5. Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).\nOutcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.", "repro": "Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the `stale` loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.\n1. The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.\n2. At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to `stale`.\n3. X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.\n4. Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.\n5. Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).\nOutcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.",
"evidence": "hub_sweep.go:202-249\n\th.mu.RLock()\n\tvar stale []struct{ userID, channelID int64; joinedAt string }\n\tfor _, vs := range allStates {\n\t\tc, ok := h.clients[vs.UserID]\n\t\tif !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock\n\t\t\tstale = append(stale, ...)\n\t\t}\n\t}\n\th.mu.RUnlock()\n\n\tfor _, s := range stale { // <-- acted on here, seconds later, no re-check\n\t\tdeleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)\n\t\t...\n\t\th.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))\n\t\th.updateKeyHolder(s.channelID)\n\t\tif h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }\n\t}\n\ncontrast, same function, hub_sweep.go:181-187\n\t\t// The permission check is a DB round-trip; a voice_join to a\n\t\t// still-permitted channel may have committed while it ran. The\n\t\t// eviction is conditional on the client still being in the checked\n\t\t// channel — never on whatever channel it is in by now.\n\t\tif !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }\n\nthe window being raced, voice_join.go:196-222\n\t\tif err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed\n\t\tstate, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here\n\t\t...\n\t\tc.setVoiceState(channelID, state.JoinedAt) // only now non-zero", "evidence": "hub_sweep.go:202-249\n\th.mu.RLock()\n\tvar stale []struct{ userID, channelID int64; joinedAt string }\n\tfor _, vs := range allStates {\n\t\tc, ok := h.clients[vs.UserID]\n\t\tif !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock\n\t\t\tstale = append(stale, ...)\n\t\t}\n\t}\n\th.mu.RUnlock()\n\n\tfor _, s := range stale { // <-- acted on here, seconds later, no re-check\n\t\tdeleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)\n\t\t...\n\t\th.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))\n\t\th.updateKeyHolder(s.channelID)\n\t\tif h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }\n\t}\n\ncontrast, same function, hub_sweep.go:181-187\n\t\t// The permission check is a DB round-trip; a voice_join to a\n\t\t// still-permitted channel may have committed while it ran. The\n\t\t// eviction is conditional on the client still being in the checked\n\t\t// channel — never on whatever channel it is in by now.\n\t\tif !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }\n\nthe window being raced, voice_join.go:196-222\n\t\tif err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed\n\t\tstate, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here\n\t\t...\n\t\tc.setVoiceState(channelID, state.JoinedAt) // only now non-zero",
"suggestedFix": "In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.", "suggestedFix": "In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.",
"status": "blocked", "status": "fixed",
"found": "2026-08-12", "found": "2026-08-12",
"hunt": "general-2026-08-12", "hunt": "general-2026-08-12",
"lens": "hotspot-server-ws", "lens": "hotspot-server-ws",
"finder": "opus", "finder": "opus",
"round": 7, "round": 7,
"confidence": "high", "confidence": "high",
"fix": null, "fix": {
"rationale": "cross-cluster edit: shares Server/db/voice_queries.go with Server/db/queries/sqlite/voice.sql - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "commit": "b8b7a2a1",
"blockedDate": "2026-08-14" "test": "TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow",
"revertProof": "covered-by-OC-0017"
},
"fixed": "2026-08-19",
"note": "already fixed on main by the OC-0017 pre-delete live-client re-check (PR #1374); verified against HEAD 2026-08-19"
}, },
{ {
"id": "OC-0087", "id": "OC-0087",
@@ -2186,16 +2202,20 @@
"repro": "Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the `ch.Type == \"dm\"` branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {\"type\":\"voice_leave\",\"payload\":{\"channel_id\":G,\"user_id\":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.", "repro": "Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the `ch.Type == \"dm\"` branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {\"type\":\"voice_leave\",\"payload\":{\"channel_id\":G,\"user_id\":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.",
"evidence": "ch, err := h.db.GetChannel(ctx, channelID)\nif err != nil {\n slog.Error(\"ws: channelReadAudience GetChannel failed, denying\", ...)\n return []int64{}\n}\nif ch != nil && ch.Type == \"dm\" { // <- nil (deleted) channel skips the DM audience entirely\n participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)\n ...\n return audience\n}\n...\nfor _, uid := range userIDs {\n if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {\n audience = append(audience, uid)\n }\n}", "evidence": "ch, err := h.db.GetChannel(ctx, channelID)\nif err != nil {\n slog.Error(\"ws: channelReadAudience GetChannel failed, denying\", ...)\n return []int64{}\n}\nif ch != nil && ch.Type == \"dm\" { // <- nil (deleted) channel skips the DM audience entirely\n participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)\n ...\n return audience\n}\n...\nfor _, uid := range userIDs {\n if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {\n audience = append(audience, uid)\n }\n}",
"suggestedFix": "In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == \"dm\"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.", "suggestedFix": "In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == \"dm\"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.",
"status": "blocked", "status": "fixed",
"found": "2026-08-12", "found": "2026-08-12",
"hunt": "general-2026-08-12", "hunt": "general-2026-08-12",
"lens": "hotspot-server-ws", "lens": "hotspot-server-ws",
"finder": "opus", "finder": "opus",
"round": 8, "round": 8,
"confidence": "high", "confidence": "high",
"fix": null, "fix": {
"rationale": "cross-cluster edit: shares Server/ws/hub_broadcast.go with Server/admin/handlers_users.go - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "commit": "b22ad84d",
"blockedDate": "2026-08-14" "test": "TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant",
"revertProof": "pass"
},
"fixed": "2026-08-19",
"note": "fail closed on a missing channel row in channelReadAudienceImpl"
}, },
{ {
"id": "OC-0091", "id": "OC-0091",
@@ -2444,16 +2464,20 @@
"why": "RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via `defer h.bumpVisibilityWatermark()` at hub_broadcast.go:497.", "why": "RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via `defer h.bumpVisibilityWatermark()` at hub_broadcast.go:497.",
"repro": "Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.", "repro": "Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.",
"evidence": "hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: \"Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path\" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.", "evidence": "hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: \"Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path\" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.",
"status": "blocked", "status": "fixed",
"found": "2026-08-13", "found": "2026-08-13",
"hunt": "2026-08-13-postopt", "hunt": "2026-08-13-postopt",
"lens": "ws-hub", "lens": "ws-hub",
"finder": "opus", "finder": "opus",
"confidence": "medium", "confidence": "medium",
"fix": null, "fix": {
"commit": "ea0430c5",
"test": "TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady",
"revertProof": "covered-by-OC-0206"
},
"suggestedFix": "Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the `defer` on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.", "suggestedFix": "Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the `defer` on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.",
"rationale": "cross-cluster edit: shares Server/ws/hub_broadcast.go with Server/admin/handlers_users.go - needs a human (agent edits saved in .superpowers/debris-2026-08-14.patch)", "fixed": "2026-08-19",
"blockedDate": "2026-08-14" "note": "already fixed on main by the OC-0206 early watermark bump in both fan-out paths (PR #1375); verified against HEAD 2026-08-19"
}, },
{ {
"id": "OC-0102", "id": "OC-0102",
@@ -4601,4 +4625,4 @@
"fixedDate": "2026-08-19" "fixedDate": "2026-08-19"
} }
] ]
} }
+10
View File
@@ -0,0 +1,10 @@
package admin
import "github.com/owncord/server/ws"
// OC-0058: handlePatchUser reaches BroadcastMemberUnban through a type
// assertion, which fails silently if *ws.Hub ever loses (or never had) the
// method. This compile-time check turns that silent miss into a build error.
// In-package (not admin_test) so it can see the unexported interface; a test
// file so production admin still depends only on HubBroadcaster.
var _ memberUnbanBroadcaster = (*ws.Hub)(nil)
+11 -2
View File
@@ -76,10 +76,19 @@ type Querier interface {
// OC-0023), and a single user with both flags set must consume two of the N // OC-0023), and a single user with both flags set must consume two of the N
// slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare` // slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare`
// across the channel's rows rather than counting rows where either is set. // across the channel's rows rather than counting rows where either is set.
// The enabling user's own bit is still 0 at gate time, so no self-exclusion // The enabling user's own bit for the flag being set CAN already be 1 at
// term is needed. // gate time (a client that lost track of the server-side flag retries the
// enable), so each gate excludes exactly that one bit from the count --
// see the per-query comments below (OC-0081).
// The channel-wide stream count excludes the requester's own camera flag
// (subtracted via the correlated outer-row reference), so re-enabling an
// already-set camera is idempotent at the cap instead of being refused
// against the requester's own stream (OC-0081). Their screenshare, and
// every other user's streams, still count.
EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error)
EnablePlugin(ctx context.Context, id int64) error EnablePlugin(ctx context.Context, id int64) error
// Mirror of EnableCameraIfUnderLimit: the count excludes the requester's
// own screenshare flag so re-enable is idempotent at the cap (OC-0081).
EnableScreenshareIfUnderLimit(ctx context.Context, arg EnableScreenshareIfUnderLimitParams) (sql.Result, error) EnableScreenshareIfUnderLimit(ctx context.Context, arg EnableScreenshareIfUnderLimitParams) (sql.Result, error)
EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error
ForceLogoutUser(ctx context.Context, userID int64) error ForceLogoutUser(ctx context.Context, userID int64) error
+13 -4
View File
@@ -102,7 +102,7 @@ const enableCameraIfUnderLimit = `-- name: EnableCameraIfUnderLimit :execresult
UPDATE voice_states SET camera = 1 UPDATE voice_states SET camera = 1
WHERE voice_states.user_id = ? AND voice_states.channel_id = ? WHERE voice_states.user_id = ? AND voice_states.channel_id = ?
AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) < ?4 AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) - voice_states.camera < ?4
` `
type EnableCameraIfUnderLimitParams struct { type EnableCameraIfUnderLimitParams struct {
@@ -118,8 +118,15 @@ type EnableCameraIfUnderLimitParams struct {
// OC-0023), and a single user with both flags set must consume two of the N // OC-0023), and a single user with both flags set must consume two of the N
// slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare` // slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare`
// across the channel's rows rather than counting rows where either is set. // across the channel's rows rather than counting rows where either is set.
// The enabling user's own bit is still 0 at gate time, so no self-exclusion // The enabling user's own bit for the flag being set CAN already be 1 at
// term is needed. // gate time (a client that lost track of the server-side flag retries the
// enable), so each gate excludes exactly that one bit from the count --
// see the per-query comments below (OC-0081).
// The channel-wide stream count excludes the requester's own camera flag
// (subtracted via the correlated outer-row reference), so re-enabling an
// already-set camera is idempotent at the cap instead of being refused
// against the requester's own stream (OC-0081). Their screenshare, and
// every other user's streams, still count.
func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error) { func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error) {
return q.db.ExecContext(ctx, enableCameraIfUnderLimit, return q.db.ExecContext(ctx, enableCameraIfUnderLimit,
arg.UserID, arg.UserID,
@@ -132,7 +139,7 @@ func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCamera
const enableScreenshareIfUnderLimit = `-- name: EnableScreenshareIfUnderLimit :execresult const enableScreenshareIfUnderLimit = `-- name: EnableScreenshareIfUnderLimit :execresult
UPDATE voice_states SET screenshare = 1 UPDATE voice_states SET screenshare = 1
WHERE voice_states.user_id = ? AND voice_states.channel_id = ? WHERE voice_states.user_id = ? AND voice_states.channel_id = ?
AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) < ?4 AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) - voice_states.screenshare < ?4
` `
type EnableScreenshareIfUnderLimitParams struct { type EnableScreenshareIfUnderLimitParams struct {
@@ -142,6 +149,8 @@ type EnableScreenshareIfUnderLimitParams struct {
MaxVideo int64 `json:"maxVideo"` MaxVideo int64 `json:"maxVideo"`
} }
// Mirror of EnableCameraIfUnderLimit: the count excludes the requester's
// own screenshare flag so re-enable is idempotent at the cap (OC-0081).
func (q *Queries) EnableScreenshareIfUnderLimit(ctx context.Context, arg EnableScreenshareIfUnderLimitParams) (sql.Result, error) { func (q *Queries) EnableScreenshareIfUnderLimit(ctx context.Context, arg EnableScreenshareIfUnderLimitParams) (sql.Result, error) {
return q.db.ExecContext(ctx, enableScreenshareIfUnderLimit, return q.db.ExecContext(ctx, enableScreenshareIfUnderLimit,
arg.UserID, arg.UserID,
@@ -0,0 +1,93 @@
package db_test
import (
"context"
"testing"
)
// OC-0081: EnableCameraIfUnderLimit's guard subquery counted every video
// stream in the channel INCLUDING the requester's own camera row, so a user
// whose server-side camera flag was already 1 could never re-enable at the
// cap: the sole publisher in a max_video=1 room got VIDEO_LIMIT against
// their own stream, with no path out (the client believes the camera is off
// and never sends a disable). Re-enable must be idempotent; a genuinely new
// publisher at the cap must still be refused; the requester's OTHER stream
// (screenshare) must still count against enabling their camera.
func TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-reen-u1")
chanID := seedVoiceChannel(t, database, "cam-reen-ch")
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 1)
if err != nil || !ok {
t.Fatalf("first enable: ok=%v err=%v, want true", ok, err)
}
// Same user, camera row already 1, still the only stream in the room.
ok, err = database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 1)
if err != nil {
t.Fatalf("re-enable: %v", err)
}
if !ok {
t.Error("re-enable at the cap was refused against the requester's own camera row")
}
}
func TestVoice_EnableCameraIfUnderLimit_OtherUserStillRefusedAtCap(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-cap-u1")
u2 := seedVoiceUser(t, database, "cam-cap-u2")
chanID := seedVoiceChannel(t, database, "cam-cap-ch")
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
_ = database.JoinVoiceChannel(context.Background(), u2, chanID)
if ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 1); err != nil || !ok {
t.Fatalf("u1 enable: ok=%v err=%v, want true", ok, err)
}
ok, err := database.EnableCameraIfUnderLimit(context.Background(), u2, chanID, 1)
if err != nil {
t.Fatalf("u2 enable: %v", err)
}
if ok {
t.Error("a new publisher was admitted past the video cap")
}
}
func TestVoice_EnableCameraIfUnderLimit_OwnScreenshareStillCounts(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-ss-u1")
chanID := seedVoiceChannel(t, database, "cam-ss-ch")
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
if ok, err := database.EnableScreenshareIfUnderLimit(context.Background(), u1, chanID, 1); err != nil || !ok {
t.Fatalf("screenshare enable: ok=%v err=%v, want true", ok, err)
}
// The camera would be a SECOND stream from this user; the cap is 1.
ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 1)
if err != nil {
t.Fatalf("camera enable: %v", err)
}
if ok {
t.Error("own screenshare must still count toward the cap when enabling the camera")
}
}
func TestVoice_EnableScreenshareIfUnderLimit_ReEnableIdempotentAtCap(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "ss-reen-u1")
chanID := seedVoiceChannel(t, database, "ss-reen-ch")
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
if ok, err := database.EnableScreenshareIfUnderLimit(context.Background(), u1, chanID, 1); err != nil || !ok {
t.Fatalf("first enable: ok=%v err=%v, want true", ok, err)
}
ok, err := database.EnableScreenshareIfUnderLimit(context.Background(), u1, chanID, 1)
if err != nil {
t.Fatalf("re-enable: %v", err)
}
if !ok {
t.Error("screenshare re-enable at the cap was refused against the requester's own row")
}
}
+13 -4
View File
@@ -99,18 +99,27 @@ UPDATE voice_states SET server_deafened = 0 WHERE user_id = ? AND channel_id = ?
-- OC-0023), and a single user with both flags set must consume two of the N -- OC-0023), and a single user with both flags set must consume two of the N
-- slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare` -- slots, not one (OC-0006) -- so both gates sum `vs2.camera + vs2.screenshare`
-- across the channel's rows rather than counting rows where either is set. -- across the channel's rows rather than counting rows where either is set.
-- The enabling user's own bit is still 0 at gate time, so no self-exclusion -- The enabling user's own bit for the flag being set CAN already be 1 at
-- term is needed. -- gate time (a client that lost track of the server-side flag retries the
-- enable), so each gate excludes exactly that one bit from the count --
-- see the per-query comments below (OC-0081).
-- name: EnableCameraIfUnderLimit :execresult -- name: EnableCameraIfUnderLimit :execresult
-- The channel-wide stream count excludes the requester's own camera flag
-- (subtracted via the correlated outer-row reference), so re-enabling an
-- already-set camera is idempotent at the cap instead of being refused
-- against the requester's own stream (OC-0081). Their screenshare, and
-- every other user's streams, still count.
UPDATE voice_states SET camera = 1 UPDATE voice_states SET camera = 1
WHERE voice_states.user_id = ? AND voice_states.channel_id = ? WHERE voice_states.user_id = ? AND voice_states.channel_id = ?
AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) < sqlc.arg(max_video); AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) - voice_states.camera < sqlc.arg(max_video);
-- name: EnableScreenshareIfUnderLimit :execresult -- name: EnableScreenshareIfUnderLimit :execresult
-- Mirror of EnableCameraIfUnderLimit: the count excludes the requester's
-- own screenshare flag so re-enable is idempotent at the cap (OC-0081).
UPDATE voice_states SET screenshare = 1 UPDATE voice_states SET screenshare = 1
WHERE voice_states.user_id = ? AND voice_states.channel_id = ? WHERE voice_states.user_id = ? AND voice_states.channel_id = ?
AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) < sqlc.arg(max_video); AND (SELECT COALESCE(SUM(vs2.camera), 0) + COALESCE(SUM(vs2.screenshare), 0) FROM voice_states AS vs2 WHERE vs2.channel_id = ?) - voice_states.screenshare < sqlc.arg(max_video);
-- name: ClearVoiceState :exec -- name: ClearVoiceState :exec
DELETE FROM voice_states WHERE user_id = ?; DELETE FROM voice_states WHERE user_id = ?;
+38 -2
View File
@@ -234,6 +234,16 @@ func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, igno
"channel_id", channelID, "err", err) "channel_id", channelID, "err", err)
return []int64{} return []int64{}
} }
// Fail closed on a missing row too (OC-0090): GetChannel returns
// (nil, nil) for a deleted channel, and falling through would hand a
// channel with no override rows left to the role scan below — which
// resolves to every connected user with base READ_MESSAGES, leaking
// e.g. a closed group-DM's voice_leave server-wide. Callers that
// tear down voice union the room's participants and the leaver back
// in afterwards, so eviction/E2EE-teardown signals still arrive.
if ch == nil {
return []int64{}
}
// Archived channels are hidden from every client regardless of // Archived channels are hidden from every client regardless of
// permissions, mirroring RefreshChannelVisibility and VisibleChannelIDs. // permissions, mirroring RefreshChannelVisibility and VisibleChannelIDs.
// Without this, an admin edit to an archived channel (or a voice // Without this, an admin edit to an archived channel (or a voice
@@ -241,10 +251,10 @@ func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, igno
// base role holds READ_MESSAGES, none of whom have the channel in their // base role holds READ_MESSAGES, none of whom have the channel in their
// ready payload or sidebar. ignoreArchived opts a caller out of this // ready payload or sidebar. ignoreArchived opts a caller out of this
// specific check only — see channelReadAudienceIgnoringArchived. // specific check only — see channelReadAudienceIgnoringArchived.
if ch != nil && ch.Archived && !ignoreArchived { if ch.Archived && !ignoreArchived {
return []int64{} return []int64{}
} }
if ch != nil && ch.Type == "dm" { if ch.Type == "dm" {
return h.channelReadAudienceDM(ctx, channelID, userIDs) return h.channelReadAudienceDM(ctx, channelID, userIDs)
} }
} }
@@ -580,6 +590,32 @@ func (h *Hub) BroadcastMemberBan(userID int64) {
h.DisconnectUser(userID) h.DisconnectUser(userID)
} }
// BroadcastMemberUnban is the mirror of BroadcastMemberBan: member_ban
// hard-deletes the row on every connected client, so an unban must re-add it
// or clients connected through the ban permanently disagree with freshly
// connecting ones. Fans out the same member_join a fresh connect would
// (clients map it to addMember — no protocol change), satisfying the admin
// package's memberUnbanBroadcaster capability; admin's hub_wiring_test.go
// pins that at compile time. (OC-0058)
func (h *Hub) BroadcastMemberUnban(userID int64) {
ctx := context.Background()
user, err := h.db.GetUserByID(ctx, userID)
if err != nil || user == nil {
slog.Error("hub: BroadcastMemberUnban GetUserByID failed", "user_id", userID, "err", err)
return
}
roleName := ""
if role, err := h.db.GetRoleForUser(ctx, userID); err == nil && role != nil {
roleName = role.Name
}
// The ban disconnected them and reconnecting was refused while banned,
// so they cannot be online at unban time — report offline regardless of
// the stale status the row carries (serve_ready's "no live connection is
// offline, whatever the row says" rule).
user.Status = "offline"
h.BroadcastToAll(buildMemberJoin(user, roleName))
}
// DisconnectUser forcibly disconnects the client identified by userID. // DisconnectUser forcibly disconnects the client identified by userID.
// No-op if the user is not currently connected. // No-op if the user is not currently connected.
func (h *Hub) DisconnectUser(userID int64) { func (h *Hub) DisconnectUser(userID int64) {
+9
View File
@@ -440,4 +440,13 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) {
for _, vs := range states { for _, vs := range states {
h.broadcastChannelScopedTo(channelID, buildVoiceLeave(channelID, vs.UserID), audience, "voice event") h.broadcastChannelScopedTo(channelID, buildVoiceLeave(channelID, vs.UserID), audience, "voice event")
} }
// Re-elect the key holder now that the room is torn down — every other
// removal path does this (finishVoiceLeave, the LiveKit webhook,
// registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). All client
// voice states for this channel were cleared above, so this deletes the
// voiceKeyHolders entry; without it a deleted channel's entry lived for
// the process lifetime (OC-0012). No locks are held here, as
// updateKeyHolder requires.
h.updateKeyHolder(channelID)
} }
@@ -0,0 +1,38 @@
package ws_test
import (
"testing"
"time"
"github.com/owncord/server/ws"
)
// OC-0012: every other voice-removal path re-elects the key holder
// (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin,
// sweepStaleVoiceStates) — CleanupVoiceForChannel, the channel delete/archive
// path, did not. The voiceKeyHolders entry for a deleted channel then stayed
// populated forever: an unbounded per-deleted-channel leak, and a stale
// IsVoiceKeyHolder verdict for a room that no longer exists.
func TestCleanupVoiceForChannel_ClearsKeyHolder(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "ckh-alice")
vcID := seedVoiceChannel(t, database, "ckh-vc")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, alice, 0, send)
hub.Register(c)
waitRegistered(t, hub, c)
// Real join flow: elects alice key holder of the one-person room.
hub.HandleMessageForTest(c, voiceJoinMsg(vcID))
drainChanTimeout(send, 200*time.Millisecond)
if !hub.IsVoiceKeyHolder(vcID, alice.ID) {
t.Fatal("precondition: the sole participant must be the key holder")
}
hub.CleanupVoiceForChannel(vcID)
if hub.IsVoiceKeyHolder(vcID, alice.ID) {
t.Fatal("voiceKeyHolders still names a holder for a cleaned-up channel")
}
}
@@ -0,0 +1,62 @@
package ws_test
import (
"encoding/json"
"testing"
"time"
"github.com/owncord/server/ws"
)
// OC-0058: the admin unban path type-asserts its hub against an optional
// BroadcastMemberUnban capability, but *ws.Hub never implemented it — the
// assertion always missed, so clients connected during the ban (which
// hard-deleted the member row on member_ban) never learned the user was
// back, permanently disagreeing with freshly connecting clients. The hub
// must implement the mirror of BroadcastMemberBan: a member_join fan-out
// that re-adds the user to every connected member store. The unbanned user
// has no live connection (the ban kicked them), so the payload must report
// them offline regardless of the stale status their row carries.
func TestBroadcastMemberUnban_FansOutMemberJoin(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "unban-alice")
bob := seedMemberUser(t, database, "unban-bob")
send := make(chan []byte, 32)
c := ws.NewTestClientWithUser(hub, bob, 0, send)
hub.Register(c)
waitRegistered(t, hub, c)
drainChanTimeout(send, 100*time.Millisecond)
hub.BroadcastMemberUnban(alice.ID)
msgs := drainChanTimeout(send, 300*time.Millisecond)
for _, m := range msgs {
if extractType(t, m) != "member_join" {
continue
}
var parsed struct {
Payload struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"user"`
Status string `json:"status"`
} `json:"payload"`
}
if err := json.Unmarshal(m, &parsed); err != nil {
t.Fatalf("unmarshal member_join: %v", err)
}
if parsed.Payload.User.ID != alice.ID {
t.Fatalf("member_join user id = %d, want %d", parsed.Payload.User.ID, alice.ID)
}
if parsed.Payload.User.Username != alice.Username {
t.Fatalf("member_join username = %q, want %q", parsed.Payload.User.Username, alice.Username)
}
if parsed.Payload.Status != "offline" {
t.Fatalf("member_join status = %q, want offline — the unbanned user cannot be connected", parsed.Payload.Status)
}
return
}
t.Fatal("no member_join reached a connected client after BroadcastMemberUnban")
}
@@ -0,0 +1,65 @@
package ws_test
import (
"context"
"testing"
"time"
"github.com/owncord/server/ws"
)
// OC-0090: channelReadAudience fails closed on a GetChannel *error*, but a
// deleted channel returns (nil, nil) — and that nil row used to skip the DM
// branch and fall through to the server-wide role scan, broadcasting a
// private DM call's voice_leave to every connected member with base
// READ_MESSAGES. The last-leaver CloseDM path hits exactly this: the channel
// row is already gone when DisconnectFromVoiceInChannel tears the call down.
// A missing row must resolve to an empty READ audience; the leaver still
// hears their own teardown via broadcastVoiceEventWithLeaver's union.
func TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant(t *testing.T) {
hub, database := newVoiceHub(t)
alice := seedMemberUser(t, database, "delchan-alice")
bob := seedMemberUser(t, database, "delchan-bob")
mallory := seedMemberUser(t, database, "delchan-mallory") // connected, READ_MESSAGES, not a participant
dmID := seedDMChannel(t, database, alice.ID, bob.ID)
aliceSend := make(chan []byte, 32)
mallorySend := make(chan []byte, 32)
aliceClient := ws.NewTestClientWithUser(hub, alice, 0, aliceSend)
malloryClient := ws.NewTestClientWithUser(hub, mallory, 0, mallorySend)
hub.Register(aliceClient)
hub.Register(malloryClient)
waitRegistered(t, hub, malloryClient)
hub.HandleMessageForTest(aliceClient, voiceJoinMsg(dmID))
drainChanTimeout(aliceSend, 200*time.Millisecond)
drainChanTimeout(mallorySend, 200*time.Millisecond)
// The DM is closed out from under the call: the channels row (and its
// CASCADE'd overrides) are gone before the voice teardown runs.
if err := database.DeleteChannel(context.Background(), dmID); err != nil {
t.Fatalf("DeleteChannel: %v", err)
}
if !hub.DisconnectFromVoiceInChannel(context.Background(), alice.ID, dmID) {
t.Fatal("DisconnectFromVoiceInChannel reported the user was not in the channel")
}
aliceMsgs := drainChanTimeout(aliceSend, 300*time.Millisecond)
foundLeave := false
for _, m := range aliceMsgs {
if extractType(t, m) == "voice_leave" {
foundLeave = true
}
}
if !foundLeave {
t.Error("the evicted participant must still receive voice_leave — it is their only teardown signal")
}
malloryMsgs := drainChanTimeout(mallorySend, 300*time.Millisecond)
for _, m := range malloryMsgs {
if extractType(t, m) == "voice_leave" {
t.Fatal("voice_leave for a deleted DM channel leaked to a connected non-participant")
}
}
}
+20 -2
View File
@@ -610,7 +610,17 @@ func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) {
hub.Register(c1) hub.Register(c1)
waitRegistered(t, hub, c1) waitRegistered(t, hub, c1)
ch := &db.Channel{ID: 77, Name: "announcements", Type: "text", Category: "News", Position: 1} // Seed a real row: every production caller broadcasts a channel already
// committed to the DB, and channelReadAudience fails closed on a missing
// row (OC-0090) — a fabricated id would resolve to an empty audience.
chID, err := database.CreateChannel(context.Background(), "announcements", "text", "News", "", 1)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: ch=%v err=%v", ch, err)
}
hub.BroadcastChannelCreate(ch) hub.BroadcastChannelCreate(ch)
// The receive select below blocks with its own timeout. // The receive select below blocks with its own timeout.
@@ -649,7 +659,15 @@ func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) {
hub.Register(c1) hub.Register(c1)
waitRegistered(t, hub, c1) waitRegistered(t, hub, c1)
ch := &db.Channel{ID: 88, Name: "updated-channel", Type: "text", Category: "General", Position: 2} // Seed a real row — see the channel_create test above (OC-0090).
chID, err := database.CreateChannel(context.Background(), "updated-channel", "text", "General", "", 2)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: ch=%v err=%v", ch, err)
}
hub.BroadcastChannelUpdate(ch) hub.BroadcastChannelUpdate(ch)
// The receive select below blocks with its own timeout. // The receive select below blocks with its own timeout.