Commit Graph
161 Commits
Author SHA1 Message Date
J3vbandClaude Opus 5 592caf985d fix(client): restore localStorage shadowed by Node 26 in jsdom tests
Node 26 defines localStorage as a native global accessor returning
undefined unless started with --localstorage-file. Vitest's jsdom
environment sets window === globalThis, so that accessor shadows
jsdom's own, breaking all 20 test files that touch localStorage
(479 tests). sessionStorage is unaffected.

Install an in-memory Storage in a setup file when the global is
missing. Not using --localstorage-file: it is file-backed and shared
across vitest's parallel workers, which would leak state between test
files.

Suite: 3572/3572 passing (was 3093/3572).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:25:26 +02:00
Claude 0918f859a0 test: close measured test-coverage gaps across server, client and Rust
Audits what actually has tests, then closes the gaps it found. Full write-up
with before/after numbers in docs/audit-test-coverage-2026-07-25.md.

Measurement first: `go test ./... -coverprofile` (what CI runs) instruments
each package only for itself, so code exercised through another package's
tests reads as uncovered — `service` reported 36.7% against a real 85%. All
analysis here uses -coverpkg=./..., and both views now have Makefile targets.

Features that had zero coverage at every layer:
- user blocking (db + service + the /api/v1/blocks routes)
- auth lockout persistence — the DB round-trip that survives a restart
- plugin install/enable/disable/uninstall and the plugin KV namespace
- event replay bounds (GetMaxEventSeq, PruneEventsOlderThan)
- LiveKit participant_joined webhook (replayed-token guard), the room-service
  client, and proxyWebSocket/copyWS
- ws_proxy.rs and livekit_proxy.rs — pure helpers extracted, matching the
  existing tofu.rs pattern, so cert-pin and header-injection checks are testable

Gaps that were hidden rather than absent:
- Server/admin reported 0.3% coverage with 307 tests passing. TestSpawnDetached_*
  re-execs the test binary; the child inherited GOCOVERDIR and the parent's
  stdout, clobbering the profile and printing "[no tests to run]". Now 71.4%,
  and CI's uploaded artifact is correct.
- vitest.config.ts excluded 2.2k LOC unexplained, including two files that
  already had tests. Trimmed to three entries, each justified inline.
- api.HandleLiveKitHealthForTest re-implemented the handler it claimed to
  expose, so eight call sites tested a copy. Added a hook to the real one.

Two bugs found and pinned rather than silently patched: logctx.WithGroup nests
req_id under the group, and drag-reorder.ts takes one listener ref per channel
but releases one per sidebar, so the count never reaches zero.

Coverage: client 92.93% -> 94.87% statements (3371 -> 3572 tests) even after
un-excluding hidden files; Rust 47 -> 74 tests; Go zero-coverage functions
~70 -> 21, with plugin 61->77%, admin 67->86%, db 76->84%, service 85->91%.

Verified: go vet, all four build-tag variants, go test -race, -tags deadlock,
vitest --coverage, cargo test --lib, cargo clippy --all-targets, playwright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEETs3Vh6sAHHb1jMBL75g
2026-07-25 14:47:21 +00:00
J3vbandClaude Opus 4.8 1423b17993 feat(client): logging & error-visibility hardening
- tauri-plugin-log: rotating Rust log file in the app-log dir so shipped users
  can retrieve proxy/TLS/TOFU diagnostics (a release build detaches the console)
- log the health-check failure cause; log persist failures in save_settings /
  store_cert_fingerprint; add a TOFU cert-pin accept/change audit trail; log
  http/livekit proxy-loop panics instead of swallowing them
- stop persisting the raw WS frame content and the auth token prefix to disk
- drain the pre-init in-memory log buffer so bootstrap logs reach disk
- surface the server X-Request-Id on API errors for cross-tier correlation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:10:38 +02:00
J3vbandClaude Opus 4.8 f3c6745c0b feat(client): add single-instance/autostart/deep-link; move window-state to plugin
Replace hand-rolled code with first-party Tauri v2 plugins where a plugin can
do the job, and add the genuine gaps:

- single-instance: focus the running window on a second launch instead of
  opening a duplicate (two WS connections / tray icons). Registered first;
  built with the "deep-link" feature so owncord:// links reach the running app.
- window-state: replace the hand-rolled save/restore plumbing with
  tauri-plugin-window-state. Keep only the one thing the plugin lacks — an
  off-screen re-center guard for windows restored onto a now-disconnected
  monitor (isRectOnScreen).
- autostart: "Launch on Login" toggle in Advanced settings, reading/writing
  real OS state via tauri-plugin-autostart (not a stored preference).
- deep-link: register the owncord:// scheme and route invite links into the
  register form. OwnCord invites are registration invites, so a link pre-fills
  and opens the form rather than completing a one-click join.

Intentionally NOT replaced: push-to-talk (ptt.rs) stays hand-rolled —
tauri-plugin-global-shortcut registers OS hotkeys that grab the key
system-wide (RegisterHotKey / XGrabKey), which cannot express non-consuming
press-and-hold PTT. Clipboard stays on the native Web API (no custom code).

Verified: tsc, eslint, prettier, 3369 unit tests, cargo check, cargo clippy
(client code clean; one pre-existing needless-borrow lint in commands.rs is
flagged only by newer local clippy, untouched here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:44:06 +02:00
J3vbandClaude Opus 4.8 8b8632774e fix(e2ee): coalesce concurrent room-key rotations; refuse blind re-pin
Follow-up to the F3 re-pin/forward-secrecy fix, closing two residuals found by
adversarial re-review of the first fix:

- Concurrent-leave rotation drop (medium): rotateKeyPeriodically's _rotatingKey
  guard silently skipped a rotation already in flight, so a keyed peer that left
  mid-rotation kept a live room key until the next periodic (<=5 min) rotation.
  A coincident keyed-peer leave now DEFERS its rekey (_rotationPending) instead
  of dropping it; the completing rotation drains it via a shared
  drainPendingRotationOrArmTimer, excluding the departed member. Applied to both
  the become-holder and periodic rotation paths; reset in clearE2EEState.

- Blind re-pin (info, defense-in-depth): the mismatch modal's Trust action pinned
  publishedKey even when its fingerprint could not be computed (nothing shown to
  verify). onAccept now refuses to pin when fingerprint is null.

Client gates green: typecheck, lint (0 errors), prettier, vitest (3364).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:11:08 +02:00
J3vbandClaude Opus 4.8 ef1aca8a67 fix(e2ee): pin the verified identity key on re-pin, rekey on keyed-peer leave
Multi-agent F3 security review surfaced two voice-E2EE defects:

- Re-pin TOCTOU (voice-E2EE MITM): the identity-mismatch modal showed a
  fingerprint from one membersStore read, but rePinPeerIdentity re-read the
  server-writable store to decide what to pin. A malicious server (F3's threat
  model) could swap in an attacker key via a user_update during the human
  out-of-band verification window and have it pinned, silently defeating the
  mismatch prompt. rePinPeerIdentity now takes the exact verified key as a
  parameter; ChannelSidebar passes the bytes whose fingerprint it displayed.

- Membership forward secrecy: the key holder rotated the room key only when the
  holder ROLE transferred, so a departed non-key-holder kept a valid room key
  until the next periodic (<=5 min) rotation. The holder now also rotates when a
  peer that held the key leaves (reusing rotateKeyPeriodically), gated on the
  leaver having actually held a key.

Client gates green: typecheck, lint (0 errors), prettier, vitest (3361).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:55:07 +02:00
J3vbandClaude Opus 4.8 53b9c46179 feat(e2ee): surface F3 voice identity verification in the channel sidebar
Render the per-peer E2EE identity-verification state (F3 TOFU) on voice
user rows in the live channel sidebar, and give legitimate key rotation an
in-app recovery path:

- Per-peer shield badge in renderVoiceChannelItem: green shield-check
  (verified, safety number in tooltip), muted shield (unverified/legacy),
  red shield-alert (mismatch, click to review).
- createIdentityMismatchModal (in CertMismatchModal.ts, reusing the .cert-*
  CSS and buildRow) — the identity-key analogue of the cert-mismatch prompt.
  It shows the changed key's fingerprint for out-of-band verification, and
  "Trust New Key" re-pins via rePinPeerIdentity to recover from a genuine
  rotation.
- Fold verification status into the sidebar's voiceStore structural
  signature so a verified/unverified/mismatch flip re-renders the badge.
- Three Lucide shield icons; .vu-verify layout CSS.

The badge lives in ChannelSidebar.renderVoiceChannelItem (the live voice
renderer); createVoiceChannel in VoiceChannel.ts is dead/unused.

Client gates green: typecheck, lint (0 errors), prettier, full vitest (3358).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:06:50 +02:00
J3vbandClaude Opus 4.8 81a0b63e65 feat(e2ee): F3 identity/TOFU + W2-4/W3-3 hardening — checkpoint before F3 UI
WIP save point. Server + W2-4/W3-3 complete and gate-green; F3 voice E2EE
identity keys + TOFU implemented and MITM-verified-closed; the F3 voice-panel
UI (safety-number display, verified/mismatch badge, re-pin modal) is still TODO.

- W2-4 attachment link (coverage confirmed); W3-3a XFF CIDR pre-parse;
  W3-3b update-binary TOCTOU (single-handle verify + O_EXCL staging)
- F3 server: migration 017 identity_public_key, PATCH /users/me persist,
  ready/member_join/user_update carry key, signed voice_e2ee_announce
- F3 client: ECDSA identity keypair (keyring + pin store), publish wired into
  ready, verifyPeerAnnounce pin-before-legacy, rePinPeerIdentity recovery
- Gates: server full CI mirror green (-race/-deadlock/lint/4 build tags);
  client typecheck/lint/format + 3337 vitest green. Rust CI-verify only.

Next: build F3 voice-panel UI, then adversarial review, then finalize commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:07:09 +02:00
J3vbandClaude Fable 5 f4b20726ff chore(client): remove dead files, exports, and unused dependencies
knip findings (repo CI config), each verified including dynamic imports,
HTML refs, and the Rust side:

Files deleted: pluginBridge.ts (its documented PluginContainer.tsx
collaborator never existed in the repo; the server plugin host stays per
D11 — reinstate from git if client plugin UI work ever starts),
message-input/file-upload.ts, message-input/picker-toggle.ts (dir now
empty, removed), message-list/virtual-scroll.ts (MessageList does its
own virtualization via FenwickTree).

Dependencies removed: zod (zero imports; typegen uses
validation_library none — stale CLAUDE.md claim fixed),
@tauri-apps/plugin-store and plugin-updater npm halves (both features
are Rust-driven via StoreExt/UpdaterExt — Rust halves stay), and
tauri-plugin-global-shortcut on BOTH sides (PTT polls via device_query;
zero GlobalShortcutExt use): Cargo.toml dep, lib.rs registration, and
the 5 capability permission lines. Inert webview capability entries
store:default/updater:default also dropped. @stryker-mutator/api added
to devDependencies (stryker.config.mjs imports its types; core pins the
same version, zero install delta).

Exports removed: livekitSession clearOnError bound-const, ConnectPage/
MainPage ReturnType aliases, readAllPersistedLogs (never wired to any
UI) with its test blocks. getLogDir kept as the suite's observability
point, tagged @public for knip. protocolTypes.ts *Value types are
generated surface — knip.json now ignores that file instead.

Rust compile is CI-verified only (no MSVC toolchain here, same as the
F4/F8 TOFU work); Cargo.lock resolution pruned cleanly. Client gate
green: tsc, oxlint/eslint 0 errors, prettier, 3304/3304 vitest, knip
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:29:01 +02:00
J3vbandClaude Opus 4.8 1485e13f9a fix(security): gate TLS trust-on-first-use behind explicit confirmation (F4/F8)
The http and ws proxies accepted ANY certificate on first use and silently
pinned it, forwarding login credentials and the bearer token before the user
ever saw the fingerprint — an on-path attacker at first contact captured them.
The three proxies also duplicated the TLS verifier and TOFU logic verbatim.

- Extract the shared verifier, cert-store helpers, and a pure `decide` function
  into src-tauri/src/tofu.rs (used by the http/ws/livekit proxies).
- Split the trust decision from persistence: a first-use cert is no longer
  pinned or forwarded to. The proxy rejects (ws: Err; http: 502) and emits a
  cert-tofu "first_use" event; the only writer of a pin is the explicit
  accept_cert_fingerprint command.
- Frontend: a global cert-tofu listener (active during the connect page's health
  checks, before any WS connect) surfaces an SSH-style first-use confirmation
  modal. On accept the fingerprint is pinned and the server re-checked; nothing
  is sent to an unconfirmed host.

Closes security-scan F4 (http proxy) and F8 (ws proxy). Verified: client
typecheck/lint/format clean, full unit suite 3311/3311 green (incl. new ws
first-use routing + modal tests). Rust compiles in CI (cargo clippy) per the
client CLAUDE.md; pure tofu logic covered by #[cfg(test)] unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:47:30 +02:00
J3vbandClaude Opus 4.8 92dae342e0 fix(client): parse OG tags with DOMParser to remove ReDoS vector
parseOgTags matched untrusted link-preview HTML (up to 50KB) against regexes with two sequential [^>]* quantifiers around a required literal, which backtrack polynomially and froze the UI thread on crafted input. Parse with DOMParser (a linear tokenizer) instead; it also correctly ignores meta-like strings inside comments/scripts. (Security scan F7)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:59:13 +02:00
J3vb 52659e4a39 Merge pull request #1200 from J3vb/feat/narrow-tauri-capabilities
feat(client): narrow Tauri HTTP capability scope
2026-07-20 16:25:26 +02:00
J3vb 352b7c8cc1 feat(client): narrow Tauri HTTP capability scope
Add a deny list for https loopback literals (localhost, 127.0.0.1, both
with and without an explicit port) to `http:allow-fetch`. All legitimate
server traffic reaches loopback over http through the Rust TOFU proxy, so
an https loopback fetch from the renderer can only be an attempt to probe
some other local service. `deny` wins over `allow` in Tauri's scope check.

Drop the scope objects from `http:allow-fetch-send` and
`http:allow-fetch-read-body`, leaving bare identifiers. tauri-plugin-http
validates the URL exactly once, in the `fetch` command; both of these take
an already-validated ResourceId and never consult a scope, and a
permission declaring `commands.allow` contributes command scope only —
it never merges into the plugin's global scope. The blocks were inert
configuration that read like defence in depth. The capability description
now records why, so they are not re-added on reflex.

The `https://*` wildcard on `http:allow-fetch` stays: link previews fetch
arbitrary user-posted URLs by design, and Tauri scopes per command, not
per JS caller. See docs/plans/tauri-capability-narrowing.md.

Add tests/unit/capabilities-scope.test.ts as a regression guard on the
shape of the grant.
2026-07-20 14:51:14 +02:00
J3vbandClaude Fable 5 89401c64a6 feat(client): call own server for GIFs, drop VITE_KLIPY_API_KEY
gifProvider.ts now goes through api.ts (TOFU-pinned via the Rust http proxy)
instead of api.klipy.com, and the VITE_KLIPY_API_KEY path is deleted outright.
The built bundle greps clean of the key name and of api.klipy.com.

The klipy.com CDN allowlist stays: media URLs still load from Klipy's CDN, and
the server is trusted to hold the key but not to dictate what the client
renders.

Degradation: on 503 GIF_DISABLED the picker shows "GIFs are not enabled on
this server" and calls onUnavailable, which disables the composer's GIF button
with a title/aria-label reason — mirroring the existing attach-button rule so
re-enabling the composer does not resurrect it. A MessageInput with no gifApi
wired renders the button disabled from the start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:29:57 +02:00
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 b8ad9db489 test(voice): pin the reconnecting status write on unexpected room drop
setVoiceStatus("reconnecting") is written from the setReconnectAc callback fired
via roomEventHandlers.handleDisconnected on an unexpected room drop — the primary
reconnecting write, previously untested (the reconnect suite only asserted the
subsequent "connected"). Add a test that connects, captures the Disconnected
handler, fires it with a non-CLIENT_INITIATED reason, and asserts both the
"reconnecting" session state and the setVoiceStatus("reconnecting") write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:56:43 +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 ff55da7161 test(voice): pin voiceStatus transitions, widget indicators, and control freeze
- livekit-session: assert joining→securing→connected on join, idle on leave,
  connected after auto-reconnect
- voice-widget: assert each status label + secured badge visibility, and controls
  disabled with reason while the socket is down, re-enabled on reconnect
- voice-callbacks: assert join/leave/disconnect do not send over a down socket
- voice.store: assert join seeds joining, leave resets idle, setter writes status
- thread voiceStatus through existing full-state fixtures; keep an active-call
  socket live in widget fixtures so control-click tests still operate enabled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:31:27 +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 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 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 5ac407a71a test(client): account for the screen share FPS select in settings tests
Follow-up to the screen share FPS setting (#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 9e6ff47194 feat(server,admin): private channels via per-role permission overrides
The read side of channel visibility was already complete — channel_overrides
rows deny ReadMessages and every list/focus/send/voice path enforces them —
but nothing could write those rows. Add the missing write path and UI:

- db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides
  (roles LEFT JOIN overrides so the UI gets everything in one call)
- admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}]
  with unknown permission bits masked via the new permissions.AllPerms,
  audit logging, and immediate permission-cache invalidation
