Three review findings on the #93 feature:
- RefreshChannelVisibility targeted clients by their connect-time role
snapshot; a user whose role changed mid-session was evaluated against the
stale role. Resolve the current role from the DB per client (fail closed).
- Visibility updates are targeted, unsequenced messages, so a client that
disconnected before an override change and later resumed via replay never
converged (stale sidebar until a fresh connect). Track a visibility-change
sequence watermark and force resumes from at/before it onto the
full-ready path.
- The admin SPA interpolated channel/user names into single-quoted JS
strings inside onclick attributes with HTML-escaping only; a name
containing a quote broke out of the string literal (XSS in the admin
panel, reachable by any user allowed to create channels). Add a jsq()
helper (JS-escape then HTML-escape) and use it for every onclick name
interpolation.
Follow-up to #93.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
The read side of channel visibility was already complete — channel_overrides
rows deny ReadMessages and every list/focus/send/voice path enforces them —
but nothing could write those rows. Add the missing write path and UI:
- db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides
(roles LEFT JOIN overrides so the UI gets everything in one call)
- admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}]
with unknown permission bits masked via the new permissions.AllPerms,
audit logging, and immediate permission-cache invalidation
- ws: Hub.RefreshChannelVisibility sends targeted channel_create /
channel_delete to connected clients after an override change, unsubscribes
hidden clients from the channel topic, and clears their focus. Sent outside
the sequenced replay path on purpose: a replayed channel_delete would be
filtered by the post-change allowed-channel set, inverting its audience.
- admin panel: per-channel Access modal (lock icon) with per-role
"Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice
Known limits (follow-ups): users offline during a revoke keep a stale
sidebar entry until their next fresh connect (server still denies access),
and users already in a voice channel are not kicked when it goes private.
Closes#93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
readPump teardown always ran handleVoiceLeave, trusting the joined_at
guard in LeaveVoiceChannelIfMatch to protect replacement sessions. On
reconnect the voice session TRANSFERS to the replacement client with
the same joined_at, so the guard cannot tell the two apart: whenever
teardown snapshotted voiceChID before the transfer zeroed it, the old
connection deleted the replacement's voice_state row (flaked on the
Windows CI runner as TestServeWS_Reconnect_PreservesVoiceState).
Gate voice cleanup on !replaced — the same condition the
presence-offline broadcast four lines down already uses. A genuinely
final disconnect behaves exactly as before, and a stale row from a
crashed replacement is still swept by the fresh-connect cleanup.
Also deflake TestHub_ConcurrentRegisterUnregister: poll for quiescence
with a deadline instead of a fixed 50ms sleep that loses to the -race
scheduler on slow runners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduce Server/service/ package with MessageService, ChannelService,
and PermissionService that encapsulate business logic previously
scattered across REST and WS handlers. The PermissionService adds
per-user in-memory caching with TTL-based expiry to eliminate
per-message DB round-trips at scale.
Services are wired into the WS hub via deps structs (strangler-fig
pattern) — existing handlers continue to work unchanged, with service
references available for incremental migration.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- I-1: Add key holder election in Hub (lowest userID per channel); reject
non-key-holder voice_e2ee_offer with NOT_KEY_HOLDER error
- I-2: Accept raw (unpadded) base64 in E2EE announce/offer handlers via
decodeBase64Loose fallback
- I-6: Copy E2EE public key value while h.mu.RLock is held in getClientE2EEPubKey
- I-7: Lower loginRateLimitPerMinute from 60 to 5
- C-1: TOCTOU fix — target channel check held under same lock as client lookup
- C-2: Include is_key_holder bool in voice_token payload so client knows
whether to initiate key distribution
- M-5/M-6: Add ErrCodeBadPayload/ErrCodeNotKeyHolder error constants
- Fix pre-existing api build errors: block_handler.go getUserFromContext,
router.go RequirePermission arg count
- Add user_blocks table to all test DB schemas (ws, api DM)
- Add voice_e2ee_test.go and constants_test.go covering all fixes
Idle WebSocket connections only revalidated sessions every 10 sent
messages, allowing revoked tokens to stay connected indefinitely.
Add sweepRevokedSessions() on a 30s ticker that checks all connected
clients against the DB and kicks any with deleted/expired sessions
or banned users.
BUG-122: Remove channelID==0 bypass in deliverBroadcast that leaked
all channel-scoped broadcasts to unfocused clients. Clients must now
send channel_focus to receive channel events.
BUG-126: Reject edits and reactions on soft-deleted messages in
handleChatEdit and handleReaction.
BUG-108: Revoke all other sessions when a user changes their password
or enables/disables TOTP 2FA. Adds DeleteOtherSessions DB function.
7 new test cases covering all three fixes.
- BUG-085: ring buffer EventsSince off-by-one — change < to <= so
afterSeq == oldestSeq returns nil (triggers full ready payload)
- BUG-087: GracefulStop not idempotent — wrap body in sync.Once to
prevent double lkProcess.Stop() on concurrent calls
- BUG-090: FTS query truncation at byte boundary — use []rune
truncation to preserve valid UTF-8 for CJK/emoji input
- BUG-091: updater downloadFile double-closes file on Windows —
add closed sentinel to guard defer against explicit Close()
Security fixes (from multi-reviewer code review):
- Add DM participant auth checks to channel_focus, typing, and REST
message endpoints — prevents unauthorized access to DM channels
- Fix TOCTOU race in GetOrCreateDMChannel using IMMEDIATE transaction
- Validate YAML credentials before LiveKit config interpolation
- Add CSS variable injection prevention in custom theme loader
- Validate localStorage JSON before unsafe type casts
LiveKit stability:
- Track remote mic audio elements for cleanup on abnormal disconnect
- Remove duplicate token refresh timer scheduling
- Add .catch() to all floating applyMicMuteState promises
- Clear reconnectAc after async post-connect work completes
- Fix double cmd.Wait() race in LiveKit process Stop()
- Reorder voice_join guards: validate channel before livekit==nil check
- Add startup warning for external LiveKit webhook CIDR mismatch
DM system fixes:
- Emit dm_channel_close WebSocket event from REST close handler
- Re-open DM for caller when channel already exists
- Fix unread count incrementing for own messages and active DMs
- Reset channelBeforeDm after Back navigation (stale state bug)
New feature:
- Voice call duration timer in VoiceWidget (MM:SS / HH:MM:SS elapsed)
- Accent color restored on app startup (was only applied in settings)
Test infrastructure:
- Add DM tables to all test schemas (hubTestSchema)
- Inject test LiveKit client in voice handler tests (fixes 28 failures)
- Exclude voiceSession.ts from coverage (browser API dependency, same
pattern as audio.ts/vad.ts/webrtc.ts)
- Remove stale TestHub_Register_CleansUpOldVoiceState test that tested
old duplicate-login behavior removed in b53c729
Fix 70+ errcheck violations by adding explicit error discards (`_ =`)
for unchecked return values across test helpers and deferred Close()
calls. Remove unused `senderID` field from broadcastMsg and unused
`defaultCleanupMaxWindow` const. Apply De Morgan's law, remove empty
branch, and simplify redundant type declaration per staticcheck.
- Redesign ConnectPage with modern dark theme, profile cards with delete buttons, login/register toggle
- Add DPAPI-encrypted password saving with "Remember my password" checkbox
- Fix permission bit constants to match SCHEMA.md (Member role 0x663)
- Add migration 004 to fix existing Member role permissions
- Add comprehensive audit logging across all server packages (auth, admin, ws, setup)
- Add member_join WebSocket broadcast so new users appear in members list in real-time
- Add host URL normalization (strip scheme prefix) for reverse proxy compatibility
- Add REST API client, ChatService orchestrator, WebSocket service with reconnection
- Add model types (WsEnvelope payloads, API responses), converters, tests