* fix(docker): build the server image with Go 1.26
The Docker verify job failed with "go.mod requires go >= 1.26 (running go
1.25.12; GOTOOLCHAIN=local)". The Go 1.26 upgrade bumped go.mod but left the
Dockerfile on golang:1.25-bookworm, and GOTOOLCHAIN=local in the base image
means it cannot download a newer toolchain.
golang:1.26-bookworm confirmed present upstream. Not verified locally (Docker
Desktop not running); the CI Docker job proves it on this PR.
* fix(client): override brace-expansion and qs to patched versions
npm audit --audit-level=high failed the Client Static Checks job with 10
vulnerabilities (8 high, 2 moderate). npm audit fix could not resolve any of
them.
There is really only one advisory behind the eight high findings:
brace-expansion <=5.0.7, a DoS via unbounded expansion length causing OOM.
minimatch, glob, test-exclude, @vitest/coverage-v8, eslint and @eslint/* were
all just transitive consumers of it, and those top-level dev deps are already
at their latest versions, so no bump reaches the fix. qs 6.11.1-6.15.1 is a
second, independent advisory arriving via @stryker-mutator/core ->
typed-rest-client.
No patch exists inside the brace-expansion 1.x or 2.x lines (the fix landed in
5.0.8), so overrides are the only route. Collapsing every copy to 5.0.9 risked
breaking minimatch 3.x, which requires it as CJS, so the whole client gate was
run to check: npm audit 0 vulnerabilities, tsc clean, oxlint unchanged
(pre-existing no-underscore-dangle warnings only), eslint exit 0, prettier
clean, and vitest 3572 tests across 129 files all passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop running the suite twice for one push to dev
Listing dev under both push and pull_request meant a single push to dev fired
both events, running every job twice (visible as duplicated checks on #1274).
While a dev -> main PR is open, pull_request(synchronize) already covers each
push to dev, so dev only needs the pull_request trigger. workflow_dispatch
covers a dev branch with no PR open yet.
* chore(deps): roll up the seven open dependabot PRs
Consolidates #1267-#1273 onto this branch so they land as one CI run instead
of seven, each of which was triggering the full suite including tauri-build.
- google.golang.org/grpc 1.81.1 -> 1.82.1 (#1267)
- github.com/google/cel-go 0.28.1 -> 0.29.0 (#1268)
- defu 6.1.4 -> 6.1.7, root lockfile (#1269)
- tauri 2.11.0 -> 2.11.1 (#1270)
- @modelcontextprotocol/sdk 1.29 -> 1.30 (#1271)
- tar 0.4.45 -> 0.4.46 (#1272)
- serde_with 3.18.0 -> 3.21.0 (#1273)
Applied by regenerating each lockfile from its manifest rather than merging
seven lockfile diffs.
Verified: go build across all four tag variants, go vet, govulncheck (0
vulnerabilities in called code), go test -race (14 packages, 0 failures),
cargo clippy --all-targets -D warnings, cargo test --lib (73 passed).
CI covers neither the root package.json nor tools/mcp-introspect, so those two
were checked by hand: changelogen still runs under defu 6.1.7 (release.yml
depends on it) and the introspect server still imports the 1.30 SDK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): drop the brace-expansion override, scope the audit to shipped deps
The brace-expansion override I added to clear npm audit broke Client Unit
Tests in CI:
TypeError: (0 , brace_expansion_1.default) is not a function
at minimatch braceExpand -> TestExclude.glob
-> V8CoverageProvider.getUntestedFiles
minimatch requires brace-expansion as CJS and v5 is not callable that way. It
only fires under --coverage, which is why a local `vitest run` missed it; CI
runs `vitest run --coverage`. Verified the fix with that exact command.
There is no patched brace-expansion in the 1.x/2.x lines those tools pin (the
fix landed in 5.0.8), and eslint, @vitest/coverage-v8 and stryker are already
latest, so no bump reaches it. Since the whole chain is dev tooling that never
ships, the gate is now `npm audit --omit=dev --audit-level=high`, which
reports 0 vulnerabilities. The reasoning and the revisit condition are
recorded in ci.yml next to the step.
The qs override stays: qs is CJS, the override is proven safe, and it closes a
real advisory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): route all cert-tofu emits through the single call site
Tauri Full Build failed on all three platforms with the generated bindings
redeclaring onCertTofu (TS2323/TS2393), which killed `tauri build` at its
beforeBuildCommand:
src/generated/events.ts(36,23): error TS2323: Cannot redeclare exported
variable 'onCertTofu'.
tauri-typegen emits one onCertTofu binding per `emit("cert-tofu", ..)` call
site it finds. ws_proxy.rs already funnelled its emits through a helper for
exactly this reason -- its doc comment says so -- but http_proxy.rs emitted
directly from all three TOFU outcomes, so the crate had four call sites.
Makes ws_proxy::emit_cert_tofu pub(crate) and routes http_proxy's trusted,
first_use and mismatch paths through it, leaving one call site crate-wide. The
now-unused Emitter import is dropped from http_proxy so clippy -D warnings
stays clean. Behaviour is unchanged: same event name, same payloads, same
order.
Not reproducible locally -- typegen only regenerates under CI's clean
checkout, and a full `npm run tauri build` here passes tsc either way -- so the
Tauri Full Build job on this PR is the proof. Verified locally: exactly one
emit("cert-tofu") call site remains, cargo clippy --all-targets -D warnings
clean, and the release build completes through bundling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run tauri build` refused to start: Tauri rejects a Rust crate and its npm
counterpart on different minor versions, and the pair had drifted to
tauri-plugin-dialog 2.6.0 vs @tauri-apps/plugin-dialog 2.7.2.
Cargo.toml already requires "2", which permits 2.7.2 -- only Cargo.lock was
stale, so this is a lockfile-only change: one package moved, the other 162
dependencies untouched. The npm side was bumped previously without the Rust
lockfile following.
Not caught by CI because the client bundle is only built in release.yml, and
Actions has been failing since 2026-07-24 on exhausted private minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(auth): add revocable API tokens (bot/service auth)
Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.
- migration 018 + dedicated api_tokens table (kept separate from sessions so
bulk logout and the per-user session cap never touch these); only the
SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
and adminAuthMiddleware now call. Sessions are matched first so existing
login behavior is unchanged; API tokens are a fallback only on session miss.
A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
api middleware integration (valid + revoked token)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): add owncord-introspect MCP server
A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.
- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
exposes api_request (full read-write passthrough), server_logs (admin SSE
ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dependencies): update and add various crate versions in Cargo.lock
* feat(admin): manage API tokens from the admin panel
Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.
- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
backups/updates): an HTTP token-mint endpoint is a network-reachable
credential-minting surface, and API tokens deliberately survive password
change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
from request context (audits who clicked, not the bound user); the raw
token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: modernize to Go 1.26 idioms + enable modernize linter
Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).
Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.
- plugin/manifest.go parent-traversal check applied by hand: modernize
skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.
No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(admin): reject banned users in admin auth (F1)
adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)
registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(service): require READ_MESSAGES to delete messages (F4)
The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)
Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(service): enforce the DM block at every DM interaction sink (F5)
The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)
voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)
The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)
Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)
A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(auth): reserve the [deleted-N] username namespace (F11, F12)
The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): strip Unicode format characters from upload filenames (F13)
The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): reserve the login attempt before the bcrypt compare (F3)
The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.
Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.
Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri
Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:
tauri-build 2.5.6 -> 2.6.3
tauri-plugin-fs 2.4.5 -> 2.5.1
tauri-plugin-http 2.5.7 -> 2.5.9
tauri-plugin-store 2.4.2 -> 2.4.4
webpki-roots 1.0.6 -> 1.0.9
All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.
Verified with cargo check --locked --all-targets (exit 0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client
Dependabot #1258.
8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:
UserBar.ts / members.store.ts "online" as UserStatus -> "online"
(the receiver already accepts the literal)
media.ts drops `as RequestInit` on a literal that is
already assignable
LoginForm.ts drops `as { message: unknown }` made
redundant by the `"message" in err` narrowing
All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@stryker-mutator/{api,core,vitest-runner} were bumped to 9.6.1 while
typescript-checker stayed at 9.6.0, which hard-pins core@9.6.0 as a peer.
npm install failed with ERESOLVE. Bump typescript-checker to match.
Also reformat two files for prettier 3.9.6, which changed how union
types are broken across lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Rebasing onto post-F3 main surfaced two spots the auto-merge left inconsistent:
- profile_handler UpdateIdentityKey path: thread ctx into the writeServiceError
call (the signature gained a context param in the server logging change)
- identity-pin store lookup: drop a needless borrow flagged by clippy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>
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>
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>
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>
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>
- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
server-echoed platforms key matches the updater plugin's
{os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
HostScopedVerifier; the GitHub installer download validates against web PKI
instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
per-arch AppImage) and returns 204 for targets without a published updater
artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
top-level pair kept pointing at the Windows binary so deployed servers still
verify); VerifyReleaseManifest resolves the entry matching the downloaded
asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
merge the two ptt tests that raced on the global PTT_VKEY atomic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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.
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>