- ws: Hub.RefreshChannelVisibility sends targeted channel_create /
  channel_delete to connected clients after an override change, unsubscribes
  hidden clients from the channel topic, and clears their focus. Sent outside
  the sequenced replay path on purpose: a replayed channel_delete would be
  filtered by the post-change allowed-channel set, inverting its audience.
- admin panel: per-channel Access modal (lock icon) with per-role
  "Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice

Known limits (follow-ups): users offline during a revoke keep a stale
sidebar entry until their next fresh connect (server still denies access),
and users already in a voice channel are not kicked when it goes private.

Closes #93

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 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 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
J3vb 65002d0d7f h 2026-04-07 10:13:47 +02: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 e66cb7413b fix: update test mocks for createLogger, ExternalE2EEKeyProvider, and keybinds string
- e2eeCrypto.test: mock createLogger instead of log
- livekit-session.test: add ExternalE2EEKeyProvider to livekit-client mock
- keybinds-tab.test: update expected string to "Press a supported key..."
2026-04-05 19:57:01 +02:00
J3vb 6e4a007b91 refactor: migrate WS handlers to V2 Command/Event architecture
Strangler-fig migration of 15 WebSocket handlers from V1 (Hub method,
*Client) to V2 (pure functions: Command, ClientInfo, deps -> Result).
V2 handlers are testable without a running Hub and produce declarative
Result values that the dispatch loop applies.

