Files
OwnCord/Client/tauri-client
J3vbandClaude Fable 5 a06801d92e release: v1.1.0-alpha.5 — voice origin fixes (desktop + same-origin), log-stream API tokens (#1296)
* fix(admin): accept same-origin first-run setup requests

A freshly generated config.yaml leaves allowed_origins commented out, so the
list is empty. The setup handler's CSRF guard assumed "no Origin header means
same-origin", but browsers send Origin on same-origin POSTs too — Chrome and
Edge always, Firefox since 70. The admin panel's own setup call is one of those
POSTs, so every new install hit "cross-origin setup request blocked" and could
never create an owner account.

The guard now accepts a request whose Origin names the same host:port as the
request's own Host header, falling back to the allowlist otherwise. That is what
the original comment intended. CSRF protection is unaffected: a cross-site
attacker cannot set Origin, the browser does, and a foreign origin still needs
an explicit allowlist entry.

Scheme is not compared. Nothing in this server derives the external scheme (no
r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would
reject legitimate requests behind a TLS-terminating proxy.

Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin
cases, plus two handler-level tests pinning both halves — same-origin succeeds
against an empty allowlist, a foreign origin still 403s and creates no user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(identity): implement identity keypair caching and error handling

* fix(client): use the real OS credential store, not keyring's mock (#1281)

The `keyring` crate declares no `default` feature. Every platform arm in
its lib.rs selects a backend only when that platform's feature is on and
otherwise falls through to `pub use mock as default`, so the client's
bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS
and Linux alike.

The mock keeps its secret in the `Entry` object itself, and each command
built its own `Entry`:

  save_identity_key -> Entry::new(..) -> set_password -> Ok(())
  load_identity_key -> Entry::new(..) -> get_password -> NoEntry

So a save reported success, the very next read in the same process
returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side
logged anything, and no entry was ever written to Credential Manager on
any machine. Downstream, the voice-E2EE identity keypair was regenerated
on reconnect, the published identity key stopped matching the key that
signed the announce, and peers correctly rejected it as a possible MITM.

Name the platform backends explicitly, and stop trusting a store that
reports a write it did not keep:

- secret_store: read every write back and compare before reporting
  success. If the store returns a value we did not write, purge it so it
  cannot shadow the fallback on the next read.
- On Windows only, fall back to a DPAPI-protected file in the app data
  dir, engaged solely after a proven round-trip failure and cleared as
  soon as the real store works again. The account name is mixed into the
  DPAPI entropy so a blob cannot be moved between entries and decrypt.
  macOS/Linux report an error instead of writing secrets to plaintext.
- Log the compiled backend at startup and add `probe_credential_store`
  so an affected machine can be diagnosed from its own log file.
- Guard the regression: `compiled_keyring_backend_is_persistent` fails
  the build if the features are ever dropped again. Verified to fail
  against `keyring = "3"`.

The E2EE fail-closed posture is unchanged: a peer whose announce
signature does not verify is still rejected.

Linux builds now need `libdbus-1-dev` for the Secret Service backend.


Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58

Co-authored-by: Claude <noreply@anthropic.com>

* fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282)

* fix(client): make the settings panel do what it says

Functional review of every control in the settings overlay. Each fix below
closes a gap between what a control promised and what it did.

- Appearance: picking a theme no longer drops a saved accent colour.
  applyThemeByName strips every inline custom property from <body>, which
  includes the accent override; under neon-glow (whose body class sets
  --accent) the user's colour silently reverted until restart.
- Overlay: reopening the panel rebuilds the active tab. The Voice & Audio
  mic meter and camera preview are torn down on close, so a reopened panel
  showed a dead meter and a black preview; tabs also now re-read prefs.
  The Logs tab's live listener is released when you switch away from it.
- Status: the UserBar picker always started at "online" and never persisted,
  while the Account tab read a pref nobody else wrote — the two surfaces
  disagreed. Both now go through lib/userStatus, sync live via the
  pref-change event, and the saved status is re-asserted on connect.
- Notifications: Do Not Disturb now suppresses the desktop notification and
  the chime, as its description in the panel claims. The taskbar flash, a
  passive cue, stays.
- Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but
  unimplemented. They are wired now (voice ones only while in voice, all of
  them suspended while the settings panel is open). "Mark as Read" had no
  feature behind it at all and is replaced by the Escape behaviour that
  actually exists.
- Account: backup codes now carry a "you won't see them again" warning and a
  copy button; the change-password form requires the current password before
  spending a server attempt and disables itself while in flight.
- Advanced: removed the Hardware Acceleration toggle. Nothing read the
  preference it wrote — the webview decides GPU compositing before any JS
  runs, so honouring it needs a Rust startup change.
- The settings sidebar name/avatar follow a rename instead of going stale,
  and settings/helpers no longer keeps a drifted copy of lib/preferences
  (the copy lacked the write guard, so a failed save could throw).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(client): close silent-failure gaps in the inline admin surface

Continuation of the settings-panel review into the rest of the client.

- Member context menu had no styling at all: AdminActions renders BEM class
  names (context-menu__item and friends) that appear nowhere in the CSS, so
  the menu had no hover, no danger colour, and the "Change Role" submenu
  pushed the menu open instead of flying out. Added the missing rules.
- The submenu offered a hardcoded admin/moderator/member list. On a server
  with custom roles those roles were unreachable, and picking a name that
  didn't resolve to a role id silently did nothing. Roles now come from the
  server's ready payload (owner excluded), and an unresolvable role reports
  an error instead of dead-ending.
- Kick / ban / delete-channel now show an in-flight state, and the two-click
  confirm disarms after a few seconds so a menu left open can't turn a stray
  click into a ban (docs/architecture/ux/settings-and-admin.md §3).
- Ban collects a reason, which the server already stores and displays
  (adminBanMember has always accepted one; the menu never passed it).
- Copying an invite code was silent: no confirmation, and a clipboard
  rejection looked identical to success. It now toasts either way.
- Creating an invite double-click-minted two of them, and revoking — which
  kills a live link — had neither a confirm nor an in-flight guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(client): restore moderator message deletion and formatting

- The delete affordance was offered only on your own messages, so a
  moderator could not moderate anything from the client. It now also
  appears when the signed-in user's role carries MANAGE_MESSAGES, derived
  from the role bitmasks the server already sends in `ready` (this is what
  docs/architecture/ux/messaging.md §4 specifies as "Delete (own /
  moderator)"). lib/permissions.ts existed for exactly this and had no
  callers at all.
- Developer-mode "Copy ID" was silent on success and swallowed clipboard
  failures; it toasts either way now.
- prettier --write on AdminActions.ts (Client Static Checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): stop the panel reporting success it didn't have

Functional review of the server admin web panel.

- An expired admin session left the panel on screen toasting "invalid or
  expired session" for every action, with no way back to the login form —
  only the log-stream code handled it. api() now handles 401 centrally:
  clear the token, return to login, and say why.
- Deleting a backup called fetch() without looking at the response, so a
  failed delete reported "Backup deleted" and left the file in place. It
  now goes through api(), and — like every other destructive action here —
  asks for confirmation first.
- A failed update check rendered as "Up to date. You're running the latest
  version", which is a lie that hides a broken update path. It now says the
  check failed and why. A failed apply no longer leaves the button stuck on
  "Applying...".
- The Edit Channel modal could only rename. PATCH /channels/{id} accepts
  topic, slow_mode, position and archived, and the channel table has an
  Archived column — which was read-only state with no control behind it.
  All four are editable now.
- Banned users showed "Yes" with no reason, even though the ban reason is
  collected on ban and returned by the API. It's now displayed.
- Login and first-run setup had no in-flight guard, so a double-click spent
  two attempts against the login lockout / setup rate limit. Settings' Save
  stayed enabled after a successful save, implying unsaved changes.
- Clipboard copies (invite code, new API token) had no rejection path: a
  refused clipboard looked exactly like a successful copy.
- Backup names in inline onclick handlers go through jsq() like every other
  interpolated string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* feat(admin): add the plugin management UI the backend already had

/api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since
Phase C Step 9 — its own header says it "exposes plugin lifecycle operations
to the admin panel", and docs/architecture/ux/settings-and-admin.md tells
operators plugin management lives in the web panel. The panel had no Plugins
section at all, so installing a plugin meant hand-crafting a multipart POST.

Panel:
- Plugins section: installed table (name, manifest description and requested
  permissions, version, enabled state, install date), zip upload with the
  16 MB server cap stated up front, enable/disable, and uninstall behind a
  confirm. One lifecycle call at a time.
- The lifecycle API sits under a different prefix than the rest of the panel
  and answers errors as plain text (http.Error), not JSON, so it gets its own
  fetch helper — sharing api() would have surfaced "unexpected token" instead
  of the server's reason. 401 still routes back to login.

Server:
- PluginRow had no JSON tags, so the list marshalled Go field names and every
  column would have rendered empty. Now snake_case like the rest of the API.
- GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means
  "nothing installed" on a live runtime and "you can't install anything" on a
  disabled one; the body can't tell them apart, so the panel's empty state
  had no way to be honest about it.

The plugin-store test helper now hands back the database the registry writes
to — the existing happy-path test wired a *different* in-memory DB into the
handler, which is why nothing noticed the list was always empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* feat(client): gate the composer on slow mode instead of failing the send

Verified the optimistic message lifecycle against docs/architecture/ux —
pending → chat_send_ok → sent, failed rows with mapped reasons, retry and
delete-draft all behave as documented. One thing did not: slow mode.

The UX spec (§5) says slow mode should "disable send with a live countdown in
the composer; do not drop the drafted message". In practice the composer knew
nothing about it: you typed, sent, and got a red failed row back — the exact
enabled-then-rejected pattern §6.2 forbids. The client never even received the
channel's slow_mode value.

- Server: channel payloads (ready, channel_create, channel_update) now carry
  slow_mode alongside can_send, for the same reason can_send is there — the
  client can express the limit as affordance. The server still enforces.
- Client: after an accepted send the composer disables itself for the channel's
  cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the
  full window (the server's limiter is the authority on when the next send is
  allowed). The draft stays in the textarea. Moderators, who bypass slow mode
  server-side, are not gated.
- The MANAGE_MESSAGES lookup added for moderator deletes moves into
  lib/permissions as currentUserPermissions/currentUserHasPermission/
  canManageMessages, so the composer and the message renderer share one
  definition instead of two.
- WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT,
  BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and
  ALREADY_JOINED were missing, so code switching on it could not name cases
  the server actually sends. Now mirrors Server/ws/errors.go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): make backup restore actually restart, and fail closed without a safety copy

