* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions
* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)
* fix(voice): 1 defect(s) (OC-0005)
* fix(client): 1 defect(s) (OC-0007)
* fix(client): 1 defect(s) (OC-0011)
* fix(client): 1 defect(s) (OC-0012)
* fix(admin): 1 defect(s) (OC-0013)
* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)
* fix(voice): 1 defect(s) (OC-0018)
* fix(voice): 1 defect(s) (OC-0019)
* fix(client): 1 defect(s) (OC-0021)
* fix(client): 1 defect(s) (OC-0025)
* fix(ws): 1 defect(s) (OC-0026)
* fix(client): 1 defect(s) (OC-0027)
* fix(client): 1 defect(s) (OC-0028)
* fix(identity): 1 defect(s) (OC-0030)
* fix(voice): 1 defect(s) (OC-0016)
* fix(client): 2 defect(s) (OC-0002, OC-0020)
OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.
OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.
* fix(voice): 1 defect(s) (OC-0008)
handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.
Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.
* fix(ws): 2 defect(s) (OC-0017, OC-0022)
OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.
OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.
* fix(voice): 1 defect(s) (OC-0023)
Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.
* fix(client): 2 defect(s) (OC-0032, OC-0033)
OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.
OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.
* fix(voice): 1 defect(s) (OC-0034)
* fix(client): 1 defect(s) (OC-0035)
A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.
* fix(voice): 1 defect(s) (OC-0036)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(client): style the user profile popup
The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.
app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.
Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.
Two latent bugs fixed while there:
- Placement guessed a 300px card height and clamped only the top edge,
so a member clicked low in the list opened a card that ran off the
bottom of the window. Measure the card and clamp both edges.
- The avatar has to hang off the body's top edge, but the body scrolls,
and `overflow-y: auto` clips horizontally too. Make it a child of the
card rather than the body.
The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop vite watching src-tauri
`npm run tauri dev` died on Windows partway through the cargo build:
Error: EBUSY: resource busy or locked, watch
'src-tauri\target\debug\deps\owncord_client_lib.dll'
Error The "beforeDevCommand" terminated with a non-zero status code.
Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.
The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.
Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(security): add 2026-08-04 whole-codebase security review (#1326)
Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.
Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:
- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
type == "dm" guard their sibling getPermChannel carries, so the same role
can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
other DM interaction sinks perform, so a blocked user can ring the person
who blocked them.
Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.
Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ
Co-authored-by: Claude <noreply@anthropic.com>
* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)
* docs: fix server reference docs (api, protocol, server-configuration, deployment)
api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
document the per-username lockout and lockout persistence
(Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
(Server/api/metrics_handler.go) and document the otel-only
Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
endpoints: setup, stats, users, audit-log, settings, tokens,
backups, updates, and the SSE log stream (Server/admin/api.go)
protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
missing rows: call_ring, call_decline, emoji_update, call_incoming,
call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
plugin_broadcast) and flag that they sit outside protocol-schema.json
server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
telemetry.otlp_insecure, and the whole logging section +
OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)
deployment.md:
- Drop the removed "version" field from the /health sample; add
broadcast_drops to the metrics sample; note the distroless non-root
image; refresh build version strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture): rewrite stale architecture pages against 5630aa1
All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:
- websocket.md: delete the false claim that docs/protocol-schema.json does
not exist — it is the codegen source of truth (Server/scripts/genprotocol,
CI-gated by make protocol-verify); note the hand-declared plugin command
family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
channel_user_overrides to the ER diagram, channels.type now includes
announcement, note 017/024/027/028 columns; drop the claim that schema.md
is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
redirect stub it is; companion-audit links refreshed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture/ux): align UI flow specs with current client behavior
- Cert first-use is a blocking trust modal on status "first_use" — the Rust
proxy rejects the first connection until the user confirms (main.ts:146-176,
tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
"trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
roles_update, emoji_update, voice_moved, voice_disconnected) and note that
call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
(AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
safe key capture), noise suppression + fallback, device hot-swap, stream
preview, and DM ring/incoming-call flow; drop the nonexistent
VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
(appendBanFlow with reason + duration); document the admin-panel deep-link
(lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: refresh client-facing and top-level docs
- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
provider is Klipy, proxied server-side with an operator-supplied key —
nothing ships in the client bundle); describe credential storage accurately
(OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
complete the audit-log action list against the actual WriteAudit call sites
and note that backup restore is not audit-logged; fix the firewall
checklist to include the LiveKit media ports (7880-7881/TCP,
50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
1.2.0-alpha.1; README "audits" section now points at the current audit
documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
(removed with the store layer); add the protocol-generate/verify targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(plans): add verified status headers and fix stale references
Every plan under docs/plans/ now carries a dated status verified against
5630aa1:
- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
unification, http-tofu-proxy, permission-middleware-consolidation (the
disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
re-marked; all six rows have since shipped, including archived channels,
which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
named leftovers (role hoist/mentionable, @RoleName mentions, categories as
entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
findings closed; two of the four F3 follow-ups have since shipped (safety
number rendered in the roster badge; rePinPeerIdentity wired to the
identity-mismatch modal), getIdentityPin fail-open remains open; noted the
scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
now taken, Server/store/ deleted, src/state/ never existed)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audits): reconcile prior audit statuses with current CI and code
- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
failing) was fixed by the mock repair but the audit was never updated —
now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
resolved — client-e2e (non-blocking, every PR) and the blocking
client-e2e-parity job both exist; backlog item 10 marked DONE
(client-tests is blocking, Playwright wired)
Only status/closure cells were edited; original finding text is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes
Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(tests): rewrite the e2e issues log against a real suite run
The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): add 2026-08-04 docs-and-coverage audit report
Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)
Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)
DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(service): enforce blocks on DM call rings (A-2026-08-03)
RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): delete dead modules (DC audit remediation)
All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
landed (SidebarArea.ts); only its own orphaned unit test still used it.
The e2e spec already asserted .unified-sidebar-header, so it is renamed
to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
routes; these called endpoints that do not exist (pairs with the
sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
the server mention_count via the dispatcher. This was the one live knip
error the CI '|| true' was masking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): retire the tauri-typegen ritual (DC-05)
src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: make knip blocking (DC-06 follow-through)
Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(server): drop the dead sounds table (A-2026-07-13)
The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments
- NewWAFMiddleware had no production caller (the router mounts the CRS
variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
its WS notification from MsgTypeDMChannelClose instead of restating the
wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
main.go removed; host_ui.go no longer advertises a route that is not
mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
PROTOCOL.md was renamed to (DC-09).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)
chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): wire orphaned native specs and typecheck the Playwright layer
- dm-system, reconnection and theme-persistence (14 tests) matched no
project's testMatch in playwright.config.native.ts, so they had never
executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
the node-API fixtures), a typecheck:e2e script, and a CI step. The one
real error it surfaced is fixed: mockTotpFailure omitted the required
simulateWsFlow flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): cover createPromptModal and the external-abort close path
modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)
The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: fix the inaccuracies the 2026-08-04 refresh missed
- contributing.md: drop the '-tags postgres' build row (no such tag exists
anywhere in Server/), add the four Make targets the 07-25 audit created
(test/test-deadlock/cover/cover-all), align the coverage statement with
the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
style at architecture/client.md instead of the tombstone, and describe
the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
the canonical policy (it said 48h where SECURITY.md promises 7 days, and
described the maintainer's advisory path rather than the reporter's);
fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
future tense for work done 2026-07-19, #7 citing a 113-file count from
months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
(branch from dev, not main); Docs Index gains the six missing live docs;
the plugin system joins the feature list; the security row no longer
anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
longer exist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template
claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).
The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the cert-tofu spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the remediation pass and close finding statuses
- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
resolved, 04/09 partial with the remainder named, 14's keep-decision
recorded) and a remediation addendum added: what shipped, the decisions
taken (plugin host API kept, reserved protocol entries kept, e2e soak not
shortcut, the 404-on-missing-role semantics note), and the full
verification table from real runs — Go race + deadlock suites green,
4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
orphan issue moved to resolved, mock listener-registry note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)
golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)
Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)
* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed
Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.
Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): mark kicked clients offline instead of reporting them replaced
Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.
Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): force a full ready when cold-tier replay hits the row cap
GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.
Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.
maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it
updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.
1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
connection's voice state without transferring it. handleVoiceLeave never
runs there: readPump skips it when replaced, and it early-returns on
already-cleared state.
Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect
Two independent key-holder desync bugs in E2EEManager:
1. _isKeyHolder had no demotion path -- set at join, promoted on participant
leave, cleared only on voice leave. The server re-elects the lowest userID
on every join, so a lower-ID joiner left the incumbent still believing it
held the key with an armed 5-minute timer. Its rotations applied the new
key locally before the server rejected the offers with NOT_KEY_HOLDER (which
the client does not handle), so it went deaf and mute every rotation cycle.
Accepting an offer proves the sender is the server-authoritative holder, so
treat it as the demotion signal and clear the timer.
2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
nothing able to refill them: handleAnnounce replies with an offer rather
than a counter-announce, and the server relays stored peer keys only on
voice_join, which an SFU-level reconnect never runs. handleOffer's
unknown-peer guard then dropped every later rotation, stranding the
reconnector on the pre-reconnect key. The clear was also unnecessary --
peers' keys stay valid when we regenerate our own pair.
vitest 4396/4396; typecheck and prettier clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS
ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.
Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.
livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.
cargo test 80 passed; clippy --all-targets -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown
A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).
The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): route sequenced DMs through the normal FIFO
writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): remove the partial file when Save fails after create
The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge
- setupKeyExchange generated the room key AFTER draining queued
announces, so a key holder joining an ongoing call sent drained peers
no offer — they waited on the 5-minute rotation timer. Keygen now
precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
connecting was dropped (getCurrentChannelId is null for the whole
key-exchange wait), stranding the client until timeout ejection. The
manager now remembers its channel from setupKeyExchange, and the
become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
receiver side), leaving the older key active. handleOffer now chains
applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
mentionCount, the mute-immune signal DmSidebar renders) never fired
live, only after a reconnect restored the server count. The dispatcher
now bumps it under the same guards as the DM unread count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tauri): restart the LiveKit proxy when the TOFU pin changes
After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(tauri): ignore two unreachable cargo-audit advisories
RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): transfer the focused channel on WS resume so the message stream survives
registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): always include a voice room's participants in its voice-event audience
broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): tear down the failed reconnect attempt's room instead of leaking it
The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): evict voice participants before deleting a channel
CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.
Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): stop leaking an E2EE worker and SetKey listener per voice join
createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(service): reject role position collisions on update, matching create
UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): close the window where a dying connection re-takes a pubsub topic
Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)
Fixes the ifElseChain lint failure on CI for both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): restore incrementDmMention deleted as dead code on dev
The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)
* ci(server): run the tag-gated wazero/otel Go tests (DC-06)
The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): fail closed when the identity pin store is unreadable (DC-08)
getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).
getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.
Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(admin): write a backup_restore audit row that survives the restore (DC-09)
Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.
The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)
Two messaging-surface gaps the UX specs carried as open:
- channel_delete on the active channel now toasts "This channel was
deleted" alongside the existing redirect (ux/channels-members-dms
§1.2) — the redirect alone read as the app spontaneously changing
channels. Non-active deletions stay silent.
- Reactions toggle optimistically (ux/messaging §5): the pill flips on
the click, registered under the send's WS envelope id — the same
correlation scheme as the optimistic message rows. updateReaction
consumes the matching self-echo instead of re-applying it (the
delta-based arithmetic would double-count), other users' echoes apply
normally, and an error reply or local transport failure rolls back
exactly that toggle via rollbackReaction in the dispatcher's error and
send-failure handlers. The pill reverting is the failure feedback.
Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): guard the role-change submenu against double-fire (DC-12)
Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.
The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)
The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.
Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().
The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(e2e): E2EE identity-verification and updater journeys (DC-04)
The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:
voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.
updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.
Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the drag-reorder module and new e2e specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): accessibility pass over the modal/overlay stack (DC-13)
The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.
- modalFactory: every factory modal now carries role=dialog + aria-modal,
moves focus in on open, restores it on every close path, and Tab-cycles
inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
their existing headings, focus trap + restore, aria-label on icon-only
close buttons, and Escape mapped to each modal's SAFE action (reject on
the trust prompts — dismissal must never grant trust; cancel on the
channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
close; the sidebar is a vertical role=tablist with roving tabindex and
ArrowUp/Down/Home/End activate-on-focus; the content pane is a
tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
aria-activedescendant tracking the active row. QuickSwitchOverlay:
dialog + keyboard-operable rows (the inert current-server row stays
unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
(Arrow/Home/End move the single Tab stop, Enter/Space activate through
the click path). inline-autocomplete: option ids + combobox attrs and
aria-activedescendant on the composer textarea — deliberately NOT
roving tabindex, since moving DOM focus out of the textarea would
break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).
Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(ux): replace file:line anchors with symbol references (DC-15)
The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place
- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
from headline gaps to covered, the §4 UX-problem bullets closed, and a
§12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
fix, the a11y pass, the UX polish, the backup_restore audit row, the
tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): fix install-settle race in the updater e2e spec
The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)
* ci(server): run the tag-gated wazero/otel Go tests (DC-06)
The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): fail closed when the identity pin store is unreadable (DC-08)
getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).
getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.
Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(admin): write a backup_restore audit row that survives the restore (DC-09)
Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.
The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)
Two messaging-surface gaps the UX specs carried as open:
- channel_delete on the active channel now toasts "This channel was
deleted" alongside the existing redirect (ux/channels-members-dms
§1.2) — the redirect alone read as the app spontaneously changing
channels. Non-active deletions stay silent.
- Reactions toggle optimistically (ux/messaging §5): the pill flips on
the click, registered under the send's WS envelope id — the same
correlation scheme as the optimistic message rows. updateReaction
consumes the matching self-echo instead of re-applying it (the
delta-based arithmetic would double-count), other users' echoes apply
normally, and an error reply or local transport failure rolls back
exactly that toggle via rollbackReaction in the dispatcher's error and
send-failure handlers. The pill reverting is the failure feedback.
Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): guard the role-change submenu against double-fire (DC-12)
Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.
The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)
The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.
Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().
The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(e2e): E2EE identity-verification and updater journeys (DC-04)
The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:
voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.
updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.
Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the drag-reorder module and new e2e specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): accessibility pass over the modal/overlay stack (DC-13)
The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.
- modalFactory: every factory modal now carries role=dialog + aria-modal,
moves focus in on open, restores it on every close path, and Tab-cycles
inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
their existing headings, focus trap + restore, aria-label on icon-only
close buttons, and Escape mapped to each modal's SAFE action (reject on
the trust prompts — dismissal must never grant trust; cancel on the
channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
close; the sidebar is a vertical role=tablist with roving tabindex and
ArrowUp/Down/Home/End activate-on-focus; the content pane is a
tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
aria-activedescendant tracking the active row. QuickSwitchOverlay:
dialog + keyboard-operable rows (the inert current-server row stays
unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
(Arrow/Home/End move the single Tab stop, Enter/Space activate through
the click path). inline-autocomplete: option ids + combobox attrs and
aria-activedescendant on the composer textarea — deliberately NOT
roving tabindex, since moving DOM focus out of the textarea would
break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).
Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(ux): replace file:line anchors with symbol references (DC-15)
The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place
- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
from headline gaps to covered, the §4 UX-problem bullets closed, and a
§12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
fix, the a11y pass, the UX polish, the backup_restore audit row, the
tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): fix install-settle race in the updater e2e spec
The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: promote client-e2e to blocking (DC-07)
The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(server): refuse in-place self-update in container deployments
Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)
Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(admin): add the admin-panel e2e journey against a real server (DC-04)
The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): final closure — every DC finding resolved or deliberately reserved
Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(attachments): stop the orphan sweep destroying every avatar and the grace period
The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.
Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): send old_password so changing a password can succeed
The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.
The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): roll back and restart when a backup restore fails mid-copy
copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.
The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): refuse client-side unmute while server-muted by a moderator
Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.
The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): force a full ready when a client's seq is ahead of the ring buffer
EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.
That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.
Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages
channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.
auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(messages): stop persisting HTML-escaped text, safely
bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '>' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.
Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.
The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown
ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.
Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): send voice_leave when the E2EE key exchange times out
connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.
Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): keep voice state when the replay-failure fallback will transfer it
handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.
Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): stop the mic meter stream when it resolves after teardown
The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.
Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion
Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:
- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
entirely when the caller authenticated with an API token (nil session).
Both handlers now use the change-password pattern: keep=0 matches no
row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
of the purged sessions) and left a stale lapsed ban_expires in place,
which makes banned=1 read as NOT banned — together a previously
temp-banned self-deleted account stayed fully usable through any
owner-minted token. Tokens are now revoked in the purge and
anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
('Owner','Admin'), so renaming the seeded Admin role silently disabled
self-deletion protection for its holders. It now keys on the canonical
OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
(The harvest's suggested criterion — Owner ID or Administrator bit
alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
left with zero participants are removed instead of becoming
unreachable, undeletable rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon
- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
and the limiter records a timestamp per call regardless of the limit
passed — so unrelated endpoints capped each other at the minimum limit
(five ordinary profile edits 429'd the password endpoint; NAT'd logins
blocked register). The prefix is now a required parameter and every
mount names its own bucket, mirroring the existing client_update:/
livekit_proxy:/gif: pattern. The sessions-list handler also stops
401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
no rate limit, and each drives an unmetered SQLite write plus pubsub
churn; they now share a 5/s per-user budget (same underlying service
call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
mode passes windows up to the 6 h admin cap, so long slow modes were
silently reset; the cleanup horizon now covers the largest real window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects
Seven harvest findings across router, config, admin, plugin and updater:
- The global 1 MiB body cap shadowed every route with a larger documented
envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
avatar could never fit its multipart framing. The exemption list is now
a named var covering uploads, plugin install, and avatar — each of
which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
log and user list could never page past row 550; the cap is now an
explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
envKeyToKoanf cut at the first underscore, producing the unknown path
event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
upsert's DO UPDATE branch — on the shared writer connection a reinstall
returned the rowid of some unrelated prior INSERT, so EnablePlugin
no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
the previous CompiledModule; the handle is now retained on the instance
and closed in deactivate, the lost-activation race, and the
closed-module release path.
- Linux server self-update was gated on the Windows-only
chatserver.exe.sig asset it never uses; the required-asset check and
the signature fetch are now GOOS-aware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown
Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:
- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
shed frame no longer burns a sequence number that sits in the replay
buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
from an older seq takes the full-ready path instead of silently losing
the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
replacement connection holds the slot — no more users stuck online.
Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.
The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown
Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:
- setMessages merges instead of clobbering: live broadcasts and
pending/failed optimistic rows that landed while the history GET was
in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
stale server read_states cannot resurrect badges on the channel the
user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
the window instead of silently discarding the fetch (which refetched
the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
(retry affordance) instead of letting rows spin forever.
One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races
Seven harvest findings in the voice/service layer, each locked by a
watched-red test:
- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
voice_join to a permitted channel that commits while the DB-backed
permission check runs can no longer be torn down. The report's
suggested pre-check guard was rejected — it leaves the same race open
between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
session to its VoiceTopic and re-elects the key holder; without them
the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
publishes p.cmd (Wait stays outside), removing the data race between
Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
a blanket permission-cache invalidation when the member list was
unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
uniqueness and the role cap are snapshot-enforced, not DB-enforced);
concurrent creates can no longer land on the same position and tie
every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
undeleted messages — the upsert is what zeroes mention_count, so
emptied channels finally clear their badge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation
Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:
- A server-initiated voice_leave for self tears down the LiveKit session
(mic publish + E2EE key material), guarded on channel match so a
late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
before stopping local tracks, freeing the server-side max_video slot a
reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
(mirroring checkpoint 2) instead of calling the global leaveVoice,
which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
deafen — granting mic while listen-only no longer hands the channel an
unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
fires Disconnected synchronously inside the loop's own connect call),
preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
a VAD-scoped generation counter, so VAD cannot resurrect itself with a
stale threshold.
Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards
Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:
- Re-election as key holder during an in-flight rotation defers (sets
_isKeyHolder + _rotationPending, mirroring the sibling branch) instead
of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
pin write now marks the peer unverified instead of displaying
"verified" with no pin persisted — an unpinned peer could never trip
mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
changed, not just the epoch — a non-key-holder never bumps epoch, so
an offer surviving clearState() into the next session passed the
epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
permanently settled the old one, making the retry window zero), and
aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
_roomKey are ready, so a concurrent announce is queued and drained
through the offer-sending path instead of being consumed offer-less.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch R1 — secret store must not report a broken keyring as empty
Three harvest findings in the Tauri credential store, each locked by a
watched-red test:
- secret_store::get treated a keyring read error as "nothing stored"
whenever the fallback file was also empty, which is indistinguishable
from first login. loadOrGenerateIdentityKeyPair reads exactly that
signal, so an unreadable keychain made the client mint and publish a
fresh identity key over the existing one, invalidating every peer's
TOFU pin. It now prefers a fallback copy and otherwise propagates the
error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
secret went to the fallback file — and get() reads the keyring first,
so the stale value shadowed the new one forever. The write-failure arm
now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
failed, leaving a short file that every later load rejects; since the
key file is never rewritten once it exists, one ENOSPC poisoned the
fallback store permanently.
Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.
The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.
fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow
The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.
Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
and the deadlock pass a plain build/test misses, and the windows-latest
runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
truncating the NEXT query's emitted SQL, semicolons in migration comments
orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.
Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.
.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out
Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:
- The TLS cert-mismatch latch fired on any host's event, so an unrelated
saved profile's rotated certificate permanently killed this socket's
reconnect loop. It now latches only for the connected host, cancels any
pending reconnect (a timer armed before the mismatch would otherwise fire
connect(), clearing the latch and resuming against that host), and resets
on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
snapshot, and MainPage's subscriber had no else branch — the message list
and composer stayed mounted and enabled against a channel the server no
longer recognizes. Both sides fixed; the mark-read from batch C1 is
suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
disabled the unrelated 1:1 composer with that person; block gating is
1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
into the next login.
Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.
main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.
One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client
Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.
Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
events after a client's last_seq, rather than accepting the surviving suffix
as a complete resume; close the snapshot-to-registration window under seqMu;
restore the focused-channel subscription during the handshake via a new
READ-gated active_channel_id auth field; supplement replay with the client's
own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
stale-voice sweep error-aware so a transient DB failure no longer evicts
every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
voice_leave to evicted users; gate camera/screenshare permission checks on
the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
(migration 030 unlinks instead); clear personal data on account deletion;
exclude banned users from owner lookup; drop the silent 1000-member cap;
advance the author's own read state on send.
Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
pass supersession checks; scope aborted-path cleanup to the attempt's own
room; send voice_leave on connect failure; stop push-to-talk from writing the
user's explicit mute flag; gate join-time PTT muting on a new backend
capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
buttons work; fix drag-reorder's phantom-drag latch and its permission gate.
Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.
Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7
* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e
Three CI failures on the previous commit, all genuine fallout from it.
golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.
knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.
Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.
Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>