Commit Graph
258 Commits
Author SHA1 Message Date
J3vb 868306a06d Merge pull request #1194 from J3vb/feat/dm-block-gating-updater-progress
feat(client): DM block composer gating + updater download progress
2026-07-20 12:39:35 +02:00
J3vb 723c1e591b Merge pull request #1193 from J3vb/feat/voice-e2ee-status
feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect
2026-07-20 12:39:11 +02:00
J3vbandClaude Fable 5 ca91d28561 feat(updater): surface download progress in the update banner
The Rust download callback was a no-op, so "Downloading update…" looked hung
for large binaries (settings-and-admin.md §5). download_and_install_update now
accumulates received bytes and emits an `update-progress` event
({ received, total }) to the webview. downloadAndInstallUpdate(serverUrl,
onProgress) listens for it and UpdateNotifier renders a percentage when the
total is known, falling back to bytes (MB) until Content-Length arrives.

Rust change is minimal and CI-gated only (not built locally per policy). Adds
TS tests for the formatter and the banner wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:19:42 +02:00
J3vbandClaude Fable 5 a546af2d4f feat(dm): gate DM composer on block state with spec reasons
Wire DM block state into the existing disabled-with-reason composer mode
(channels-members-dms.md §3.2). A new blocks.store holds two directions:

- blockedByMe (from GET /blocks on every ready) -> "You've blocked this
  user. Unblock to send messages."
- blockedByThem (inferred from a refused DM send: ErrBlocked -> FORBIDDEN,
  cleared on the next ready) -> neutral "You can't message this user right
  now.", never revealing the block explicitly.

ChannelController reads dmComposerBlockReason(recipientId) and subscribes to
blocks.store so an unblock (shrunken GET /blocks) re-enables the composer
live; blockedByMe takes precedence when both apply. Adds api.listBlocks(),
threads an optional api into wireDispatcher, and covers both directions plus
un-gating in blocks-store / channel-controller / dispatcher tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:19:31 +02:00
J3vbandClaude Fable 5 544360642e fix(voice): freeze sidebar voice join/leave row on WS reconnect
The WS-reconnect control freeze (spec item 6) only reached the VoiceWidget's
in-call controls. The actual join affordance — clicking a voice-channel row in
ChannelSidebar — stayed a plain clickable div with no disabled state, so a click
while the socket was reconnecting/disconnected was a silent no-op (only the
VoiceCallbacks socketLive() backstop stopped the send).

Gate renderVoiceChannelItem on ui.store.connectionStatus using the same
disabled-with-reason pattern as VoiceWidget: apply a .disabled class,
aria-disabled, and a "Reconnecting…" / "Not connected" title while not connected,
and make the click a no-op. Subscribe the sidebar to connectionStatus so the row
freezes/unfreezes reactively (mirrors the existing collapsedCategories selector).

Docs: README.md §3 callout now notes the sidebar join affordance takes the
disabled-with-reason state too; voice-and-e2ee.md lists ChannelSidebar.ts as a
source of truth for the freeze.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:53:48 +02:00
J3vbandClaude Fable 5 827eea77ed feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect
Add a store-backed voice.store.voiceStatus (idle | joining | securing |
connected | reconnecting), written as the single source of truth from
livekitSession at each lifecycle transition: joining at connectAndSetup start,
securing when ECDH key exchange begins, connected on the connected transition
(initial join and auto-reconnect), reconnecting when the room drops, idle on
leaveVoice. joinVoiceChannel seeds joining optimistically on click.

VoiceWidget renders the phase in its header: 'Connecting…' / 'Securing…' (amber)
and a persistent '🔒 Secured' badge once the room key is ready, replacing the
log-line-only E2EE feedback. While ui.store.connectionStatus is not 'connected',
the widget disables its controls with a 'Reconnecting…' / 'Not connected' reason,
and the VoiceCallbacks join/leave paths refuse to send over a down socket.
LiveKit's own reconnection machinery is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:31:17 +02:00
J3vbandClaude Fable 5 80903a4ccb fix(client): revoke server session on user-initiated logout
api.logout() (POST /auth/logout) existed but was never called, leaving the
bearer token valid server-side after a client-local logout. Add a small
logout() helper that fires the revocation best-effort — fire-and-forget with
its rejection swallowed — then runs clearAuth() synchronously, so a slow,
offline, or rejecting server can never block or delay the local logout. Wire
it into the settings Log Out button. Tests pin both paths: logout is called,
and local logout still completes when the request rejects or never settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:39:12 +02:00
J3vbandClaude Fable 5 93850689a2 fix(client): unify role lookups onto the dispatcher-updated store
SidebarMemberSection read role name->id from a parallel roles.store that
nothing ever wrote to — only channels.store.setRoles is updated by the
dispatcher on `ready`. Repoint the reader at channels.store and delete the
dead roles.store (its setRoles/getRoleIdByName coverage already lives in
channels.store.test.ts). Adds a regression test pinning that the member UI
resolves role ids from the store the dispatcher writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:39:01 +02:00
Claude 45e51cca1c fix(client): review follow-ups for the connection-status batch
Fixes from an adversarial review of the previous commit:

- MainPage banner: sync the banner with the current store status at mount.
  The selector subscription baselines on the current value and only fires
  on change, so a MainPage mounted mid-outage (status already
  "reconnecting") would never show the banner — the whole retry cycle maps
  to the same 3-state value. The status→banner dispatch is extracted to
  ServerBanner.applyConnectionStatus and unit-tested.
- History-fetch failure is no longer silent when the channel already has
  rows (live broadcasts / optimistic sends): the inline error region only
  renders in an empty channel, so loadMessages now also raises a toast in
  that case.
- Composer disable reason distinguishes "Reconnecting…" from
  "Not connected" per the spec §3 table (it previously showed
  "Reconnecting…" while disconnected, contradicting the banner).
- The single-writer wiring is extracted to
  dispatcher.wireConnectionStatus(ws) and pinned by a test (it was
  previously an untestable main.ts module-scope line — deleting it would
  have failed zero tests).
- Docs honesty: messaging.md's transport-drop diagram arm now shows both
  codes (channel full → NETWORK, closed/not-open → OFFLINE) instead of
  claiming NETWORK for both; README §3's callout now explicitly lists the
  voice column ("frozen" during reconnect) as a remaining gap instead of
  implying the section is fully closed; the composer table documents both
  offline reasons.
- New pinning tests: SidebarArea passes ws to UserBar (the production-bug
  fix was previously unasserted), ServerBanner.showDisconnected,
  applyConnectionStatus mapping, ChannelController onRetryLoad /
  onRetry-resend / onDeleteDraft, composer reason per status, and the
  history-failure toast fallback.

Verified: tsc + full client unit suite (3234 tests) + oxlint/eslint +
prettier all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:42:36 +00:00
Claude fc5a94cb50 feat(client): connection-status store + no-silent-failure batch
Implements the next four gaps from the client UX spec (docs/architecture/ux).

Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
  (new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
  MainPage reconnect banner (which also gains a "Disconnected" state via
  ServerBanner.showDisconnected instead of going stale), ChannelController
  composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
  the status picker was permanently disabled and its presence_update path
  dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
  (it needs the exact internal transition); LiveKit voice reconnection stays
  independent ("retrying underneath").

Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
  the envelope id, and failures notify a new onSendFailure(id, code)
  listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
  microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
  like a server error reply; id-less sends (heartbeat) and fire-and-forget
  sends (typing, presence) stay silent by design. MessageList renders the
  new NETWORK reason ("Connection problem — message not sent").

uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
  like every other REST call; main.ts sets the "Your session expired — sign
  in again." transient error so the connect page shows the reason.

History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
  = idle) with setChannelLoading/setChannelLoadError; setMessages and
  clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
  fetch and marks error inline instead of a toast; MessageList renders an
  in-region spinner placeholder or an inline error + Retry (onRetryLoad
  re-invokes loadMessages via ChannelController).

Also fixes two pre-existing eslint errors in api.ts (redundant assertions).

Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).

Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:08:19 +00:00
Claude e0ab0744ee feat(client): optimistic message send + composer permission gating
Implements the two highest-impact gaps from the client UX spec.

Optimistic send:
- messages.store gains addOptimisticMessage / markSendFailed /
  removeOptimistic, and confirmSend now stamps the real id + "sent" on
  the ack. addMessage reconciles the broadcast by real id (idempotent,
  replay-safe) with a defensive author match, so an echo never
  duplicates. Message gains status/correlationId/errorCode.
- ChannelController.performSend renders a pending row immediately and
  supports retry / delete-draft (retry preserves attachments).
- MessageList renders pending (dimmed) and failed (reason + Retry /
  Delete) rows; the hover action bar is limited to confirmed rows.
- Failures are precise: the server echoes the request id on error
  replies (buildErrorMsgWithID), so the dispatcher maps SLOW_MODE /
  FORBIDDEN / RATE_LIMITED / BAD_REQUEST to the exact row instead of
  dropping the code. An offline send is shown failed, not silently lost.

Composer permission + connection gating:
- The server computes an authoritative per-channel can_send in the ready
  payload (channelCanSend mirrors MessageService.checkSendPermission:
  READ|SEND, MANAGE_MESSAGES for announcement, admin bypass, channel
  overrides). channels.store carries it as Channel.canSend.
- MessageInput gains a disabled-with-reason mode; ChannelController
  derives the reason from can_send + channel type + connection status and
  disables the composer reactively (announcement read-only, no-permission,
  reconnecting) rather than accepting a click and failing. Older servers
  that omit can_send default permissive.

Docs: the corresponding "Current gap" callouts in docs/architecture/ux
are updated to reflect the implementation.

Verified: full server suite + client tsc + 3204 unit tests + lint + gofmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 17:47:39 +00:00
Claude 071426c0d8 feat(server,client): announcement channels (D1, closes A-2026-07-01)
Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.

Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.