Verification pass over the remaining review items. Two real defects in restore,
one duplicate resolved; cert TOFU and the replay path checked out as-is.

Restore:
- The handler closed the database, swapped the file underneath it, told the
  admin "database restored — server restarting", broadcast a 5-second restart
  countdown to every client... and then kept running. Nothing restarted it, so
  the server answered every subsequent request against a closed DB until an
  operator noticed. It now respawns for real, reusing the update-apply pattern
  (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam.
- A failed pre-restore backup was a warning, and the irreversible overwrite
  went ahead anyway — removing the safety net the panel explicitly promises
  ("A pre-restore backup will be created"), precisely when it matters. It now
  aborts with the database untouched.
- The safety copy was written to a cwd-relative "data/backups" while every
  other backup handler uses the absolute backupBaseDir, so a server started
  from another directory filed it somewhere the operator would never find.

Both new tests were confirmed to fail against the previous behaviour.

Client:
- SidebarArea kept a private 140-line copy of the member-list wiring that
  SidebarMemberSection already provides (the extracted, tested one was never
  imported). Fixing the silent role-change failure earlier meant patching both;
  now there is one copy.

Verified without changes: the optimistic send lifecycle (pending →
chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft),
reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression
of unread/notifications), and cert TOFU (first-use and mismatch modals, accept
re-pins and reconnects, reject disconnects back to connect).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): remove the data race in the restart test hook

CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_
Success polled a plain bool that the restore handler's goroutine wrote, and
swapped the restartSelf package var from the test goroutine while that handler
read it.

The hook is now behind a mutex with an atomic flag in StubRestart. Production
behaviour is unchanged — the race was entirely in the test seam I added.

Verified with `go test -race -count=2 ./admin/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

---------

Co-authored-by: Claude <noreply@anthropic.com>

* refactor + perf: split largest source files into modules; optimize hot paths (#1283)

* refactor(updater): split updater.go into cohesive files

Split the 1070-line updater.go into four files within the same package:
updater.go (core types, release checking), download.go (download and
tarball extraction), verify.go (signatures, checksums, staged binary),
and assets.go (client assets, text-asset cache, HTTP fetching).

Pure mechanical move — no behavior or API changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(ws): split hub.go into cohesive files

Split the 1289-line hub.go into five files within the same package:
hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go
(broadcast fan-out and per-user sends), hub_events.go (sequencing,
replay, persistence), hub_sweep.go (stale client/session/voice
sweepers), and hub_livekit.go (LiveKit accessors).

Also optimizes wrapWithSeq on the hot broadcast path: build the seq
prefix with a single preallocated append + strconv.AppendUint instead
of fmt.Sprintf, halving allocations per broadcast message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(client): extract E2EEManager from livekitSession

Move all client-side E2EE key-exchange logic (~550 lines) out of
LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH
keypair management, identity signing and TOFU pin verification,
announce/offer handling, key-holder election, membership rekeying, and
periodic key rotation. Dependencies are injected following the existing
roomEventHandlers pattern.

LiveKitSession keeps thin public delegates (handleE2EEAnnounce,
handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the
module-level bound exports and the public API are unchanged.
livekitSession.ts shrinks from 1955 to 1409 lines.

Adds focused unit tests for E2EEManager (key-holder setup, pending
announce queue, offer resolution, clearState, rotation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(server): hot-path and query optimizations

Logging (biggest win): rewrite the admin log RingBuffer as a true ring
(fixed array + head/count) instead of allocating a fresh 2000-entry
slice + full copy per log line; gate the ring handler on a configurable
level instead of unconditional DEBUG capture; move the broadcast debug
log out of the seqMu critical section; drop the per-message slog.With
clone in the WS handler.

Database: new migration 019 adds idx_attachments_message (message pages
no longer scan the attachments table), a covering role-leading index on
channel_overrides (replacing a duplicate of the UNIQUE auto-index), a
partial index for pinned messages, and narrows the FTS trigger to
content changes only; ANALYZE runs after migrations. Rewrite
GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries
that range-scan idx_messages_channel — O(unread) instead of O(all
messages) per WS connect. New GetUserDMChannelIDs replaces the full DM
query where only IDs are needed. CreateMessage/EditMessageContent use
RETURNING, removing the re-read after every send/edit.

Write-path contention: TouchSession throttled to once per minute per
session (was one UPDATE per authenticated request); EventPersister
flushes its batch in a single transaction with per-row fallback;
revoked-session and stale-voice sweeps run off the hub dispatch
goroutine with an in-flight guard, and session checks are batched into
one IN query; the rate limiter is sharded into 32 buckets with
allocation-free strconv key building (auth.Key).

WS structural: voice E2EE channel fan-out goes through the existing
pubsub voice topic instead of scanning every connected client under
h.mu; channelReadAudience memoizes role lookups per call;
hasChannelAccess drops its redundant duplicate permission check;
voice_join batches SPEAK/VIDEO/SCREENSHARE checks via
HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics
stop allocating via Sprintf/global mutex.

Verified with go test -race across all packages, go vet, gofmt, and
sqlc generate idempotency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(client): render-path, logging, and bundle optimizations

Logging: the logger no longer runs permanently at debug — level is set
from the environment at startup (debug in dev, info in prod), so every
hot-path debug entry stops being serialized, buffered, consoled, and
persisted to disk; per-URL debug logs in embed rendering removed.

Render path: MessageList's store selector is scoped to the mounted
channel, so messages in other channels no longer trigger re-renders,
and a new incremental tail-append fast path appends rows instead of
tearing down the whole window; Intl.DateTimeFormat instances are cached
at module level; parseTimestamp memoizes epoch millis; media prefs
(showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with
pref-change invalidation; members store gains a roleRevision counter so
MessageList stops rebuilding a role map on every presence/typing event.

MemberList patches presence changes in place (status dot + offline
class) via a row map instead of rebuilding every row, with single-pass
role grouping. ChannelSidebar splits its voice subscription into a
structural selector (excluding speaking) and a speaking-only patcher
using a cached element map instead of per-user querySelector on every
speaker event.

Memory: GIF/media elements are unobserved before the message window
discards them, fixing unbounded IntersectionObserver retention of
detached DOM (including frozen-frame data URLs).

Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic
imports and manualChunks; the READY handler's stale-voice check reads
the voice store instead of requiring the module synchronously.

Adds 11 focused tests (different-channel no-rerender, append fast path,
media release, presence patch, speaking patch). Full unit suite:
3606/3606 passing; typecheck, lint, and production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284)

The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate:
under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock
allocates, so the steady-state ring write measures 1 alloc/call. Extend
the build constraint to !race && !deadlock — the test's guarantee is
about the ring buffer itself, which the -race-less default pass covers.

Make bcryptCost a var with an exported SetCostForTesting hook that also
resets the dummy timing pad, and call it with bcrypt.MinCost from the
api, auth, and admin TestMains. Password hashing at production cost 12
dominated those suites (~264 hashes): with the race detector the api
package alone took ~860s; it now runs in ~33s. Nothing under test
depends on hash strength, and no test asserts the cost.

Hygiene in the same pass: migration 020 drops idx_sessions_token and
idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure
write overhead) with updated db_test assertions; remove the dead
tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg
since Go 1.11); gofmt storage/storage.go comment alignment.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* perf(ws): route hot-path permission checks through the cached PermissionService (#1285)

The ws package was the only major subsystem still doing live per-check
permission queries (GetRoleForUser + GetChannelPermissions per check):
a V2 voice join cost 9+ DB reads across its four gates, and every
channel broadcast resolved one role query per connected client.

Hub now holds svc.Permissions and the voice deps carry it (nil-safe:
bare test fixtures fall back to the existing live path, fail-closed
semantics preserved everywhere). Converted sites: the voice join and
token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls,
requireChannelAccess, channelReadAudience, and RefreshChannelVisibility.

Caching these is revocation-correct: every permission-changing mutation
already invalidates synchronously before hub fan-out (InvalidateUser on
role change, InvalidateAll on override change), the 30s TTL is only a
backstop, and the service's gen-counter guard prevents a populate that
races an invalidation from caching stale data — the audience-resolution
comments now document that invariant. The stale-voice sweeper's check
deliberately stays live: it is the last-line backstop for revocations
that might bypass an invalidation hook, runs once a minute for only
in-voice clients, and its eviction test pins exactly that guarantee.

requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the
cache only for positive verdicts and falling through to the live path
on denial.

Adds perm_cache_test.go: role-change invalidation is immediate (no TTL
wait), and a counting-store test proving the second check is served
from cache. All pinning tests (authz, voice_perm_stale, channel
visibility agreement, sweep eviction) pass unmodified.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286)

* perf(db): batch audit writes through an async writer

Audit inserts ran synchronously on the request path — including one
INSERT per WebSocket connect — each an implicit transaction on the
single SQLite connection.

WriteAudit keeps its exact signature and D8 policy (never fail the
caller, never silently discard): it now upgrades to an async path when
the passed Auditor also implements AsyncAuditor. *DB implements that
via an atomic pointer that main.go populates at server startup with an
AuditWriter modeled on the event persister (bounded queue, batched
single-transaction flush with per-row fallback, drain-on-stop, atomic
counters, non-blocking enqueue that error-logs drops without leaking
the detail field). The token CLI and tests never install a writer, so
they keep today's synchronous behavior with zero call-site changes.

The writer's Stop defer registers after database.Close's so the LIFO
unwind drains the queue before the DB shuts.

Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop,
flush-failure accounting, poison-row fallback, concurrent enqueue, and
seam tests pinning sync-without-writer vs async-with-writer behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(client): actually defer livekit-client; honor saved log level at startup

The manualChunks split was cosmetic: index.html modulepreloaded the
531 kB livekit chunk and the entry statically imported it. All four
import chains from startup are now cut — auth.store's logout leaveVoice
and ptt's setMuted go through dynamic imports, applyStoredAppearance
moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the
settings tree (whose overlay now loads on first open), and MainPage
itself is a dynamic import in renderPage, guarded against the
destroy-before-mount race by a navigation-generation helper and
pre-warmed once the socket connects.

Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no
modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession
load as lazy chunks.

The logger now honors the Logs tab's saved minimum level at startup
(applyStoredLogLevel with the legacy-key migration moved into
lib/preferences.ts) and re-applies it live on pref changes.

Dead code: remove unreachable VoiceChannel.ts (superseded by
ChannelSidebar's renderer) and its test, plus all knip-flagged unused
re-exports in message-list/renderers.ts and ConnectPage's unused form
types — knip is now clean apart from pre-existing config hints.

Tests: +12 (navigation guard incl. stale-mount discard; logger startup
pref, migration, and live re-apply); ptt/stored-appearance updated for
dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint,
and production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(db): split SQLite into single-writer + multi-reader connection pools

The entire server serialized on one SQLite connection: every read
queued behind every other read and every write, throwing away WAL's
concurrent-reader capability.

File-backed databases now open two pools from a DSN that carries all
seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA
only configures one arbitrary pooled connection — moving them into the
DSN is what makes >1 connection safe, foreign_keys included): a
single-connection writer with _txlock=immediate, and a reader pool
sized max(4, NumCPU). In-memory databases keep the exact historical
single-connection behavior, which preserves every :memory: test site
and the connection-scoped PRAGMA-toggle tests untouched.

Routing lives in a dbtx router implementing sqlc's DBTX: statements go
to the reader only when provably read-only (leading SELECT/PRAGMA after
skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE
... RETURNING through QueryRowContext/QueryContext, which must stay on
the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and
the SQLDb() escape hatch all pin to the writer. Every former sqlDB
reference across the package was re-pointed deliberately.

New pool_test.go pins the properties the split must preserve on a
file-backed DB: foreign_keys=1 across many reader connections, WAL
journal mode, FK enforcement through both write paths, 8x8
concurrent reader/writer hammering with exact row counts, and a read
completing against the pre-tx snapshot while a write transaction is
open — the property this change exists to unlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* test(client)+chore: split the two largest test files; eslint 10; audit clean

Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect /
ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module,
and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain /
vad-worklet / vad-fallback files. Test bodies moved verbatim; the
suite count is unchanged at 3593 passing.

Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer
range already covers v10, flat config unchanged, zero new findings)
and pin test-exclude ^8 via the existing overrides block so the
coverage chain picks up patched glob/minimatch/brace-expansion.
npm audit: 8 high -> 0 vulnerabilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(server): split remaining large files; dependency hygiene notes

Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers /
chat / voice / voice_lifecycle / misc test files — bodies verbatim,
746 passing tests before and after. Split service/message.go (781)
into message_crud / message_reactions / message_query / message_perms
with types and the constructor staying put, and ws/serve.go (754) into
serve / serve_pumps / serve_auth / serve_ready.

Dependency findings (no changes needed): coraza-coreruleset's stale
Feb-2024 pseudo-version is unreachable from our code — it enters the
module graph only through coraza's own internal tests, and our WAF uses
inline directives, never the CRS (fresher rules would require adopting
the /v4 module and rewiring the WAF config — deliberate follow-up, not
hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and
never built into our binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* style: satisfy golangci-lint modernize/staticcheck in new pool and audit code

CI's golangci-lint pass (not run locally until now) flagged the
Phase 3/4 additions: range-over-int loops, interface{} -> any on the
dbtx router, WaitGroup.Go in the pool tests, and a De Morgan
simplification in isReadOnlySQL's identifier-boundary check. Pure
style — verified against the same golangci-lint v2.11.3 binary CI
uses (0 issues) and re-ran db/ws race + deadlock suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287)

* fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile

cargo-audit identified the two Dependabot alerts on the default branch:
quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic
runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded
namespace allocation DoS), fixed in >=0.41. Both were transitive:
plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via
notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves
to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml
entirely. cargo-audit is now clean of vulnerabilities; the remaining
20 informational notices are the unmaintained GTK3-binding crates
inherent to Tauri v2 on Linux. Verified plist compiles against
quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI
installs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* feat(waf): layer the maintained OWASP Core Rule Set onto the WAF

The WAF previously ran six inline directives only — the CRS never
loaded (the old coreruleset dep was a stale graph-only pseudo-version).
A second Coraza engine now loads the embedded CRS from
coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules,
which stay byte-identical and keep blocking exactly as before.

CRS ships in a new server.waf_crs_mode knob (off|detect|block),
defaulting to detect: chat traffic is CRS-false-positive-prone (a new
test pins that block mode rejects benign SQL-ish chat prose at the
default threshold), so operators get rule-match visibility via
structured logs first and opt into blocking after tuning. Setup
mirrors the official connector: Host/Transfer-Encoding restored to the
transaction (else 920280 fires on everything), phase 2 always runs so
query-string attacks are scored, PUT/PATCH/DELETE added to the CRS
method policy for this REST API, body limits matched to the app's
1 MiB cap with uploads excluded from body access and the content-type
policy.

Also fixes a latent middleware bug: the body was previously swapped
for the buffered reader even when nothing was buffered, which would
have handed body-access-off routes an empty body; now pinned by a test
across all modes.

Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection
without blocking, block-mode blocking + benign passthrough, upload
body preservation); waf_test.go passes unmodified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* test(ws): replace fixed sleeps with condition-based waits

The ws suite paced async hub effects with 537 fixed time.Sleep calls —
slow at best, flaky under load at worst. They are now condition-based:
a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set
(waitRegistered exploits the hub's in-order client-event processing),
plus blocking decode-scans for the DM tests.

The bulk deletion is grounded in verified production facts, unchanged
by this commit: sendMsg is a synchronous buffered send (error replies
are already buffered when the handler returns), the voice control /
rollback / cleanup / sweep paths are synchronous, and serve.go
registers the client before writing the ready frame. Absence
assertions were deliberately NOT inverted into polling — they keep
bounded windows, each commented.

20 sleeps remain, all justified in place: poll intervals inside
condition loops, absence windows, clock-granularity pacing, and the
event-pruner's inherently time-based no-prune-after-cancel assertion.

Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the
remainder is GracefulStop's hard-coded production 5s drain, out of
scope here); race flake check passes 3 consecutive iterations;
deadlock pass and golangci-lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288)

* fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit

WAF detect mode wired logCRSMatch as the engine-level error callback, which
fires one slog.Warn per matched rule on the request goroutine. In the default
detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly
scoring, so each request logged a burst of Warn lines in the hot path.

Aggregate per request from per-transaction state instead of the shared global
callback: in the default detect path leave the engine error callback nil and,
in the existing crsTx defer, emit at most one Warn per request that had matches
(count + highest-severity rule), demoting the full rule-id list to Debug.
Block mode keeps per-rule logging (blocked requests are rare and their detail
is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery
so existing tests stay unmodified. Detection, interruption, and body handling
are unchanged — only the detect-path logging shape.

The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow
flush the 5s ctx could win, returning while run() was still flushing. main.go's
LIFO defers then closed the DB pool under a live flusher, losing audits. Stop
now always waits on done (the goroutine has stopped touching the store) while
ctx bounds only the drain inside run() via a published stopCtxDone channel, so
a slow store delays shutdown by at most one in-flight flush and the pool is
never closed under a live writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* fix(client): plug listener leaks, guard lazy livekit load, honor saved log level

Follow-up audit of the recently-landed lazy-livekit and session wiring found
three real issues:

- clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice
  on every logout, pulling the ~531 kB livekit chunk into the logout path even
  when no voice session was ever active. Guard the import on an active voice
  session (currentChannelId set and status not idle) and add a .catch so a
  failed teardown import can't reject unhandled.

- The onStateChange handler unsubscribed session listeners only on the ready
  transition, not on disconnected; user_update and ready listeners registered
  per session were never collected for cleanup. Collect them into a
  sessionUnsubs array cleaned up on both ready and disconnected, preventing
  duplicate handlers accumulating across reconnects.

- The Logs tab min-level select ignored the persisted log level when no
  explicit dropdown preference was saved. Add logger.getLogLevel() and default
  the select to it so the UI reflects the level actually in effect.

Also add .catch to the ptt setMuted dynamic import. New unit tests cover the
clearAuth guard, getLogLevel, and the LogsTab default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289)

The CRS WAF engine failed to initialize on Windows, taking the whole api
package's test suite red there. coraza's seclang parser resolves Include
globs through path/filepath: for every match of `Include @owasp_crs/*.conf`
it calls filepath.Join(currentDir, match), which on Windows rewrites the
forward slashes to backslashes. It then feeds names like
`@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS
is the ruleset's embed.FS, which is always forward-slash and rejects a
backslash name, so newCRSWAF returned "file does not exist" and no CRS rule
under a subdirectory was ever loaded.

Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/
Glob) that converts backslashes to forward slashes before delegating. This
fixes CRS loading on Windows without patching coraza or the ruleset module and
is a no-op where the separator is already "/". The Linux-only local
verification for the CRS work missed this because coraza never emits
backslashes there.

The new test reproduces the failure mode on any OS by constructing the exact
backslash name coraza produces on Windows: the raw ruleset FS fails to read
it, the wrapper resolves it, and a forward-slash path still works.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291)

The Client E2E CI job never completed: every run hit its 25-minute cap and
was cancelled. ~229 of the 255 web tests were failing, all cascading from
the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout
burn on 1 worker deterministically exceeds the cap.

Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts
now awaits invoke("start_http_proxy") and builds REST URLs as
http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for
the unstubbed command, so every URL got a literal "null" port and Request
construction threw before the mocked plugin:http transport was consulted.
Login rejected, [data-testid='app-layout'] never mounted, and every
logged-in test burned its full timeout. Stubbing start_http_proxy with any
numeric port fixes the cascade because route matching is substring-based.

The tail of failures after that fix were tests asserting behavior the app
intentionally changed:

- The ready payload can no longer pre-connect the local user to voice: the
  dispatcher treats "self in ready.voice_states while idle" as stale state
  from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote
  users only (2, 3), and widget tests join through the real click path via
  a new joinVoiceChannelByName helper.
- The mock's voice_join reply no longer includes a voice_token: a token
  starts a real LiveKit session that deterministically self-destructs in
  the browser mock (E2EE key exchange timeout ~15s / connect-refused
  retries), tearing the widget down mid-test. These web tests validate the
  WS/UI layer only; real LiveKit is covered by the native suite. The reply
  also gained the full VoiceStatePayload shape — the sidebar renders
  user.username directly, and the omitted field broke the whole voice-user
  list render.
- Message-load failure now renders an inline region error + Retry instead
  of a toast (UX spec 2), so the toast specs assert the inline UI and get
  their auto-dismiss vehicle from the delete-confirmation toast.

CI hardening so a future systemic breakage can never burn the full cap
again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now
self-terminates with a usable report instead of being SIGKILLed), with the
workflow's timeout-minutes 25 as the outer backstop. The job stays
continue-on-error until it has proven stably green across a few pushes;
the ci.yml comment documents that flip trigger.

Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2).
Unit tests (3598), typecheck, and prettier all clean.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290)

* feat(admin): first-run setup wizard with config.yaml write-back

Turn the single-screen owner-account setup into a guided multi-step wizard
so non-technical operators never have to hand-edit YAML:

- config: new comment-preserving config.Save (yaml.Node round-trip, atomic
  temp+rename write, verified loadable before replacing the file) plus a
  shared config.DefaultPath. Persists the runtime-generated LiveKit
  credentials so voice tokens survive restarts.
- admin: POST /admin/api/setup accepts an optional "wizard" object
  (server name, MOTD, registration, port, TLS mode/domain, upload limit,
  voice quality). Values are validated before the account is created; DB
  settings and config.yaml are written after; failures downgrade to
  warnings so the created owner is never orphaned behind a 5xx. When a
  startup-only value changed the server restarts itself (reusing the
  backup/update restart machinery) and returns the new admin URL.
- admin: GET /admin/api/setup/status now returns secret-free prefill
  defaults while setup is pending.
- admin panel: six-step wizard UI (welcome, account, server basics,
  uploads & voice, access, review) with plain-language explanations, a
  restart/reconnect screen, and a "skip" path that keeps the legacy
  account-only flow byte-for-byte.
- legacy payload {username,password} and all existing call sites keep
  working (SetupOptions is a trailing variadic parameter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* feat(voice): auto-download the LiveKit server binary

Voice now works with zero manual setup: when voice.auto_download_livekit
is enabled and no voice.livekit_binary is configured, the server fetches
the pinned livekit-server release (v1.13.5, overridable via
voice.livekit_version) from the official LiveKit GitHub releases in the
background at startup, verifies it against the release's checksums.txt,
extracts it into data/livekit/, and manages it as the existing companion
process (crash recovery, health checks, graceful shutdown).

- ws: new livekit_download.go — pinned version, per-platform asset
  mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's
  goreleaser config), size-capped downloads, hash verification and
  extraction through one open handle (TOCTOU-safe), O_EXCL staging,
  atomic rename, stale-version cleanup. LiveKitProcess.Start resolves
  the binary asynchronously with retries so boot is never blocked.
- config: voice.auto_download_livekit + voice.livekit_version; enabled
  in the generated default config so fresh installs get working voice
  out of the box, while the compiled-in default stays off for existing
  configs. config.Load now loads the default file it just wrote, so the
  first boot runs with exactly the configuration the file documents.
- wizard: "Voice chat" toggle (on by default) in the Uploads & voice
  step; the choice is written to config.yaml and factored into the
  restart decision.
- docs: livekit-setup, server-configuration, deployment, README.

Verified end-to-end against the real v1.13.5 release: download,
checksum match, extraction, and process spawn all succeed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* chore: remove stray server.log, ignore local run logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293)

* fix(voice): accept the desktop client's webview origins on the LiveKit proxy

The desktop client's chat connection goes through its Rust proxy, which
sends no Origin header, so the safe-default empty allowed_origins never
blocked it. The LiveKit JS SDK's signal requests and validate probes,
however, are issued directly from the webview and carry its fixed origin
(http(s)://tauri.localhost on WebView2, tauri://localhost on
WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and
returned 403, so on every default install voice failed for any desktop
client that wasn't on the server machine — chat worked, voice didn't,
with /livekit/rtc/v1 403s in the server log.

Treat these fixed first-party origins as always allowed. This is the
same trust already extended to absent-Origin requests: web content can
never present them (browsers resolve *.localhost to loopback and cannot
reach the tauri:// scheme), so the CSRF surface is unchanged. Exact,
case-insensitive matching only — lookalikes (tauri.localhost.evil.com,
tauri.localhost:8080) still require an explicit allowlist entry.

Operators no longer need to hand-add these origins to
server.allowed_origins for voice to work; that list is now only for
web/browser clients. Docs and the generated config comment updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* chore(client): bump version to 1.1.0-alpha.5

The v1.1.0-alpha.4 release shipped client artifacts still versioned
1.1.0-alpha.3 because the client manifests were never bumped — deployed
desktop clients therefore consider themselves up to date and never
auto-update. Bump package.json, package-lock.json, tauri.conf.json,
Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's
clients update normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* ci(release): fail the release when client version does not match the tag

Guards against the v1.1.0-alpha.4 mistake recurring: a new
verify-versions job compares the pushed tag against tauri.conf.json,
package.json and Cargo.toml and fails before any build starts; every
build job now depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): allow API-token principals to use the SSE log stream (#1294)

The log stream was session-only: POST /admin/api/logs/ticket required a
*db.Session in the request context (deliberately nil for API-token
principals), and the stream handler re-validated the ticket hash against
the sessions table alone. API tokens could reach every other
/admin/api/* route but not the log stream, breaking the mcp-introspect
server_logs tool that docs/mcp-introspect.md documents as working.

Bind tickets to the hash of whichever bearer credential authenticated
the request, and resolve it in the stream handler via
auth.ResolveTokenHash — the same session-first, API-token-fallback path
the admin middleware uses. Ban, role demotion, and mid-stream revocation
of either credential kind cut the stream exactly as before.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295)

A page served by the server itself (e.g. a browser client at
https://<server>:8443) chats fine but cannot join voice: browsers attach
the page origin to every WebSocket handshake, and the LiveKit proxy's
hand-rolled isOriginAllowed only recognized "no Origin" as same-origin,
so the RTC upgrade 403'd while same-origin fetches (which omit Origin)
succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the
server log.

Allow an Origin whose host equals the request Host, mirroring
websocket.Accept's default same-origin policy that the chat WS endpoint
already applies — which is exactly why chat worked and voice didn't. Web
content on another origin can never present this origin (the browser pins
it), so the CSRF surface is unchanged. Same host on a different port
remains cross-origin and denied.

Also log rejected origins on the 403 path (origin, path, remote) —
this failure was previously undiagnosable from the server log, which
recorded the 403 but not the offending origin.

Existing allowlist tests used origins colliding with httptest's default
request host (example.com), which the new semantics correctly treat as
same-origin; their fixtures now use distinct hosts so they keep
exercising the allowlist path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): curate v1.1.0-alpha.5 entries — voice origin fixes, log-stream API tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:30:31 +02:00
..