New abstractions:
- Command interface + typed constructors with input validation
- 7 Event routing interfaces (Channel, ExcludeSender, SequencedDM,
  UserTargeted, BroadcastAll, VoiceChannel, VoiceChannelGuarded)
- Per-domain deps structs (PingDeps, ChatDeps, PresenceDeps,
  ReactionDeps, VoiceDeps) with interface-based DI
- EmitEvents router matching events to delivery mechanisms
- DispatchV2 with panic recovery and runtime.Stack logging

Security hardening:
- Pre-sanitize byte length guard before bluemonday (DoS prevention)
- GetRoleForUser single-JOIN query avoids password hash on hot path
- channel_id positivity enforced in all command constructors
- Log injection prevention: msgType/reqID capped to 64 chars
- Nil KeyHolder dep returns ErrCodeInternal (not silent bypass)
- VoiceChannelGuardedEvent atomic check-and-send under h.mu.RLock

V1-only (complex state/mutex requirements): voice_join, voice_leave.

All tests pass with -race. No CI regressions expected.
2026-04-05 19:03:22 +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 f841ba87d7 fix: resolve tsc errors — logger import, Uint8Array generics, VoiceTokenPayload type, test assertion 2026-04-04 23:40:37 +02:00
J3vb 50bb54510f test: add e2eeCrypto unit tests — wrap/unwrap, fingerprint, entropy 2026-04-04 23:20:09 +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
J3vb 321428c7f4 feat: security hardening - restrict PTT capture keys, gate devtools to DEV, fix race conditions and DM sequencing
- Restrict PTT key capture to non-text keys only (function, navigation, mouse buttons) via allowlist (BUG-136)
- Gate DevTools button and F12/Ctrl+Shift+I shortcut behind import.meta.env.DEV
- Disable devtools Tauri feature in production (Cargo.toml default feature removed)
- Remove overly broad http:default capability, replace with scoped http:allow-fetch
- Set withGlobalTauri to false to avoid global __TAURI__ surface exposure
- Fix reconnect race: add abort checks after room creation, URL resolve, and connect (BUG-070)
- Fix ws.ts reconnect guard: bail out safely when config is null after disconnect
- Fix DM broadcast double-send and add monotonic seq + replay buffer support via sendSequencedToUsers
- Add seqMu mutex to serialize seq assignment across broadcastDM and deliverBroadcast paths
- Fix handleFreshConnect to unregister client and close connection on buildReady failure
- Add tests for PTT allowlist, ws reconnect config-null guard, livekit abort-after-connect, and DM sequencing
2026-04-04 09:08:01 +02:00
J3vb 1fccb1cb9f fix: format gif-provider.test.ts with Prettier 2026-04-03 16:59:10 +02:00
J3vb ae6ee0b06d fix: correct docker/setup-buildx-action SHA and update TenorGif to GifResult in tests
- Fix typo in setup-buildx-action pin (d8db...→d36ec...) that caused CI to fail
- Replace stale TenorGif type with GifResult in gif-picker.test.ts lines 531-532
2026-04-03 16:49:07 +02:00
J3vb 0a08fd72bd feat: migrate GIF picker from Tenor to Klipy
Tenor shuts down June 30, 2026. Klipy is a drop-in replacement built
by the ex-Tenor team, free for production use.