Server:
- migrations/016: recreate the channel-type triggers to allow
  text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
  rejects posts to announcement channels from users lacking MANAGE_MESSAGES
  (SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
  GetChannelUnreadCounts (db) now include announcement channels alongside
  text, so they track unread/last-message like text channels.

Client:
- ChannelSidebar renders announcement channels with a megaphone icon
  (added to the icon set) instead of the '#' text prefix; they otherwise
  behave like text channels (already typed in ChannelType).

Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.

Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 16:00:18 +00:00
Claude dab4d73e09 feat(client): TOFU HTTP proxy for REST — close audit A-2026-07-02 (D5)
The REST path previously used tauri-plugin-http with
danger.acceptInvalidCerts, so it accepted ANY certificate while the WS
and LiveKit paths were TOFU-pinned in Rust — and the bearer token rides
every REST request. This routes REST through a new Rust loopback
TCP->TLS proxy that pins the server certificate to the same
trust-on-first-use fingerprint as the WS proxy.

Rust (src-tauri):
- New http_proxy.rs: per-host loopback tunnels (HttpProxyState map);
  per-connection TOFU via CaptureVerifier + tofu_check, sharing
  ws_proxy's cert store (cert_store_key) and emitting the same
  cert-tofu events (first-use banner / mismatch modal). First request's
  Host is rewritten and Connection: close injected so one request rides
  each connection. Mismatch returns a clean 502 to the loopback fetch.
- Register HttpProxyState + start_http_proxy/stop_http_proxy in lib.rs.
- Drop the dangerous-settings feature from tauri-plugin-http.

TypeScript (src):
- New lib/httpProxy.ts: ensureHttpProxy(host) (per-host cache +
  concurrent-start dedup) / stopHttpProxy(host).
- api.ts, profiles.ts (health), attachments.ts (image + download) resolve
  server URLs to http://127.0.0.1:{port}; remove the allowSelfSigned
  config field and every acceptInvalidCerts block. External hosts (CDNs,
  OG previews, YouTube) keep normal TLS validation.
- main.ts constructs the API client without allowSelfSigned.
- capabilities/default.json: allow http://127.0.0.1:* fetch scope.

Tests:
- New tests/unit/http-proxy.test.ts (cache, dedup, stop/restart).
- api.test.ts and attachments-render.test.ts: mock httpProxy, replace the
  acceptInvalidCerts assertions with proxy-origin assertions.

Verified: tsc --noEmit clean; new + affected vitest suites green
(176 tests); the http_proxy pure logic (host validation, header rewrite)
passes as standalone Rust unit tests; oxlint/eslint counts unchanged
from HEAD; prettier clean. The full Tauri build (cargo) requires GUI
system libs not present in this environment and runs on CI/real runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 14:26:25 +00:00
Claude bf508c9e6b chore(client): remove abandoned SolidJS beachhead (D6)
The Solid.js migration was abandoned (per CHANGELOG); the 154-LOC
beachhead and its scaffolding remained in-tree, leaving two UI paradigms
for contributors. Removed:

- src/components/solid/ (Badge, ChannelListItem, PluginContainer — none
  imported by production code)
- src/lib/solidMount.ts and src/lib/solidAdapter.ts
- tests/setup-solid.ts and tests/setup-solid.test.tsx
- vite-plugin-solid from vite.config.ts and vitest.config.ts (and the
  now-unneeded tsx test include + setupFiles)
- jsx/jsxImportSource from tsconfig.json
- solid-js, @solidjs/testing-library, vite-plugin-solid from package.json

docs/client-architecture.md (which described the SolidJS design) is
retired to a pointer at docs/architecture/client.md; README links
updated. Audit A-2026-07-12 and decision D6 marked closed.

Verified: tsc --noEmit clean (previous 3 test-file errors were caused by
the Solid jsx config and are gone); oxlint/eslint error counts identical
to HEAD (pre-existing); vitest runner healthy on a sample suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 13:57:28 +00:00
Claude 2e7a80171b feat(server,client): protocol codegen + audit quick-wins batch
Protocol codegen (decision D4, audit A-2026-07-08):
- Add docs/protocol-schema.json as the real single source of truth for
  WS message-type constants, making the long-standing 'generated from'
  comment in both constant files true.
- Add Server/scripts/genprotocol, a generator emitting both
  Server/ws/message_types.go and Client .../lib/protocolTypes.ts
  (constants byte-for-byte value-identical to before; only headers,
  ordering alignment, and provenance comments changed).
- Add make protocol-generate / protocol-verify and wire protocol-verify
  into CI next to sqlc-verify.

Quick wins (decision D8):
- admin: log LogAudit write failures in the backup handlers instead of
  discarding them (prior audit #10).
- api: fix self-contradictory upload Cache-Control to 'private,
  no-cache' per remediation plan W3-4; drop the now-unused
  fileCacheMaxAgeSeconds constant; update test.
- ws: route the hub settings cache through db.GetSetting instead of
  inline SQL.
- ws: fix a latent data race — main.go wires SetEventPersister and
  SetEventStore after NewRouter has already started the hub Run loop,
  which reads those fields on the broadcast/replay paths. They (and
  pluginSink, which one test sets post-Run) are now atomic pointers;
  the remaining pre-Run-only setters reject late calls with an error
  log instead of racing silently.

Update the audit closure table and decisions doc statuses accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 13:32:58 +00:00
Claude d576f06aa1 fix(client): make source-quality screen share FPS take effect at capture
createLocalScreenTracks injects a default 1080p30 resolution when none is
set and mutates the passed options object, so (a) a 'source' share was
captured at 30 fps regardless of the FPS setting, with only a best-effort
applyConstraints afterwards, and (b) the shared 'source' preset object was
permanently mutated after the first share. Capture options are now always
copies; 'source' with an explicit 60/120 override passes a zero-size
resolution sentinel (uncapped in livekit's constraint translation) with the
frame rate in the raw video constraints, so the fps applies at
getDisplayMedia time.

Follow-up to #115.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 11:20:01 +00:00
Claude 1201992ab0 fix(client): skip window-state save while minimized
A minimized window reports placeholder coordinates (-32000 on Windows); the
move event fired by minimize was persisting them, so quitting while
minimized silently discarded the remembered position (the new off-screen
validation then falls back to centered). Skip the save while minimized so
the last real geometry survives.

Follow-up to #124.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 11:20:00 +00:00
Claude 6f0a04113f feat(client): screen share FPS setting with 60 and 120 fps options
Screen share frame rate was hardcoded per quality (5/15/30). Add a
"Screen Share FPS" setting (30 default / 60 / 120) next to Stream Quality:

- 30 keeps the existing per-quality caps unchanged
- 60/120 override the capture constraints and publish maxFramerate for all
  qualities, with bitrate scaled 1.5x/2x to keep the image sharp
- "source" quality (no fixed resolution) applies the fps to the live
  capture track via applyConstraints, best-effort

Actual delivered fps still depends on what the capture source and display
can sustain.

Closes #115

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 10:50:11 +00:00
Claude 85f05e999c fix(client): make the screenshare volume slider actually change volume
Three defects made the screenshare tile's volume slider ineffective:

- The 0-200 slider mapped to element volume /200 clamped to [0,1], while
  the element attached at 1.0 — dragging the upper half did nothing. The
  screenshare slider is now 0-100 with 100 = 1.0 (HTMLAudioElement.volume
  cannot exceed 1.0; mic tiles keep the 0-200 boost range via LiveKit's
  GainNode-backed setVolume).
- Setting a volume before the screenshare audio track attached was silently
  dropped. The per-user volume now persists independently of the element
  map and is applied on attach.
- Changing the master output volume overwrote per-user screenshare volumes
  with just the master multiplier; they now scale together.

The slider and mute button also initialize from persisted state when a tile
is rebuilt, and unmuting via the button re-applies the restored volume.

Fixes #121

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 10:50:10 +00:00
Claude 3eb990165d fix(client): hide native WebView2 password reveal icon
WebView2/Edge renders its own password-reveal eye inside password inputs,
stacking with the app's custom toggle on the login form. Hide the native
::-ms-reveal / ::-ms-clear controls globally.

Fixes #123

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 10:50:10 +00:00
Claude 54430eed45 fix(client): validate saved window position against connected monitors
Restoring a stale window position (e.g. from a disconnected monitor) placed
the window off-screen with no way to see it. Before applying the saved
position, check that the rect is reachable on some monitor reported by
availableMonitors(): at least 100px of horizontal overlap and a grabbable
title bar row. If not, keep the default centered placement. Also reject
non-finite or non-positive saved dimensions. If monitors cannot be queried,
restore proceeds unchanged.

Fixes #124

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 10:50:10 +00:00
J3vbandClaude Fable 5 c5bd46d682 style(client): apply prettier across src and tests
Mechanical prettier --write; 297 files had drifted while the CI
format gate was dead. No functional changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:46:53 +02:00
J3vbandClaude Fable 5 2e6bce154e fix(ci): unblock the client pipeline; isolate the known-red test job
- Remove the unused 'type Event' named import from the committed
  generated events.ts and teach the CI patch step to strip it on future
  regenerations — the previous line-anchored patch deliberately skipped
  imports, so ESLint failed on every run.
- Split vitest into its own client-tests job so the known-red suite
  (P2 triage pending) is exactly one visible failing check instead of
  masking the audit/lint/typecheck/prettier gates, which are now green.
- Drop the three stale roadmap files (PHASE_BC_LOCAL_TODO.md,
  phase-b-acceleration.md, phase-c-differentiation.md) — referenced
  nowhere since the CHANGELOG cleanup; already deleted on the
  security-hardening branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:46:52 +02:00
J3vb 34a84bcd5d Merge pull request #1132 from J3vb/claude/plan-phases-b-c-bGpoS
refactor: migrate WS handlers to V2 Command/Event architecture
2026-04-07 10:59:20 +02:00
J3vb 65002d0d7f h 2026-04-07 10:13:47 +02:00
Claude 6608dd392f fix(client): correct prettier endOfLine and oxlint disable directives
The Client Typecheck & Test CI job was failing on the prettier format
check. Two real root causes, fixed properly:

1. prettier endOfLine was set to 'crlf' but the repo stores files with
   LF (no .gitattributes forcing eol), so 'prettier --check' failed on
   292 files on the Linux CI runner. Set endOfLine to 'lf' to match the
   on-disk reality. Also reformat the 2 files (pluginBridge.ts,
   solidAdapter.ts) that had genuine style issues.

2. 15 'eslint-disable-next-line' comments targeted oxlint-only rules
   (no-await-in-loop, no-unassigned-vars) that ESLint does not enable,
   so ESLint reported them as 'Unused eslint-disable directive'
   warnings. Switched the directive prefix to 'oxlint-disable-next-line'
   — oxlint still honors them (its native syntax), and ESLint no longer
   parses them as eslint directives, so the warnings are gone without
   suppressing the safety check or removing the directives that oxlint
   actually relies on.

Verified locally: oxlint, tsc --noEmit, eslint, prettier --check, and
npm audit --audit-level=high all exit 0.
2026-04-07 07:46:49 +00:00
Claude 1c476ccb58 merge: reconcile sister branch claude/plan-phases-b-c-bGpoS
Resolves the parallel Phase B/C work that landed on the sister branch
while this branch was in review. Both branches independently implemented
real OTel + Wazero runtimes; this merge keeps the best of each.

Conflict resolution
- Server/plugin/sandbox_wazero.go: rewritten as a hybrid. Keeps the
  HEAD lifecycle (eager `platformInit` with WASI preview-1, explicit
  `platformDeactivate` per-instance, runtime closed via Registry.Close)
  AND adopts the sister branch's richer artefacts:
    * `WithMemoryLimitPages` actually enforces `cfg.MaxMemoryMB`,
    * the JSON-over-linear-memory ABI
      (`allocate` / `command_dispatch(ptr,len) → (ptr,len)` /
      `deallocate`),
    * `listExportedCommands` auto-binds commands the plugin exports
      via `list_commands` at activation time (capability-gated).
- Server/plugin/registry.go: kept the HEAD `activate()` snapshot
  pattern (read `runtimePlatform` under RLock, pass into
  `activateWithRuntime` as a parameter) so a concurrent Close can't
  race the wazero call. Sister branch's LoadAll stale-staging cleanup
  and UninstallPlugin on-disk dir removal came in via auto-merge.
- Server/telemetry/telemetry_otel.go: kept the HEAD implementation
  (race-fixed AppMetrics rebind, uint64 overflow guard, idempotent
  shutdown, trace-provider cleanup on prom failure) and wired in the
  sister branch's `OTLPInsecure` config field for plaintext gRPC opt-in.
- Server/go.mod: accepted sister branch's `BurntSushi/toml v1.6.0`
  for the new TOML manifest support.
- Client/tauri-client/vitest.config.ts: union of both globs
  (`tests/**/*.test.ts`, `src/**/*.test.ts`, `src/**/*.test.tsx`).
- PHASE_BC_LOCAL_TODO.md: combined the two checkbox histories;
  TOML manifest, hello.wasm fixture, and OTLPInsecure are all marked
  done now.

Sister branch additions accepted via auto-merge
- Server/plugin/examples/hello/{hello.wasm,main.go}: precompiled 925
  KiB TinyGo plugin with the full ABI (allocate, deallocate,
  list_commands, command_dispatch, on_event).
- Server/plugin/manifest_{toml,nottoml}.go: TOML manifest parser
  behind the wazero build tag, JSON fallback elsewhere.
- Server/plugin/loader.go: prefers `plugin.toml`, falls back to
  `plugin.json`.
- Server/api/plugins_handler.go: structured error responses + slog.
- Server/main.go, Server/config/config.go: OTLPInsecure plumbing,
  defaults polish.
- docs/{contributing.md,server-configuration.md}: documentation
  updates.

Test status
- `go build` passes on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- `go vet` passes on every tag combination.
- `go test ./...` passes on default and on -tags otel,wazero.
- Client: `npx tsc --noEmit` clean; vitest 3188/3188 across 112 files.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
2026-04-07 06:23:09 +00:00
Claude 47d848ee0a feat(phase-bc): implement real OTel + Wazero runtimes; harden install path
Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.

Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
  modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
  wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
  meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
  a concurrent NewAppMetrics() could observe a swapped provider but read
  stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
  failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
  uint64 values that exceed math.MaxInt64 fall back to a STRING attr
  rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
  histogram recording, shutdown idempotency, AppMetrics rebind, and
  the uint64 overflow fallback.

Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
  pre-instantiated; activateWithRuntime compiles + instantiates each
  plugin module under that runtime; platformDeactivate closes per-
  plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
  freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
  a parameter to activateWithRuntime; the call no longer re-reads the
  field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
  present; missing/broken exports return a user-facing diagnostic
  instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
  re-enable after disable (verifies the leak fix), close-twice safety,
  invalid wasm rejection, and DispatchCommand with a missing export.
  Fixture is a 41-byte embedded add.wasm; no external asset required.

Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
  Content-Type is not application/zip|x-zip-compressed|octet-stream
  (415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
  zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
  / size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
  content-type rejection, magic rejection, happy path, lifecycle 503,
  invalid id, and isZipContentType / hasZipMagic helpers.

Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
  glob to include src/**/*.test.tsx so Badge.test.tsx is actually
  discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
  instead of "*", and exposes a destroy() that detaches the message
  listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
  "solid-js/web" (the latter does not re-export it), unblocking
  npx tsc --noEmit.

Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
  api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.

PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
2026-04-06 21:46:22 +00:00
J3vb 031f20fb96 fix(phase-bc): Solid.js npm install — fix JSX import and lint error
- npm install: pulled solid-js, vite-plugin-solid, @solidjs/testing-library
- Fix solidMount.ts: import JSX from "solid-js" not "solid-js/web"
- Fix PluginContainer.tsx: suppress no-unassigned-vars for Solid ref pattern
- Build green (tsc + vite); 111 test files / 3186 tests all pass
2026-04-06 23:05:50 +02:00
Claude 46aeccc49b fix(phase-bc): address review findings — auth, SSRF, seq alignment
Phase B + C review pass: critical security and correctness fixes.

Security
- S1: plugin admin endpoints now require admin.RequireAdminAuth in addition
  to AdminIPRestrict. Previously a LAN attacker on the allowed CIDR could
  list/enable/disable/uninstall plugins without a session.
- S2: rewrite plugin HTTPDo allowlist with proper net/url parsing. Empty
  entries are ignored, suffix matches require a dot boundary, and a custom
  Dialer rejects loopback / RFC1918 / link-local addresses to close the
  DNS-rebinding TOCTOU window. Redirects re-validated.
- S3 + #9: manifest Name pinned to ^[a-z0-9][a-z0-9_-]{0,63}$, Entrypoint
  and UI tab assets validated against absolute / "..", NUL byte, backslash
  and non-canonical paths. Asset handler hardened with filepath.Rel check
  for symlink and prefix-without-separator escapes.
- S5: pluginBridge postMessage handler ignores the pluginId in the message
  body and uses an e.source -> contentWindow lookup instead, defeating
  spoofed messages from same-origin scripts.
- S8: HTTPDo body capped at 5 MiB via io.LimitReader, redirects bounded
  to 5 hops.

Correctness
- Critical seq alignment: PersistEvent now takes the hub-assigned seq as a
  required parameter so the events table row seq always matches the wrapped
  payload seq. Hub seeds its in-memory atomic counter from MAX(events.seq)
  on startup. Drops in the persister queue no longer mis-align row vs
  payload seq.
- #1: live plugin.Registry constructed in main.go BEFORE NewRouter and
  threaded through; admin handler is no longer wired with nil.
- #3: EventPersister.Stop is now safe to call without a prior Start by
  tracking a started flag — previously deadlocked waiting on done.

Wiring
- NewRouter signature gains *plugin.Registry; two test callers updated.
- admin.RequireAdminAuth exported as a thin wrapper over the existing
  package-private adminAuthMiddleware.
- sqlc query templates updated for the new PersistEvent + GetMaxEventSeq
  contracts (sqlite + postgres).

https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
2026-04-06 09:29:29 +00:00
Claude a2cb224323 feat: scaffold Phase B + C (events, telemetry, plugins, Solid.js)
Phase B Step 6 — Solid.js incremental migration
  - vite-plugin-solid + solid-js + @solidjs/testing-library in package.json
  - vite.config.ts compiles src/components/solid/** as Solid TSX
  - tsconfig.json gains jsx: preserve / jsxImportSource: solid-js
  - lib/solidAdapter.ts wraps existing custom Stores as Solid signals
  - lib/solidMount.ts adapts Solid render to {mount,destroy} contract
  - components/solid/Badge.tsx (proof-of-concept leaf)
  - components/solid/ChannelListItem.tsx (store-subscribed leaf)
  - components/solid/Badge.test.tsx pipeline smoke test
  - components/solid/README.md documents the migration recipe

Phase B Step 7 — Event persistence layer
  - SQLite + Postgres migrations for the events table
  - sqlc query files for both engines
  - EventStore interface + SQLite raw-SQL impl + MemStore impl + pg stubs
  - ws.EventPersister: async batched writer (queue / flush / drain / drop)
  - ws.StartEventPruner: background retention pruner
  - hub persists every replay-buffer push and exposes reconnect-tier counters
  - serve.handleReconnect: tiered replay (buffer -> DB -> full re-sync)
  - EventPersistenceConfig + main.go wiring
  - event_persister_test.go covers batching / drops / drain

Phase B Step 8 — OpenTelemetry skeleton
  - Server/telemetry package with public Provider/Tracer/Meter/Counter API
  - telemetry_default.go (no-op build) + telemetry_otel.go (build tag otel)
  - telemetry/metrics.go declares the AppMetrics bundle
  - HTTPMiddleware mounted in Chi router (pass-through in default build)
  - PrometheusHandler optionally mounted at /metrics
  - Spans on MessageService.SendMessage, PermissionService.HasChannelPerm,
    ChannelService.ListVisibleChannels
  - Reconnect-tier counter wired into the global meter
  - TelemetryConfig defaults

Phase C Step 9 — Wazero plugin runtime skeleton
  - Server/plugin package: manifest parser, loader, registry, host APIs
    (commands, storage, events, http, ui), errors
  - sandbox_default.go (no-op) + sandbox_wazero.go (build tag wazero)
  - SQLite + Postgres migrations for plugins + plugin_kv tables
  - PluginStore interface + impls + pg stubs
  - plugin/examples/hello manifest + README
  - plugin_test.go covers manifest, loader, capability gating
  - api/plugins_handler.go admin REST surface, mounted under admin group
  - PluginsConfig + main.go wiring (disabled by default)
  - Client: lib/pluginBridge.ts iframe + postMessage host
  - Client: components/solid/PluginContainer.tsx Solid host component

Verification
  - Default build (no -tags) is intended to compile cleanly with no new
    third-party dependencies. The sandbox lacked Go 1.25.0 so go build
    could not run; PHASE_BC_LOCAL_TODO.md enumerates the local follow-up
    work (npm install, go mod tidy, sqlc-generate, real otel/wazero
    wiring, remaining service spans, full Solid migration).
2026-04-06 09:00:47 +00:00
J3vb 34c8e12d0f fix: preserve isKeyHolder in pendingJoin drain loop
PendingVoiceJoin was missing the isKeyHolder field, so when the drain
loop called connectAndSetup for a queued join it defaulted to false,
entering the "wait for room key from key holder" E2EE path and hanging
indefinitely. Store isKeyHolder in the pending join and forward it.
2026-04-05 20:41:04 +02:00
J3vb ef2dee4adc fix: resolve floating promise ESLint errors in livekitSession.ts
- void localRoom.disconnect() in error handler
- void this.rotateKeyPeriodically() in setTimeout callback
2026-04-05 19:46:41 +02:00
J3vb e2f8858019 fix: resolve CI lint and ESLint failures
- Remove commented-out code flagged by gocritic
- Use bytes.Equal instead of string conversion comparison
- Remove unused buildRateLimitError function
- Remove unnecessary type assertions in e2eeCrypto.ts
2026-04-05 19:37:00 +02:00
J3vb 37566c17cb Merge pull request #116 from J3vb/dev
Force merge: CI issues will be fixed in upcoming Command/Event architecture refactor
2026-04-05 11:32:36 +02:00
J3vb 5239a75642 Merge pull request #1126 from J3vb/claude/security-audit-full-mmIrL
Force merge: CI issues will be fixed in upcoming Command/Event architecture refactor
2026-04-05 11:32:26 +02:00
J3vb fa591333e2 Merge branch 'main' of https://github.com/J3vb/OwnCord
# Conflicts:
#	README.md
2026-04-05 09:23:19 +02:00
J3vb cb59109c98 updated readme 2026-04-05 08:50:01 +02:00
J3vb f841ba87d7 fix: resolve tsc errors — logger import, Uint8Array generics, VoiceTokenPayload type, test assertion 2026-04-04 23:40:37 +02:00
J3vb 36e81ebec7 fix: address code review IMPORTANT issues — atomic TOCTOU, updateKeyHolder race, dead keyReceived var 2026-04-04 23:30:36 +02:00
J3vb 59aa5a7808 fix: wire is_key_holder from server payload through dispatcher to handleVoiceToken 2026-04-04 23:25:41 +02:00
J3vb 6a29a3a143 fix: TypeScript E2EE hardening — key holder from server, base64, timeout, session fixes
- M-1: Replace uint8ToBase64 string concat loop with Array.from().join()
- M-3: Remove non-null assertion on hex.match() in computeKeyFingerprint
- M-4: Replace TextEncoder module-level side effects with precomputed Uint8Array literals
- C-2/I-4: Read is_key_holder from voice_token payload; remove voiceStore-based key holder election
- I-5: Hard fail on E2EE timeout — call leaveVoice() and emit e2ee_timeout error instead of proceeding without E2EE
- C-3: Disconnect localRoom in connectAndSetup catch block to prevent resource leaks
- I-3: Atomic reconnect state transition already handled via setReconnectAc (no change needed)
2026-04-04 23:23:54 +02:00
J3vb f962d59cd6 fix: update tests and fix pending-join drain regression for CI
Update LiveKitSession tests to use _state discriminated union instead of
old flat field names (room, currentChannelId, latestToken, etc.) removed
in the state machine refactor. Also fix renderers.test.ts URL resolution
by setting a server host in beforeEach so isSafeUrl can parse relative
attachment URLs in jsdom. Stage all four Go test files so the CI Go job
runs them.

Additionally fix a regression in connectAndSetup's finally block: when a
pendingJoin is queued during a stale-join abort, preserve the connecting
state so handleVoiceToken's drain loop can pick it up rather than losing
it by resetting to idle.
2026-04-04 21:50:57 +02:00
Claude 774a7bcce9 fix: remaining E2EE hardening — rotation, retry, fingerprint, validation
Periodic key rotation:
- Key holder rotates room key every 5 minutes for forward secrecy,
  independent of participant changes. Timer managed by key holder only.

Offer retry mechanism:
- Non-key-holders re-announce their public key after 10s timeout and
  wait 5s more before giving up. Covers lost offers from target
  disconnect during async key wrapping.
- Key holder now re-sends room key offer on duplicate announces (peer
  may be re-requesting after a missed offer), instead of ignoring them.

Key fingerprint verification:
- New computeKeyFingerprint() in e2eeCrypto.ts — SHA-256 hash of raw
  public key formatted as "AB12 CD34 ..." for out-of-band verification.
  Can be displayed in UI for MITM detection.

Server hardening:
- Public key size limit tightened from 256 to 128 bytes (P-256
  uncompressed = 65 bytes = ~88 base64 chars).

Client hardening:
- WebCrypto availability check at module load — throws descriptive
  error if crypto.subtle is unavailable (non-HTTPS context).
- base64ToUint8() now wraps atob() in try-catch with clear error message.

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 19:43:17 +00:00
Claude 277c2d3e76 fix: address critical E2EE review findings — races, validation, reconnect
Critical fixes:
- C1: Key holder election now uses lowest-user-ID from voiceStore instead
  of "am I first in peerPublicKeys" heuristic, preventing simultaneous
  join race where both participants generate conflicting room keys
- C2: TOCTOU race in handleVoiceE2EEOffer — target channel check now
  happens inside h.mu.RLock() section (atomically with client lookup)
- C3: Server now validates base64 encoding for public_key, encrypted_key,
  and iv before relaying, preventing client-side DoS via malformed payloads

High fixes:
- H1: ECDH keypair regenerated on reconnect with fresh announce, so
  stale keys don't persist and key rotation during disconnect is handled
- H2: E2EE epoch counter prevents stale offers from overwriting a
  rotated room key (handleE2EEOffer discards if epoch changed during unwrap)
- H3: After key rotation, re-check for peers that arrived during the async
  wrapping loop and send them the new key too
- H4: _ecdhKeyPair and _roomKey captured in local vars before async
  operations to prevent null dereference if clearE2EEState runs concurrently

Medium fixes:
- M1: User notified via onErrorCallback when E2EE key exchange times out
- M2: Timeout timer properly cleared to prevent leak and unhandled rejection
- M3: Duplicate announces deduplicated — same key ignored, changed key logged

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 19:35:41 +00:00
Claude b89a7efa6a fix: harden E2EE key exchange — election, rotation, queuing, error handling
- Use deterministic key holder election (lowest user_id) instead of
  Map insertion order which is not guaranteed to match join order
- Use parseUserId() instead of raw parseInt() for LiveKit identity parsing
- Add concurrent key rotation guard (_rotatingKey flag) to prevent
  races when multiple participants leave in rapid succession
- Queue voice_e2ee_announce messages that arrive before ECDH keypair
  is ready; drain after keypair generation in connectAndSetup
- Propagate decryption failures to roomKeyResolver so connectAndSetup
  unblocks with an error instead of hanging
- Reject (not resolve) roomKeyResolver on voice leave for proper cleanup
- Convert dynamic await import("@lib/e2eeCrypto") to static imports
- Add VOICE_E2EE_ANNOUNCE/OFFER to protocolTypes.ts enum constants
- Use typed S.VOICE_E2EE_* constants in dispatcher instead of string casts
- Add payload size limits for encrypted_key (1024) and iv (128) on server

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 19:13:51 +00:00
Claude 0c4d9f702c feat: implement true E2EE for voice via client-side ECDH key exchange
Replace server-generated symmetric keys with client-side ECDH P-256 key
exchange. The server now only relays opaque public keys and encrypted
room key blobs — it never sees the actual room encryption key.

Protocol:
- voice_e2ee_announce: clients broadcast ECDH public keys
- voice_e2ee_offer: key holder wraps room key for each peer via ECDH+HKDF+AES-GCM
- Key rotation on participant leave (forward secrecy)

Server changes:
- Remove VoiceE2EEKeys (server-side key generation)
- Add relay handlers for announce/offer messages
- Store per-client ECDH public keys on Client struct
- Send existing public keys to new joiners during voice state sync

Client changes:
- New e2eeCrypto.ts: ECDH P-256, HKDF-SHA256, AES-256-GCM key wrapping
- LiveKitSession generates keypair on join, manages key holder election
- Key holder generates room key and wraps for each peer
- Non-holders wait for offer before connecting to LiveKit
- Room key rotated when any participant leaves

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 19:02:21 +00:00
Claude 1d8bfe2d6a fix: revert breaking security changes and update tests for version removal
Restores dangerous-settings and allowSelfSigned which are required for
self-hosted servers with self-signed certificates. Makes HealthResponse.version
optional to match server-side removal, and updates router tests to assert
version is correctly omitted from unauthenticated endpoints.

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 18:02:26 +00:00
Claude 1673c37b9c fix: comprehensive security hardening from full codebase audit
Addresses 14 findings from the security audit across all severity levels:

CRITICAL:
- C-1: Add user blocking system (migration, DB queries, REST API, WS DM
  send check) to prevent harassment via unconsented DMs
- C-2: Remove server version from unauthenticated /health and /info endpoints
  to prevent fingerprinting

HIGH:
- H-1: Remove dangerous-settings feature from tauri-plugin-http
- H-3: Default allowSelfSigned to false in API client (was hardcoded true)
- H-4: Cap invite expiration to 30 days (720 hours)
- H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM)
- H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow)
- H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role

MEDIUM:
- M-2: Deny access to legacy NULL-uploader unlinked attachments
- M-4: Log warnings on TOTP plaintext decryption fallback paths
- M-8: Remove acceptInvalidCerts from OG preview fetches
- M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk)
- M-12: Add LIMIT to ListInvites (200) and ListMembers (1000)
- M-14: Add CHECK constraint trigger on channels.type (text/voice/dm)

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 16:48:57 +00:00
Claude 330fd8eed7 feat: add end-to-end encryption for voice/video via LiveKit SFrame
Server generates a per-channel 256-bit symmetric key (crypto/rand) when
the first participant joins voice. The key is distributed to all
participants via the voice_token WS message (already TLS-encrypted) and
cleared when the channel empties for forward secrecy per session.

Client configures LiveKit Room with ExternalE2EEKeyProvider and an
SFrame e2ee-worker. All audio/video frames are encrypted client-side
before reaching the SFU — the server never sees plaintext media.

Changes:
- Server: new VoiceE2EEKeys store, e2ee_key in voice_token payload
- Client: E2EE Room options, key provider wiring for connect/reconnect
- CSP: added worker-src 'self' blob: for the E2EE Web Worker

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 16:34:52 +00:00