mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
main
34
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 |
||
|
|
d880b64d64 |
test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
03fcb7d518 |
fix: execute the 2026-08-19 audit fix order (docs refresh + five FRAGILE fixes) (#1396)
* docs(plans): phased remediation plan for the 2026-08-19 audit Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per finding group, statuses updated in place as phases land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * test(client): give the renderWindow-breaker test its own timeout (audit F-5) 30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a loaded runner; the test timed out once under CI-like load and passes in isolation, so it now carries an explicit 20s budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10) schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL (matching 030's rebuild), index inventory rewritten from cumulative migration state, writer/reader pool split described, default-roles table made a consistent post-migration snapshot, dbgen preamble updated. protocol.md: DM chat events documented as sequenced/ring-buffered/replayable (they are), plugin_broadcast seq flipped to Yes, retry_after claim removed (no WS error carries it), the five enforced-but-documented-as-None rate limits added (channel_focus, mark_read, call_decline, chat_command, ping), E2EE announce/offer budgets corrected incl. the per-target inner cap, BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/ roles field lists completed, member_join top-level status documented. api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP limiter and host:port livekit_url, error-code table now matches emitted codes (INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap exemptions listed, identity_public_key documented on PATCH /users/me, plugin endpoints' plain-text errors + X-Plugin-Runtime header documented, /health 503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply restart-conflict 409s added. Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes (buildReady comment, e2e spec-count comments, logctx stray word, three plan status headers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16) Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6 session-cap eviction failure in CreateSession (db/auth_queries.go), and the channel_focus read-state write failure (service/channel.go) all discarded their errors with no trace — a brute-force lockout could silently fail to survive a restart. In-memory behavior is unchanged (warn-and-continue); the lockout write paths are pinned by tests mirroring OC-0061's load-path test. The session-cap and read-state sites are log-only additions on seams the existing suites already exercise on the success path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1) The block gate ran only at voice_join and voluntary voice_token_refresh, so a blocked user already in the shared 1:1 DM call kept their session indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser now severs the call through the dmVoiceEvictor capability handleCloseDM already exercises, using a new find-only FindDMChannelIDBetween lookup (sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler tests: shared-DM eviction, no-DM no-op, group-only no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): close the role-reassignment/handshake race (audit F-2) A role reassignment landing mid-handshake was invisible for the socket's whole life: both handshake paths resolved permissions from the auth-time c.user snapshot, revokeUnreadableChannels early-returns for a user not yet in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a reconnect replaced the client. Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the resume-fallback path re-reads the role once more after registerNow and runs the revocation pass when it moved, so the reassignment-vs-registration orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the live client immediately before acting, mirroring RefreshChannelVisibility. Pinned by four tests driving real WS handshakes through the existing race hooks plus a new pre-register/pre-act hook pair; ws suite green under the default and deadlock builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * refactor(client): remove the inert replay-dedup machinery (audit F-6) The server writes auth_ok before the replay burst, so replayDedup — created on socket-open and cleared when auth_ok is processed — could never be active for a real replayed frame, and the dispatcher's isReplaying() unread gates never fired. Their no-op behavior is the correct behavior (a buffer/db resume has no ready payload, so replayed frames must count as unread), so the machinery, the gates, and the misleading comments are removed rather than repaired. The pinning tests injected replay frames in an order a spec-compliant server never produces; they are replaced by a test pinning the real contract (frames after auth_ok are dispatched verbatim; duplicate handling belongs to the stores). Client suite green: 5036/5036. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs(plans): mark remediation phases 1-6 done Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): nolint the context-less revoke call golangci-lint flags revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface); annotate the one call site inside a ctx-taking function, matching the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
8787b9066d |
fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
086979b7e8 |
Release v1.2.0-alpha.1 -> main (#1309)
* 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> * fix(release): strip bundled libwayland from Linux AppImages (white screen on Arch) (#1297) linuxdeploy bundles the Ubuntu 22.04 runner's libwayland-{client,cursor, egl,server} into the AppImage, and AppRun forces them onto LD_LIBRARY_PATH. On hosts with newer Mesa (Arch, Fedora), EGL init dlopens libwayland-client, hits the stale bundled copy, and fails with "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..." - WebKit's web process dies and the window stays white. Reproduced in an Arch container with the published alpha.5 aarch64 AppImage (identical stderr to the field report); the same image renders normally on Ubuntu 24.04, and removing the four bundled libwayland libs makes it render on both. WEBKIT_DISABLE_COMPOSITING_MODE=1 does NOT help (tested). Add scripts/strip-appimage-bundled-libs.sh and run it in both Linux release jobs after the Tauri build: strip the libs, repack with appimagetool, regenerate the updater tar.gz, and re-sign both artifacts with the Tauri updater key. Every supported distro ships libwayland at or above the 1.20 the client links against, so the host copy is always the right one. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(client): send the session bearer token when fetching attachments (#1298) Uploaded images rendered only as loading placeholders: the server's /api/v1/files/{id} endpoint requires a Bearer token (it enforces per-channel ACLs), but the client's attachment image fetch and file download never attached one, so every request came back 401 and the placeholder was never replaced. Server-hosted attachment fetches now go through fetchServerFile, which routes through the cert-pinned TOFU proxy with the session token from the auth store. The token is only ever sent to the configured server host — external image URLs keep a plain, credential-free fetch. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): enable microphone/camera detection on Linux (WebKitGTK) (#1299) On Linux no audio or video devices were ever detected: WebKitGTK ships with enable-media-stream and enable-webrtc off, and wry installs no permission-request handler on its webkitgtk backend (unlike macOS, where it auto-grants media capture), so WebKit's default denies every getUserMedia/enumerateDevices request. Add a Linux-only setup hook that turns both settings on for the main window's webview and grants WebKitUserMediaPermissionRequest and WebKitDeviceInfoPermissionRequest. All other permission request types still fall through to WebKit's default deny. The webkit2gtk crate becomes a direct dependency, pinned to the exact version wry already links (=2.0.2, v2_38 for enable-webrtc), so the binary's native library footprint is unchanged — the AppImage bundle set stays identical and the libwayland strip step from #1297 is unaffected. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat(client): kick to login and reset call state on server shutdown (#1300) When the server shut down, connected clients stayed on the main page in an endless "Reconnecting..." loop, and a live call's webcam/screenshare toggles kept whatever state they had. The server already broadcasts server_restart with reason "shutdown" from hub.GracefulStop before closing connections — the client just ignored the reason. The dispatcher now treats reason "shutdown" as terminal: it signs the user out (clearAuth), which navigates back to the login screen, leaves the voice session — stopping any live camera/screenshare tracks — and resets all call settings (camera, screenshare, mute, deafen, channel) to their normal state. Other restart reasons (update, setup, backup_restore) keep the existing countdown-banner + auto-reconnect behavior. clearAuth gains a LogoutReason so the logout wiring can tell a server-initiated kick from a user logout or invalid-token path: on "server_shutdown" the saved credential is kept (the token is still valid), so profiles with auto-login reconnect on their own once the server comes back, instead of losing their stored login on every server restart. The main page also skips the restart countdown banner for shutdown notices since the page unmounts immediately. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): credential fallback store on every OS, not just Windows (#1301) Credential saves still failed outright on machines where the OS keychain does not round-trip — most commonly a Linux desktop with no Secret Service provider (no gnome-keyring / KWallet, e.g. a bare window manager) and a locked macOS Keychain. The verified-write fallback introduced for the 2026-07 keyring regression existed on Windows only; on macOS and Linux secret_store::set returned an error and nothing was persisted, so logins and the voice-E2EE identity key vanished on every restart. The fallback now engages on every desktop platform, under the same rule as before: only after a keychain write has provably failed to round-trip, with the OS credential store taking over again the moment it recovers. Windows keeps DPAPI. macOS/Linux entries are sealed with ChaCha20-Poly1305 (via ring, already in the tree) under a per-install random key file written owner-only (0600) to the app data dir; the account name is bound in as AEAD associated data, mirroring the DPAPI entropy, so a blob cannot be moved between entries. Secrets at rest are never plaintext, and a copied fallback store is useless without the key file beside it. The shared set/get fallback path is now platform-neutral with only the sealing primitive per-OS, Backend gains an EncryptedFile variant, and fallback_crypto ships round-trip, AAD-mismatch, tamper, nonce uniqueness, and key-file permission tests that run in CI. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): keep stream audio playing when the user mutes/deafens (#1302) Muting yourself in a call (which the deafen control also engages — deafen forces mute) silenced the audio of any screen-share stream being watched: the deafen path unsubscribed every remote audio publication, including ScreenShareAudio tracks, and the subscribe-time guard blocked new stream-audio tracks the same way. Muting/deafening yourself gates voices, not the content someone is streaming. Both paths now exempt ScreenShareAudio: the stream's audio keeps playing while the user is muted or deafened, and remains controllable through its own per-tile mute button and volume slider. Microphone (voice) audio is still fully unsubscribed on deafen exactly as before. The mic-mute path itself never touched incoming stream audio (verified against livekit-client: setMicrophoneEnabled, RemoteParticipant.setVolume and the audio pipeline are all scoped to the Microphone source) — the coupling was only ever the deafen subscription sweep. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity quick wins (blocks UI, topics, role colors, profile popup, temp bans, archived filtering) (#1303) * feat: Discord-parity quick wins — blocks UI, topics, role colors, profile popup, temp bans, archived filtering Adds docs/plans/discord-parity.md (full gap analysis vs Discord free/Nitro, phased plan) and lands phase 1 — the six features where one side already existed and the other was never finished: - Block/unblock from the client: PUT/DELETE /blocks/{userId} were server-only; the member context menu now offers Block (with confirm) / Unblock to every user, admin actions stay role-gated. New setUserBlockedByMe store helper. - Channel topics end-to-end: topic now ships in the WS ready payload (protocol.md updated), renders live in the chat header, and is editable in the client's Edit Channel modal (PATCH already supported it). - Role colors from server data: member list groups and message username colors now use roles.color from ready (with theme-var fallbacks) instead of a hardcoded 4-name switch; custom roles render their own groups, and members with an unknown role render in a gray group instead of vanishing. - Profile popup mounted: left-clicking a member opens the existing UserProfilePopup (previously dead code); its Message button starts a DM. Action buttons without handlers are no longer rendered. - Temp bans: PATCH /admin/api/users/{id} accepts ban_duration_hours (1..8760) feeding the existing BanUser expiry plumbing; ban menu gains a duration selector (Forever/1h/1d/7d/30d). - Archived channels actually hide: VisibleChannelIDs now skips archived refs, so REST list, ready payload, and replay filtering all exclude them; archiving live-syncs connected clients via RefreshChannelVisibility. The admin panel still lists archived channels for unarchiving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): rewrite visibility if-else chain as switch (gocritic ifElseChain) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity phases 2–6 (moderation, mentions, markdown, roles, social) (#1304) * feat: parity phase 2 — moderation depth (live permission bits, voice moderation, purge) - Admin perimeter now admits any role holding a moderation-capable bit (AdminPerimeter mask); each route group re-checks its own bit: channels/overrides -> MANAGE_CHANNELS, audit log -> VIEW_AUDIT_LOG, settings -> MANAGE_SERVER, force-logout -> KICK_MEMBERS. Ban and role assignment authorize inside ModerationService (BAN_MEMBERS / MANAGE_ROLES). New GET /admin/api/me lets the panel hide tabs and row actions the caller cannot use; the desktop member-list menu gates on permission bits from the ready role list instead of role names. - Hierarchy beyond ban: ChangeUserRole requires the actor to strictly outrank the target and refuses to assign a role at or above the actor's own position (closes "any admin can promote anyone to Owner"); ForceLogout enforces the same rule. - Voice moderation on MUTE_MEMBERS: voice_mod_mute/deafen/move/kick WS commands (bit + strict outrank, 5/s rate limit, audit-logged). voice_states gains server_muted/server_deafened, carried on voice_state; server mute is enforced at the SFU via LiveKit MutePublishedTrack and the target's own unmute attempts are refused with SERVER_MUTED/SERVER_DEAFENED. Move/kick run the hub voice-leave routine then send voice_moved (client rejoins through the normal join path) or voice_disconnected. Client voice-row menu grows a moderation section gated on the bit. - Bulk delete: POST /api/v1/channels/{id}/messages/purge {limit 1-100, before?} gated on READ|MANAGE_MESSAGES, soft-deletes preserving tombstones, one message_purge audit row, fans out a single chat_bulk_deleted broadcast. Channel context menu gains "Purge Messages…" for holders of MANAGE_MESSAGES. - Honest kick semantics: the session-revoking "Kick" action is renamed Force Logout in the client and admin panel (endpoint unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): use slices.Contains in voice moderation tests (modernize) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(test): widen the occupied pre_restore window in the abort-restore test The test blocked the safety backup by occupying pre_restore_<ts>.db names for the next 4 seconds; on slow Windows CI runners the request outlived the window and the restore succeeded, failing the 500 assertion. Occupy two minutes of candidates instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 3 — real mentions (server resolution, badges, notifications, autocomplete) - Mentions resolve server-side at send time: whole-word @username parsing (address-shaped text rejected), case-insensitive against unique usernames, 20-mention cap; stored in message_mentions in the same writer transaction as the message. chat_message/chat_edited and REST history/pinned/search carry mentions + mentions_everyone. - New MENTION_EVERYONE permission (bit 21, seeded to Owner/Admin/Moderator) gates @everyone/@here; never honored in DMs. @here skips offline users. Fan-out respects per-channel read permissions and skips users who blocked the author. - read_states.mention_count is live: incremented on insert (never on edit), zeroed by channel_focus, shipped per channel in ready. - Client: mentions highlight only when they resolve; mentioning the current user accents the whole row; #channel-name renders a navigating chip; channels show a red mention badge that outranks the unread badge; notifications say "X mentioned you in #channel" and the suppress-@everyone pref now suppresses only honored everyone-mentions; the composer gets an @-autocomplete popup (prefix-ranked, keyboard-driven, @everyone/@here offered only with the permission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 4 — markdown rendering, message navigation, reactions/media/read-state polish - Discord-flavored markdown via a tokenizer (message-list/markdown.ts): bold/italic/underline/strike/spoiler with nesting, escaping and a word-boundary rule keeping snake_case literal; line-start quotes, headings, lists; masked links restricted to absolute http(s) + isSafeUrl (rejects render as literal source); language-tagged code fences with a hand-rolled highlighter (no new dependency); markdown is inert inside code. Renderer stays a strict DOM builder — no innerHTML. Composer gains Ctrl+B/I/U wrapping. - Message navigation: GET /channels/{id}/messages/around/{messageId} (half-before/half-after window, has-more flags via over-fetch); detached-window support in the messages store with a "Jump to Present" pill; search/pin jumps fetch the window when the target isn't loaded; reply previews are clickable; "Copy Message Link" + owncord://message/{channel}/{message} deep-link route; pasted message links render as jump chips. - Who-reacted: GET .../reactions/{emoji}/users (100 cap) + hover tooltip with per-message+emoji cache invalidated on reaction_update. - Inline media: video/audio attachments render native players from MIME allowlists (unknown containers keep the download chip); SVG stays out. - Read-state polish: NEW-messages divider, explicit Mark as Read / Mark All as Read, DM unread count badges (real counts shipped in ready instead of a dot; DM mention counts survive reconnect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 5 — role CRUD, per-user overrides, override matrix, client channel management - Roles are real entities: /admin/api/roles CRUD + reorder behind MANAGE_ROLES, with all rules in a new RoleService measured against the actor's position (only strictly-below roles may be touched; never grant a bit your own role lacks; seeded Owner immutable; default role undeletable — deletion reassigns members, drops its overrides, and invalidates exactly the moved members' cached perms in one writer transaction). Case-insensitive unique names (migration 023), normalized colors, roles_update broadcast keeps clients current, and both admin surfaces stopped hardcoding the four seeded roles. A new ASCII guard test protects sqlc-generated SQL from a byte/rune offset bug that silently splices queries when comments contain non-ASCII. - Per-user channel overrides (migration 024): resolution is now base -> role override -> user override with one implementation (EffectiveChannelPerms); both layers load in two batch queries behind every visibility/permission site, per-role visibility memoization removed (two members of one role can now differ), and the @everyone fan-out honors user-layer allow and deny. Admin REST + full tri-state override matrix UI (role or user per channel) replace the single "Can access" checkbox; the visibility-agreement test grew a same-role different-overrides case. - Categories stopped being magic strings: any channel type under any free-text category (server + client validation removed), category editable everywhere with datalist suggestions, voice channels group under their real category. - Desktop channel management: Edit Channel gains slowmode presets, NSFW toggle, and voice user/video limits (bounds-checked server-side, broadcast on channel_create/update via one shared constructor); NSFW channels show a per-session age-gate overlay; VIEW_AUDIT_LOG holders get an Audit Log entry point opening the admin panel at #audit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 6 — custom emoji, profiles & presence, group DMs, DM calls, channel mutes - Custom emoji end-to-end: the dormant emoji table gains a mime column and real routes (list/upload/delete + authenticated image serving, MANAGE_SERVER-gated, 512KiB / 128px caps validated against sniffed bytes, SVG refused, 200-emoji cap, audited, emoji_update broadcast). :shortcode: renders inline (jumbo when emoji-only, never in code), the picker gains a Server category, the composer a :-autocomplete, reactions accept and render custom emoji, and the admin panel gets an Emoji section. - Profiles: avatar upload (sniffed, capped, served authenticated) with one shared client avatar helper replacing letter-initials everywhere; display_name (heading with @username handle preserved for mentions), about, and custom_status columns with sanitized bounds; user_update broadcast keeps clients current. - Presence: invisible is a real stored status collapsed to offline for every other viewer at every serialization site (owner sees truth); connect no longer force-stamps online (idle/dnd/invisible survive reconnect — the flash-online bug is gone); auto-idle after 10 minutes of inactivity that never overrides a manual status. The @here fan-out now collapses status first so invisible users are not pinged. - Group DMs: channels.is_group discriminator; create (2-8 others, bidirectional block checks), rename (participants only), leave (channel deleted with the last participant); per-viewer dm_channel_open payloads; stacked-avatar rows, multi-select member picker, participant headers; 1:1-only composer block gating. - DM calls: call_ring/call_decline signaling over existing DM voice (no new call state), Call button in DM headers, incoming-call banner with accept/decline/30s timeout and chime. - Per-channel mutes (client prefs): muted channels/DMs stay silent for non-mention noise (badge dims, mentions still notify), managed from context menus and the Notifications tab. The dead Friends nav item is removed as the plan prescribed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * Pre-release review fixes + v1.2.0-alpha.1 prep (#1305) * fix(review): pre-release security & performance fixes for the parity work Security: - Channel-override endpoints (role + per-user) now enforce grantability: a MANAGE_CHANNELS holder can no longer grant itself or a user a permission bit its own role lacks, and the role-layer endpoint refuses targeting a role at or above the actor's position (Administrator bypasses). Closes a privilege-escalation path opened when the override routes were downgraded from ADMINISTRATOR-only. - DM voice events no longer leak: channelReadAudience resolves a DM channel's audience from its participants (intersected with connected clients) instead of the role scan, which passed every user with base READ_MESSAGES since DMs carry no overrides. A private DM call's voice_state/voice_leave now reaches only its participants. - Invisible users no longer flash online on connect: member_join carries a viewer-safe status (db.BroadcastStatus) and the client defaults a missing status to offline instead of hardcoding online. - Voice moderation can no longer reach a private DM call: voiceModTarget refuses a DM-channel target unless the actor is a participant, with the same shape as "not in voice" so nothing about the call leaks. Correctness: - Un-deafening a member now also clears the deafen-implied server mute, so the target regains the ability to unmute themselves instead of staying silenced at the SFU until a separate unmute. Performance: - IncrementMentionCounts batches its upserts into chunked multi-row statements instead of one exec per recipient, so an @everyone mention holds the SQLite writer for one exec per 500 readers instead of N. - applyMentionCounts resolves mentions against a set built once from the readers instead of a nested O(mentions x readers) scan. - The markdown parser's bracket/paren matching is computed once per line instead of rescanned at every opener, removing the O(n^2) worst case on pathological input. - Video/audio attachment blob URLs are now LRU-capped and revoked, and the attachment caches are cleared on logout, fixing an unbounded per-session Blob leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * chore(release): prep v1.2.0-alpha.1 Bump the client manifests (package.json, package-lock.json, tauri.conf.json, Cargo.toml, Cargo.lock) from 1.1.0-alpha.5 to 1.2.0-alpha.1 so the release workflow's verify-versions guard passes for tag v1.2.0-alpha.1. The server version is injected via ldflags at build time and needs no bump. Add a curated CHANGELOG section for v1.2.0-alpha.1 documenting the Discord-parity feature drop (mentions, markdown, custom emoji, message navigation, role management, per-user overrides, voice moderation, profiles, group DMs, DM calls, channel mutes) and the pre-release security/performance review, plus an operator note covering the nine new migrations and the new WebSocket message types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * perf(mentions): apply mention counts off the send path SendMessage resolved every reader and wrote the mention/@everyone badge counts synchronously after the commit but before returning, so a mention in a large channel delayed delivering the message to everyone else by the full reader-resolution chain plus the batched increment. Move that bookkeeping onto a background goroutine via an injectable dispatcher field (bg, defaulting to `go fn()`). The write already ran on a cancellation-detached context and swallowed its errors, so detaching it from the request is safe; the count is advisory, so the tiny window where a reader's channel_focus clears it just before the increment lands is harmless (matching Discord's eventual consistency). Tests read the counts synchronously right after a send, so the shared mention fixture and the ws mentions test opt into an inline runner (RunBackgroundInlineForTest / the hub's RunMentionCountsInlineForTest seam); a new test exercises the real async path by polling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * refactor(client): extract shared inline-autocomplete factory MentionAutocomplete and EmojiAutocomplete duplicated ~90 lines of identical listbox scaffolding (AbortController cleanup, suggestions/ activeIndex state, the root listbox + .ma-list, mousedown-to-choose rows, and a byte-identical arrow/Enter/Tab/Escape keydown switch), so a fix to one silently diverged from the other. Factor that into createInlineAutocomplete<T>, parameterized by the four things that actually differ: the filter, the selected value, the per-row children, and the row/root test ids + class (emoji keeps the shared mention-autocomplete base class plus its own, and only mentions prime the list on create). Both components become thin adapters that keep their existing exports — createMention/EmojiAutocomplete, the pure filter functions, and the MIN/MAX constants — unchanged, so MessageInput and every test are untouched and still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): drop now-unused appendChildren import in MentionAutocomplete The row rendering moved into the shared inline-autocomplete factory, so the import is no longer referenced; oxlint fails the Client Static Checks job on the unused identifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(review): full-project review — hierarchy, role positions, search, clarity (#1306) From a full-codebase review (Opus security + Sonnet server/client + Haiku consistency): - Per-user channel overrides now enforce the same role-hierarchy guard the role-layer endpoint already has: a non-admin MANAGE_CHANNELS holder can no longer write or clear a per-user override against a member ranked at or above their own. Without it, because the per-user layer is last in the resolution order, a Moderator could deny a higher-ranked member the channel access their role grants. Applied to both PUT and DELETE. - CreateRole no longer places two default-positioned roles at the same position: it steps to the highest free slot below the actor and rejects an explicit position that is already taken. Colliding positions read as equal rank in every hierarchy check, so two such roles could never manage each other's members. The rank guard still takes precedence over the collision message for an at/above-rank position. - Search overlay no longer silently drops a query that arrives inside the 500ms rate-limit window (which sits above the 300ms debounce): it reschedules the search for when the window opens instead of leaving the previous query's results on screen. - Corrected a misleading TODO on chat_send attachments: they are upload UUIDs resolved by ownership at link time, not URLs, so a javascript:/data: string is never stored or rendered — a scheme check would wrongly reject valid ids. The comment now states this and the loop variable/error name say "id". Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR Co-authored-by: Claude <noreply@anthropic.com> * Test hardening: fuzzing, contract/upgrade, load, e2e (#1307) * fix(image): reject zero-dimension images in header decode FuzzImageDimensions found two inputs the emoji/image size guard accepted as valid with a nil error despite having no real dimensions: - a GIF whose logical screen descriptor decodes to height=0 via Go's own image.DecodeConfig, and - a VP8 keyframe whose size field is all zeros (VP8, unlike VP8L/VP8X, stores the size directly, so 0x0 is a validly-shaped header). Both callers compare the returned size straight against their pixel cap, so a degenerate 0-dimension header slipped through as a "small" image. Reject non-positive dimensions centrally in imageDimensions and reject zero VP8 dimensions in webpDimensions, so the invariant holds even for a caller that forgets its own bounds check. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): add Go fuzzers and TS property tests for parsers/validators Adds coverage on the parsers and validators most exposed to hostile input, each with a tricky seed corpus and invariant assertions: Server (Go native fuzzing): - FuzzParseMentionTokens: never panics; resolved count within cap. - FuzzSanitizeFTSQuery: output never errors against real SQLite FTS5. - FuzzValidateShortcode: accepted shortcodes match the documented charset/length. - FuzzEffectivePerms / FuzzEffectiveChannelPerms: ADMINISTRATOR implies all bits, user-deny beats role-allow, result is a subset of AllPerms. Client (fast-check property tests): - markdown tokenizer never throws and emits no script/on*/javascript: sinks, bounded time on pathological input. - mention/emoji content parsing never throws. - filterMentionSuggestions/filterEmojiSuggestions never throw and respect the caps and the MIN_EMOJI_QUERY/permission gates. The image-header fuzzer that found the zero-dimension bug landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(migration): add full-chain and upgrade round-trip tests Applies every embedded migration to a fresh DB and asserts the resulting schema is coherent, then applies the full chain on top of a pre-parity (migration 019) snapshot and asserts it upgrades without error and preserves seeded rows. Protects existing operators on the v1.2.0 upgrade (9 new migrations, 020 through 028). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(protocol): assert protocol schema matches generated Go constants Asserts every wire constant in docs/protocol-schema.json has a matching generated Go constant and vice-versa, with a small explicit exception list for intentionally-undocumented internal constants. Catches the chat_command-style drift the review flagged before it reaches the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(load): add hub load/soak harness with goleak verification Adds a long test (skipped under -short, run under -race in CI) that concurrently registers and unregisters 200 WS clients across churn rounds while six broadcaster goroutines fan out to the hub, then asserts via go.uber.org/goleak that no goroutines leak and no deadlock or panic occurs. Exercises the client registry, broadcast audience resolution, and the background mention goroutine under contention -- the class of bug the race detector only reveals at scale. Adds a BroadcastVoiceEventForTest seam to export_test.go for the broadcaster loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(e2e): add blocking parity-feature Playwright specs Adds end-to-end coverage for the v1.2.0 parity features that had none, all tagged "@parity" and driven through the existing mocked-Tauri harness (tests/e2e/helpers.ts) — 15 tests across three files: - gating-badges.parity.spec.ts: NSFW age-gate mount/continue, mention red badge (ready-payload render + live incoming-mention bump), per-channel mute toggle + localStorage persistence. - social.parity.spec.ts: group-DM create via the member picker (asserts the POST /dms/group request), group render + leave (DELETE), and Change Role via the member context menu (asserts the PATCH /admin/api/users/{id}). - emoji-voicemod.parity.spec.ts: custom-emoji ":shortcode" autocomplete + message-list <img> render, and the voice-moderation menu — both the admin-can path (asserts voice_mod_mute / voice_mod_kick ws_send) and the gated path (menu absent without MUTE_MEMBERS). The specs assert the exact outgoing HTTP/WS request where the flow is request-driven, not just DOM side effects. No product bugs were found. Adds a dedicated CI job "Client E2E (parity subset, blocking)" that runs only the @parity specs (playwright --grep "@parity") WITHOUT continue-on-error, so a regression in these features fails CI. The pre-existing full e2e job stays non-blocking, per the maintainer note that it needs a few green pushes before graduating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * More hardening: fuzz the input surface + fix mis-written tests (#1308) * fix(upload): keep sanitizeUploadFilename output a safe, valid basename FuzzSanitizeUploadFilename found two inputs the upload-filename sanitizer returned unchanged in violation of its own contract: - "/" survived verbatim: filepath.Base("/") returns "/" (root is its own basename), and the final reserved-name check only special-cased "", ".", and "..", so a path separator reached the served download name and the client's save-dialog prefill. - a name longer than the 255-byte cap was truncated with a byte slice (name[:max]), which can land mid-rune and yield invalid UTF-8 — which then misbehaves in JSON encoding, on disk, and in download-name handling. Now any residual '/' is dropped in the character filter, and truncation trims back to the last full rune so the result is always valid UTF-8. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): fuzz the file/path and content/identity input surface Adds Go native fuzzers on the untrusted-input parsers/validators the first fuzzing pass didn't reach, each with a tricky seed corpus and both a never-panics and a semantic/security invariant: - storage.sanitizeFilename + resolvedPath composition (a name that passes sanitize must resolve inside the storage dir — no traversal), and storage.ValidateFileType (error iff a blocked magic prefix matches, for any header length). - plugin.validateRelativePath (accepted paths are non-absolute, separator- and traversal-free). - service.sanitizeContent: output carries no surviving <script/js:/on* sink, is length-bounded, and is idempotent (the bluemonday StrictPolicy contract). Two documented regression seeds pin the "inert plain text that merely contains the word javascript:/onclick=" non-bug. - auth.ValidateUsername / ValidatePasswordStrength — accept implies the documented charset/length. - api.validateAvatarURL (never accepts a non-https / javascript: / data: URL) and api.validateDisplayName. - ws.parseParticipantIdentity / parseRoomChannelID — never panic on adversarial LiveKit webhook strings. Each target survived active fuzzing (hundreds of thousands to millions of execs) with no crash; the one real bug found (sanitizeUploadFilename) landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test: make mis-written tests actually assert their claimed behavior A test-quality audit found tests that ran an action but asserted nothing (or asserted a tautology), so they would pass even if the code under test were deleted. Each is now wired to the real observable effect it names — no product code changed, no assertion weakened: Client (vitest): - notifications.test.ts: 19 notifyIncomingMessage tests had zero expect() calls; each now asserts the sendNotification / requestUserAttention / oscillator mock per its name (suppress vs fire, truncation, fallback title), with mockClear() so a stale call can't make it trivially green. Three catch-path tests now assert the debug log fired. One test whose title contradicted its body (and the code's guard) was renamed to match verified behavior. - livekit-session.test.ts: token-refresh test asserts the stored token and the rearmed refresh timer; the two "no active room" device-switch tests assert Room.switchActiveDevice is not called. - connection-stats.test.ts: the "start is idempotent" test now advances timers and asserts the poll callback fires once per tick (no double interval). - voice-audio-tab.test.ts: the cleanup test now actually starts a camera preview (it previously couldn't reach the camera-stop path) and asserts both mic and camera tracks are stopped. - dispatcher.test.ts: replaced an expect(true).toBe(true) with assertions on the voice-store speaking state the handler writes, incl. a control. - sidebar-area.test.ts: performs the back-navigation the test described and asserts the pre-DM text channel (not the DM) is restored. - profiles.test.ts: asserts no profile is created/mutated for a missing id. - log-persistence.test.ts: activeFlush tests assert flush sequencing, and the cleanup error test asserts the logged error. Server (Go): - db/coverage_boost_test.go: TestCreateAttachment_WithDimensions now links the attachment to a message and verifies the persisted width/height via GetAttachmentsByMessageIDs, instead of only checking a row exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * style(fuzz): satisfy golangci-lint on the new fuzz seed corpora - Escape the raw bidi/zero-width Unicode format characters embedded in the seed strings as \u escape sequences (staticcheck ST1018) — same runes, now greppable and lint-clean. - Range over strings.SplitSeq instead of strings.Split in the relative-path fuzzer's traversal check (modernize). No change to what any seed exercises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * docs(changelog): note pre-release test hardening and the two bugs it found #1307 and #1308 landed fuzzing, migration/protocol/load tests, a blocking @parity e2e job, and a test-quality audit. Two of those were real product fixes (zero-dimension image headers, sanitizeUploadFilename) that belong in the release notes, not just the test log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(changelog): restore the alpha.5 behavioural notes dropped in the rewrite The v1.2.0-alpha.1 section replaced the v1.1.0-alpha.5 one wholesale, taking the LiveKit-proxy origin-gate and log-stream API-token bullets with it. Both fixes are in this release's code (#1293, #1294, #1295) — only their operator notes went missing, and an operator upgrading from alpha.3 would never have seen them. Restored verbatim from main. This is the sole content main had that dev lacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): gate CREDENTIAL_FALLBACK_KEY_FILE to non-Windows `cargo clippy -- -D warnings` failed the Windows Tauri build with "constant CREDENTIAL_FALLBACK_KEY_FILE is never used". Its only consumer, `fallback_crypto`, is `#[cfg(not(windows))]` (lib.rs:6) because Windows seals fallback entries with DPAPI instead — so on Windows the constant is genuinely dead and -D warnings promotes that to an error. Gated the constant to match its consumer rather than silencing it with #[allow(dead_code)], so it still trips if it ever goes dead on the platforms that do use it. Latent on dev, not introduced here: Tauri Full Build is gated on base_ref == 'main', and the fast suite only compiles Rust on ubuntu (rust-tests runs on ubuntu-22.04), where fallback_crypto *is* compiled. Nothing built the Rust lib for Windows until this dev -> main PR. Verified locally on Windows: `cargo clippy -- -D warnings` and `cargo clippy --all-targets -- -D warnings` both exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): stop writing a credential byte to the log on bad LiveKit config CodeQL go/clear-text-logging (high, alert #13): the YAML-safety check in generateConfig rejected a bad credential with fmt.Errorf("LiveKit credential contains unsafe YAML character %q", ch) where ch is a byte taken from LiveKitAPIKey or LiveKitAPISecret. Start() wraps that error and api/router.go logs it, so a byte of the API key or secret reached the server log in clear text. The check now uses strings.ContainsAny and names the offending config field instead of echoing the byte — strictly more useful to an operator, who previously got a character with no indication of which credential it came from. Same rejection set, so behaviour is otherwise unchanged. Adds a regression test asserting the error names the field and contains no part of either credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(plugin): resolve UI asset paths at construction, not per request CodeQL go/path-injection (high, alerts #11 and #12): AssetHandler built the on-disk path from req.URL.Path on every request, then validated it with filepath.Rel. The validation was sound — traversal was already blocked by the manifest allowlist, the Rel check, and the serve-time Lstat — but a path was still being constructed from user input, which is the pattern the rule flags and the one that goes wrong when someone later edits the ordering. Each declared asset is now resolved and traversal-checked once, when the handler is built, into an asset-name -> absolute-path map. At serve time the request path is only ever a map key, so no filesystem path is derived from user input at all. An asset that fails validation is absent from the map and 404s, as an undeclared file already did. Also moves filepath.Abs/Join/Rel off the per-request path. The serve-time Lstat symlink and IsRegular checks stay exactly as they were — they close the post-install TOCTOU window and are still needed per request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(plugin): constrain default-build registry tests to !wazero registry_test.go opens "Registry lifecycle tests for the default (non-wazero) build" and asserts activation fails with ErrRuntimeUnavailable, but carried no build constraint. Under -tags wazero a real runtime is linked in, so TestRegistry_Activate_ WithoutRuntime and TestRegistry_EnablePlugin_RollsBackWhenActivationFails both failed. Nothing caught it: CI builds all three tag variants but only runs tests untagged, so these have been red under -tags wazero without surfacing. Adds the //go:build !wazero the file always implied, matching the sandbox_default.go / sandbox_wazero.go split already used here. Its helpers are used by no other file, so nothing else loses coverage; the wazero build keeps its own activation tests in sandbox_wazero_test.go. Co-Authored-By: Claude Opus 5 (1M context) <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> |
||
|
|
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> |
||
|
|
17b17eb1b3 |
fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1) adminAuthMiddleware accepted a Bearer token on session validity plus the ADMINISTRATOR bit alone and never consulted ban state, so a ban never revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that api.AuthMiddleware already uses, at both admin credential-resolution points. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2) registerNow subscribed any client with voice state to that channel's text-message topic regardless of READ_MESSAGES. The handshake's already-computed readable-channel set is now passed into registerNow and the subscription only happens when the voice channel is in it, preserving authorized reconnect delivery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(service): require READ_MESSAGES to delete messages (F4) The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a role locked out of a private channel could still delete every message in it. Requires ReadMessages alongside ManageMessages (and alongside SendMessages on the author path) and derives the mod flag from that same gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8) Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private channel could still pin and unpin its messages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(service): enforce the DM block at every DM interaction sink (F5) The DM block was only checked on send, leaving edit, reactions, pins and typing as bypasses. One shared requireDMNotBlocked is now called from all of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6) voice_token_refresh re-minted a LiveKit token without re-checking CONNECT_VOICE, so a revoked permission kept working for the life of the session. The permission is now re-checked where the token is minted, and a 60s sweep evicts participants whose permission was revoked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7) The limiter key was built from unvalidated client input, letting an attacker grow the limiter map without bound. The limiter now runs after validation and keys on (sender, voiceChannelID), never on client input. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9) Voice state of private channels was broadcast to every connected client, leaking channel membership. All 11 emit sites now route through one READ-filtered fan-out, channel-tagged so replay filters too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): redact the LiveKit access token from proxy dial-failure logs (F10) A dial failure wrote the LiveKit access-token JWT into the server log via the URL in the error. redactKey now runs on the error before it reaches slog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): reserve the [deleted-N] username namespace (F11, F12) The tombstone username namespace used by account deletion was freely registrable, letting a user impersonate a deleted account. The namespace is now reserved at validation, and DeleteAccount retries with a random suffix on collision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): strip Unicode format characters from upload filenames (F13) The attachment filename sanitizer stripped control characters but not unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped alongside controls and foreign path separators are cut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): reserve the login attempt before the bcrypt compare (F3) The per-username lockout was a read-only IsLockedOut check followed by a failure recorded only after the ~250ms bcrypt compare, so N concurrent requests all passed the stale check before any of them recorded a failure. The per-username cap is the only cross-IP brute-force defence (the middleware limits per IP), so a distributed burst landed N guesses per 15-minute window instead of 10. Both counters are now reserved atomically with limiter.Allow before the compare, and the lockout decision moves to the read-only limiter.Check so the reservation is not double-counted. The limits are sized at threshold+1, which leaves the sequential accepted-input set byte-identical to the previous behaviour: failures 1-10 still land, the 10th still trips the lockout, and the account owner's correct password on attempt 10 still returns 200. Sizing at threshold instead would make 9 cheap wrong guesses convert the victim's own correct password into a 15-minute lockout - the regression that got two earlier attempts at this fix rejected, now pinned by a boundary test. Deliberately scoped to handleLogin. The report also suggested widening to the password-confirmation endpoints, but those are authenticated, share a single pw_confirm_fail key across the TOTP endpoints, and widening there is what got the first attempt rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri Rolls up dependabot #1259, #1260, #1261, #1262 and #1263: tauri-build 2.5.6 -> 2.6.3 tauri-plugin-fs 2.4.5 -> 2.5.1 tauri-plugin-http 2.5.7 -> 2.5.9 tauri-plugin-store 2.4.2 -> 2.4.4 webpki-roots 1.0.6 -> 1.0.9 All five are lockfile-only; the manifest constraints already permitted the new versions. The five PRs each rewrote overlapping regions of the same Cargo.lock and so could not be merged independently, so the lockfile was regenerated with cargo update --precise for each crate instead. The combined result is smaller than the sum of the five diffs because they share transitive updates. Verified with cargo check --locked --all-targets (exit 0). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client Dependabot #1258. 8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which surfaces four assertions that were already redundant and now fail the lint gate. They are removed here rather than in a follow-up so no commit in this branch leaves `npm run lint` red: UserBar.ts / members.store.ts "online" as UserStatus -> "online" (the receiver already accepts the literal) media.ts drops `as RequestInit` on a literal that is already assignable LoginForm.ts drops `as { message: unknown }` made redundant by the `"message" in err` narrowing All four are the rule's own autofix. Verified: npm run typecheck, npm run lint, npm run format:check all clean, and the unit suite is 3572/3572 green across 129 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6afa9e974c |
refactor(server): thread context.Context through the db layer and all callers
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7b178ff30b |
fix(security): harden server against verified code-review findings
Applies fixes for 20 adversarially-verified findings from a whole-codebase security review (server side). All Go build-tag variants build, `go vet` is clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a pre-existing nil-harness failure unrelated to these changes). High severity: - auth: close TOCTOU in TOTP verify rate-limit by recording each attempt atomically up-front (was Check-then-Allow), restoring the per-user brute-force cap. - plugin: enforce the CPU/time budget on every WASM guest call via a WithTimeout context (WithCloseOnContextDone interrupts runaways); the configured budget was previously parsed but never applied. - api/waf: inspect request bodies for chunked (ContentLength==-1) requests so the SQLi/XSS/RCE body rules can no longer be bypassed. - ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which fan out to every participant and could force mass disconnects. Medium severity: - api: run bcrypt on the unknown-user login path (no || short-circuit) to remove the timing-based username-enumeration oracle. - ws: verify LiveKit webhooks via the SDK receiver so the signature is bound to the body hash (kills forgery/replay). - authz: require READ_MESSAGES for reactions and for plugin-command broadcasts; route the latter through RequireChannelAccess. - api: cache the client-update signature fetch and rate-limit the endpoint. - service: propagate DeleteOtherSessions failure from ChangePassword instead of silently reporting success. - api: trust the rightmost non-proxy X-Forwarded-For entry, not the client-controllable leftmost one. - plugin: route auto-registered commands through the conflict-checked RegisterCommand; pin the DNS-validated IP for host_http dials (DNS-rebinding TOCTOU). - api: mark access-controlled downloads private/no-cache + Vary: Origin. Low severity: - auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth (was returning the ciphertext as plaintext). - api: apply the livekit-proxy path allowlist to WebSocket upgrades too. - service: verify attachment ownership before linking (IDOR). - admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the update binary hash immediately before rename+spawn (TOCTOU). - service: require BanMembers + role hierarchy for moderation ban/unban. chore: stop tracking the stray Server/owncord-server.exe build artifact. Test infra: add uploader_id to the hand-rolled ws test attachment schemas and make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1673c37b9c |
fix: comprehensive security hardening from full codebase audit
Addresses 14 findings from the security audit across all severity levels: CRITICAL: - C-1: Add user blocking system (migration, DB queries, REST API, WS DM send check) to prevent harassment via unconsented DMs - C-2: Remove server version from unauthenticated /health and /info endpoints to prevent fingerprinting HIGH: - H-1: Remove dangerous-settings feature from tauri-plugin-http - H-3: Default allowSelfSigned to false in API client (was hardcoded true) - H-4: Cap invite expiration to 30 days (720 hours) - H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM) - H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow) - H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role MEDIUM: - M-2: Deny access to legacy NULL-uploader unlinked attachments - M-4: Log warnings on TOTP plaintext decryption fallback paths - M-8: Remove acceptInvalidCerts from OG preview fetches - M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk) - M-12: Add LIMIT to ListInvites (200) and ListMembers (1000) - M-14: Add CHECK constraint trigger on channels.type (text/voice/dm) https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT |
||
|
|
9f32736278 |
fix: resolve all 12 golangci-lint issues
- nilerr: mark intentional nil returns in DecryptTOTPSecret (backwards compat for unencrypted legacy secrets) - gosec G703: suppress path traversal false positives in backup handlers (paths already sanitized by HasPrefix guard) - contextcheck: thread context through handleWebhookParticipantJoined/Left; nolint goroutine in handleFreshConnect that intentionally detaches from request context - errcheck: handle fmt.Fprintf return in handleWAFInterruption - gocritic elseif: flatten else-if chain in upload_handler access check - gocritic ifElseChain: rewrite asset name matching as switch in updater - unparam: remove unused totpKey param from handleLogin (TOTP verification handled by separate handleVerifyTOTP endpoint) |
||
|
|
f40e6787a9 |
fix: resolve remaining LOW security findings (L2-L14)
- Document single-instance requirement for in-memory rate stores (L2) - Add IsOwnerRole() helper for explicit owner guards (L4) - WS auth deadline uses request context, not context.Background (L5) - Double-check voice state before clearing in webhook handler (L8) - Upload stores measured write size instead of client header.Size (L11) - Startup warning when config upload size exceeds HTTP body limit (L12) - Update check endpoint now requires owner role (L13) - Backup paths resolved to absolute at init time (L14) |
||
|
|
e35e1b346c |
fix: encrypt TOTP secrets at rest and filter replay buffer by permissions
M1 — TOTP secrets are now AES-256-GCM encrypted before being stored in the database. Key is auto-generated on first run (data/totp.key) or set via OWNCORD_TOTP_KEY env var. Existing plaintext secrets are detected and returned as-is for backwards compatibility. M3 — Replay buffer events are now tagged with their channel ID. On reconnect, the server computes the user's current accessible channels and only replays events from those channels. Global broadcasts (presence, voice state, member updates) are always replayed. Falls back to full ready payload if permission computation fails. |
||
|
|
9e48e8d8e8 |
fix: security hardening — 11 findings across auth, WS, upload, admin, data
Security audit across all 11 sections (AUTH-001 through DATA-001) found 0 critical, 1 high, 7 medium, 15 low issues. This commit addresses: - Add json:"-" to User.PasswordHash, User.TOTPSecret, Session.TokenHash to prevent accidental serialization of sensitive fields (M7) - Add X-Content-Type-Options: nosniff to file serve responses (M5) - Apply owner-only guard to backup list endpoint for consistency (M6) - Persist rate-limit lockouts to SQLite so they survive restarts (M2) - Normalize DM non-participant responses to 404 to prevent oracle (L3) - Add explicit per-entry expiry check in partial auth Lookup/Consume (L1) - Truncate unknown WS message type to 64 chars before echo (L6) - Rate-limit ping handler to 2/sec per user (L7) - Replace raw error strings in update handlers with generic messages (L15) - Update 4 tests to match new 404 behavior for DM non-participant |
||
|
|
d056229b28 |
fix: self-signed cert no longer generated as CA (BUG-138)
Changed IsCA to false, removed KeyUsageCertSign, and reduced validity from 10 years to 2 years. A compromised key can no longer sign additional certificates trusted by TOFU-pinning clients. |
||
|
|
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. |
||
|
|
ecbffddf19 |
refactor: extract magic numbers to named constants
Create Server/api/constants.go (26 constants) and Server/auth/constants.go (6 constants) for rate limits, timeouts, size limits, and token generation. Replace all inline magic numbers with descriptive names across 9 source files. No behavior changes — same values, just named for contributor readability. |
||
|
|
a40b42bbed |
fix: resolve 24 critical and high issues from full code & security review
CRITICAL (5): - Hub panic recovery now calls h.Stop() after 3 panics (ws/hub.go) - Ring buffer EventsSince returns non-nil empty slice for current seq (ws/ringbuffer.go) - PTT event listener stores unsubscribe handle to prevent leak (ptt.ts) - verifyTotp respects config.allowSelfSigned instead of hardcoding (api.ts) - ptt_listen_for_key uses spawn_blocking to avoid thread pool starvation (ptt.rs) HIGH - Server (13): - TOTP rate-limit checked after body decode; counters reset on success - TOTP enable returns 409 if already enabled (must disable first) - Global search pre-computes accessible channel IDs for FTS WHERE clause - DeleteAccount queries roles by name instead of hard-coded IDs - BackupToSafe uses absClean in VACUUM INTO - Voice camera slot uses atomic EnableCameraIfUnderLimit DB method - readPump snapshots voiceChID before unregister for TOCTOU safety - Voice join sets state after token send; rollback takes broadcast flag - Updater download uses probe pattern instead of overflow write - Webhook checks Authorization header before reading body - Storage.Save adds fsync and fixes double-close - Default WS origin denies cross-origin (was: accept all) HIGH - Client (6): - WS reconnect uses generation counter to discard stale events - AudioPipeline uses generation counter against stale worklet callbacks - Screenshare mute state preserved across reconnect (not full leave) - handleVoiceToken uses iterative loop instead of unbounded recursion - store.ts re-entrancy guard with pending update queue - Notification AudioContext cleaned up on logout Reviewed by 4 parallel agents across Server Core, Server Realtime, Client & Tauri, and Security. 55 total findings; 24 CRITICAL+HIGH fixed here, 31 MEDIUM+LOW tracked in vault backlog (T-265–T-295). |
||
|
|
a0fd5e8fda |
security: fix 11 vulnerabilities from security review
Batch 1 — Immediate priority: - C4: Atomic voice channel capacity (JoinVoiceChannelIfCapacity) - H5: Sanitize emoji field with bluemonday (stored XSS) - H8: Permission check before FTS search (timing oracle) - M8: Filter ready payload channels by ReadMessages - H10: Remove password/TOTP from admin ListAllUsers query Batch 2 — Next sprint: - C1: TOTP replay prevention (UsedTOTPCodeStore, 90s TTL) - C2: Per-user TOTP brute-force rate limit (10/15min) - C3: Delete requires SendMessages or ManageMessages - H1: Expired sessions deleted on detection - H3: Bearer token whitespace trimmed - H6: Log warning when WS origin checking disabled |
||
|
|
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 |
||
|
|
ce64be4e14 | fix: safe registration_open default + TOTP constant-time comparison (T-199, T-201) | ||
|
|
90b4f268e2 |
feat: TOTP 2FA settings UI, server hardening, full validation pass
Client: - Add TOTP enrollment/disable UI in Settings > Account (AccountTab.ts) - Fix api.ts enableTotp/confirmTotp/disableTotp to require password param - Add totp_enabled field to UserWithRole type - Wire SettingsOverlay TOTP callbacks through MainPage and ConnectPage - 27 new tests: totp-settings (18), api TOTP methods (6), auth store (3) Server: - Fix targetBoolSetting to default false on ErrNotFound (fresh DB compat) - Fix admin settings test: boolean keys use valid values, not "testvalue" - Add require_2fa validation to settings handler (normalizeSettingUpdates) - Remove unused authenticateAdmin from logstream.go Docs: - Mark DOCUMENTATION_AUDIT Critical Finding #1 as RESOLVED - Update CLAUDE.md Key Features with 2FA/TOTP bullet - Update CLIENT-ARCHITECTURE.md with TOTP components - Update CHATSERVER.md login flow and rate limiting table - Create session log, update task tracking (T-192–T-201) |
||
|
|
2976863ad0 |
fix: atomic invite registration, fail-closed search, proxy-aware rate limiting
- Atomic CreateUserWithInvite prevents invite burn on failed registration - Channel search fails closed on channel-type and override lookup errors - Malformed FTS input returns 400 instead of 500 - Search rate limiting uses own namespace, respects trusted proxy IPs - Login lockout keyed by forwarded client IP behind reverse proxy - Trusted same-server OG previews re-enabled with self-signed cert support - Normalized host matching for embeds/attachments - Regression tests for all changes (auth, channel, embeds) |
||
|
|
7404347a1d |
fix: address code review — 8 issues across server and client
Server fixes: - voice_leave: broadcast voice_leave even on DB error so peers don't see ghost users (H1) - migrate: record migration inside transaction for atomicity (H2) - password: use init() with panic for dummyHash to catch bcrypt init failures (M1) - password: replace //nolint:errcheck with explicit _ = discard (M2) - handlers: clarify edit permission comment, fix error message wording (M3) - auth_handler: distinguish duplicate username (400) from DB error (500) (M4) Client fixes: - media: revert YouTube oEmbed to browser fetch — no need to disable cert verification (C1) - messages.store: fix prependMessages cap to keep newest messages, not oldest (H5) - ChannelSidebar: ref-count globalDragAc to prevent multi-instance teardown race (H6) - attachments: replace console.error with project logger (M6) - ws: clarify lastSeq reset comment to match actual behavior (M5) |
||
|
|
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 |
||
|
|
ce4326766a |
fix: resolve all golangci-lint issues blocking CI server build
Fix 70+ errcheck violations by adding explicit error discards (`_ =`) for unchecked return values across test helpers and deferred Close() calls. Remove unused `senderID` field from broadcastMsg and unused `defaultCleanupMaxWindow` const. Apply De Morgan's law, remove empty branch, and simplify redundant type declaration per staticcheck. |
||
|
|
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 |
||
|
|
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 |
||
|
|
b7dd6eabe9 |
feat: implement Phase 2 auth & security with TDD
- auth/session: 256-bit crypto-random tokens, SHA-256 hashing for storage - auth/password: bcrypt cost 12, strength validation (8-72 chars) - auth/ratelimit: sliding-window RateLimiter with lockout, thread-safe - db/models: User, Session, Invite, Role types - db/auth_queries: full user/session/invite CRUD with in-memory test coverage - api/middleware: AuthMiddleware (Bearer token), RequirePermission (bitfield), RateLimitMiddleware (X-Real-IP, Retry-After header) - api/auth_handler: POST register/login, POST logout, GET me - Generic errors — username existence never revealed - Rate limits: 3/min register, 5/min login, lockout after 10 failures - api/invite_handler: create/list/revoke behind MANAGE_INVITES permission - bluemonday sanitization on all user-supplied string fields Test coverage: auth 90.9%, db 84.4%, api 80.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 |