- audit-2026-07-19.md: A-2026-07-07 and A-2026-07-09 → RESOLVED 2026-07-20 in the findings tables; §6 backlog rows 3 and 11 struck through as DONE (D9/D10) - plans/audit-2026-07-19-decisions.md: D9/D10 status → Implemented - plans/channel-visibility-unification.md, v2-dispatch-migration.md: status → implemented; v2 note records the applier-trigger shape the voice handlers actually landed with - architecture/server.md: WS box "V1+V2 dispatch" → "typed command dispatch" - architecture/websocket.md: intro + §D4c redrawn as the single typed path (no V1 fallback) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.2 KiB
Channel-Visibility Unification (backlog item 3) — Design
Status: implemented 2026-07-20 (D9) Decision: D9 in audit-2026-07-19-decisions.md Closes: audit finding A-2026-07-07 (backlog §6 item 3)
Problem
Per the audit, the "does this role see this channel" rule is implemented ~4× with comments telling each copy it "must mirror" the others:
service.ChannelService.ListVisibleChannels(RESTGET /channels),ws.buildReady(the ready payload's channel list),ws.computeAllowedChannels(replay-buffer filtering on reconnect),ws.Hub.RefreshChannelVisibility(targeted channel_create/delete after an override change).
All four inline the same predicate — HasAdmin(perms) || EffectivePerms(perms, allow, deny)&ReadMessages == ReadMessages — plus the same "skip dm channels,
fail closed on nil role" scaffolding. The recent private-channel fixes show this
churns; a drift between copies is an information-disclosure bug.
Approach — funnel all four through the checker that already exists
The unified predicate is already written: permissions.Checker.HasChannelPermBatch
(pre-fetched overrides map) and .HasChannelPerm (single-channel). Three of the
four sites bypass it by re-inlining the bit math; RefreshChannelVisibility does
its own GetChannelPermissions + EffectivePerms. The fix is to route every
site through the checker, not to invent a new abstraction:
- Add one thin helper to
permissions.Checker:VisibleChannelIDs(rolePerms int64, channels []ChannelRef, overrides map[int64]ChannelOverride) map[int64]bool— iterates, skipsdm, and calls the existingHasChannelPermBatchper channel.ChannelRef{ID int64; Type string}is declared inpermissions(the package deliberately avoids importingdb, so callers map their[]db.Channeldown to[]ChannelRef). ListVisibleChannels,buildReady, andcomputeAllowedChannelscall it.buildReadystill fetches the overrides map once (it reuses it forcan_sendand unread), then hands the same map toVisibleChannelIDs.computeAllowedChannelsunions the result with the user's open DM channels (unchanged). Nil role → empty set, which the helper yields naturally.RefreshChannelVisibility's per-role check calls the existingHasChannelPerm(rolePerms, roleID, ch.ID, ReadMessages)instead of its inline copy. Its targeted-send +visibilityChangeSeqwatermark mechanics are untouched.
Files touched
Server/permissions/checker.go— addChannelRef+VisibleChannelIDs.Server/permissions/checker_test.go— cover the helper (admin bypass, deny override hides, no-override inherits base, dm skipped).Server/service/channel.go—ListVisibleChannelsdelegates.Server/ws/serve.go—buildReady,computeAllowedChannelsdelegate; drop the "mirrors …" comments.Server/ws/hub.go—RefreshChannelVisibilityusesHasChannelPerm.- Docs:
docs/audit-2026-07-19.md(A-2026-07-07 → RESOLVED),docs/architecture/server.md/websocket.mdif a visibility callout names the duplication.
Test plan
- Unit tests for
VisibleChannelIDsinpermissions. - REST/WS agreement test (the audit's explicit ask): seed a real in-memory
dbwith roles, per-channel overrides, and text/announcement/voice/dm channels; assertListVisibleChannels, the channel set insidebuildReady, and the non-DM subset ofcomputeAllowedChannelsare byte-for-byte the same set for the same user across admin / member-with-deny / nil-role cases. - Existing
serve_test.go,hub_test.go,channel_handlertests stay green unchanged (behavior is identical; only the code path is shared).
Non-goals
- No change to permission bits, override storage, or the
EffectivePermsalgebra — only where the existing predicate is called from. - DM visibility stays membership-based (added on top of the server-channel set, not routed through the checker).
can_send, unread counts, and voice-state filtering inbuildReadyare unchanged.- Not touching
RefreshChannelVisibility's targeted-send / watermark design (backlog 11/12 territory) — only its read predicate.