mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
Generated
+139
-129
@@ -2,135 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
## 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.
|
||||
**0 open** · 0 blocked · 186 fixed · 4 declined · 0 refuted · 1 duplicate
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
`Server/api/profile_handler.go:575` · found 2026-08-13 · hunt `2026-08-13-postopt` · lens `api-authz`
|
||||
|
||||
@@ -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.",
|
||||
"repro": "Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.",
|
||||
"evidence": "Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-09",
|
||||
"hunt": "voice-e2ee-2026-08-09",
|
||||
"lens": "keyholder-election",
|
||||
"fix": null,
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fix": {
|
||||
"commit": "6f3485e7",
|
||||
"test": "TestCleanupVoiceForChannel_ClearsKeyHolder",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"fixed": "2026-08-19",
|
||||
"note": "CleanupVoiceForChannel now re-elects via updateKeyHolder"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"evidence": "case *req.Banned && hub != nil:\n\thub.BroadcastMemberBan(id) // real method on *ws.Hub\ncase !*req.Banned && hub != nil:\n\tif mub, ok := hub.(memberUnbanBroadcaster); ok {\n\t\tmub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false\n\t}",
|
||||
"suggestedFix": "Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-12",
|
||||
"hunt": "general-2026-08-12",
|
||||
"lens": "state-desync",
|
||||
"finder": "opus",
|
||||
"round": 2,
|
||||
"confidence": "high",
|
||||
"fix": null,
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fix": {
|
||||
"commit": "d2289560",
|
||||
"test": "TestBroadcastMemberUnban_FansOutMemberJoin",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"fixed": "2026-08-19",
|
||||
"note": "BroadcastMemberUnban implemented on *ws.Hub; compile-time wiring assertion added in admin"
|
||||
},
|
||||
{
|
||||
"id": "OC-0059",
|
||||
@@ -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.",
|
||||
"evidence": "Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.",
|
||||
"suggestedFix": "Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-12",
|
||||
"hunt": "general-2026-08-12",
|
||||
"lens": "hotspot-server-ws",
|
||||
"finder": "opus",
|
||||
"round": 6,
|
||||
"confidence": "high",
|
||||
"fix": null,
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fix": {
|
||||
"commit": "6a5a3a7c",
|
||||
"test": "TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"fixed": "2026-08-19",
|
||||
"note": "own-flag exclusion in both Enable*IfUnderLimit gates; sqlc regenerated"
|
||||
},
|
||||
{
|
||||
"id": "OC-0082",
|
||||
@@ -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.",
|
||||
"evidence": "hub_sweep.go:202-249\n\th.mu.RLock()\n\tvar stale []struct{ userID, channelID int64; joinedAt string }\n\tfor _, vs := range allStates {\n\t\tc, ok := h.clients[vs.UserID]\n\t\tif !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock\n\t\t\tstale = append(stale, ...)\n\t\t}\n\t}\n\th.mu.RUnlock()\n\n\tfor _, s := range stale { // <-- acted on here, seconds later, no re-check\n\t\tdeleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)\n\t\t...\n\t\th.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))\n\t\th.updateKeyHolder(s.channelID)\n\t\tif h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) }\n\t}\n\ncontrast, same function, hub_sweep.go:181-187\n\t\t// The permission check is a DB round-trip; a voice_join to a\n\t\t// still-permitted channel may have committed while it ran. The\n\t\t// eviction is conditional on the client still being in the checked\n\t\t// channel — never on whatever channel it is in by now.\n\t\tif !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }\n\nthe window being raced, voice_join.go:196-222\n\t\tif err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed\n\t\tstate, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here\n\t\t...\n\t\tc.setVoiceState(channelID, state.JoinedAt) // only now non-zero",
|
||||
"suggestedFix": "In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and `continue` for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-12",
|
||||
"hunt": "general-2026-08-12",
|
||||
"lens": "hotspot-server-ws",
|
||||
"finder": "opus",
|
||||
"round": 7,
|
||||
"confidence": "high",
|
||||
"fix": null,
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fix": {
|
||||
"commit": "b8b7a2a1",
|
||||
"test": "TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow",
|
||||
"revertProof": "covered-by-OC-0017"
|
||||
},
|
||||
"fixed": "2026-08-19",
|
||||
"note": "already fixed on main by the OC-0017 pre-delete live-client re-check (PR #1374); verified against HEAD 2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0087",
|
||||
@@ -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.",
|
||||
"evidence": "ch, err := h.db.GetChannel(ctx, channelID)\nif err != nil {\n slog.Error(\"ws: channelReadAudience GetChannel failed, denying\", ...)\n return []int64{}\n}\nif ch != nil && ch.Type == \"dm\" { // <- nil (deleted) channel skips the DM audience entirely\n participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)\n ...\n return audience\n}\n...\nfor _, uid := range userIDs {\n if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {\n audience = append(audience, uid)\n }\n}",
|
||||
"suggestedFix": "In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the `if err != nil` block, add `if ch == nil { return []int64{} }` before the `ch.Type == \"dm\"` check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-12",
|
||||
"hunt": "general-2026-08-12",
|
||||
"lens": "hotspot-server-ws",
|
||||
"finder": "opus",
|
||||
"round": 8,
|
||||
"confidence": "high",
|
||||
"fix": null,
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fix": {
|
||||
"commit": "b22ad84d",
|
||||
"test": "TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"fixed": "2026-08-19",
|
||||
"note": "fail closed on a missing channel row in channelReadAudienceImpl"
|
||||
},
|
||||
{
|
||||
"id": "OC-0091",
|
||||
@@ -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.",
|
||||
"repro": "Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.",
|
||||
"evidence": "hub_broadcast.go:252 `h.mu.RLock(); clients := make([]*Client, 0, len(h.clients))` … loop 315-357 … 365 `h.bumpVisibilityWatermark()` // comment at 359-364: \"Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path\" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 `defer h.bumpVisibilityWatermark()`.",
|
||||
"status": "blocked",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-13",
|
||||
"hunt": "2026-08-13-postopt",
|
||||
"lens": "ws-hub",
|
||||
"finder": "opus",
|
||||
"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.",
|
||||
"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)",
|
||||
"blockedDate": "2026-08-14"
|
||||
"fixed": "2026-08-19",
|
||||
"note": "already fixed on main by the OC-0206 early watermark bump in both fan-out paths (PR #1375); verified against HEAD 2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0102",
|
||||
|
||||
@@ -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)
|
||||
@@ -76,10 +76,19 @@ type Querier interface {
|
||||
// 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`
|
||||
// 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
|
||||
// term is needed.
|
||||
// The enabling user's own bit for the flag being set CAN already be 1 at
|
||||
// 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)
|
||||
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)
|
||||
EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error
|
||||
ForceLogoutUser(ctx context.Context, userID int64) error
|
||||
|
||||
@@ -102,7 +102,7 @@ const enableCameraIfUnderLimit = `-- name: EnableCameraIfUnderLimit :execresult
|
||||
|
||||
UPDATE voice_states SET camera = 1
|
||||
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 {
|
||||
@@ -118,8 +118,15 @@ type EnableCameraIfUnderLimitParams struct {
|
||||
// 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`
|
||||
// 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
|
||||
// term is needed.
|
||||
// The enabling user's own bit for the flag being set CAN already be 1 at
|
||||
// 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) {
|
||||
return q.db.ExecContext(ctx, enableCameraIfUnderLimit,
|
||||
arg.UserID,
|
||||
@@ -132,7 +139,7 @@ func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCamera
|
||||
const enableScreenshareIfUnderLimit = `-- name: EnableScreenshareIfUnderLimit :execresult
|
||||
UPDATE voice_states SET screenshare = 1
|
||||
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 {
|
||||
@@ -142,6 +149,8 @@ type EnableScreenshareIfUnderLimitParams struct {
|
||||
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) {
|
||||
return q.db.ExecContext(ctx, enableScreenshareIfUnderLimit,
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
-- 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.
|
||||
-- The enabling user's own bit is still 0 at gate time, so no self-exclusion
|
||||
-- term is needed.
|
||||
-- The enabling user's own bit for the flag being set CAN already be 1 at
|
||||
-- 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
|
||||
-- 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
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
DELETE FROM voice_states WHERE user_id = ?;
|
||||
|
||||
@@ -234,6 +234,16 @@ func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, igno
|
||||
"channel_id", channelID, "err", err)
|
||||
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
|
||||
// permissions, mirroring RefreshChannelVisibility and VisibleChannelIDs.
|
||||
// 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
|
||||
// ready payload or sidebar. ignoreArchived opts a caller out of this
|
||||
// specific check only — see channelReadAudienceIgnoringArchived.
|
||||
if ch != nil && ch.Archived && !ignoreArchived {
|
||||
if ch.Archived && !ignoreArchived {
|
||||
return []int64{}
|
||||
}
|
||||
if ch != nil && ch.Type == "dm" {
|
||||
if ch.Type == "dm" {
|
||||
return h.channelReadAudienceDM(ctx, channelID, userIDs)
|
||||
}
|
||||
}
|
||||
@@ -580,6 +590,32 @@ func (h *Hub) BroadcastMemberBan(userID int64) {
|
||||
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.
|
||||
// No-op if the user is not currently connected.
|
||||
func (h *Hub) DisconnectUser(userID int64) {
|
||||
|
||||
@@ -440,4 +440,13 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) {
|
||||
for _, vs := range states {
|
||||
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
@@ -610,7 +610,17 @@ func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) {
|
||||
hub.Register(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)
|
||||
// The receive select below blocks with its own timeout.
|
||||
|
||||
@@ -649,7 +659,15 @@ func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) {
|
||||
hub.Register(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)
|
||||
// The receive select below blocks with its own timeout.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user