Resolves the modify/delete conflict with dev (which removed
phase-a-foundation.md in a1e8970). The Implementation Status and
Actionable TODOs sections are preserved under docs/ alongside the
other project docs, matching the existing docs/*.md convention.
The original phase-a-foundation.md design brief is gone per dev's
intent; only the post-implementation status and follow-up checklist
survive.
ChatDeps, PresenceDeps, ReactionDeps no longer carry *db.DB or
*permissions.Checker — those were only needed before the service
migration. VoiceDeps retains them for voice handlers not yet migrated.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- upload_handler: uses PermissionService.HasChannelPerm (removes last
hasChannelPermREST usage)
- profile_handler: delegates to UserService with proper ErrConflict on
duplicate username
- topic_rate_limiter: per-topic throughput caps (100 msg/s default),
wired into deliverBroadcast for channel-scoped broadcasts
- voice_join: subscribes client to VoiceTopic on join
- voice_leave: unsubscribes client from VoiceTopic on leave
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- upload_handler.go: uses PermissionService.HasChannelPerm instead of
the deleted hasChannelPermREST helper
- profile_handler.go: delegates to UserService for profile updates,
password changes, session listing, and session revocation
- Remove hasChannelPermREST from channel_handler.go (no longer needed)
- UserService.UpdateProfile now returns ErrConflict on duplicate username
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Critical fixes:
- Move svc creation in router.go above MountInviteRoutes/MountChannelRoutes
(was used before definition — compile error)
- Restore hasChannelPermREST in channel_handler.go for upload_handler.go
(was removed but still referenced — compile error)
Permission cache invalidation:
- Add PermissionInvalidator interface to admin package
- Wire through NewHandler → NewAdminAPI → handlePatchUser
- Call InvalidateUser(userID) after role changes in admin panel
- Update all admin test files to pass nil as new parameter
Also clarifies WithTx documentation for SQLite single-writer semantics.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Client now has three send channels:
- sendHigh (64 slots): DMs, mentions — drained first by writePump
- send (256 slots): chat messages, reactions — drained second
- sendLow (64 slots): typing, presence — drained last, dropped on overflow
writePump drains high-priority messages before checking normal/low.
PubSub gains PublishHigh/PublishLow alongside existing Publish.
EmitEvents routes events by priority:
- High: SequencedDMEvent, UserTargetedEvent
- Normal: ChannelEvent, VoiceChannelEvent
- Low: ExcludeSenderEvent (typing), PresenceEvent
Slow clients get typing/presence dropped first (sendLowMsg silently
drops), then disconnect on normal buffer overflow, ensuring DMs are
never lost to typing indicator backpressure.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
PublishLowPriority uses trySendMsg to silently drop messages when a
client's buffer is full, instead of disconnecting them. This provides
priority-based backpressure: chat messages use normal Publish (disconnect
on overflow), while ephemeral events like typing indicators and presence
updates use PublishLowPriority (drop on overflow).
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- Add UserService, DMService, InviteService, BlockService
- Migrate REST handlers (channel, DM, invite, block) to use services
- Remove block_handler.go (merged into dm_handler.go)
- Update all services to accept store.Store instead of *db.DB
- Router creates SQLiteStore and passes to service.New()
Handlers are now thin HTTP adapters: parse request → call service →
map error → write JSON. All business logic lives in the service layer.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
When a client focuses a channel, subscribe to its pub/sub topic.
When switching channels, unsubscribe from the old topic first.
This completes the pub/sub integration — channel broadcasts now
route only to clients subscribed to the relevant topic.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Introduce topic-based PubSub for O(subscribers) message routing:
- Clients subscribe to "global" and "user:{id}" on connect
- Channel broadcasts route through "channel:{id}" topics
- deliverBroadcast() uses PubSub instead of iterating all clients
- UnsubscribeAll on disconnect/kick cleans up subscriptions
- Sequence numbering and replay buffer preserved unchanged
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Define the Store interface composing domain-specific sub-interfaces
(MessageStore, ChannelStore, UserStore, etc.) that decouple services
from the concrete database. SQLiteStore wraps *db.DB, delegating all
operations to existing query methods.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Handlers now delegate all business logic (validation, permission checks,
DB operations) to MessageService and ChannelService instead of calling
*db.DB directly. This eliminates logic duplication and enables the
service layer's permission cache.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
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
- Remove unused `ver` param from handleHealth and handleInfo (version
was intentionally removed from unauthenticated endpoints per C-2)
- Rename decodeBase64Loose to validateBase64Loose returning only error,
matching actual usage (all callers only validate, never use the bytes)
PendingVoiceJoin was missing the isKeyHolder field, so when the drain
loop called connectAndSetup for a queued join it defaulted to false,
entering the "wait for room key from key holder" E2EE path and hanging
indefinitely. Store isKeyHolder in the pending join and forward it.
- Remove commented-out code flagged by gocritic
- Use bytes.Equal instead of string conversion comparison
- Remove unused buildRateLimitError function
- Remove unnecessary type assertions in e2eeCrypto.ts
- 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
Update LiveKitSession tests to use _state discriminated union instead of
old flat field names (room, currentChannelId, latestToken, etc.) removed
in the state machine refactor. Also fix renderers.test.ts URL resolution
by setting a server host in beforeEach so isSafeUrl can parse relative
attachment URLs in jsdom. Stage all four Go test files so the CI Go job
runs them.
Additionally fix a regression in connectAndSetup's finally block: when a
pendingJoin is queued during a stale-join abort, preserve the connecting
state so handleVoiceToken's drain loop can pick it up rather than losing
it by resetting to idle.
Periodic key rotation:
- Key holder rotates room key every 5 minutes for forward secrecy,
independent of participant changes. Timer managed by key holder only.
Offer retry mechanism:
- Non-key-holders re-announce their public key after 10s timeout and
wait 5s more before giving up. Covers lost offers from target
disconnect during async key wrapping.
- Key holder now re-sends room key offer on duplicate announces (peer
may be re-requesting after a missed offer), instead of ignoring them.
Key fingerprint verification:
- New computeKeyFingerprint() in e2eeCrypto.ts — SHA-256 hash of raw
public key formatted as "AB12 CD34 ..." for out-of-band verification.
Can be displayed in UI for MITM detection.
Server hardening:
- Public key size limit tightened from 256 to 128 bytes (P-256
uncompressed = 65 bytes = ~88 base64 chars).
Client hardening:
- WebCrypto availability check at module load — throws descriptive
error if crypto.subtle is unavailable (non-HTTPS context).
- base64ToUint8() now wraps atob() in try-catch with clear error message.
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Critical fixes:
- C1: Key holder election now uses lowest-user-ID from voiceStore instead
of "am I first in peerPublicKeys" heuristic, preventing simultaneous
join race where both participants generate conflicting room keys
- C2: TOCTOU race in handleVoiceE2EEOffer — target channel check now
happens inside h.mu.RLock() section (atomically with client lookup)
- C3: Server now validates base64 encoding for public_key, encrypted_key,
and iv before relaying, preventing client-side DoS via malformed payloads
High fixes:
- H1: ECDH keypair regenerated on reconnect with fresh announce, so
stale keys don't persist and key rotation during disconnect is handled
- H2: E2EE epoch counter prevents stale offers from overwriting a
rotated room key (handleE2EEOffer discards if epoch changed during unwrap)
- H3: After key rotation, re-check for peers that arrived during the async
wrapping loop and send them the new key too
- H4: _ecdhKeyPair and _roomKey captured in local vars before async
operations to prevent null dereference if clearE2EEState runs concurrently
Medium fixes:
- M1: User notified via onErrorCallback when E2EE key exchange times out
- M2: Timeout timer properly cleared to prevent leak and unhandled rejection
- M3: Duplicate announces deduplicated — same key ignored, changed key logged
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
- Use deterministic key holder election (lowest user_id) instead of
Map insertion order which is not guaranteed to match join order
- Use parseUserId() instead of raw parseInt() for LiveKit identity parsing
- Add concurrent key rotation guard (_rotatingKey flag) to prevent
races when multiple participants leave in rapid succession
- Queue voice_e2ee_announce messages that arrive before ECDH keypair
is ready; drain after keypair generation in connectAndSetup
- Propagate decryption failures to roomKeyResolver so connectAndSetup
unblocks with an error instead of hanging
- Reject (not resolve) roomKeyResolver on voice leave for proper cleanup
- Convert dynamic await import("@lib/e2eeCrypto") to static imports
- Add VOICE_E2EE_ANNOUNCE/OFFER to protocolTypes.ts enum constants
- Use typed S.VOICE_E2EE_* constants in dispatcher instead of string casts
- Add payload size limits for encrypted_key (1024) and iv (128) on server
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Replace server-generated symmetric keys with client-side ECDH P-256 key
exchange. The server now only relays opaque public keys and encrypted
room key blobs — it never sees the actual room encryption key.
Protocol:
- voice_e2ee_announce: clients broadcast ECDH public keys
- voice_e2ee_offer: key holder wraps room key for each peer via ECDH+HKDF+AES-GCM
- Key rotation on participant leave (forward secrecy)
Server changes:
- Remove VoiceE2EEKeys (server-side key generation)
- Add relay handlers for announce/offer messages
- Store per-client ECDH public keys on Client struct
- Send existing public keys to new joiners during voice state sync
Client changes:
- New e2eeCrypto.ts: ECDH P-256, HKDF-SHA256, AES-256-GCM key wrapping
- LiveKitSession generates keypair on join, manages key holder election
- Key holder generates room key and wraps for each peer
- Non-holders wait for offer before connecting to LiveKit
- Room key rotated when any participant leaves
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Restores dangerous-settings and allowSelfSigned which are required for
self-hosted servers with self-signed certificates. Makes HealthResponse.version
optional to match server-side removal, and updates router tests to assert
version is correctly omitted from unauthenticated endpoints.
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Addresses 14 findings from the security audit across all severity levels:
CRITICAL:
- C-1: Add user blocking system (migration, DB queries, REST API, WS DM
send check) to prevent harassment via unconsented DMs
- C-2: Remove server version from unauthenticated /health and /info endpoints
to prevent fingerprinting
HIGH:
- H-1: Remove dangerous-settings feature from tauri-plugin-http
- H-3: Default allowSelfSigned to false in API client (was hardcoded true)
- H-4: Cap invite expiration to 30 days (720 hours)
- H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM)
- H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow)
- H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role
MEDIUM:
- M-2: Deny access to legacy NULL-uploader unlinked attachments
- M-4: Log warnings on TOTP plaintext decryption fallback paths
- M-8: Remove acceptInvalidCerts from OG preview fetches
- M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk)
- M-12: Add LIMIT to ListInvites (200) and ListMembers (1000)
- M-14: Add CHECK constraint trigger on channels.type (text/voice/dm)
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Server generates a per-channel 256-bit symmetric key (crypto/rand) when
the first participant joins voice. The key is distributed to all
participants via the voice_token WS message (already TLS-encrypted) and
cleared when the channel empties for forward secrecy per session.
Client configures LiveKit Room with ExternalE2EEKeyProvider and an
SFrame e2ee-worker. All audio/video frames are encrypted client-side
before reaching the SFU — the server never sees plaintext media.
Changes:
- Server: new VoiceE2EEKeys store, e2ee_key in voice_token payload
- Client: E2EE Room options, key provider wiring for connect/reconnect
- CSP: added worker-src 'self' blob: for the E2EE Web Worker
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
- Restrict PTT key capture to non-text keys only (function, navigation, mouse buttons) via allowlist (BUG-136)
- Gate DevTools button and F12/Ctrl+Shift+I shortcut behind import.meta.env.DEV
- Disable devtools Tauri feature in production (Cargo.toml default feature removed)
- Remove overly broad http:default capability, replace with scoped http:allow-fetch
- Set withGlobalTauri to false to avoid global __TAURI__ surface exposure
- Fix reconnect race: add abort checks after room creation, URL resolve, and connect (BUG-070)
- Fix ws.ts reconnect guard: bail out safely when config is null after disconnect
- Fix DM broadcast double-send and add monotonic seq + replay buffer support via sendSequencedToUsers
- Add seqMu mutex to serialize seq assignment across broadcastDM and deliverBroadcast paths
- Fix handleFreshConnect to unregister client and close connection on buildReady failure
- Add tests for PTT allowlist, ws reconnect config-null guard, livekit abort-after-connect, and DM sequencing
- Fix typo in setup-buildx-action pin (d8db...→d36ec...) that caused CI to fail
- Replace stale TenorGif type with GifResult in gif-picker.test.ts lines 531-532