mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
main
42
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b7d388a39c |
release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)
* Fix 27 findings from 2026-08-21 bug hunt (#1400) * chore(findings): record 2026-08-21 bug hunt (38 findings) * fix(api): 1 defect(s) (OC-0240) * fix(client): 1 defect(s) (OC-0241) * fix(plugin): 2 defect(s) (OC-0243, OC-0265) * fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259) * fix(client): 1 defect(s) (OC-0247) * fix(client): 2 defect(s) (OC-0248, OC-0258) * fix(identity): 1 defect(s) (OC-0250) * fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272) * fix(admin): 1 defect(s) (OC-0253) * fix(client): 1 defect(s) (OC-0254) * fix(voice): 1 defect(s) (OC-0255) * fix(ws): 1 defect(s) (OC-0260) * fix(client): 1 defect(s) (OC-0261) * fix(client): 1 defect(s) (OC-0262) * fix(client): 1 defect(s) (OC-0263) * fix(client): 1 defect(s) (OC-0264) * fix(client): 1 defect(s) (OC-0268) * fix(ws): 1 defect(s) (OC-0273) * fix(service): 1 defect(s) (OC-0275) * style: satisfy golangci-lint and prettier on 2026-08-21 fix commits - drop ineffectual backupDir reset before return (registry.go, OC-0265) - reflow long boolean expression (attachments.ts, OC-0241) * fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251) * fix(voice): 1 defect(s) (OC-0267) * fix(admin): 1 defect(s) (OC-0274) * fix(voice): 1 defect(s) (OC-0245) * fix(ws): 1 defect(s) (OC-0271) * fix(voice): 2 defect(s) (OC-0239, OC-0257) * fix(ws): 1 defect(s) (OC-0266) * fix(voice): 1 defect(s) (OC-0270) * style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes - range-over-int and slices.Contains modernizations in new Go test files - prettier reflow in dispatcher.ts * chore(findings): mark 2026-08-21 hunt findings fixed/declined 37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS requires a product decision, not a mechanical patch). --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: 35 findings from the 2026-08-22 bug hunt (#1402) * fix(voice): 1 defect(s) (OC-0277) * fix(voice): 1 defect(s) (OC-0278) * fix(client): 1 defect(s) (OC-0280) refreshDmSidebar() rebuilds the entire DM sidebar subtree on every dmStore.channels change - which includes presence flips and new messages, not just DM list changes. The "Find a conversation" filter text and input focus live only in that destroyed subtree, so they were silently wiped mid-typing. Capture and restore both across the destroy+recreate cycle. * fix(ws): 1 defect(s) (OC-0285) * fix(client): 1 defect(s) (OC-0286) * fix(client): 1 defect(s) (OC-0288) Consume the legacy unscoped mute key after migrating it onto the first host, so a brand-new host with no scoped key of its own no longer reads through to the same legacy list and inherits another server's mutes. * fix(voice): 1 defect(s) (OC-0290) * fix(db): 1 defect(s) (OC-0293) DecrementMentionCounts reversed mention_count bumps that were never applied: message_mentions stores every resolved mention id including the author's blockers, while applyMentionCounts excludes blockers before incrementing. Deleting a blocked author's message therefore wiped an unrelated, genuine mention badge on the same read_states row. Mirror the block exclusion in the decrement UPDATE. * fix(db): 1 defect(s) (OC-0294) DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards. * fix(client): 1 defect(s) (OC-0295) MemberList rebuilt every row on any non-presence-only membersStore change and on every roles_update, but registered each row's click/contextmenu listeners on the component-lifetime disposable.signal, which only aborts at destroy(). Discarded rows therefore stayed reachable (and their listeners live) for the component's whole lifetime. Route per-row listeners through a per-render AbortController that is aborted and replaced at the top of every render, and aborted again in destroy(). * fix(identity): 1 defect(s) (OC-0297) UpdateProfile's post-commit re-read of the user row could fail for reasons unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion) and was reported as ErrInternal even though UpdateUserProfile had already committed. Callers that treat any UpdateProfile error as proof the write never landed — handleUploadAvatar deletes the file it just stored — would delete a file the committed avatar column now points at, permanently breaking the avatar with no user_update broadcast. Since UpdateUserProfile only writes username/avatar/display_name/about, merge those four onto the pre-write snapshot to reconstruct the committed row without needing the re-read to succeed, and log the read failure. * fix(ws): 2 defect(s) (OC-0298, OC-0299) - OC-0298: applyConnectStatus stamped c.user.Status even when the UpdateUserStatus write failed, so auth_ok and the presence broadcast claimed a status users.status disagreed with, and buildReady's ListMembers read never self-corrected for the session. - OC-0299: refreshUserSnapshot silently fell back to roleName "member" when the new role lookup failed, pinning the session to a fabricated role on the wire. It now fails closed like the sibling lookups in upgradeAndAuth and handleFreshConnect. * fix(client): 1 defect(s) (OC-0300) * fix(client): 1 defect(s) (OC-0301) * fix(ws): 1 defect(s) (OC-0302) * fix(api): 1 defect(s) (OC-0305) handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route. * fix(client): 2 defect(s) (OC-0306, OC-0308) * fix(client): 1 defect(s) (OC-0307) QuickSwitcher registered a per-row click listener against the overlay-lifetime AbortSignal, but renderResults() rebuilds every row on each keystroke, arrow key, and store refresh. Discarded rows kept their listeners alive until the overlay closed. Replaced with one delegated click listener on the stable results container, keyed off the data-channelid each row already carries. * fix(client): 1 defect(s) (OC-0310) * fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292) Reap a soft-deleted message's attachment files, count lapsed temporary bans as active users in the require_2fa enrollment gate, and only apply the 2FA-enrollment precondition when require_2fa itself is being enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * test(api): sync apiTestSchema with the user_blocks migration DeleteAccount's mention-count reversal joins user_blocks; the api package's hand-rolled schema fixture predates migration 012. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296) Decouple the E2EE identity-mismatch modal and right-click popovers from the sidebar's per-render abort signal, and let global drag listeners survive a mid-drag re-render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(voice): 2 defect(s) (OC-0283, OC-0287) Retire a departed peer's E2EE key unconditionally on leave, and surface a failed microphone unmute instead of reporting an unmuted state the room never saw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309) Guard the DM call button against redialing the channel already joined, resolve the incoming-call banner's caller through the nickname-aware display name, and keep the DM profile sidebar subscribed to live member/status updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * style(client): prettier-format the dm-store test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 1 defect(s) (OC-0284) Make message soft-delete a compare-and-set so a repeated chat_delete cannot reverse mention counts twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 2 defect(s) (OC-0276, OC-0304) Re-sync a resumed connection's voice E2EE peer keys in registerNow (announce frames are unsequenced and cannot be replayed), and apply the live-connection presence rule to every DM payload DMService builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): record the 2026-08-21 hunt findings as fixed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): independent revert-proof pass for OC-0276..OC-0310 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * refactor(service): extract DeleteMessage authorization into a helper Keeps DeleteMessage under the cyclop complexity ceiling after the OC-0284 guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo --------- Co-authored-by: Claude <noreply@anthropic.com> * chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403) Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo Co-authored-by: Claude <noreply@anthropic.com> * chore(graphify): refresh knowledge graph * fix: close the three B0 P0 gates and record a measured baseline (#1409) * chore(security): stop tracking the private security-finding reports docs/security-findings/ holds detailed reports for defects that are not yet fixed. The directory was untracked but not ignored, so any 'git add .' would have published seven unfixed vulnerability traces to a public repository. Findings are coordinated through private GitHub Security Advisories (docs/security.md); only opaque identifiers and safe status belong in tracked plans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): repair the two red P0 unit contracts (G-01, G-02) G-02: noise-suppression-restart stubbed MediaStream with vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw 'is not a constructor' at the new MediaStream([inputTrack]) call in noise-suppression.ts before reaching any assertion. Replaced with a real class; the OC-0277 assertions are unchanged. G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on AbortSignal.prototype.addEventListener and asserted zero abort registrations, but the leak it names registered row listeners via element.addEventListener(..., { signal }) — a path that never calls that prototype method. Measured: the leak produces 0 registrations (test passes), while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5 across 5 distinct signals (test fails). The guard passed on the bug and failed on the fix. It now captures the signal each window's row listeners register against and asserts the invariant its name always claimed: one signal per rendered window, a fresh signal per jump, and every superseded window already aborted with exactly one live. Verified both directions — green on the fix, and 'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal. Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): make the Playwright suite terminate The runner finished every test and then never exited, printing no summary — so the failure read as 'tests never finish' when it was 'process never exits'. getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several PipeWrap: the Vite dev server was still running. Playwright's webServer teardown does not kill it here. Measured, full suite each time: npm run dev hangs, tests pass node node_modules/vite/bin/vite.js hangs, tests pass reuseExistingServer: false hangs, tests pass gracefulShutdown SIGTERM/3s hangs, tests pass npx vite exits, 290 of 293 FAIL no webServer (pre-started) exits, 293 pass in 33s npx only appears to fix it: npx exits once Vite is up, Playwright reads that as the server dying and tears the group down mid-run, so later tests get ERR_CONNECTION_REFUSED. globalTeardown now kills the process listening on the dev port, releasing the runner's handle. The webServer command spawns Vite's entry point directly so the listening process is Playwright's own child — via 'npm run dev' the npm process would still hold the handle open. It also reaps servers orphaned by an interrupted run, which reuseExistingServer would otherwise silently adopt. An earlier revision used netstat, which is not on PATH in every shell here; the swallowed ENOENT made the fix look applied while the hang persisted. It now uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than failing silently. npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener. playwright.config.prod.ts carried the same npm-wrapper shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(client): align .nvmrc with the Node version CI uses Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the machine the audit was measured on runs 26. A baseline measured against .nvmrc is not the baseline CI produces, which defeats the point of B0. Scoped to .nvmrc only. The full single-source-of-truth work — package engines, contributor docs, release — stays in B1 (RL-17 / C-01). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): add the beta audit set and the B0 baseline The 2026-08-23 audit set has been sitting untracked: repository-health and repository-layout audits, beta product requirements, requirement traceability, the issue register, and the B0-B10 roadmap. They are the plan of record for beta and belong in the repository. Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current evidence snapshot'. Every row is marked measured or carried, so nothing is inherited silently. It also records three audit claims that did not survive verification: - G-01 was an inverted guard, not a stale assertion — it passed on the bug and failed on the fix. - The Playwright hang matched none of the three hypotheses; the runner could not kill its own dev server. - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues, verified with -v to rule out the known zero-linters false-green. Adds b0-dev-branch-protection.sh, which records the applied dev branch protection and the reasoning behind each setting. Security detail stays private: the register carries only opaque SEC-* families and safe closure criteria, per the roadmap's public/private handling policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(graphify): refresh the knowledge graph Own commit, per CLAUDE.md — the graph payload does not belong in the diff of the changes that triggered it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): add the active-plan index and fix a stale status header (G-04) Planning documents had no recorded state, so a reader could not tell current guidance from shipped history. docs/plans/README.md now indexes every plan as active, partially implemented, design-only, or shipped, and names the source of truth for each concern so a defect count is never read out of a plan. Status is recorded in the index rather than by moving or rewriting the historical plans, so links from audits and commit messages keep resolving. One real stale claim found and fixed: audit-2026-08-19-remediation.md still read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done 2026-08-20 (merged |
||
|
|
6a26f2a839 |
fix(server): drain fully before the self-update/restore restart handoff (#1380)
* feat(server): supervisor detection and server.restart_mode config key RunningUnderSupervisor detects systemd (INVOCATION_ID) and, best-effort, NSSM (NSSM_SERVICE_NAME — 2.24 does not set it, so NSSM deployments set the mode explicitly). server.restart_mode (auto|spawn|supervised, default auto, env OWNCORD_SERVER_RESTART_MODE) selects how a self-restart hands off after the server drains: exit for the supervisor to relaunch, or spawn the replacement directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * fix(server): make the self-restart handoff drain fully before starting the successor The update/restore/wizard restart previously spawned the replacement while the old server was still serving, then SIGTERMed itself and hard-exited after 10s. That design failed in every documented deployment mode: under the shipped systemd unit the spawned child (same cgroup) was killed when the old main process exited and Restart=on-failure never relaunched a clean exit; on Windows the self-SIGTERM is unsupported and silently dropped, so graceful shutdown never ran — hub.GracefulStop (the only caller of LiveKitProcess.Stop) was skipped, orphaning livekit-server on TCP 7880/UDP 50000-60000 and dropping queued event/audit batches; and NSSM's relaunch raced the self-spawned replacement for the database lock. Admin handlers now perform only the on-disk swap and request a restart through an injected hook (admin.SetRestartHandoff). The main package's restart coordinator cancels the parent of run()'s signal.NotifyContext — the exact drain a SIGTERM triggers, on every platform — and after run() has fully torn down (listeners closed, hub and LiveKit stopped, queues flushed, DB closed and its lock released) main() performs the handoff: spawn the replacement in spawn mode, or exit 0 for the supervisor in supervised mode. A 90s backstop force-exits a wedged teardown; the DB-lock and bind retries demote to safety nets. A three-state guard (idle/busy/restart-pending) serializes update apply, backup restore, and setup-wizard restarts against each other: concurrent applies no longer race the same staged .new file or broadcast a spurious update_aborted, and conflicting requests get 409 UPDATE_IN_PROGRESS / RESTART_PENDING. The swap being free of process side effects also makes the apply success path unit-testable for the first time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * fix(server): errno-based bind-conflict detection, ACME bind retry, LiveKit Pdeathsig isAddrInUse now unwraps to the platform errno (EADDRINUSE; WSAEADDRINUSE 10048 on Windows) with the English strings kept only as fallback — the string-only match never fired on localized Windows, silently disabling the bind retry. The retry loop is extracted into serveWithBindRetry and now also covers the ACME :80 challenge server, which previously gave up on first conflict and stayed dead (breaking HTTP-01 renewals) until the next restart. The .old-binary boot cleanup retries briefly for the window where a spawn-mode predecessor has not fully exited. The companion livekit-server gets Pdeathsig SIGKILL on Linux so a parent killed without teardown (kill -9, OOM, backstop exit) cannot orphan it with the voice ports held. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * docs(deploy): Restart=always unit and per-supervisor restart-mode guidance Restart=always is what lets the deliberate clean exit after a self-update/restore relaunch under systemd (systemctl stop is never auto-restarted; failure exits behave as before). Deployment docs gain the required NSSM AppEnvironmentExtra line, the Task Scheduler and Docker restart-policy notes, and the new drain-then-handoff update flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
f5faf82a60 |
infra: observability, backups, guardrails, and deployment hardening (#1376)
* docs: add infrastructure roadmap plan Records the verified recommendations from an infrastructure review in three tracks: raising the single-instance ceiling, cheap seams for a possible multi-instance future, and ops hygiene. Includes explicit anti-recommendations and sequencing. Security-sensitive detail is intentionally excluded per docs/security.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): real health checks and saturation metrics /api/v1/metrics now exposes signals that were already computed in memory but never surfaced: reconnect replay tier hits, event-persister counters, SQLite writer-pool wait stats, aggregate per-client backpressure counters (including previously invisible low-priority drops), and permission-cache hit/miss. /health now returns a real verdict: hub dispatch-loop liveness, a bounded database ping, and a free-disk check, returning 503 with a subsystem reason when degraded. Checks are cached so the unauthenticated endpoint cannot amplify load. The hub's panic breaker now exits the process so a supervisor can restart it, instead of leaving broadcast delivery silently dead while clients still appear online. OTel instruments that were declared but never recorded are now wired (ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total, ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds). Also corrects the docs/api.md description of broadcast_drops, which counts hub-queue overflow, not client send-queue overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): implement scheduled backups, retention, and backup verification The backup_schedule and backup_retention settings have existed in the admin panel and API since the initial schema but were never read by any code. The 15-minute maintenance loop now enforces them: a scheduled backup is taken when the newest backup on disk is older than the schedule interval (manual backups reset the clock), and retention prunes backups older than the configured days while always keeping the newest one. Backups are now verified with PRAGMA integrity_check immediately after VACUUM INTO (a failed backup is removed rather than listed as restorable) and again before a restore may overwrite the live database. A failed VACUUM INTO also cleans up its partial output file — but never a pre-existing one. The backup directory is configurable via a new backup.dir key (default data/backups) so operators can point backups at another disk or an off-host mount, mirroring the SetDatabasePath plumb. Restore-handler tests now use real SQLite fixtures (the integrity gate correctly refuses text files) with the mid-copy failure injected through a test-only copy hook. Also adds audited gosec suppressions to the Windows disk-free syscall added in the previous commit, which the Windows lint leg flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): capacity and failure-mode guardrails - server.max_ws_connections: optional cap on concurrent WebSocket clients, checked before the upgrade with a 503 + Retry-After; rejections are counted and exposed as ws_conn_rejects in /api/v1/metrics. - Single-process database lock: an OS-level advisory lock (flock / exclusive handle) beside the SQLite file makes a second server process fail fast with a clear message instead of silently fighting the first over process-local state. A bounded retry covers the self-update/restore restart handoff, and the lock mechanism failing (e.g. network filesystems) only warns. - Disk-space awareness: boot-time warnings for the data and backup volumes, plus a disk_free_mb metrics field, via a small cross-platform diskutil package (already used by /health). - Upload storage failures: storage.Save now marks server-side filesystem failures with a sentinel (storage.ErrIO); handlers return 507 for those instead of blaming the client with a 400, and the emoji route stops echoing raw storage errors (which embed absolute paths) into responses. - Unknown config keys now warn at startup — a typo like admin_alowed_cidrs previously kept the default silently while the operator believed the setting changed. Never fatal: newer servers tolerate older configs. - Admin settings honesty: the three stored-but-inert settings (server_icon, max_upload_bytes, voice_quality) are shown read-only with a note pointing at the real config.yaml keys, instead of pretending to apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(db): write-path efficiency and capacity knobs - channel_focus/mark_read now skip the read-state UPSERT when the stored row already matches (same last_message_id, no mentions) — refocus events fire at up to 10/s/user and every no-op write still occupied the single SQLite writer connection. The extra existence check runs on the reader pool, which doesn't serialize. Same shape as the session-touch throttle. - DeleteExpiredSessions is now sargable: migration 031 normalizes legacy expiry formats to the RFC3339-Z layout the server writes and indexes expires_at, replacing the strftime full-table scan that ran on the writer every 15 minutes. - Boot-time ANALYZE runs only when a migration actually applied; unchanged schemas get the cheap PRAGMA optimize instead (which also covers crash-restarts that never reached the shutdown optimize). - The read/write SQL router gets a table-driven test with explicit expected values (INSERT ... RETURNING must hit the writer despite being :one). - New knobs, all defaulting to current behavior: database.max_readers, security.auth_rate_limit_multiplier (for shared-NAT communities), event_persistence.replay_ring_size and replay_cold_limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): shutdown lifecycle ordering - The event pruner and maintenance loop are now joined (bounded) before the database closes: bgCtx cancellation used to run AFTER database.Close via LIFO defers, contradicting its own comment, and neither goroutine was ever waited on — a mid-tick scheduled backup or prune could still hold the writer while the pool tore down. StartEventPruner returns a done channel with the same join contract EventPersister.Stop already had. - srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers' broadcasts still reach a live hub and the event persister instead of vanishing from the replay/event store across a restart. Shutdown does not wait on hijacked WebSocket connections, so the swap adds no delay. - GracefulStopContext threads the 30s shutdown budget into the hub: the 5s client-notice window (matching the countdown clients are shown) ends early when the budget expires, and is skipped entirely when nobody is connected — early-return startup paths and idle servers no longer sleep 5s for an audience of zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish - deploy/owncord.service: hardened systemd unit template with the two verified caveats encoded (install dir stays writable for self-update under ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a 'Linux (systemd)' deployment docs section — the Linux service story was previously 'Docker or nothing'. - New 'Reverse Proxy Topology' docs section with a working nginx snippet and the correct signaling-vs-media distinction: /livekit/* is already proxied by the server, only WebRTC media ports must be directly reachable. - docker-compose: log rotation, commented resource limits, and a healthcheck backed by a new 'chatserver healthcheck' subcommand (the distroless image has no shell) that probes /health without config side effects. - release.yml: a concurrency group (queue, never cancel), and boot-smoke gates — the freshly built server binaries and the Docker image are cold booted and probed healthy BEFORE anything is signed or pushed. The release feed drives signed self-updates, so a binary that compiles but dies on boot previously would have shipped itself to every auto-updating instance. - ci.yml: client-check/client-tests move to ubuntu with the reasoning recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a written graduation criterion instead of an open-ended non-blocking status. - docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs; architecture overview records presence/voice state as the fifth single-instance blocker and the macOS client scope decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams - Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped frames, correct message types (typing_start, presence_update), the correct /api/v1/ws path, and thresholds that fail a run where nobody authenticated or went ready — the script had drifted to pre-envelope framing and reported 100% green while every auth failed on the first frame. A new workflow_dispatch-only load-baseline workflow boots a real server, seeds users through the setup/invite APIs, runs the script, and uploads the k6 summary plus a metrics snapshot for before/after comparison. - Role-scoped channel-override changes now evict only the affected role's members from the permission cache (fail-safe: unreadable member list still flushes everything). InvalidateAll here repopulated every connected user — two reads each — synchronously inside the admin request via RefreshChannelVisibility, a stampede that scaled with total population rather than the role's size. Same pattern the per-user override endpoints already used. - Connect/disconnect presence broadcasts now pass through a 300ms latest-wins coalescer (QueuePresence): each un-coalesced presence change is a sequenced global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm fired O(users) of them from the connect critical path. A flap inside the window collapses to its final state; the wire format, seq ordering, and replay behaviour are unchanged, and the delivery path (BroadcastPresence) is untouched. - Storage seam: api handlers now consume a FileStore interface (consumer-side, same pattern as service.Store) with Open returning a seekable storage.File — writing down the contract (range-request seeks included) an alternative backend would have to meet, without building one. - The metrics surfaces and the LiveKit webhook/health endpoints get their own allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an externally-hosted LiveKit no longer requires widening the admin panel's perimeter. Startup now also warns when admin_allowed_cidrs is customized while trusted_proxies is empty — behind a proxy or container network the check would otherwise compare the proxy's private address, not the client's. - The container healthcheck probe now PINS the server's own certificate from disk (VerifyConnection, exact-match) instead of skipping TLS verification, addressing the CodeQL finding on the previous commit; WebPKI verification is used when no local cert exists (ACME). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): address self-review findings on the hardening branch Seven fixes from a high-effort review of the full branch diff: - healthcheck CLI now works under tls.mode acme: it overrides ServerName with the configured domain for WebPKI verification instead of pinning a cert that doesn't exist (or is stale) in that mode. Previously an ACME deployment's container healthcheck failed forever. - /health pings the READER pool (new db.PingRead): the writer ping queued behind a scheduled backup's VACUUM INTO and reported the server degraded for the whole backup — which an autoheal watchdog would turn into a nightly mid-backup restart. - /health runs its cached checks under context.WithoutCancel so a probe that disconnects mid-request cannot poison the shared cache with a false degraded verdict for the next 5 seconds. - The token CLI uses a new db.OpenShared that skips the single-process lock: minting a token against a running server is safe under WAL and was a documented workflow the lock had broken. - The per-user TOTP failure cap is no longer scaled by security.auth_rate_limit_multiplier — that knob exists for per-IP limits; scaling the only cross-IP brute-force defence multiplied an attacker's distributed guess budget. Mirrors the unscaled per-user login threshold. - A direct presence_update now drops the user's queued entry in the connect/disconnect coalescer, so a stale connect-time presence can no longer flush 300ms later over the user's fresher chosen status. - The scheduled-backup filename collision loop breaks on any stat error and bounds its suffix probing, instead of spinning the maintenance goroutine forever on a persistent EACCES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * test(admin): real SQLite fixture for the merged Close-failure restore test TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375) with a plain-text backup fixture; this branch's restore handler verifies backups with integrity_check before touching the live database, so the text fixture was (correctly) refused with 400 before the Close-failure branch under test was reached. Use a real backup via BackupToSafe, matching the other restore tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4ff199e14f |
fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client (#1331)
* fix(client): style the user profile popup
The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.
app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.
Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.
Two latent bugs fixed while there:
- Placement guessed a 300px card height and clamped only the top edge,
so a member clicked low in the list opened a card that ran off the
bottom of the window. Measure the card and clamp both edges.
- The avatar has to hang off the body's top edge, but the body scrolls,
and `overflow-y: auto` clips horizontally too. Make it a child of the
card rather than the body.
The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop vite watching src-tauri
`npm run tauri dev` died on Windows partway through the cargo build:
Error: EBUSY: resource busy or locked, watch
'src-tauri\target\debug\deps\owncord_client_lib.dll'
Error The "beforeDevCommand" terminated with a non-zero status code.
Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.
The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.
Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(security): add 2026-08-04 whole-codebase security review (#1326)
Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.
Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:
- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
type == "dm" guard their sibling getPermChannel carries, so the same role
can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
other DM interaction sinks perform, so a blocked user can ring the person
who blocked them.
Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.
Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ
Co-authored-by: Claude <noreply@anthropic.com>
* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)
* docs: fix server reference docs (api, protocol, server-configuration, deployment)
api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
document the per-username lockout and lockout persistence
(Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
(Server/api/metrics_handler.go) and document the otel-only
Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
endpoints: setup, stats, users, audit-log, settings, tokens,
backups, updates, and the SSE log stream (Server/admin/api.go)
protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
missing rows: call_ring, call_decline, emoji_update, call_incoming,
call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
plugin_broadcast) and flag that they sit outside protocol-schema.json
server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
telemetry.otlp_insecure, and the whole logging section +
OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)
deployment.md:
- Drop the removed "version" field from the /health sample; add
broadcast_drops to the metrics sample; note the distroless non-root
image; refresh build version strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture): rewrite stale architecture pages against
|
||
|
|
a06801d92e |
release: v1.1.0-alpha.5 — voice origin fixes (desktop + same-origin), log-stream API tokens (#1296)
* fix(admin): accept same-origin first-run setup requests A freshly generated config.yaml leaves allowed_origins commented out, so the list is empty. The setup handler's CSRF guard assumed "no Origin header means same-origin", but browsers send Origin on same-origin POSTs too — Chrome and Edge always, Firefox since 70. The admin panel's own setup call is one of those POSTs, so every new install hit "cross-origin setup request blocked" and could never create an owner account. The guard now accepts a request whose Origin names the same host:port as the request's own Host header, falling back to the allowlist otherwise. That is what the original comment intended. CSRF protection is unaffected: a cross-site attacker cannot set Origin, the browser does, and a foreign origin still needs an explicit allowlist entry. Scheme is not compared. Nothing in this server derives the external scheme (no r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would reject legitimate requests behind a TLS-terminating proxy. Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin cases, plus two handler-level tests pinning both halves — same-origin succeeds against an empty allowlist, a foreign origin still 403s and creates no user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(identity): implement identity keypair caching and error handling * fix(client): use the real OS credential store, not keyring's mock (#1281) The `keyring` crate declares no `default` feature. Every platform arm in its lib.rs selects a backend only when that platform's feature is on and otherwise falls through to `pub use mock as default`, so the client's bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS and Linux alike. The mock keeps its secret in the `Entry` object itself, and each command built its own `Entry`: save_identity_key -> Entry::new(..) -> set_password -> Ok(()) load_identity_key -> Entry::new(..) -> get_password -> NoEntry So a save reported success, the very next read in the same process returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side logged anything, and no entry was ever written to Credential Manager on any machine. Downstream, the voice-E2EE identity keypair was regenerated on reconnect, the published identity key stopped matching the key that signed the announce, and peers correctly rejected it as a possible MITM. Name the platform backends explicitly, and stop trusting a store that reports a write it did not keep: - secret_store: read every write back and compare before reporting success. If the store returns a value we did not write, purge it so it cannot shadow the fallback on the next read. - On Windows only, fall back to a DPAPI-protected file in the app data dir, engaged solely after a proven round-trip failure and cleared as soon as the real store works again. The account name is mixed into the DPAPI entropy so a blob cannot be moved between entries and decrypt. macOS/Linux report an error instead of writing secrets to plaintext. - Log the compiled backend at startup and add `probe_credential_store` so an affected machine can be diagnosed from its own log file. - Guard the regression: `compiled_keyring_backend_is_persistent` fails the build if the features are ever dropped again. Verified to fail against `keyring = "3"`. The E2EE fail-closed posture is unchanged: a peer whose announce signature does not verify is still rejected. Linux builds now need `libdbus-1-dev` for the Secret Service backend. Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58 Co-authored-by: Claude <noreply@anthropic.com> * fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282) * fix(client): make the settings panel do what it says Functional review of every control in the settings overlay. Each fix below closes a gap between what a control promised and what it did. - Appearance: picking a theme no longer drops a saved accent colour. applyThemeByName strips every inline custom property from <body>, which includes the accent override; under neon-glow (whose body class sets --accent) the user's colour silently reverted until restart. - Overlay: reopening the panel rebuilds the active tab. The Voice & Audio mic meter and camera preview are torn down on close, so a reopened panel showed a dead meter and a black preview; tabs also now re-read prefs. The Logs tab's live listener is released when you switch away from it. - Status: the UserBar picker always started at "online" and never persisted, while the Account tab read a pref nobody else wrote — the two surfaces disagreed. Both now go through lib/userStatus, sync live via the pref-change event, and the saved status is re-asserted on connect. - Notifications: Do Not Disturb now suppresses the desktop notification and the chime, as its description in the panel claims. The taskbar flash, a passive cue, stays. - Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but unimplemented. They are wired now (voice ones only while in voice, all of them suspended while the settings panel is open). "Mark as Read" had no feature behind it at all and is replaced by the Escape behaviour that actually exists. - Account: backup codes now carry a "you won't see them again" warning and a copy button; the change-password form requires the current password before spending a server attempt and disables itself while in flight. - Advanced: removed the Hardware Acceleration toggle. Nothing read the preference it wrote — the webview decides GPU compositing before any JS runs, so honouring it needs a Rust startup change. - The settings sidebar name/avatar follow a rename instead of going stale, and settings/helpers no longer keeps a drifted copy of lib/preferences (the copy lacked the write guard, so a failed save could throw). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): close silent-failure gaps in the inline admin surface Continuation of the settings-panel review into the rest of the client. - Member context menu had no styling at all: AdminActions renders BEM class names (context-menu__item and friends) that appear nowhere in the CSS, so the menu had no hover, no danger colour, and the "Change Role" submenu pushed the menu open instead of flying out. Added the missing rules. - The submenu offered a hardcoded admin/moderator/member list. On a server with custom roles those roles were unreachable, and picking a name that didn't resolve to a role id silently did nothing. Roles now come from the server's ready payload (owner excluded), and an unresolvable role reports an error instead of dead-ending. - Kick / ban / delete-channel now show an in-flight state, and the two-click confirm disarms after a few seconds so a menu left open can't turn a stray click into a ban (docs/architecture/ux/settings-and-admin.md §3). - Ban collects a reason, which the server already stores and displays (adminBanMember has always accepted one; the menu never passed it). - Copying an invite code was silent: no confirmation, and a clipboard rejection looked identical to success. It now toasts either way. - Creating an invite double-click-minted two of them, and revoking — which kills a live link — had neither a confirm nor an in-flight guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): restore moderator message deletion and formatting - The delete affordance was offered only on your own messages, so a moderator could not moderate anything from the client. It now also appears when the signed-in user's role carries MANAGE_MESSAGES, derived from the role bitmasks the server already sends in `ready` (this is what docs/architecture/ux/messaging.md §4 specifies as "Delete (own / moderator)"). lib/permissions.ts existed for exactly this and had no callers at all. - Developer-mode "Copy ID" was silent on success and swallowed clipboard failures; it toasts either way now. - prettier --write on AdminActions.ts (Client Static Checks). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): stop the panel reporting success it didn't have Functional review of the server admin web panel. - An expired admin session left the panel on screen toasting "invalid or expired session" for every action, with no way back to the login form — only the log-stream code handled it. api() now handles 401 centrally: clear the token, return to login, and say why. - Deleting a backup called fetch() without looking at the response, so a failed delete reported "Backup deleted" and left the file in place. It now goes through api(), and — like every other destructive action here — asks for confirmation first. - A failed update check rendered as "Up to date. You're running the latest version", which is a lie that hides a broken update path. It now says the check failed and why. A failed apply no longer leaves the button stuck on "Applying...". - The Edit Channel modal could only rename. PATCH /channels/{id} accepts topic, slow_mode, position and archived, and the channel table has an Archived column — which was read-only state with no control behind it. All four are editable now. - Banned users showed "Yes" with no reason, even though the ban reason is collected on ban and returned by the API. It's now displayed. - Login and first-run setup had no in-flight guard, so a double-click spent two attempts against the login lockout / setup rate limit. Settings' Save stayed enabled after a successful save, implying unsaved changes. - Clipboard copies (invite code, new API token) had no rejection path: a refused clipboard looked exactly like a successful copy. - Backup names in inline onclick handlers go through jsq() like every other interpolated string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(admin): add the plugin management UI the backend already had /api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since Phase C Step 9 — its own header says it "exposes plugin lifecycle operations to the admin panel", and docs/architecture/ux/settings-and-admin.md tells operators plugin management lives in the web panel. The panel had no Plugins section at all, so installing a plugin meant hand-crafting a multipart POST. Panel: - Plugins section: installed table (name, manifest description and requested permissions, version, enabled state, install date), zip upload with the 16 MB server cap stated up front, enable/disable, and uninstall behind a confirm. One lifecycle call at a time. - The lifecycle API sits under a different prefix than the rest of the panel and answers errors as plain text (http.Error), not JSON, so it gets its own fetch helper — sharing api() would have surfaced "unexpected token" instead of the server's reason. 401 still routes back to login. Server: - PluginRow had no JSON tags, so the list marshalled Go field names and every column would have rendered empty. Now snake_case like the rest of the API. - GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means "nothing installed" on a live runtime and "you can't install anything" on a disabled one; the body can't tell them apart, so the panel's empty state had no way to be honest about it. The plugin-store test helper now hands back the database the registry writes to — the existing happy-path test wired a *different* in-memory DB into the handler, which is why nothing noticed the list was always empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(client): gate the composer on slow mode instead of failing the send Verified the optimistic message lifecycle against docs/architecture/ux — pending → chat_send_ok → sent, failed rows with mapped reasons, retry and delete-draft all behave as documented. One thing did not: slow mode. The UX spec (§5) says slow mode should "disable send with a live countdown in the composer; do not drop the drafted message". In practice the composer knew nothing about it: you typed, sent, and got a red failed row back — the exact enabled-then-rejected pattern §6.2 forbids. The client never even received the channel's slow_mode value. - Server: channel payloads (ready, channel_create, channel_update) now carry slow_mode alongside can_send, for the same reason can_send is there — the client can express the limit as affordance. The server still enforces. - Client: after an accepted send the composer disables itself for the channel's cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the full window (the server's limiter is the authority on when the next send is allowed). The draft stays in the textarea. Moderators, who bypass slow mode server-side, are not gated. - The MANAGE_MESSAGES lookup added for moderator deletes moves into lib/permissions as currentUserPermissions/currentUserHasPermission/ canManageMessages, so the composer and the message renderer share one definition instead of two. - WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT, BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and ALREADY_JOINED were missing, so code switching on it could not name cases the server actually sends. Now mirrors Server/ws/errors.go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): make backup restore actually restart, and fail closed without a safety copy Verification pass over the remaining review items. Two real defects in restore, one duplicate resolved; cert TOFU and the replay path checked out as-is. Restore: - The handler closed the database, swapped the file underneath it, told the admin "database restored — server restarting", broadcast a 5-second restart countdown to every client... and then kept running. Nothing restarted it, so the server answered every subsequent request against a closed DB until an operator noticed. It now respawns for real, reusing the update-apply pattern (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam. - A failed pre-restore backup was a warning, and the irreversible overwrite went ahead anyway — removing the safety net the panel explicitly promises ("A pre-restore backup will be created"), precisely when it matters. It now aborts with the database untouched. - The safety copy was written to a cwd-relative "data/backups" while every other backup handler uses the absolute backupBaseDir, so a server started from another directory filed it somewhere the operator would never find. Both new tests were confirmed to fail against the previous behaviour. Client: - SidebarArea kept a private 140-line copy of the member-list wiring that SidebarMemberSection already provides (the extracted, tested one was never imported). Fixing the silent role-change failure earlier meant patching both; now there is one copy. Verified without changes: the optimistic send lifecycle (pending → chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft), reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression of unread/notifications), and cert TOFU (first-use and mismatch modals, accept re-pins and reconnects, reject disconnects back to connect). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): remove the data race in the restart test hook CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_ Success polled a plain bool that the restore handler's goroutine wrote, and swapped the restartSelf package var from the test goroutine while that handler read it. The hook is now behind a mutex with an atomic flag in StubRestart. Production behaviour is unchanged — the race was entirely in the test seam I added. Verified with `go test -race -count=2 ./admin/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor + perf: split largest source files into modules; optimize hot paths (#1283) * refactor(updater): split updater.go into cohesive files Split the 1070-line updater.go into four files within the same package: updater.go (core types, release checking), download.go (download and tarball extraction), verify.go (signatures, checksums, staged binary), and assets.go (client assets, text-asset cache, HTTP fetching). Pure mechanical move — no behavior or API changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(ws): split hub.go into cohesive files Split the 1289-line hub.go into five files within the same package: hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go (broadcast fan-out and per-user sends), hub_events.go (sequencing, replay, persistence), hub_sweep.go (stale client/session/voice sweepers), and hub_livekit.go (LiveKit accessors). Also optimizes wrapWithSeq on the hot broadcast path: build the seq prefix with a single preallocated append + strconv.AppendUint instead of fmt.Sprintf, halving allocations per broadcast message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(client): extract E2EEManager from livekitSession Move all client-side E2EE key-exchange logic (~550 lines) out of LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH keypair management, identity signing and TOFU pin verification, announce/offer handling, key-holder election, membership rekeying, and periodic key rotation. Dependencies are injected following the existing roomEventHandlers pattern. LiveKitSession keeps thin public delegates (handleE2EEAnnounce, handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the module-level bound exports and the public API are unchanged. livekitSession.ts shrinks from 1955 to 1409 lines. Adds focused unit tests for E2EEManager (key-holder setup, pending announce queue, offer resolution, clearState, rotation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(server): hot-path and query optimizations Logging (biggest win): rewrite the admin log RingBuffer as a true ring (fixed array + head/count) instead of allocating a fresh 2000-entry slice + full copy per log line; gate the ring handler on a configurable level instead of unconditional DEBUG capture; move the broadcast debug log out of the seqMu critical section; drop the per-message slog.With clone in the WS handler. Database: new migration 019 adds idx_attachments_message (message pages no longer scan the attachments table), a covering role-leading index on channel_overrides (replacing a duplicate of the UNIQUE auto-index), a partial index for pinned messages, and narrows the FTS trigger to content changes only; ANALYZE runs after migrations. Rewrite GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries that range-scan idx_messages_channel — O(unread) instead of O(all messages) per WS connect. New GetUserDMChannelIDs replaces the full DM query where only IDs are needed. CreateMessage/EditMessageContent use RETURNING, removing the re-read after every send/edit. Write-path contention: TouchSession throttled to once per minute per session (was one UPDATE per authenticated request); EventPersister flushes its batch in a single transaction with per-row fallback; revoked-session and stale-voice sweeps run off the hub dispatch goroutine with an in-flight guard, and session checks are batched into one IN query; the rate limiter is sharded into 32 buckets with allocation-free strconv key building (auth.Key). WS structural: voice E2EE channel fan-out goes through the existing pubsub voice topic instead of scanning every connected client under h.mu; channelReadAudience memoizes role lookups per call; hasChannelAccess drops its redundant duplicate permission check; voice_join batches SPEAK/VIDEO/SCREENSHARE checks via HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics stop allocating via Sprintf/global mutex. Verified with go test -race across all packages, go vet, gofmt, and sqlc generate idempotency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): render-path, logging, and bundle optimizations Logging: the logger no longer runs permanently at debug — level is set from the environment at startup (debug in dev, info in prod), so every hot-path debug entry stops being serialized, buffered, consoled, and persisted to disk; per-URL debug logs in embed rendering removed. Render path: MessageList's store selector is scoped to the mounted channel, so messages in other channels no longer trigger re-renders, and a new incremental tail-append fast path appends rows instead of tearing down the whole window; Intl.DateTimeFormat instances are cached at module level; parseTimestamp memoizes epoch millis; media prefs (showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with pref-change invalidation; members store gains a roleRevision counter so MessageList stops rebuilding a role map on every presence/typing event. MemberList patches presence changes in place (status dot + offline class) via a row map instead of rebuilding every row, with single-pass role grouping. ChannelSidebar splits its voice subscription into a structural selector (excluding speaking) and a speaking-only patcher using a cached element map instead of per-user querySelector on every speaker event. Memory: GIF/media elements are unobserved before the message window discards them, fixing unbounded IntersectionObserver retention of detached DOM (including frozen-frame data URLs). Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic imports and manualChunks; the READY handler's stale-voice check reads the voice store instead of requiring the module synchronously. Adds 11 focused tests (different-channel no-rerender, append fast path, media release, presence patch, speaking patch). Full unit suite: 3606/3606 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284) The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate: under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock allocates, so the steady-state ring write measures 1 alloc/call. Extend the build constraint to !race && !deadlock — the test's guarantee is about the ring buffer itself, which the -race-less default pass covers. Make bcryptCost a var with an exported SetCostForTesting hook that also resets the dummy timing pad, and call it with bcrypt.MinCost from the api, auth, and admin TestMains. Password hashing at production cost 12 dominated those suites (~264 hashes): with the race detector the api package alone took ~860s; it now runs in ~33s. Nothing under test depends on hash strength, and no test asserts the cost. Hygiene in the same pass: migration 020 drops idx_sessions_token and idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure write overhead) with updated db_test assertions; remove the dead tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg since Go 1.11); gofmt storage/storage.go comment alignment. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf(ws): route hot-path permission checks through the cached PermissionService (#1285) The ws package was the only major subsystem still doing live per-check permission queries (GetRoleForUser + GetChannelPermissions per check): a V2 voice join cost 9+ DB reads across its four gates, and every channel broadcast resolved one role query per connected client. Hub now holds svc.Permissions and the voice deps carry it (nil-safe: bare test fixtures fall back to the existing live path, fail-closed semantics preserved everywhere). Converted sites: the voice join and token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls, requireChannelAccess, channelReadAudience, and RefreshChannelVisibility. Caching these is revocation-correct: every permission-changing mutation already invalidates synchronously before hub fan-out (InvalidateUser on role change, InvalidateAll on override change), the 30s TTL is only a backstop, and the service's gen-counter guard prevents a populate that races an invalidation from caching stale data — the audience-resolution comments now document that invariant. The stale-voice sweeper's check deliberately stays live: it is the last-line backstop for revocations that might bypass an invalidation hook, runs once a minute for only in-voice clients, and its eviction test pins exactly that guarantee. requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the cache only for positive verdicts and falling through to the live path on denial. Adds perm_cache_test.go: role-change invalidation is immediate (no TTL wait), and a counting-store test proving the second check is served from cache. All pinning tests (authz, voice_perm_stale, channel visibility agreement, sweep eviction) pass unmodified. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286) * perf(db): batch audit writes through an async writer Audit inserts ran synchronously on the request path — including one INSERT per WebSocket connect — each an implicit transaction on the single SQLite connection. WriteAudit keeps its exact signature and D8 policy (never fail the caller, never silently discard): it now upgrades to an async path when the passed Auditor also implements AsyncAuditor. *DB implements that via an atomic pointer that main.go populates at server startup with an AuditWriter modeled on the event persister (bounded queue, batched single-transaction flush with per-row fallback, drain-on-stop, atomic counters, non-blocking enqueue that error-logs drops without leaking the detail field). The token CLI and tests never install a writer, so they keep today's synchronous behavior with zero call-site changes. The writer's Stop defer registers after database.Close's so the LIFO unwind drains the queue before the DB shuts. Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop, flush-failure accounting, poison-row fallback, concurrent enqueue, and seam tests pinning sync-without-writer vs async-with-writer behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): actually defer livekit-client; honor saved log level at startup The manualChunks split was cosmetic: index.html modulepreloaded the 531 kB livekit chunk and the entry statically imported it. All four import chains from startup are now cut — auth.store's logout leaveVoice and ptt's setMuted go through dynamic imports, applyStoredAppearance moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the settings tree (whose overlay now loads on first open), and MainPage itself is a dynamic import in renderPage, guarded against the destroy-before-mount race by a navigation-generation helper and pre-warmed once the socket connects. Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession load as lazy chunks. The logger now honors the Logs tab's saved minimum level at startup (applyStoredLogLevel with the legacy-key migration moved into lib/preferences.ts) and re-applies it live on pref changes. Dead code: remove unreachable VoiceChannel.ts (superseded by ChannelSidebar's renderer) and its test, plus all knip-flagged unused re-exports in message-list/renderers.ts and ConnectPage's unused form types — knip is now clean apart from pre-existing config hints. Tests: +12 (navigation guard incl. stale-mount discard; logger startup pref, migration, and live re-apply); ptt/stored-appearance updated for dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(db): split SQLite into single-writer + multi-reader connection pools The entire server serialized on one SQLite connection: every read queued behind every other read and every write, throwing away WAL's concurrent-reader capability. File-backed databases now open two pools from a DSN that carries all seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA only configures one arbitrary pooled connection — moving them into the DSN is what makes >1 connection safe, foreign_keys included): a single-connection writer with _txlock=immediate, and a reader pool sized max(4, NumCPU). In-memory databases keep the exact historical single-connection behavior, which preserves every :memory: test site and the connection-scoped PRAGMA-toggle tests untouched. Routing lives in a dbtx router implementing sqlc's DBTX: statements go to the reader only when provably read-only (leading SELECT/PRAGMA after skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE ... RETURNING through QueryRowContext/QueryContext, which must stay on the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and the SQLDb() escape hatch all pin to the writer. Every former sqlDB reference across the package was re-pointed deliberately. New pool_test.go pins the properties the split must preserve on a file-backed DB: foreign_keys=1 across many reader connections, WAL journal mode, FK enforcement through both write paths, 8x8 concurrent reader/writer hammering with exact row counts, and a read completing against the pre-tx snapshot while a write transaction is open — the property this change exists to unlock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(client)+chore: split the two largest test files; eslint 10; audit clean Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect / ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module, and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain / vad-worklet / vad-fallback files. Test bodies moved verbatim; the suite count is unchanged at 3593 passing. Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer range already covers v10, flat config unchanged, zero new findings) and pin test-exclude ^8 via the existing overrides block so the coverage chain picks up patched glob/minimatch/brace-expansion. npm audit: 8 high -> 0 vulnerabilities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(server): split remaining large files; dependency hygiene notes Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers / chat / voice / voice_lifecycle / misc test files — bodies verbatim, 746 passing tests before and after. Split service/message.go (781) into message_crud / message_reactions / message_query / message_perms with types and the constructor staying put, and ws/serve.go (754) into serve / serve_pumps / serve_auth / serve_ready. Dependency findings (no changes needed): coraza-coreruleset's stale Feb-2024 pseudo-version is unreachable from our code — it enters the module graph only through coraza's own internal tests, and our WAF uses inline directives, never the CRS (fresher rules would require adopting the /v4 module and rewiring the WAF config — deliberate follow-up, not hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and never built into our binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * style: satisfy golangci-lint modernize/staticcheck in new pool and audit code CI's golangci-lint pass (not run locally until now) flagged the Phase 3/4 additions: range-over-int loops, interface{} -> any on the dbtx router, WaitGroup.Go in the pool tests, and a De Morgan simplification in isReadOnlySQL's identifier-boundary check. Pure style — verified against the same golangci-lint v2.11.3 binary CI uses (0 issues) and re-ran db/ws race + deadlock suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287) * fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile cargo-audit identified the two Dependabot alerts on the default branch: quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded namespace allocation DoS), fixed in >=0.41. Both were transitive: plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml entirely. cargo-audit is now clean of vulnerabilities; the remaining 20 informational notices are the unmaintained GTK3-binding crates inherent to Tauri v2 on Linux. Verified plist compiles against quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI installs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * feat(waf): layer the maintained OWASP Core Rule Set onto the WAF The WAF previously ran six inline directives only — the CRS never loaded (the old coreruleset dep was a stale graph-only pseudo-version). A second Coraza engine now loads the embedded CRS from coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules, which stay byte-identical and keep blocking exactly as before. CRS ships in a new server.waf_crs_mode knob (off|detect|block), defaulting to detect: chat traffic is CRS-false-positive-prone (a new test pins that block mode rejects benign SQL-ish chat prose at the default threshold), so operators get rule-match visibility via structured logs first and opt into blocking after tuning. Setup mirrors the official connector: Host/Transfer-Encoding restored to the transaction (else 920280 fires on everything), phase 2 always runs so query-string attacks are scored, PUT/PATCH/DELETE added to the CRS method policy for this REST API, body limits matched to the app's 1 MiB cap with uploads excluded from body access and the content-type policy. Also fixes a latent middleware bug: the body was previously swapped for the buffered reader even when nothing was buffered, which would have handed body-access-off routes an empty body; now pinned by a test across all modes. Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection without blocking, block-mode blocking + benign passthrough, upload body preservation); waf_test.go passes unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(ws): replace fixed sleeps with condition-based waits The ws suite paced async hub effects with 537 fixed time.Sleep calls — slow at best, flaky under load at worst. They are now condition-based: a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set (waitRegistered exploits the hub's in-order client-event processing), plus blocking decode-scans for the DM tests. The bulk deletion is grounded in verified production facts, unchanged by this commit: sendMsg is a synchronous buffered send (error replies are already buffered when the handler returns), the voice control / rollback / cleanup / sweep paths are synchronous, and serve.go registers the client before writing the ready frame. Absence assertions were deliberately NOT inverted into polling — they keep bounded windows, each commented. 20 sleeps remain, all justified in place: poll intervals inside condition loops, absence windows, clock-granularity pacing, and the event-pruner's inherently time-based no-prune-after-cancel assertion. Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the remainder is GracefulStop's hard-coded production 5s drain, out of scope here); race flake check passes 3 consecutive iterations; deadlock pass and golangci-lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288) * fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit WAF detect mode wired logCRSMatch as the engine-level error callback, which fires one slog.Warn per matched rule on the request goroutine. In the default detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly scoring, so each request logged a burst of Warn lines in the hot path. Aggregate per request from per-transaction state instead of the shared global callback: in the default detect path leave the engine error callback nil and, in the existing crsTx defer, emit at most one Warn per request that had matches (count + highest-severity rule), demoting the full rule-id list to Debug. Block mode keeps per-rule logging (blocked requests are rare and their detail is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery so existing tests stay unmodified. Detection, interruption, and body handling are unchanged — only the detect-path logging shape. The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow flush the 5s ctx could win, returning while run() was still flushing. main.go's LIFO defers then closed the DB pool under a live flusher, losing audits. Stop now always waits on done (the goroutine has stopped touching the store) while ctx bounds only the drain inside run() via a published stopCtxDone channel, so a slow store delays shutdown by at most one in-flight flush and the pool is never closed under a live writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * fix(client): plug listener leaks, guard lazy livekit load, honor saved log level Follow-up audit of the recently-landed lazy-livekit and session wiring found three real issues: - clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice on every logout, pulling the ~531 kB livekit chunk into the logout path even when no voice session was ever active. Guard the import on an active voice session (currentChannelId set and status not idle) and add a .catch so a failed teardown import can't reject unhandled. - The onStateChange handler unsubscribed session listeners only on the ready transition, not on disconnected; user_update and ready listeners registered per session were never collected for cleanup. Collect them into a sessionUnsubs array cleaned up on both ready and disconnected, preventing duplicate handlers accumulating across reconnects. - The Logs tab min-level select ignored the persisted log level when no explicit dropdown preference was saved. Add logger.getLogLevel() and default the select to it so the UI reflects the level actually in effect. Also add .catch to the ptt setMuted dynamic import. New unit tests cover the clearAuth guard, getLogLevel, and the LogsTab default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289) The CRS WAF engine failed to initialize on Windows, taking the whole api package's test suite red there. coraza's seclang parser resolves Include globs through path/filepath: for every match of `Include @owasp_crs/*.conf` it calls filepath.Join(currentDir, match), which on Windows rewrites the forward slashes to backslashes. It then feeds names like `@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS is the ruleset's embed.FS, which is always forward-slash and rejects a backslash name, so newCRSWAF returned "file does not exist" and no CRS rule under a subdirectory was ever loaded. Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/ Glob) that converts backslashes to forward slashes before delegating. This fixes CRS loading on Windows without patching coraza or the ruleset module and is a no-op where the separator is already "/". The Linux-only local verification for the CRS work missed this because coraza never emits backslashes there. The new test reproduces the failure mode on any OS by constructing the exact backslash name coraza produces on Windows: the raw ruleset FS fails to read it, the wrapper resolves it, and a forward-slash path still works. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291) The Client E2E CI job never completed: every run hit its 25-minute cap and was cancelled. ~229 of the 255 web tests were failing, all cascading from the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout burn on 1 worker deterministically exceeds the cap. Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts now awaits invoke("start_http_proxy") and builds REST URLs as http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for the unstubbed command, so every URL got a literal "null" port and Request construction threw before the mocked plugin:http transport was consulted. Login rejected, [data-testid='app-layout'] never mounted, and every logged-in test burned its full timeout. Stubbing start_http_proxy with any numeric port fixes the cascade because route matching is substring-based. The tail of failures after that fix were tests asserting behavior the app intentionally changed: - The ready payload can no longer pre-connect the local user to voice: the dispatcher treats "self in ready.voice_states while idle" as stale state from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote users only (2, 3), and widget tests join through the real click path via a new joinVoiceChannelByName helper. - The mock's voice_join reply no longer includes a voice_token: a token starts a real LiveKit session that deterministically self-destructs in the browser mock (E2EE key exchange timeout ~15s / connect-refused retries), tearing the widget down mid-test. These web tests validate the WS/UI layer only; real LiveKit is covered by the native suite. The reply also gained the full VoiceStatePayload shape — the sidebar renders user.username directly, and the omitted field broke the whole voice-user list render. - Message-load failure now renders an inline region error + Retry instead of a toast (UX spec 2), so the toast specs assert the inline UI and get their auto-dismiss vehicle from the delete-confirmation toast. CI hardening so a future systemic breakage can never burn the full cap again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now self-terminates with a usable report instead of being SIGKILLed), with the workflow's timeout-minutes 25 as the outer backstop. The job stays continue-on-error until it has proven stably green across a few pushes; the ci.yml comment documents that flip trigger. Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2). Unit tests (3598), typecheck, and prettier all clean. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290) * feat(admin): first-run setup wizard with config.yaml write-back Turn the single-screen owner-account setup into a guided multi-step wizard so non-technical operators never have to hand-edit YAML: - config: new comment-preserving config.Save (yaml.Node round-trip, atomic temp+rename write, verified loadable before replacing the file) plus a shared config.DefaultPath. Persists the runtime-generated LiveKit credentials so voice tokens survive restarts. - admin: POST /admin/api/setup accepts an optional "wizard" object (server name, MOTD, registration, port, TLS mode/domain, upload limit, voice quality). Values are validated before the account is created; DB settings and config.yaml are written after; failures downgrade to warnings so the created owner is never orphaned behind a 5xx. When a startup-only value changed the server restarts itself (reusing the backup/update restart machinery) and returns the new admin URL. - admin: GET /admin/api/setup/status now returns secret-free prefill defaults while setup is pending. - admin panel: six-step wizard UI (welcome, account, server basics, uploads & voice, access, review) with plain-language explanations, a restart/reconnect screen, and a "skip" path that keeps the legacy account-only flow byte-for-byte. - legacy payload {username,password} and all existing call sites keep working (SetupOptions is a trailing variadic parameter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * feat(voice): auto-download the LiveKit server binary Voice now works with zero manual setup: when voice.auto_download_livekit is enabled and no voice.livekit_binary is configured, the server fetches the pinned livekit-server release (v1.13.5, overridable via voice.livekit_version) from the official LiveKit GitHub releases in the background at startup, verifies it against the release's checksums.txt, extracts it into data/livekit/, and manages it as the existing companion process (crash recovery, health checks, graceful shutdown). - ws: new livekit_download.go — pinned version, per-platform asset mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's goreleaser config), size-capped downloads, hash verification and extraction through one open handle (TOCTOU-safe), O_EXCL staging, atomic rename, stale-version cleanup. LiveKitProcess.Start resolves the binary asynchronously with retries so boot is never blocked. - config: voice.auto_download_livekit + voice.livekit_version; enabled in the generated default config so fresh installs get working voice out of the box, while the compiled-in default stays off for existing configs. config.Load now loads the default file it just wrote, so the first boot runs with exactly the configuration the file documents. - wizard: "Voice chat" toggle (on by default) in the Uploads & voice step; the choice is written to config.yaml and factored into the restart decision. - docs: livekit-setup, server-configuration, deployment, README. Verified end-to-end against the real v1.13.5 release: download, checksum match, extraction, and process spawn all succeed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore: remove stray server.log, ignore local run logs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293) * fix(voice): accept the desktop client's webview origins on the LiveKit proxy The desktop client's chat connection goes through its Rust proxy, which sends no Origin header, so the safe-default empty allowed_origins never blocked it. The LiveKit JS SDK's signal requests and validate probes, however, are issued directly from the webview and carry its fixed origin (http(s)://tauri.localhost on WebView2, tauri://localhost on WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and returned 403, so on every default install voice failed for any desktop client that wasn't on the server machine — chat worked, voice didn't, with /livekit/rtc/v1 403s in the server log. Treat these fixed first-party origins as always allowed. This is the same trust already extended to absent-Origin requests: web content can never present them (browsers resolve *.localhost to loopback and cannot reach the tauri:// scheme), so the CSRF surface is unchanged. Exact, case-insensitive matching only — lookalikes (tauri.localhost.evil.com, tauri.localhost:8080) still require an explicit allowlist entry. Operators no longer need to hand-add these origins to server.allowed_origins for voice to work; that list is now only for web/browser clients. Docs and the generated config comment updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore(client): bump version to 1.1.0-alpha.5 The v1.1.0-alpha.4 release shipped client artifacts still versioned 1.1.0-alpha.3 because the client manifests were never bumped — deployed desktop clients therefore consider themselves up to date and never auto-update. Bump package.json, package-lock.json, tauri.conf.json, Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's clients update normally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * ci(release): fail the release when client version does not match the tag Guards against the v1.1.0-alpha.4 mistake recurring: a new verify-versions job compares the pushed tag against tauri.conf.json, package.json and Cargo.toml and fails before any build starts; every build job now depends on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(admin): allow API-token principals to use the SSE log stream (#1294) The log stream was session-only: POST /admin/api/logs/ticket required a *db.Session in the request context (deliberately nil for API-token principals), and the stream handler re-validated the ticket hash against the sessions table alone. API tokens could reach every other /admin/api/* route but not the log stream, breaking the mcp-introspect server_logs tool that docs/mcp-introspect.md documents as working. Bind tickets to the hash of whichever bearer credential authenticated the request, and resolve it in the stream handler via auth.ResolveTokenHash — the same session-first, API-token-fallback path the admin middleware uses. Ban, role demotion, and mid-stream revocation of either credential kind cut the stream exactly as before. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295) A page served by the server itself (e.g. a browser client at https://<server>:8443) chats fine but cannot join voice: browsers attach the page origin to every WebSocket handshake, and the LiveKit proxy's hand-rolled isOriginAllowed only recognized "no Origin" as same-origin, so the RTC upgrade 403'd while same-origin fetches (which omit Origin) succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the server log. Allow an Origin whose host equals the request Host, mirroring websocket.Accept's default same-origin policy that the chat WS endpoint already applies — which is exactly why chat worked and voice didn't. Web content on another origin can never present this origin (the browser pins it), so the CSRF surface is unchanged. Same host on a different port remains cross-origin and denied. Also log rejected origins on the 403 path (origin, path, remote) — this failure was previously undiagnosable from the server log, which recorded the 403 but not the offending origin. Existing allowlist tests used origins colliding with httptest's default request host (example.com), which the new semantics correctly treat as same-origin; their fixtures now use distinct hosts so they keep exercising the allowlist path. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * docs(changelog): curate v1.1.0-alpha.5 entries — voice origin fixes, log-stream API tokens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49595e48d7 |
release: v1.1.0-alpha.4 — first-run setup wizard, LiveKit auto-download, WAF & CI fixes (#1292)
* fix(admin): accept same-origin first-run setup requests A freshly generated config.yaml leaves allowed_origins commented out, so the list is empty. The setup handler's CSRF guard assumed "no Origin header means same-origin", but browsers send Origin on same-origin POSTs too — Chrome and Edge always, Firefox since 70. The admin panel's own setup call is one of those POSTs, so every new install hit "cross-origin setup request blocked" and could never create an owner account. The guard now accepts a request whose Origin names the same host:port as the request's own Host header, falling back to the allowlist otherwise. That is what the original comment intended. CSRF protection is unaffected: a cross-site attacker cannot set Origin, the browser does, and a foreign origin still needs an explicit allowlist entry. Scheme is not compared. Nothing in this server derives the external scheme (no r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would reject legitimate requests behind a TLS-terminating proxy. Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin cases, plus two handler-level tests pinning both halves — same-origin succeeds against an empty allowlist, a foreign origin still 403s and creates no user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(identity): implement identity keypair caching and error handling * fix(client): use the real OS credential store, not keyring's mock (#1281) The `keyring` crate declares no `default` feature. Every platform arm in its lib.rs selects a backend only when that platform's feature is on and otherwise falls through to `pub use mock as default`, so the client's bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS and Linux alike. The mock keeps its secret in the `Entry` object itself, and each command built its own `Entry`: save_identity_key -> Entry::new(..) -> set_password -> Ok(()) load_identity_key -> Entry::new(..) -> get_password -> NoEntry So a save reported success, the very next read in the same process returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side logged anything, and no entry was ever written to Credential Manager on any machine. Downstream, the voice-E2EE identity keypair was regenerated on reconnect, the published identity key stopped matching the key that signed the announce, and peers correctly rejected it as a possible MITM. Name the platform backends explicitly, and stop trusting a store that reports a write it did not keep: - secret_store: read every write back and compare before reporting success. If the store returns a value we did not write, purge it so it cannot shadow the fallback on the next read. - On Windows only, fall back to a DPAPI-protected file in the app data dir, engaged solely after a proven round-trip failure and cleared as soon as the real store works again. The account name is mixed into the DPAPI entropy so a blob cannot be moved between entries and decrypt. macOS/Linux report an error instead of writing secrets to plaintext. - Log the compiled backend at startup and add `probe_credential_store` so an affected machine can be diagnosed from its own log file. - Guard the regression: `compiled_keyring_backend_is_persistent` fails the build if the features are ever dropped again. Verified to fail against `keyring = "3"`. The E2EE fail-closed posture is unchanged: a peer whose announce signature does not verify is still rejected. Linux builds now need `libdbus-1-dev` for the Secret Service backend. Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58 Co-authored-by: Claude <noreply@anthropic.com> * fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282) * fix(client): make the settings panel do what it says Functional review of every control in the settings overlay. Each fix below closes a gap between what a control promised and what it did. - Appearance: picking a theme no longer drops a saved accent colour. applyThemeByName strips every inline custom property from <body>, which includes the accent override; under neon-glow (whose body class sets --accent) the user's colour silently reverted until restart. - Overlay: reopening the panel rebuilds the active tab. The Voice & Audio mic meter and camera preview are torn down on close, so a reopened panel showed a dead meter and a black preview; tabs also now re-read prefs. The Logs tab's live listener is released when you switch away from it. - Status: the UserBar picker always started at "online" and never persisted, while the Account tab read a pref nobody else wrote — the two surfaces disagreed. Both now go through lib/userStatus, sync live via the pref-change event, and the saved status is re-asserted on connect. - Notifications: Do Not Disturb now suppresses the desktop notification and the chime, as its description in the panel claims. The taskbar flash, a passive cue, stays. - Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but unimplemented. They are wired now (voice ones only while in voice, all of them suspended while the settings panel is open). "Mark as Read" had no feature behind it at all and is replaced by the Escape behaviour that actually exists. - Account: backup codes now carry a "you won't see them again" warning and a copy button; the change-password form requires the current password before spending a server attempt and disables itself while in flight. - Advanced: removed the Hardware Acceleration toggle. Nothing read the preference it wrote — the webview decides GPU compositing before any JS runs, so honouring it needs a Rust startup change. - The settings sidebar name/avatar follow a rename instead of going stale, and settings/helpers no longer keeps a drifted copy of lib/preferences (the copy lacked the write guard, so a failed save could throw). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): close silent-failure gaps in the inline admin surface Continuation of the settings-panel review into the rest of the client. - Member context menu had no styling at all: AdminActions renders BEM class names (context-menu__item and friends) that appear nowhere in the CSS, so the menu had no hover, no danger colour, and the "Change Role" submenu pushed the menu open instead of flying out. Added the missing rules. - The submenu offered a hardcoded admin/moderator/member list. On a server with custom roles those roles were unreachable, and picking a name that didn't resolve to a role id silently did nothing. Roles now come from the server's ready payload (owner excluded), and an unresolvable role reports an error instead of dead-ending. - Kick / ban / delete-channel now show an in-flight state, and the two-click confirm disarms after a few seconds so a menu left open can't turn a stray click into a ban (docs/architecture/ux/settings-and-admin.md §3). - Ban collects a reason, which the server already stores and displays (adminBanMember has always accepted one; the menu never passed it). - Copying an invite code was silent: no confirmation, and a clipboard rejection looked identical to success. It now toasts either way. - Creating an invite double-click-minted two of them, and revoking — which kills a live link — had neither a confirm nor an in-flight guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): restore moderator message deletion and formatting - The delete affordance was offered only on your own messages, so a moderator could not moderate anything from the client. It now also appears when the signed-in user's role carries MANAGE_MESSAGES, derived from the role bitmasks the server already sends in `ready` (this is what docs/architecture/ux/messaging.md §4 specifies as "Delete (own / moderator)"). lib/permissions.ts existed for exactly this and had no callers at all. - Developer-mode "Copy ID" was silent on success and swallowed clipboard failures; it toasts either way now. - prettier --write on AdminActions.ts (Client Static Checks). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): stop the panel reporting success it didn't have Functional review of the server admin web panel. - An expired admin session left the panel on screen toasting "invalid or expired session" for every action, with no way back to the login form — only the log-stream code handled it. api() now handles 401 centrally: clear the token, return to login, and say why. - Deleting a backup called fetch() without looking at the response, so a failed delete reported "Backup deleted" and left the file in place. It now goes through api(), and — like every other destructive action here — asks for confirmation first. - A failed update check rendered as "Up to date. You're running the latest version", which is a lie that hides a broken update path. It now says the check failed and why. A failed apply no longer leaves the button stuck on "Applying...". - The Edit Channel modal could only rename. PATCH /channels/{id} accepts topic, slow_mode, position and archived, and the channel table has an Archived column — which was read-only state with no control behind it. All four are editable now. - Banned users showed "Yes" with no reason, even though the ban reason is collected on ban and returned by the API. It's now displayed. - Login and first-run setup had no in-flight guard, so a double-click spent two attempts against the login lockout / setup rate limit. Settings' Save stayed enabled after a successful save, implying unsaved changes. - Clipboard copies (invite code, new API token) had no rejection path: a refused clipboard looked exactly like a successful copy. - Backup names in inline onclick handlers go through jsq() like every other interpolated string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(admin): add the plugin management UI the backend already had /api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since Phase C Step 9 — its own header says it "exposes plugin lifecycle operations to the admin panel", and docs/architecture/ux/settings-and-admin.md tells operators plugin management lives in the web panel. The panel had no Plugins section at all, so installing a plugin meant hand-crafting a multipart POST. Panel: - Plugins section: installed table (name, manifest description and requested permissions, version, enabled state, install date), zip upload with the 16 MB server cap stated up front, enable/disable, and uninstall behind a confirm. One lifecycle call at a time. - The lifecycle API sits under a different prefix than the rest of the panel and answers errors as plain text (http.Error), not JSON, so it gets its own fetch helper — sharing api() would have surfaced "unexpected token" instead of the server's reason. 401 still routes back to login. Server: - PluginRow had no JSON tags, so the list marshalled Go field names and every column would have rendered empty. Now snake_case like the rest of the API. - GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means "nothing installed" on a live runtime and "you can't install anything" on a disabled one; the body can't tell them apart, so the panel's empty state had no way to be honest about it. The plugin-store test helper now hands back the database the registry writes to — the existing happy-path test wired a *different* in-memory DB into the handler, which is why nothing noticed the list was always empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(client): gate the composer on slow mode instead of failing the send Verified the optimistic message lifecycle against docs/architecture/ux — pending → chat_send_ok → sent, failed rows with mapped reasons, retry and delete-draft all behave as documented. One thing did not: slow mode. The UX spec (§5) says slow mode should "disable send with a live countdown in the composer; do not drop the drafted message". In practice the composer knew nothing about it: you typed, sent, and got a red failed row back — the exact enabled-then-rejected pattern §6.2 forbids. The client never even received the channel's slow_mode value. - Server: channel payloads (ready, channel_create, channel_update) now carry slow_mode alongside can_send, for the same reason can_send is there — the client can express the limit as affordance. The server still enforces. - Client: after an accepted send the composer disables itself for the channel's cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the full window (the server's limiter is the authority on when the next send is allowed). The draft stays in the textarea. Moderators, who bypass slow mode server-side, are not gated. - The MANAGE_MESSAGES lookup added for moderator deletes moves into lib/permissions as currentUserPermissions/currentUserHasPermission/ canManageMessages, so the composer and the message renderer share one definition instead of two. - WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT, BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and ALREADY_JOINED were missing, so code switching on it could not name cases the server actually sends. Now mirrors Server/ws/errors.go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): make backup restore actually restart, and fail closed without a safety copy Verification pass over the remaining review items. Two real defects in restore, one duplicate resolved; cert TOFU and the replay path checked out as-is. Restore: - The handler closed the database, swapped the file underneath it, told the admin "database restored — server restarting", broadcast a 5-second restart countdown to every client... and then kept running. Nothing restarted it, so the server answered every subsequent request against a closed DB until an operator noticed. It now respawns for real, reusing the update-apply pattern (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam. - A failed pre-restore backup was a warning, and the irreversible overwrite went ahead anyway — removing the safety net the panel explicitly promises ("A pre-restore backup will be created"), precisely when it matters. It now aborts with the database untouched. - The safety copy was written to a cwd-relative "data/backups" while every other backup handler uses the absolute backupBaseDir, so a server started from another directory filed it somewhere the operator would never find. Both new tests were confirmed to fail against the previous behaviour. Client: - SidebarArea kept a private 140-line copy of the member-list wiring that SidebarMemberSection already provides (the extracted, tested one was never imported). Fixing the silent role-change failure earlier meant patching both; now there is one copy. Verified without changes: the optimistic send lifecycle (pending → chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft), reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression of unread/notifications), and cert TOFU (first-use and mismatch modals, accept re-pins and reconnects, reject disconnects back to connect). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): remove the data race in the restart test hook CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_ Success polled a plain bool that the restore handler's goroutine wrote, and swapped the restartSelf package var from the test goroutine while that handler read it. The hook is now behind a mutex with an atomic flag in StubRestart. Production behaviour is unchanged — the race was entirely in the test seam I added. Verified with `go test -race -count=2 ./admin/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor + perf: split largest source files into modules; optimize hot paths (#1283) * refactor(updater): split updater.go into cohesive files Split the 1070-line updater.go into four files within the same package: updater.go (core types, release checking), download.go (download and tarball extraction), verify.go (signatures, checksums, staged binary), and assets.go (client assets, text-asset cache, HTTP fetching). Pure mechanical move — no behavior or API changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(ws): split hub.go into cohesive files Split the 1289-line hub.go into five files within the same package: hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go (broadcast fan-out and per-user sends), hub_events.go (sequencing, replay, persistence), hub_sweep.go (stale client/session/voice sweepers), and hub_livekit.go (LiveKit accessors). Also optimizes wrapWithSeq on the hot broadcast path: build the seq prefix with a single preallocated append + strconv.AppendUint instead of fmt.Sprintf, halving allocations per broadcast message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(client): extract E2EEManager from livekitSession Move all client-side E2EE key-exchange logic (~550 lines) out of LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH keypair management, identity signing and TOFU pin verification, announce/offer handling, key-holder election, membership rekeying, and periodic key rotation. Dependencies are injected following the existing roomEventHandlers pattern. LiveKitSession keeps thin public delegates (handleE2EEAnnounce, handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the module-level bound exports and the public API are unchanged. livekitSession.ts shrinks from 1955 to 1409 lines. Adds focused unit tests for E2EEManager (key-holder setup, pending announce queue, offer resolution, clearState, rotation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(server): hot-path and query optimizations Logging (biggest win): rewrite the admin log RingBuffer as a true ring (fixed array + head/count) instead of allocating a fresh 2000-entry slice + full copy per log line; gate the ring handler on a configurable level instead of unconditional DEBUG capture; move the broadcast debug log out of the seqMu critical section; drop the per-message slog.With clone in the WS handler. Database: new migration 019 adds idx_attachments_message (message pages no longer scan the attachments table), a covering role-leading index on channel_overrides (replacing a duplicate of the UNIQUE auto-index), a partial index for pinned messages, and narrows the FTS trigger to content changes only; ANALYZE runs after migrations. Rewrite GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries that range-scan idx_messages_channel — O(unread) instead of O(all messages) per WS connect. New GetUserDMChannelIDs replaces the full DM query where only IDs are needed. CreateMessage/EditMessageContent use RETURNING, removing the re-read after every send/edit. Write-path contention: TouchSession throttled to once per minute per session (was one UPDATE per authenticated request); EventPersister flushes its batch in a single transaction with per-row fallback; revoked-session and stale-voice sweeps run off the hub dispatch goroutine with an in-flight guard, and session checks are batched into one IN query; the rate limiter is sharded into 32 buckets with allocation-free strconv key building (auth.Key). WS structural: voice E2EE channel fan-out goes through the existing pubsub voice topic instead of scanning every connected client under h.mu; channelReadAudience memoizes role lookups per call; hasChannelAccess drops its redundant duplicate permission check; voice_join batches SPEAK/VIDEO/SCREENSHARE checks via HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics stop allocating via Sprintf/global mutex. Verified with go test -race across all packages, go vet, gofmt, and sqlc generate idempotency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): render-path, logging, and bundle optimizations Logging: the logger no longer runs permanently at debug — level is set from the environment at startup (debug in dev, info in prod), so every hot-path debug entry stops being serialized, buffered, consoled, and persisted to disk; per-URL debug logs in embed rendering removed. Render path: MessageList's store selector is scoped to the mounted channel, so messages in other channels no longer trigger re-renders, and a new incremental tail-append fast path appends rows instead of tearing down the whole window; Intl.DateTimeFormat instances are cached at module level; parseTimestamp memoizes epoch millis; media prefs (showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with pref-change invalidation; members store gains a roleRevision counter so MessageList stops rebuilding a role map on every presence/typing event. MemberList patches presence changes in place (status dot + offline class) via a row map instead of rebuilding every row, with single-pass role grouping. ChannelSidebar splits its voice subscription into a structural selector (excluding speaking) and a speaking-only patcher using a cached element map instead of per-user querySelector on every speaker event. Memory: GIF/media elements are unobserved before the message window discards them, fixing unbounded IntersectionObserver retention of detached DOM (including frozen-frame data URLs). Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic imports and manualChunks; the READY handler's stale-voice check reads the voice store instead of requiring the module synchronously. Adds 11 focused tests (different-channel no-rerender, append fast path, media release, presence patch, speaking patch). Full unit suite: 3606/3606 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284) The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate: under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock allocates, so the steady-state ring write measures 1 alloc/call. Extend the build constraint to !race && !deadlock — the test's guarantee is about the ring buffer itself, which the -race-less default pass covers. Make bcryptCost a var with an exported SetCostForTesting hook that also resets the dummy timing pad, and call it with bcrypt.MinCost from the api, auth, and admin TestMains. Password hashing at production cost 12 dominated those suites (~264 hashes): with the race detector the api package alone took ~860s; it now runs in ~33s. Nothing under test depends on hash strength, and no test asserts the cost. Hygiene in the same pass: migration 020 drops idx_sessions_token and idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure write overhead) with updated db_test assertions; remove the dead tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg since Go 1.11); gofmt storage/storage.go comment alignment. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf(ws): route hot-path permission checks through the cached PermissionService (#1285) The ws package was the only major subsystem still doing live per-check permission queries (GetRoleForUser + GetChannelPermissions per check): a V2 voice join cost 9+ DB reads across its four gates, and every channel broadcast resolved one role query per connected client. Hub now holds svc.Permissions and the voice deps carry it (nil-safe: bare test fixtures fall back to the existing live path, fail-closed semantics preserved everywhere). Converted sites: the voice join and token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls, requireChannelAccess, channelReadAudience, and RefreshChannelVisibility. Caching these is revocation-correct: every permission-changing mutation already invalidates synchronously before hub fan-out (InvalidateUser on role change, InvalidateAll on override change), the 30s TTL is only a backstop, and the service's gen-counter guard prevents a populate that races an invalidation from caching stale data — the audience-resolution comments now document that invariant. The stale-voice sweeper's check deliberately stays live: it is the last-line backstop for revocations that might bypass an invalidation hook, runs once a minute for only in-voice clients, and its eviction test pins exactly that guarantee. requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the cache only for positive verdicts and falling through to the live path on denial. Adds perm_cache_test.go: role-change invalidation is immediate (no TTL wait), and a counting-store test proving the second check is served from cache. All pinning tests (authz, voice_perm_stale, channel visibility agreement, sweep eviction) pass unmodified. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286) * perf(db): batch audit writes through an async writer Audit inserts ran synchronously on the request path — including one INSERT per WebSocket connect — each an implicit transaction on the single SQLite connection. WriteAudit keeps its exact signature and D8 policy (never fail the caller, never silently discard): it now upgrades to an async path when the passed Auditor also implements AsyncAuditor. *DB implements that via an atomic pointer that main.go populates at server startup with an AuditWriter modeled on the event persister (bounded queue, batched single-transaction flush with per-row fallback, drain-on-stop, atomic counters, non-blocking enqueue that error-logs drops without leaking the detail field). The token CLI and tests never install a writer, so they keep today's synchronous behavior with zero call-site changes. The writer's Stop defer registers after database.Close's so the LIFO unwind drains the queue before the DB shuts. Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop, flush-failure accounting, poison-row fallback, concurrent enqueue, and seam tests pinning sync-without-writer vs async-with-writer behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): actually defer livekit-client; honor saved log level at startup The manualChunks split was cosmetic: index.html modulepreloaded the 531 kB livekit chunk and the entry statically imported it. All four import chains from startup are now cut — auth.store's logout leaveVoice and ptt's setMuted go through dynamic imports, applyStoredAppearance moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the settings tree (whose overlay now loads on first open), and MainPage itself is a dynamic import in renderPage, guarded against the destroy-before-mount race by a navigation-generation helper and pre-warmed once the socket connects. Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession load as lazy chunks. The logger now honors the Logs tab's saved minimum level at startup (applyStoredLogLevel with the legacy-key migration moved into lib/preferences.ts) and re-applies it live on pref changes. Dead code: remove unreachable VoiceChannel.ts (superseded by ChannelSidebar's renderer) and its test, plus all knip-flagged unused re-exports in message-list/renderers.ts and ConnectPage's unused form types — knip is now clean apart from pre-existing config hints. Tests: +12 (navigation guard incl. stale-mount discard; logger startup pref, migration, and live re-apply); ptt/stored-appearance updated for dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(db): split SQLite into single-writer + multi-reader connection pools The entire server serialized on one SQLite connection: every read queued behind every other read and every write, throwing away WAL's concurrent-reader capability. File-backed databases now open two pools from a DSN that carries all seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA only configures one arbitrary pooled connection — moving them into the DSN is what makes >1 connection safe, foreign_keys included): a single-connection writer with _txlock=immediate, and a reader pool sized max(4, NumCPU). In-memory databases keep the exact historical single-connection behavior, which preserves every :memory: test site and the connection-scoped PRAGMA-toggle tests untouched. Routing lives in a dbtx router implementing sqlc's DBTX: statements go to the reader only when provably read-only (leading SELECT/PRAGMA after skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE ... RETURNING through QueryRowContext/QueryContext, which must stay on the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and the SQLDb() escape hatch all pin to the writer. Every former sqlDB reference across the package was re-pointed deliberately. New pool_test.go pins the properties the split must preserve on a file-backed DB: foreign_keys=1 across many reader connections, WAL journal mode, FK enforcement through both write paths, 8x8 concurrent reader/writer hammering with exact row counts, and a read completing against the pre-tx snapshot while a write transaction is open — the property this change exists to unlock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(client)+chore: split the two largest test files; eslint 10; audit clean Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect / ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module, and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain / vad-worklet / vad-fallback files. Test bodies moved verbatim; the suite count is unchanged at 3593 passing. Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer range already covers v10, flat config unchanged, zero new findings) and pin test-exclude ^8 via the existing overrides block so the coverage chain picks up patched glob/minimatch/brace-expansion. npm audit: 8 high -> 0 vulnerabilities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(server): split remaining large files; dependency hygiene notes Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers / chat / voice / voice_lifecycle / misc test files — bodies verbatim, 746 passing tests before and after. Split service/message.go (781) into message_crud / message_reactions / message_query / message_perms with types and the constructor staying put, and ws/serve.go (754) into serve / serve_pumps / serve_auth / serve_ready. Dependency findings (no changes needed): coraza-coreruleset's stale Feb-2024 pseudo-version is unreachable from our code — it enters the module graph only through coraza's own internal tests, and our WAF uses inline directives, never the CRS (fresher rules would require adopting the /v4 module and rewiring the WAF config — deliberate follow-up, not hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and never built into our binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * style: satisfy golangci-lint modernize/staticcheck in new pool and audit code CI's golangci-lint pass (not run locally until now) flagged the Phase 3/4 additions: range-over-int loops, interface{} -> any on the dbtx router, WaitGroup.Go in the pool tests, and a De Morgan simplification in isReadOnlySQL's identifier-boundary check. Pure style — verified against the same golangci-lint v2.11.3 binary CI uses (0 issues) and re-ran db/ws race + deadlock suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287) * fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile cargo-audit identified the two Dependabot alerts on the default branch: quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded namespace allocation DoS), fixed in >=0.41. Both were transitive: plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml entirely. cargo-audit is now clean of vulnerabilities; the remaining 20 informational notices are the unmaintained GTK3-binding crates inherent to Tauri v2 on Linux. Verified plist compiles against quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI installs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * feat(waf): layer the maintained OWASP Core Rule Set onto the WAF The WAF previously ran six inline directives only — the CRS never loaded (the old coreruleset dep was a stale graph-only pseudo-version). A second Coraza engine now loads the embedded CRS from coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules, which stay byte-identical and keep blocking exactly as before. CRS ships in a new server.waf_crs_mode knob (off|detect|block), defaulting to detect: chat traffic is CRS-false-positive-prone (a new test pins that block mode rejects benign SQL-ish chat prose at the default threshold), so operators get rule-match visibility via structured logs first and opt into blocking after tuning. Setup mirrors the official connector: Host/Transfer-Encoding restored to the transaction (else 920280 fires on everything), phase 2 always runs so query-string attacks are scored, PUT/PATCH/DELETE added to the CRS method policy for this REST API, body limits matched to the app's 1 MiB cap with uploads excluded from body access and the content-type policy. Also fixes a latent middleware bug: the body was previously swapped for the buffered reader even when nothing was buffered, which would have handed body-access-off routes an empty body; now pinned by a test across all modes. Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection without blocking, block-mode blocking + benign passthrough, upload body preservation); waf_test.go passes unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(ws): replace fixed sleeps with condition-based waits The ws suite paced async hub effects with 537 fixed time.Sleep calls — slow at best, flaky under load at worst. They are now condition-based: a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set (waitRegistered exploits the hub's in-order client-event processing), plus blocking decode-scans for the DM tests. The bulk deletion is grounded in verified production facts, unchanged by this commit: sendMsg is a synchronous buffered send (error replies are already buffered when the handler returns), the voice control / rollback / cleanup / sweep paths are synchronous, and serve.go registers the client before writing the ready frame. Absence assertions were deliberately NOT inverted into polling — they keep bounded windows, each commented. 20 sleeps remain, all justified in place: poll intervals inside condition loops, absence windows, clock-granularity pacing, and the event-pruner's inherently time-based no-prune-after-cancel assertion. Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the remainder is GracefulStop's hard-coded production 5s drain, out of scope here); race flake check passes 3 consecutive iterations; deadlock pass and golangci-lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288) * fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit WAF detect mode wired logCRSMatch as the engine-level error callback, which fires one slog.Warn per matched rule on the request goroutine. In the default detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly scoring, so each request logged a burst of Warn lines in the hot path. Aggregate per request from per-transaction state instead of the shared global callback: in the default detect path leave the engine error callback nil and, in the existing crsTx defer, emit at most one Warn per request that had matches (count + highest-severity rule), demoting the full rule-id list to Debug. Block mode keeps per-rule logging (blocked requests are rare and their detail is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery so existing tests stay unmodified. Detection, interruption, and body handling are unchanged — only the detect-path logging shape. The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow flush the 5s ctx could win, returning while run() was still flushing. main.go's LIFO defers then closed the DB pool under a live flusher, losing audits. Stop now always waits on done (the goroutine has stopped touching the store) while ctx bounds only the drain inside run() via a published stopCtxDone channel, so a slow store delays shutdown by at most one in-flight flush and the pool is never closed under a live writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * fix(client): plug listener leaks, guard lazy livekit load, honor saved log level Follow-up audit of the recently-landed lazy-livekit and session wiring found three real issues: - clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice on every logout, pulling the ~531 kB livekit chunk into the logout path even when no voice session was ever active. Guard the import on an active voice session (currentChannelId set and status not idle) and add a .catch so a failed teardown import can't reject unhandled. - The onStateChange handler unsubscribed session listeners only on the ready transition, not on disconnected; user_update and ready listeners registered per session were never collected for cleanup. Collect them into a sessionUnsubs array cleaned up on both ready and disconnected, preventing duplicate handlers accumulating across reconnects. - The Logs tab min-level select ignored the persisted log level when no explicit dropdown preference was saved. Add logger.getLogLevel() and default the select to it so the UI reflects the level actually in effect. Also add .catch to the ptt setMuted dynamic import. New unit tests cover the clearAuth guard, getLogLevel, and the LogsTab default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289) The CRS WAF engine failed to initialize on Windows, taking the whole api package's test suite red there. coraza's seclang parser resolves Include globs through path/filepath: for every match of `Include @owasp_crs/*.conf` it calls filepath.Join(currentDir, match), which on Windows rewrites the forward slashes to backslashes. It then feeds names like `@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS is the ruleset's embed.FS, which is always forward-slash and rejects a backslash name, so newCRSWAF returned "file does not exist" and no CRS rule under a subdirectory was ever loaded. Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/ Glob) that converts backslashes to forward slashes before delegating. This fixes CRS loading on Windows without patching coraza or the ruleset module and is a no-op where the separator is already "/". The Linux-only local verification for the CRS work missed this because coraza never emits backslashes there. The new test reproduces the failure mode on any OS by constructing the exact backslash name coraza produces on Windows: the raw ruleset FS fails to read it, the wrapper resolves it, and a forward-slash path still works. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291) The Client E2E CI job never completed: every run hit its 25-minute cap and was cancelled. ~229 of the 255 web tests were failing, all cascading from the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout burn on 1 worker deterministically exceeds the cap. Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts now awaits invoke("start_http_proxy") and builds REST URLs as http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for the unstubbed command, so every URL got a literal "null" port and Request construction threw before the mocked plugin:http transport was consulted. Login rejected, [data-testid='app-layout'] never mounted, and every logged-in test burned its full timeout. Stubbing start_http_proxy with any numeric port fixes the cascade because route matching is substring-based. The tail of failures after that fix were tests asserting behavior the app intentionally changed: - The ready payload can no longer pre-connect the local user to voice: the dispatcher treats "self in ready.voice_states while idle" as stale state from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote users only (2, 3), and widget tests join through the real click path via a new joinVoiceChannelByName helper. - The mock's voice_join reply no longer includes a voice_token: a token starts a real LiveKit session that deterministically self-destructs in the browser mock (E2EE key exchange timeout ~15s / connect-refused retries), tearing the widget down mid-test. These web tests validate the WS/UI layer only; real LiveKit is covered by the native suite. The reply also gained the full VoiceStatePayload shape — the sidebar renders user.username directly, and the omitted field broke the whole voice-user list render. - Message-load failure now renders an inline region error + Retry instead of a toast (UX spec 2), so the toast specs assert the inline UI and get their auto-dismiss vehicle from the delete-confirmation toast. CI hardening so a future systemic breakage can never burn the full cap again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now self-terminates with a usable report instead of being SIGKILLed), with the workflow's timeout-minutes 25 as the outer backstop. The job stays continue-on-error until it has proven stably green across a few pushes; the ci.yml comment documents that flip trigger. Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2). Unit tests (3598), typecheck, and prettier all clean. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290) * feat(admin): first-run setup wizard with config.yaml write-back Turn the single-screen owner-account setup into a guided multi-step wizard so non-technical operators never have to hand-edit YAML: - config: new comment-preserving config.Save (yaml.Node round-trip, atomic temp+rename write, verified loadable before replacing the file) plus a shared config.DefaultPath. Persists the runtime-generated LiveKit credentials so voice tokens survive restarts. - admin: POST /admin/api/setup accepts an optional "wizard" object (server name, MOTD, registration, port, TLS mode/domain, upload limit, voice quality). Values are validated before the account is created; DB settings and config.yaml are written after; failures downgrade to warnings so the created owner is never orphaned behind a 5xx. When a startup-only value changed the server restarts itself (reusing the backup/update restart machinery) and returns the new admin URL. - admin: GET /admin/api/setup/status now returns secret-free prefill defaults while setup is pending. - admin panel: six-step wizard UI (welcome, account, server basics, uploads & voice, access, review) with plain-language explanations, a restart/reconnect screen, and a "skip" path that keeps the legacy account-only flow byte-for-byte. - legacy payload {username,password} and all existing call sites keep working (SetupOptions is a trailing variadic parameter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * feat(voice): auto-download the LiveKit server binary Voice now works with zero manual setup: when voice.auto_download_livekit is enabled and no voice.livekit_binary is configured, the server fetches the pinned livekit-server release (v1.13.5, overridable via voice.livekit_version) from the official LiveKit GitHub releases in the background at startup, verifies it against the release's checksums.txt, extracts it into data/livekit/, and manages it as the existing companion process (crash recovery, health checks, graceful shutdown). - ws: new livekit_download.go — pinned version, per-platform asset mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's goreleaser config), size-capped downloads, hash verification and extraction through one open handle (TOCTOU-safe), O_EXCL staging, atomic rename, stale-version cleanup. LiveKitProcess.Start resolves the binary asynchronously with retries so boot is never blocked. - config: voice.auto_download_livekit + voice.livekit_version; enabled in the generated default config so fresh installs get working voice out of the box, while the compiled-in default stays off for existing configs. config.Load now loads the default file it just wrote, so the first boot runs with exactly the configuration the file documents. - wizard: "Voice chat" toggle (on by default) in the Uploads & voice step; the choice is written to config.yaml and factored into the restart decision. - docs: livekit-setup, server-configuration, deployment, README. Verified end-to-end against the real v1.13.5 release: download, checksum match, extraction, and process spawn all succeed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore: remove stray server.log, ignore local run logs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a0ae0dd2a |
feat(release): fold distribution back into the source repo
The separate J3vb/OwnCord-releases repo existed only because this repo was private: it carried the AGPL source snapshot and provided a publicly-readable update feed. Once this repo is public both roles collapse into its own Releases page, so the mirror is pure redundancy. - Server/config/config.go: github.repo default OwnCord-releases -> OwnCord. This one default drives both the server self-update and the client auto-update chain (tauri.conf.json updater.endpoints is empty, so the client resolves through the server). No test pinned the old value. - release.yml: drop the mirror step and its RELEASES_REPO_TOKEN guard, whose AGPL/private-repo premise no longer holds. The existing Create GitHub Release step is now the sole publish target. All 31 SHA pins verified intact. - Repoint the README badge/download link, both SECURITY.md links, the server-configuration table and sample, the system-overview diagram node and the CHANGELOG note. SECURITY.md's advisory link is the load-bearing one: left alone it would 404 once the mirror repo is deleted. - README: Go 1.25+ -> 1.26+ (badge and prerequisite) to match the toolchain actually required. Deleting the mirror repo loses nothing: both repos' v1.1.0-alpha.2 carry byte-identical asset sets, signatures and update manifest included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
58005c9c6f |
feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4fc21cb372 |
feat(server): logging & error-visibility hardening
Make server failures debuggable without leaking secrets: - configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL) - preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly from bad tokens; log the previously-silent expired-session cleanup goroutine - route HTTP handler panics through slog (was chi stderr-only, invisible to the admin log stream) - stackutil: argument-free panic stacks so key/token bytes never reach the admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/ GIFConfig/Config and db.User/db.Session - logctx: req_id/trace_id correlation on ...Context log calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
01f7795557 |
feat(api): proxy Klipy GIF requests server-side so no key ships to clients
The client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:
- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
comes from the new `gif.api_key` config section (koanf,
OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
can hide the picker instead of showing a broken one. Auth is checked first,
so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
the narrow struct is the allowlist, so an upstream that echoed the key
could not leak it. Upstream errors become a generic 502 and the key is
redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
traffic cannot exhaust the shared bucket used by password/TOTP endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
b9180bdbeb |
feat(server): support dual-homed voice hosts and user-managed livekit.yaml
Servers reachable via both a LAN IP and a public IP could only serve voice on one of them: config.yaml accepts a single voice.node_ip and OwnCord regenerates data/livekit.yaml on every start, discarding manual edits. LiveKit has no multi-IP list, but it does support advertising internal host candidates alongside the external mapping. - New voice.advertise_internal_ip (OWNCORD_VOICE_ADVERTISE_INTERNAL_IP): emits rtc.advertise_internal_ip: true so LAN clients get a reachable candidate while remote clients keep using node_ip. - livekit.yaml escape hatch: if the file exists without the auto-generated marker header, OwnCord leaves it untouched, giving operators access to every LiveKit option (ips.includes, interfaces, stun_servers, ...). The generated header documents how to take ownership. Closes #111 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK |
||
|
|
a762dc712d |
fix(api): keep per-client keys distinct under broad trusted_proxies (W2-5)
With trusted_proxies covering client networks (e.g. 10.0.0.0/8 over LAN clients), the right-to-left XFF walk skipped every entry, exhausted, and fell back to the proxy's RemoteAddr — collapsing all clients into one rate-limit/lockout bucket, so one user's failed logins locked out everyone. On exhaustion the walk now returns the leftmost valid entry (furthest-upstream hop), the best distinct per-client key such a config allows. An untrusted RemoteAddr still never gets its headers honoured. Also (W3-3): the CIDR list parses once per request instead of once per XFF candidate, config load warns about invalid CIDR entries at startup (a silently skipped entry silently un-trusts the proxy), and the sample config documents that trusted_proxies must list only proxy hops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0c093e8403 |
chore(server): delete unfinished Postgres scaffolding
PostgresStore was 86% stubs behind a build tag nothing enables, pgdbgen carried hand-added build tags that fought sqlc-verify, and the runtime never threaded store.Store through the handler boundary. Single-engine reality shrinks the W1-3 attachment-ownership fix and ends the pgdbgen churn. Removed: store/postgres.go, db/pgdbgen/, db/queries/postgres/, migrations/postgres/, the sqlc postgres block, pgx from go.mod, the startup-refusal branch, and the dead Postgres config surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e25018d6d2 |
feat(release): decouple distribution from the source repo
Publish every release to the public OwnCord-releases repo with a full source snapshot (AGPL section 6 for binary recipients), and make the updater's repo coordinates configurable (github.owner/github.repo), defaulting to OwnCord-releases. Both the server self-update and the /client-update chain follow the new default, so deployed servers keep updating after the source repo goes private. The publish step fails closed: a private source repo with no RELEASES_REPO_TOKEN aborts the release instead of silently shipping binaries with no public source or update feed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1f4a89ced1 |
audit: fix all C/H/M/L severity findings from Phase B+C PR review
Agent-Logs-Url: https://github.com/J3vb/OwnCord/sessions/2d415fd9-6138-495b-ac29-c54d08ae4ce4 Co-authored-by: J3vb <192430104+J3vb@users.noreply.github.com> |
||
|
|
7476c177b7 |
chore(phase-bc): pass 3 cleanup — perf, observability, hardening
Eight focused follow-ups from the medium-severity review bucket. All
in-sandbox tractable; no module changes, no new dependencies.
Performance
- Drop the defensive memcpy in EventPersister.Enqueue. The hub already
passes a fresh slice from wrapWithSeq and the copy was happening under
seqMu, serializing broadcast throughput. Documented the no-mutate
contract on the call site.
Observability
- AppMetrics gains WSEventsPersistErrors counter; the persister run loop
bumps both it and the existing WSEventsPersisted counter via cached
metrics handle.
- Hub.persistEvent now extracts the real event type ("chat_message",
"voice_join", ...) from the wrapped JSON envelope via a small
no-allocation byte scan instead of recording the generic
"broadcast"/"channel_broadcast" label.
- Added OTel spans + ServiceCallDurationMs histogram entries on one
public method per remaining service: DMService.CreateDM,
VoiceService.JoinChannel, InviteService.CreateInvite,
ModerationService.BanUser, BlockService.BlockUser,
UserService.UpdateProfile. Mirrors the existing pattern from
MessageService.SendMessage.
Hardening
- plugin/loader now Lstat-walks each plugin directory and rejects any
symlink, plus refuses an entrypoint that is itself a symlink. The
asset handler's prefix check stays as defense in depth.
- ipAllowed (plugin HTTP capability) now rejects RFC6598 carrier-grade
NAT (100.64.0.0/10), closing a gap in net.IP.IsPrivate which only
covers RFC1918 + RFC4193.
- Registry.activateAll syncs Instance.Enabled := true after a successful
activate so callers reading the in-memory flag see the live state.
Documentation
- defaultYAML now documents the new event_persistence, telemetry, and
plugins config blocks with their defaults and one-line descriptions.
- PHASE_BC_LOCAL_TODO.md ticks off five items (defaultYAML docs ×2,
remaining service spans, registry wiring already-fixed in Pass 2).
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
|
||
|
|
a2cb224323 |
feat: scaffold Phase B + C (events, telemetry, plugins, Solid.js)
Phase B Step 6 — Solid.js incremental migration
- vite-plugin-solid + solid-js + @solidjs/testing-library in package.json
- vite.config.ts compiles src/components/solid/** as Solid TSX
- tsconfig.json gains jsx: preserve / jsxImportSource: solid-js
- lib/solidAdapter.ts wraps existing custom Stores as Solid signals
- lib/solidMount.ts adapts Solid render to {mount,destroy} contract
- components/solid/Badge.tsx (proof-of-concept leaf)
- components/solid/ChannelListItem.tsx (store-subscribed leaf)
- components/solid/Badge.test.tsx pipeline smoke test
- components/solid/README.md documents the migration recipe
Phase B Step 7 — Event persistence layer
- SQLite + Postgres migrations for the events table
- sqlc query files for both engines
- EventStore interface + SQLite raw-SQL impl + MemStore impl + pg stubs
- ws.EventPersister: async batched writer (queue / flush / drain / drop)
- ws.StartEventPruner: background retention pruner
- hub persists every replay-buffer push and exposes reconnect-tier counters
- serve.handleReconnect: tiered replay (buffer -> DB -> full re-sync)
- EventPersistenceConfig + main.go wiring
- event_persister_test.go covers batching / drops / drain
Phase B Step 8 — OpenTelemetry skeleton
- Server/telemetry package with public Provider/Tracer/Meter/Counter API
- telemetry_default.go (no-op build) + telemetry_otel.go (build tag otel)
- telemetry/metrics.go declares the AppMetrics bundle
- HTTPMiddleware mounted in Chi router (pass-through in default build)
- PrometheusHandler optionally mounted at /metrics
- Spans on MessageService.SendMessage, PermissionService.HasChannelPerm,
ChannelService.ListVisibleChannels
- Reconnect-tier counter wired into the global meter
- TelemetryConfig defaults
Phase C Step 9 — Wazero plugin runtime skeleton
- Server/plugin package: manifest parser, loader, registry, host APIs
(commands, storage, events, http, ui), errors
- sandbox_default.go (no-op) + sandbox_wazero.go (build tag wazero)
- SQLite + Postgres migrations for plugins + plugin_kv tables
- PluginStore interface + impls + pg stubs
- plugin/examples/hello manifest + README
- plugin_test.go covers manifest, loader, capability gating
- api/plugins_handler.go admin REST surface, mounted under admin group
- PluginsConfig + main.go wiring (disabled by default)
- Client: lib/pluginBridge.ts iframe + postMessage host
- Client: components/solid/PluginContainer.tsx Solid host component
Verification
- Default build (no -tags) is intended to compile cleanly with no new
third-party dependencies. The sandbox lacked Go 1.25.0 so go build
could not run; PHASE_BC_LOCAL_TODO.md enumerates the local follow-up
work (npm install, go mod tidy, sqlc-generate, real otel/wazero
wiring, remaining service spans, full Solid migration).
|
||
|
|
dede2c61a7 |
phase-a: scaffold postgres backend (schema, queries, store stub, config)
- migrations/postgres/: consolidated pg schema with tsvector FTS, CITEXT usernames, native CHECK constraints, native BOOLEAN/TIMESTAMPTZ types - db/queries/postgres/: 14 sqlc query files dialect-translated from sqlite ($N placeholders, NOW(), TRUE/FALSE, ON CONFLICT DO UPDATE, RETURNING id, :execrows for mutations needing row count) - sqlc.yaml: second engine entry -> pgdbgen package under pgx/v5 - Makefile: sqlc-verify covers both dbgen + pgdbgen - store/postgres.go: PostgresStore behind //go:build postgres, full Store interface (112 methods). Connection lifecycle real; query methods stub ErrPostgresNotImplemented awaiting pgdbgen wrappers - config: DatabaseConfig.Type/Host/Port/User/Password/Name/SSLMode/MaxConns - main.go: explicit dispatch on database.type; postgres errors with clear pointer at remaining work until pgdbgen + boundary refactor land - phase-a-foundation.md: implementation status + actionable TODO checklist including forward-only sqlite->postgres data migration design |
||
|
|
c3a8aa477c |
fix: resolve 20 code review bugs across Rust, TypeScript, and Go
Critical/High Rust (Tauri client): - BUG-140: replace .run() with .build() + RunEvent::Exit handler; native error dialog on startup failure - BUG-141: eliminate PTT thread TOCTOU race with Mutex critical section; add AtomicBool shutdown and catch_unwind - BUG-144: fix TOFU cert store corruption — read-before-write rollback restores previous fingerprint on save failure (all 3 write sites) - BUG-145: add VK code range guard (1..=254) in is_key_down; fix cast to (state as i16) < 0 - BUG-147: replace bare spawns with JoinSet; abort_all + drain on exit; unconditional closed event - BUG-150: add CRLF guard in handle_connection before header rewriting - BUG-151: wrap header read loop in tokio::time::timeout(10s) - BUG-158: extract CERTS_STORE/SETTINGS_STORE to constants.rs (eliminate 3 duplicates) - HIGH-2: PTT thread self-cleanup uses unwrap_or_else defensive pattern - HIGH-4: ws_send distinguishes Full vs Closed errors; warn log on backpressure Critical/High TypeScript (Tauri client): - BUG-142: join-generation counter prevents stale connectAndSetup completions - BUG-143: replace 8 mutable LiveKit session fields with discriminated union SessionState - BUG-146: 60s token refresh deadline; cleared on reply or voice leave - BUG-148: ResizeObserver hoisted to outer scope; disconnect() in destroy() before ac.abort() - BUG-152: dismissSignal.aborted guard already present (no change needed) - BUG-153: measureRendered split into two-pass read-then-write; eliminates per-message reflow - BUG-154: WS dedup cache batch-evicts to 80% on overflow (amortised O(1)) - BUG-157: pendingUpdates replaced with coalesced function-composition slot (O(1) queue depth) Go server: - BUG-149: safe two-value type assertion in getOutboundIP with localhost fallback - BUG-155: broadcast buffer 256→1024; broadcastDrops atomic counter exposed in /api/v1/metrics - BUG-156: LiveKitHealthCheck and implementations accept ctx context.Context; all call sites pass r.Context() (12 files) - BUG-159: MaxMessageBytes constant in config/constants.go; replaces 1<<20 literals in serve.go and updater.go - HIGH-1: cert store rollback reads old value before write; restores previous cert on save failure All validation passes: go build, go vet, cargo check, npm typecheck |
||
|
|
5cec992c1f |
chore: add dev tooling — Stryker, gremlins, k6, toxiproxy, Coraza WAF, Zod
Install and configure mutation testing (Stryker for client, go-gremlins for server), load testing (k6), chaos testing (toxiproxy), WAF middleware (Coraza with OWASP rules, opt-in via waf_enabled config), and Zod for runtime schema validation. All tools verified building cleanly. |
||
|
|
a24dbd5d55 |
feat: add syncutil mutex, test scaffolding, and server hardening
- Add syncutil package with deadlock-detecting mutex (build-tag switchable) - Add main_test.go TestMain scaffolding across all server packages - Harden concurrency in ws, admin, auth, and updater packages - Update CI workflow, go.mod/sum, Cargo.lock, and root changelogen tooling |
||
|
|
447a4543e7 |
chore: remaining server changes (code quality, go mod tidy)
Go mod tidy, minor server-side adjustments from security verification and code quality cleanup pass. |
||
|
|
2a62f31c39 |
test: boost server coverage — auth 60→95%, db 69→81%, config 75→85%
Add comprehensive tests across all Go packages: - auth: username validation, concurrent rate limiting, TOTP stores, timing - config: env overrides, default credential detection, voice defaults - db: search, message queries, special char handling - api: handler edge cases, error paths, DM/invite/TOTP coverage - ws: voice handler paths, integration scenarios - updater: version comparison, timeout handling 6 of 8 packages now at 80%+ coverage. |
||
|
|
f3036727ae |
fix: address remaining code review findings (C-3, H-5, H-6, M-2 through M-16)
- C-3: inject setupLimiter into NewAdminAPI instead of package-level global - H-5: generateRandomKey returns error instead of panicking - H-6: replace init() bcrypt with sync.Once lazy initialization - M-2: remove unsafe-inline from admin CSP - M-3: sanitize upload filenames (strip control chars, truncate to 255) - M-5: truncate User-Agent to 512 bytes before storing as device - M-10: MaxBodySizeUnless uses prefix matching instead of exact path - M-12: wrap seedExistingDatabase in a single transaction - M-13: use errors.Is for EOF check in upload handler - M-14: log writeJSON encoding errors instead of discarding - M-16: standardize error codes to INTERNAL_ERROR across all handlers |
||
|
|
28f33644de |
fix: address remaining code review findings (C-3, H-5, H-6, M-1 through M-16)
- C-3: inject setupLimiter into NewAdminAPI instead of package-level global - H-5: generateRandomKey returns error instead of panicking - H-6: replace init() bcrypt with sync.Once lazy initialization - M-2: remove unsafe-inline from admin CSP script-src and style-src - M-3: sanitize upload filenames (strip control chars, truncate to 255) - M-5: truncate User-Agent to 512 bytes before storing as device - M-10: MaxBodySizeUnless uses prefix matching instead of exact path - M-12: wrap seedExistingDatabase in a single transaction - M-13: use errors.Is for EOF check in upload handler - M-14: log writeJSON encoding errors instead of discarding - M-16: standardize error codes to INTERNAL_ERROR across all handlers |
||
|
|
fa1435e4de |
fix: address critical and high code review findings
- C-1: handle filepath.Abs error in backup path traversal guards - C-2: WAL checkpoint before live DB restore to prevent corruption - C-4: default AllowedOrigins to empty (deny cross-origin by default) - C-5: renumber duplicate 003_ migration prefix (003-008 -> 003-009) - H-1: sanitize FTS5 query input to prevent operator injection - H-3: send SIGTERM for graceful shutdown before os.Exit in updater - H-9: fix RingBuffer memory leak from unbounded backing array growth - H-10: use errorResponse struct consistently in upload handler |
||
|
|
032456758e |
fix: LiveKit voice connection for remote clients behind reverse proxy
- Fix race condition: handleDisconnected no longer nulls the room during initial connect, allowing the retry loop to complete all 3 attempts - Fix TLS proxy port: default to 443 instead of 8443 when server host has no explicit port (servers behind nginx/reverse proxy) - Fix cert store key: strip :443 suffix so LiveKit proxy fingerprint lookup matches ws_proxy's stored key format - Add node_ip config option for LiveKit WebRTC ICE candidates (required for remote users behind NAT) - Add resolved URL to all connection error/retry/reconnect logs for easier debugging - Add diagnostic logging to resolveLiveKitUrl showing which path was taken (direct/proxy/passthrough) |
||
|
|
15b779f86b |
fix: voice mute pipeline, security hardening, and video tile controls
- Deafen now also mutes the local microphone (privacy fix) - Mute/deafen zero the audio pipeline GainNode to guarantee silence when the replaced sender track bypasses LiveKit's track disable - Send voice_leave on failed auto-reconnect to prevent ghost states - Gate voice_join on LiveKit availability (reject if h.livekit == nil) - IP-restrict /api/v1/livekit/webhook to admin CIDRs - Warn at startup when LiveKit API keys are auto-generated (ephemeral) - Fix connecting guard: move pending-join dispatch outside finally block - Add volume slider + mute button overlay on remote video tiles - Document token refresh limitation for 4h+ sessions - Fix TS2306 in rnnoise-worklet test (ts-expect-error for worklet import) |
||
|
|
e0437d4d8d |
feat: LiveKit migration — permissions, auth hardening, voice improvements
Pre-review snapshot of LiveKit migration changes including: - Permission computation fix (allow-wins semantics) - Timing-safe password comparison with dummy hash - Rate limiter window fix - Dev credential clearing for LiveKit - Voice leave/join broadcast improvements - Migration transaction wrapping - Chat edit/delete permission guards - TOTP verification endpoint - Embed regex injection fix |
||
|
|
3f58345e6c |
fix: address code review — security hardening, leak fixes, credential safety
CRITICAL: - Add AuthMiddleware + rate limiting to /livekit/* proxy route (was unauthenticated) - Remove well-known default LiveKit credentials from source; auto-generate unique random keys on first run so voice works out of the box securely - Reject the old "devkey"/"owncord-dev-secret-key-min-32chars" in NewLiveKitClient HIGH: - Add 5s timeouts to RemoveParticipant/ListParticipants SDK calls (goroutine leak) - Fix config.yaml default file permissions from 0644 to 0600 - Fix voice store desync on unexpected LiveKit disconnect (phantom UI state) - Add in-flight guard to handleVoiceToken (race on rapid channel switch) - Fix lightbox listener leak on rapid reopen (orphaned mousemove/mouseup/keydown) - Fix allTracked WeakRef set unbounded growth in media-visibility - Replace debug console.log with createLogger in embeds.ts - Stop persisting password in Windows credential blob (only token needed) |
||
|
|
2ac8e886a9 |
fix: camera button delay and video feed flickering + security hardening
Camera fixes: - Optimistic setLocalCamera(true) before async setCameraEnabled for instant button highlight, with revert on failure - Only call checkVideoMode when camera-relevant state changes, not on every speaking poll tick (100ms) - VideoGrid.addStream updates existing cells in place instead of destroy+recreate to prevent black frame flashes - VideoModeController tracks localTileAdded to avoid redundant addStream calls Security & hardening (from prior session review): - Settings store key allowlist prevents arbitrary key writes - Certificate fingerprint validates SHA-256 colon-hex format - CredentialData Debug impl redacts token and password - LiveKit config file written with 0600 permissions - Token TTL reduced from 24h to 4h - Null-check on client.user before token generation - Thread-safe getChannelID/trySendMsg helpers on Client - Warn on default dev LiveKit credentials - Devtools feature-gated behind cfg(feature = "devtools") - Updater uses configure_client for self-signed cert acceptance - Clamp voice sensitivity input to 0-100 range - Clear lastConnectToken/Host on logout - Fix animation frame leak in VoiceAudioTab mic meter |
||
|
|
bf3fada16b |
fix: proxy LiveKit through HTTPS to fix mixed-content block
- Add reverse proxy at /livekit/* that forwards to LiveKit server - Server sends relative URL "/livekit" in voice_token; client resolves to wss://server:port/livekit using known server host - Fix API secret minimum length (32 chars required by LiveKit) - Pass TLS config to LiveKit process manager for TURN certs |
||
|
|
80e880d536 |
feat: add LiveKit infrastructure (Phase 0)
- Replace VoiceConfig STUN/TURN/MediaPort fields with LiveKit API key, secret, URL, and binary path - Add livekit_process.go: companion process manager with auto-restart and config generation - Add livekit.go: token generation, room service client, participant management, video track counting - Add LiveKit server SDK dependency (server-sdk-go/v2) - Update config tests for new VoiceConfig fields |
||
|
|
b2038cecdb |
feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes
Client: - Wire up all 4 notification toggles (desktop, taskbar flash, sounds, @everyone) - Add compact mode CSS with visible layout differences - Add GIF picker with Tenor API (trending + search) - Render inline images/GIFs instead of link previews for direct URLs - Add push-to-talk via Rust GetAsyncKeyState polling (non-consuming) - Add key capture UI for PTT keybinds (supports mouse buttons) - Add error/success feedback on account settings (password, username) - Fix mic stream cleanup on tab switch (VoiceAudioTab factory pattern) - Fix message timestamps using UTC with proper timezone conversion - Fix emoji reaction picker (was returning early on empty emoji) - Fix chat scroll jumpiness with Discord-style overflow-anchor + ResizeObserver - Pre-measure all message heights on load to prevent first-scroll jump - Improve scrollbar visibility with semi-transparent white thumb - Show client version on Logs tab Server: - Add admin_allowed_cidrs config to restrict /admin to private networks - Fix voice config defaults lost when YAML section has omitted fields - Add negative caching to updater (5min error cache) |
||
|
|
5311d0a7e3 |
feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging
Voice was broken over NAT due to multiple issues across the audio pipeline:
- Fix GainNode silence: WebView2 silences remote WebRTC streams routed through
Web Audio createMediaStreamSource→GainNode→createMediaStreamDestination.
Replaced with direct HTMLAudioElement playback for remote audio.
- Fix NAT traversal: Add Google public STUN server (stun.l.google.com:19302)
so remote clients can discover their public IP for ICE connectivity.
- Fix signaling race: Catch createOffer InvalidStateError when server
renegotiation offer arrives before client's initial offer is sent.
- Fix device switch: Use replaceTrack() instead of removeTrack+addTrack
to avoid SDP renegotiation. Safe rollback on failure (stop old last).
- Fix speaking flicker: setSpeakers skips local user (VAD is sole authority).
- Fix VAD sample rate: Force 48kHz AudioContext instead of system default
(192kHz) which spread FFT bins too wide for voice frequency detection.
- Fix CSP for WASM: Add wasm-unsafe-eval to script-src for RNNoise.
- Fix clearAuth leak: leaveVoice() called before resetVoiceStore().
- Fix ICE rate limit: Separate limit for ICE candidates (50/s vs 20/s).
- Fix stale ICE errors: Silently drop voice_ice with no PeerConnection.
- Fix audio play() race: Deferred to queueMicrotask after DOM attachment.
Debugging infrastructure:
- Logs tab: Copy All button, Voice Diagnostics panel with live session
state, Probe Audio Levels (measures actual signal at 3 pipeline points),
Test Direct Playback button, Copy Diagnostics button.
- Client logging: WebRTC (PeerConnection lifecycle, ICE candidates with
type/address, track events, negotiation), VAD (start/threshold/destroy),
Audio (device acquisition with settings, device changes), noise suppression
(WASM load timing, worklet vs fallback path), voiceSession (remote stream
parsing failures, deafen state, audio element playback events).
- Server logging: SFU init config, voice room mode transitions/track
lifecycle/close, RTP forwarding with packet counts and first-packet
detection, 5s no-packet warning, track fan-out counts, subscriber
transceiver state, ICE candidate details, voice credentials issued.
- Logger: Error objects now serialize .message and .stack instead of {}.
Per-user volume right-click now works on voice user rows in sidebar.
RNNoise ML noise suppression with AudioWorklet + ScriptProcessor fallback.
Tests: 7 new test cases (replaceTrack, setSpeakers skip-local, clearAuth).
|
||
|
|
9c1d99683c |
fix: address PR review findings (issues #9-#14)
- Fix capacity over-allocation and use strings.Builder in getReactionsBatch (#9) - Replace `any` types and cache Tauri invoke in window-state.ts (#10) - Remove custom `contains` helper, fix NilHub tests to pass nil (#11) - Add nil guards before hub method calls in admin handlers (#12) - Run golangci-lint v2: modernize interface{}/any, range-over-int loops, remove dead code, fix errcheck, add .golangci.yml config (#13) - Add 23 client unit test suites (694 tests), exclude Tauri-coupled files from coverage, achieve 80%+ threshold (#14) Closes #9, closes #10, closes #11, closes #12, closes #13, closes #14 |
||
|
|
c1c25ed26c |
feat: implement full client UI from mockup — 10 phases, 331 tests
Client UI: - Design system: Colors, Typography, Controls resource dictionaries - Message actions: reply compose bar, hover edit/delete/reply buttons - Rich content: code blocks, attachments, system messages, content parser - Server strip: 72px sidebar with server icons, home button, add server - Status picker: popup for changing online/idle/dnd/invisible status - ConnectPage: server health check dots with auto-refresh - User popup: profile card with banner, avatar, roles, member since - Emoji picker: 6 categories, search, grid of Unicode emojis - Settings overlay: full-screen with sidebar navigation - Friends/DM view: sidebar + friends list with tabs (online/all/pending) - Toast notifications: auto-dismiss after 3s with fade animation Models & services: - Attachment model added to Message, ApiMessage, ChatMessagePayload - EditMessageAsync, DeleteMessageAsync, SendStatusChangeAsync APIs - MessageContentParser (code blocks, inline code, bold, italic) - EmojiData, ToastService, HealthStatusToBrushConverter Server (from prior session): - Voice room management, SFU, speaker detection - ACME/TLS support, config improvements - Protocol and schema updates Tests: 331 passing (61 converter + 24 voice service + 34 voice VM + 41 parser + 9 edit/delete + existing) |
||
|
|
6eba999233 |
feat: add Let's Encrypt ACME support, fix security issues, improve server UX
Server: - Add Let's Encrypt (ACME) TLS mode with autocert, HTTP-01 challenges on :80, and automatic certificate renewal (tls.mode: "acme" in config.yaml) - Add ASCII art startup banner with server info and endpoint URLs - Fix CSP blocking admin panel inline styles/scripts (per-route override) - Suppress TLS handshake error noise in console output - Fix TOCTOU race in invite consumption (atomic UPDATE with row-count check) - Fix sendMsg mutex race condition (hold lock for entire send) - Fix permission override formula (deny-first, allow-wins) - Fix voice join parsing channelID before permission check - Add session expiry check at WebSocket auth and periodic revalidation - Add message length limit (4000 chars) and emoji length validation (32 bytes) - Add file size enforcement in storage after io.Copy - Add checksum URL validation in updater - Add backup path traversal protection (BackupToSafe) - Add self-modification guard in admin handlePatchUser - Fix admin ownerOnlyMiddleware to use context user instead of re-auth - Remove redundant startup log lines (banner shows same info) - Add periodic expired session cleanup (15-min ticker) - Add permissions package with bitfield constants and EffectivePerms - Add rate limiter cleanup goroutine to prevent unbounded growth - Add auth helpers (IsEffectivelyBanned, IsSessionExpired) - Add WebSocket origin validation Client: - Add TOFU certificate trust service - Add receive loop error handling - Fix redundant else-if in OnChatMessage |
||
|
|
80ceabc78b | fix: set default TLS cert/key paths to data/cert.pem and data/key.pem | ||
|
|
27b7c000da | feat: add updater package with GitHub Release checking and checksum verification | ||
|
|
ab389764b5 |
feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)
Phase 5 — Voice: - migrations/002_voice_states.sql: voice_states table with FK + index - db/voice_queries: JoinVoiceChannel, LeaveVoiceChannel, GetVoiceState, GetChannelVoiceStates, UpdateVoiceMute, UpdateVoiceDeafen, ClearVoiceState - ws/voice_handlers: handleVoiceJoin (perm check, DB, broadcast existing states), handleVoiceLeave, handleVoiceMute, handleVoiceDeafen, handleVoiceSignal (rate-limited relay, SDP never logged), handleSoundboard (rate-limited, USE_SOUNDBOARD perm check) - ws/handlers: dispatch voice_join/leave/mute/deafen/offer/answer/ice/soundboard - ws/serve: call handleVoiceLeave on disconnect; include voice states in ready payload - ws/messages: buildVoiceState, buildVoiceLeave, buildVoiceSignalRelay - api/voice_handler: GET /api/v1/voice/credentials — HMAC-SHA1 TURN creds - config: VoiceConfig (TURNSecret, STUNPort, TURNPort, TURNEnabled) Phase 6 — Admin Panel: - migrations/003_audit_log.sql: audit_log table with indexes - db/admin_queries: GetServerStats, ListAllUsers, UpdateUserRole, ForceLogoutUser, AdminCreate/Update/DeleteChannel, LogAudit, GetAuditLog, GetSetting, SetSetting, GetAllSettings, BackupTo - admin/api: full REST API — stats, users, channels, audit log, settings, backup; adminAuthMiddleware (ADMINISTRATOR bit), ownerOnlyMiddleware - admin/static/index.html: single-page admin panel (dark theme, vanilla JS, no CDN) — dashboard, users, channels, audit log, settings sections - admin/admin.go: NewHandler wiring go:embed static files + API Fixes: Channel struct json tags (was serializing as "ID" not "id"), duplicate getWithToken helper renamed in voice_handler_test.go Test coverage: admin 59.1%, api 78.2%, auth 90.9%, db 82.0%, ws 37.9% |
||
|
|
a1434ad07f |
feat: implement Phase 1 server skeleton with TDD
- Scaffold Go module (github.com/owncord/server) with all package dirs - config: koanf-based YAML loader with env var overrides, default generation - db: pure-Go SQLite (modernc, no CGO), WAL mode, FK enforcement, full 15-table schema from SCHEMA.md including FTS5 and idempotent migrations - auth/tls: ECDSA P-256 self-signed cert generation, LoadOrGenerate for all 4 TLS modes (self_signed, acme, manual, off) - api: chi router with request ID middleware, /health and /api/v1/info - main: graceful shutdown (30s timeout), structured slog JSON logging - Stubs for ws, storage, admin packages ready for Phase 2+ Test coverage: api 100%, auth 85.7%, db 82.4%, config 80.6% Binary: chatserver.exe 12MB, GOOS=windows GOARCH=amd64 |