- Replace tenor.ts with gifProvider.ts (api.klipy.com/v2)
- CDN allowlist updated to *.klipy.com (static.klipy.com is the real CDN)
- Add Klipy watermark logo to sent GIFs in chat (bottom-left)
- Update attribution text to "Powered by Klipy"
- Wire VITE_KLIPY_API_KEY secret into release workflow
- Add .env to .gitignore to protect local API key
2026-04-03 13:58:40 +02:00
J3vb 4393bf1349 fix: remove duplicate setScreenshareAudioVolume in video-grid mock 2026-04-03 12:43:24 +02:00
J3vb 5b5fb708f3 feat: add server Linux support (#105)
PR#88 Added server linux support and changed workflow
2026-04-03 12:42:30 +02:00
J3vb 0b1d178d31 fix: wire parseChecksumFileAny for Linux checksum lookup and add missing mock export
- updater.go: DownloadAndVerify now uses parseChecksumFileAny with
  checksumEntryNamesForGOOS so the linux/ path prefix produced by the
  release workflow's sha256sum is found correctly (fixes
  TestDownloadAndVerify_Success on ubuntu-latest)
- video-grid.test.ts: add setScreenshareAudioVolume to the
  @lib/livekitSession mock so vitest does not throw on the export
  that VideoGrid.ts imports
2026-04-03 12:27:33 +02:00