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
- deployment.md: new Docker section with quick-start, config.yaml notes,
data persistence, upgrade, and LiveKit reference
- livekit-setup.md: new Docker section with .env / livekit.yaml setup,
node_ip explanation, and firewall table; companion process section
retitled for clarity
- docker-compose.yml: owncord + livekit/livekit-server on shared network
- Secrets (API key/secret) injected via .env → OWNCORD_VOICE_* env vars
- livekit.yaml.example: template config with port ranges and node_ip guidance
- .env.example: secret template with min-length reminder for API secret
- .gitignore: add .env / Server/.env to prevent accidental secret commits
Users: cp .env.example .env && cp livekit.yaml.example livekit.yaml,
fill in values, then docker compose up -d
Client:
- Replace Win32 Credential Manager with cross-platform keyring crate
(Windows Credential Manager / Linux Secret Service / macOS Keychain)
- Add Linux PTT support via device_query crate with VK-code-compatible
mapping; thread-local DeviceState avoids repeated /dev/input/ opens
- Add AppImage + deb bundle targets to tauri.conf.json with Linux
metadata and deb runtime dependencies
Cargo.toml:
- Add keyring = "3" (all platforms)
- Add device_query = "2" (Linux only, cfg guard)
- Remove Win32_Security_Credentials feature (no longer needed)
CI/CD:
- Add ubuntu-22.04 and ubuntu-22.04-arm to tauri-build matrix
- Fix Linux deps step condition: startsWith(matrix.os, 'ubuntu')
- Add server Docker build verification job (build-only, no push)
- Add release-client-linux (x86_64) and release-client-linux-arm64
jobs producing AppImage + deb artifacts
- Add release-server-docker job pushing to ghcr.io on version tags
- Update publish job to include all Linux and ARM64 artifacts
Server:
- Add multi-stage Dockerfile (golang:1.25-bookworm → distroless/static)
- Non-root user (uid 65532), /app/data volume, port 8443 exposed
- Add .dockerignore excluding binaries, data, and local config
Tenor shuts down June 30, 2026. Klipy is a drop-in replacement built
by the ex-Tenor team, free for production use.
- Replace tenor.ts with gifProvider.ts (api.klipy.com/v2)
- CDN allowlist updated to *.klipy.com (static.klipy.com is the real CDN)
- Add Klipy watermark logo to sent GIFs in chat (bottom-left)
- Update attribution text to "Powered by Klipy"
- Wire VITE_KLIPY_API_KEY secret into release workflow
- Add .env to .gitignore to protect local API key
- updater.go: DownloadAndVerify now uses parseChecksumFileAny with
checksumEntryNamesForGOOS so the linux/ path prefix produced by the
release workflow's sha256sum is found correctly (fixes
TestDownloadAndVerify_Success on ubuntu-latest)
- video-grid.test.ts: add setScreenshareAudioVolume to the
@lib/livekitSession mock so vitest does not throw on the export
that VideoGrid.ts imports
- ci.yml: add top-level `permissions: contents: read` to restrict
GITHUB_TOKEN to minimum required (fixes 3 missing-workflow-permissions alerts)
- claude-code-review.yml: remove unsafe `ref: pull_request.head.sha`
checkout in pull_request_target workflow and pin checkout to SHA
(fixes untrusted-checkout/high alert)
- tenor.ts: add codeql suppression comment for hard-coded-credentials;
the fallback key is Google's public anonymous demo key, not a secret
- Consolidate ws-state and cert-tofu emit calls in ws_proxy.rs into
private helper functions (emit_ws_state, emit_cert_tofu). One call
site per event name prevents tauri-typegen 0.5.0 from generating
duplicate event listener functions.
- Add CI fixup step that injects 'export type Value = unknown' into
generated types.ts — tauri-typegen cannot map serde_json::Value to
a TypeScript type, so the generated file references an undefined type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>