* 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>
Adds dev to the push and pull_request triggers so work on dev gets the full
check suite, and gates the Docker image build to main. That build is
verification only, so dev now runs lint, typecheck, unit tests, server tests
and clippy without waiting on it. tauri-build was already limited to PRs
targeting main.
Also adds workflow_dispatch (previous commit) so CI can be re-run without a
push.
Adds workflow_dispatch so CI can be re-run on demand without pushing a commit.
Actions runs again now that the repo is public (private minutes were exhausted
since 2026-07-24, which is why every job failed with zero steps).
Same treatment as .claude/ and CLAUDE.md: removed from the index and gitignored,
file left on disk. It is Claude Code project config, so it stays local with the
rest of the agent setup.
tools/mcp-introspect/ stays tracked -- it is a real dev tool, not agent config,
and .mcp.json is the only thing referencing it.
Rewords the framing to say what this actually is: something I build for fun and
run for a small group of friends, not a product. Tagline, alpha notice and the
"How it's built" section are rewritten in that voice, and the status heading
becomes "What works right now".
Also fixes structure inherited from the original: the what-it-is paragraph was
orphaned underneath the development-model heading, so it now sits directly after
the alpha notice, with the screenshots following and "How it's built" after
those. Stray blank-line runs removed.
Badges, tables, build instructions and the docs index are unchanged.
The separate J3vb/OwnCord-releases repo existed only because this repo was
private: it carried the AGPL source snapshot and provided a publicly-readable
update feed. Once this repo is public both roles collapse into its own Releases
page, so the mirror is pure redundancy.
- Server/config/config.go: github.repo default OwnCord-releases -> OwnCord.
This one default drives both the server self-update and the client
auto-update chain (tauri.conf.json updater.endpoints is empty, so the client
resolves through the server). No test pinned the old value.
- release.yml: drop the mirror step and its RELEASES_REPO_TOKEN guard, whose
AGPL/private-repo premise no longer holds. The existing Create GitHub Release
step is now the sole publish target. All 31 SHA pins verified intact.
- Repoint the README badge/download link, both SECURITY.md links, the
server-configuration table and sample, the system-overview diagram node and
the CHANGELOG note. SECURITY.md's advisory link is the load-bearing one: left
alone it would 404 once the mirror repo is deleted.
- README: Go 1.25+ -> 1.26+ (badge and prerequisite) to match the toolchain
actually required.
Deleting the mirror repo loses nothing: both repos' v1.1.0-alpha.2 carry
byte-identical asset sets, signatures and update manifest included.
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>
Removes .claude/ and every CLAUDE.md from the index and gitignores them; the
files stay on disk untouched. Ahead of the repo going public, this keeps agent
instructions and local skills out of the published tree. A slashless gitignore
pattern matches at any depth, so one CLAUDE.md rule covers the root, Server/
and Client/tauri-client/ copies.
Also drops a stray gitlink at .claude/worktrees/tauri-plugins (mode 160000,
committed by accident) that would have cloned as a broken submodule.
.mcp.json stays tracked: it lists MCP servers, uses ${OWNCORD_API_TOKEN}
indirection rather than a literal, and holds no secrets.
The voice gate authorized a client-supplied channel_id with role bits only,
and DM channels carry no channel_overrides rows, so any member's base
CONNECT_VOICE bit minted a LiveKit RoomJoin and CanSubscribe token for any DM.
Both voice entry points now go through a gate that re-runs the old role
predicate and additionally requires DM participation, delegating that rule to
the existing permissions.Checker.RequireChannelAccess rather than adding a
second implementation of it.
Verified by a panel of agents; a negative control of the base tree plus only
the new test file fails both non-participant tests with a LiveKit room token
issued for a DM the user is not a participant of, while both participant tests
pass on base and patched alike.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
channel_create and channel_update were handed to BroadcastToAll and enqueued
with channelID 0, so the full channel payload -- name, topic and category of a
channel that channel_overrides hides from the recipient's role -- went to every
connected client and was replayed unconditionally from the ring buffer. Both
now resolve an audience through the same READ_MESSAGES helper the voice path
uses and enqueue under the real channel id, which filters live delivery and
both replay tiers by one mechanism. channel_delete stays unfiltered by design:
the row is already gone, so a check there would strand the channel in the
sidebar of users who saw it via a positive override.
Verified by a panel of agents; a base-revert control fails on both the live
leak and the replay leak, while the pre-existing broadcast tests pass
unmodified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unbounded client-controlled values reached the 2000-entry admin log ring
buffer and its SSE fan-out, letting an unauthenticated burst pin large amounts
of heap. A boundRequestID middleware now drops an inbound X-Request-Id over
128 bytes or outside printable ASCII, so chi generates its own, and the logged
request path is capped at 256 bytes. Both hunks are needed: a raw-socket probe
showed a 1MB r.URL.Path reaches the same sink independently of the header.
Verified by a panel of agents; an unpatched-tree reproduction fails 3 of the 4
added tests with the attacker bytes visible in the log record. UUID, 32-hex,
W3C traceparent and chi's own generated id format all still pass unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both process-wide negative caches were filled with errors from a fetch driven
by the caller's context, so an unauthenticated client that aborted a request
which hit a cache miss wrote its own context.Canceled into a 5-minute shared
failure cache -- also blocking the owner's admin panel. The outbound fetch at
both sites is now driven by a server-owned context (WithoutCancel plus a 30s
timeout), so caller cancellation can no longer reach the cache while genuine
upstream failures are still cached.
Verified by a panel of agents; the added tests fail against an unpatched base
with a poisoned cache, and the pre-existing error-caching tests still pass.
The missing singleflight on CheckForUpdate is pre-existing and unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every third-party action in release.yml resolved through a mutable tag or
branch inside jobs that hold TAURI_SIGNING_PRIVATE_KEY and
SERVER_UPDATE_SIGNING_PRIVATE_KEY, so anyone able to repoint an upstream ref
gained code execution beside OwnCord's code-signing keys. All 31 uses refs are
now pinned to full commit SHAs with version comments, matching what ci.yml
already does.
No tests cover this change: nothing in the project exercises
.github/workflows, and GitHub Actions cannot run in the local environment.
The change was verified by a panel of agents on review alone. Confirmed here:
the diff touches 31 uses lines and nothing else, release.yml still parses with
all 6 jobs and their step counts intact, all 8 actions shared with ci.yml
carry byte-identical pins, and the 3 release-only pins were checked against
upstream. The release is now frozen to the pinned versions; Dependabot manages
that ecosystem weekly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EditMessage authorized the non-DM path with permissions.SendMessages alone
while every sibling message sink requires ReadMessages plus the mutate bit, so
a user denied READ_MESSAGES could still rewrite an old post and have the edit
broadcast to the channel. The edit gate now calls the existing
checkSendPermission helper and collapses its error into the sink's
pre-existing opaque ErrForbidden, so the reply stays a non-oracle.
Verified by a panel of agents; the added test fails against the unpatched
tree, showing the edit succeeded before the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guest's linear memory was taken from mod.Memory() and used unchecked, so
an untrusted plugin wasm with no memory section nil-dereferenced on the
unrecovered startup path and crashed the server. All guest-memory access now
goes through one guestMemory() helper that detects wazero's non-nil interface
wrapping a nil *MemoryInstance, binding no commands at activation and
returning the existing missing-export diagnostic on dispatch.
Verified by a panel of agents; the added regression test panics with the
finding's exact stack against the unpatched tree.
Note: TestRegistry_Activate_WithoutRuntime and
TestRegistry_EnablePlugin_RollsBackWhenActivationFails fail under
-tags wazero, confirmed here to fail identically on the base tree. They are
pre-existing and unrelated; CI builds the wazero variant but does not test it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
READ_MESSAGES was authorized once at channel_focus and then frozen into a
durable pub/sub subscription that no role change re-evaluated, so a demoted
user kept receiving every message posted in channels their new role can no
longer read. BroadcastMemberUpdate now recomputes the allowed set from the
user's current role and unsubscribes each held channel topic it no longer
covers, evicting the socket if visibility cannot be resolved.
Verified by a panel of agents; both added tests were confirmed failing
against the unpatched tree.
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>
Both families version-lock their packages with exact peer pins
(typescript-checker@9.6.0 requires core@9.6.0, not ^9.6.0), so
dependabot's default PR-per-package split breaks npm install whenever
only some of them merge. This is what took main down today.
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>
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>
Make server failures debuggable without leaking secrets:
- configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL)
- preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly
from bad tokens; log the previously-silent expired-session cleanup goroutine
- route HTTP handler panics through slog (was chi stderr-only, invisible to
the admin log stream)
- stackutil: argument-free panic stacks so key/token bytes never reach the
admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/
GIFConfig/Config and db.User/db.Session
- logctx: req_id/trace_id correlation on ...Context log calls
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>
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
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>
Each verified: the generated dbgen method's only references were the
.sql definition and dbgen output itself (no wrapper in db/*.go, no
test, no script). ArchiveChannel, DeleteAttachment,
FindExistingDMChannel, GetDefaultRole, GetMessagesByChannel,
GetMessagesByChannelBeforeCursor, GetMessagesForAPIBeforeCursor,
GetPinnedMessageRows, GetPlugin, GetPluginByName, InsertDMChannel,
InsertDMOpenState, InsertDMParticipants, LinkAttachmentToMessage,
SetChannelMixingThreshold, SetChannelVoiceMaxVideo,
SetChannelVoiceQuality, UpdateVoiceSpeaking.
dbgen regenerated with the pinned sqlc v1.30.0 (132 → 114 queries);
sqlc-verify clean; db/service/ws suites green including -race.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applied from a deadcode (RTA from mains, all build tags) sweep with
per-symbol adversarial verification:
Deleted (nothing but their own self-tests used them):
- admin.Handler (deprecated since Phase 6; production mounts NewHandler)
plus its two self-tests
- ws.Hub.broadcastVoiceStateUpdate + wrapper + two self-tests (pre-V2
leftover; the live voice_state path is the hub voice routines)
- ws.VoiceLeaveEvent + methods ('retained as scaffolding', never
constructed in production; MsgTypeVoiceLeaveBC stays — live via the
leave routine)
- ws.parseIdentity (production calls parseParticipantIdentity directly;
ParseIdentityForTest now exercises the real parser)
- telemetry.Float64 (String/Int64 are used; the float case is covered by
the otel-tagged internal test, re-addable when a caller appears)
Moved into export_test.go so they leave the production binary (all
callers are same-package tests): the eight ws test-client constructors
and voice/E2EE setters from ws/client.go, admin.SetBackupBaseDir
(new admin/export_test.go), api.SecurityHeaders (test-only wrapper;
production uses SecurityHeadersWithTLS — docs/api.md updated to the
real name). Client.getVoiceJoinToken/setVoiceChID inlined into their
existing ForTest wrappers; TestSetVoiceChID_* self-tests deleted.
Kept after verification: updater.SetBaseURL (11 cross-package test call
sites) and telemetry.resetAppMetricsForInit (live under -tags otel —
untagged deadcode false positive).
Full gate green: gofmt/vet, 4 build-tag variants, full suite, deadlock,
race.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- security-hardening-remediation.md: status header now records that only
W2-4 and W3-3 remain open (verified by the 2026-07-23 deletion audit),
with a staleness note scoping the deleted store/-and-Postgres
references as historical. Closes audit finding A-2026-07-15.
- security-scan-2026-07-22-remediation.md: F6 recorded as committed
(ef58c04); resume checklist trimmed — F3 (voice E2EE identity TOFU)
is the only remaining finding.
- audit-2026-07-19.md: A-2026-07-15 closure row flipped to RESOLVED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified-safe deletions from the 2026-07-23 deletion audit, applied now
that the permission-consolidation work (which deferred IsOwnerRole) has
landed:
- Server/service/voice.go: VoiceService was constructed in service.New
and never called by any handler, ws routine, or test.
- permissions.IsOwnerRole: zero callers.
- Server/admin/static/admin-mockup.html: 1299 lines embedded into every
release binary via //go:embed static, referenced by nothing.
- .cache/project-map/*.json: tool cache committed before .gitignore
grew the .cache/ rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design note (permission-middleware-consolidation.md, status implemented),
closure-table + §3 rows for A-2026-07-16, the A-2026-07-07 amendment
recording the missed fifth site, the D13 decision row, and the settled
two-scope authorization contract in architecture/server.md. Backlog row
12 stays untouched: the auth-route sweep is deferred to a future D14.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes audit finding A-2026-07-16, two defects in the same rule:
- permissions.HasServerPerm (admin bypass OR all-of bit test) replaces
the hand-rolled copies in api.RequirePermission (whose raw test was
any-of for multi-bit masks) and ModerationService.requireBanPermission.
RequirePermission's doc comment now states the scope contract: role
bitfield only, channel overrides deliberately not consulted.
- PermissionService.getOrPopulate and ChannelService.ListVisibleChannels
no longer substitute an empty override map when
GetAllChannelPermissionsForRole errors. That silently dropped every
channel-level deny — and the permission cache then served the degraded
snapshot for permCacheTTL (30s) across ~25 callers. Both fail closed
now; admins skip the fetch entirely (they bypass channel checks).
- PermissionService.HasChannelPerm delegates to Checker.HasChannelPermBatch
and MessageService.GetAccessibleChannelIDs to VisibleChannelIDs — the
missed fifth D9 site, making that closure true rather than aspirational.
- AuthMiddleware rejects a dangling role_id (GetRoleByID returns nil,
nil) with 401 instead of putting a nil role in the request context.
Locked by failing-first tests: override-fetch-error denies (cached and
uncached paths), admin-outage skip, multi-bit all-of, channel allow
override must not grant a server-wide route, 403 locks on both
RequirePermission routes, dangling-role 401.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An InvalidateUser/InvalidateChannel/InvalidateAll landing between
getOrPopulate's DB read and its cache store was silently overwritten by
the stale snapshot, serving revoked permissions for up to permCacheTTL
(30s). Guard the cache write with a generation counter bumped by every
invalidation; a populate that lost the race returns its snapshot for the
current request but caches nothing (security scan 2026-07-22, F6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Handoff doc: F1/F2/F5/F7 and F4/F8 committed, F6 done but riding with the permission-consolidation WIP, and the full F3 (voice E2EE identity keys + TOFU) design + PR split for the remaining work.
Co-Authored-By: Claude Opus 4.8 <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>
hasChannelPerm resolved permissions from the connect-time role snapshot (c.user.RoleID), so a user reassigned to a lower role kept the old role's voice privileges (CONNECT_VOICE and the SPEAK/VIDEO grants in the LiveKit token) until reconnect. Resolve the current role via GetRoleForUser(c.userID), matching the V2 handlers. (Security scan F5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
invokeCommand drove a shared wazero module (allocate/mem.Write/command_dispatch/mem.Read) with no per-instance lock, so concurrent invocations of the same plugin command raced the module's linear-memory buffer. Add a per-Instance mutex around the guest-call sequence. Confirmed under -race. (Security scan F2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-username brute-force lockout keyed on the raw request username while GetUserByUsername matches COLLATE NOCASE, so case variants (admin/Admin/ADMIN) each got an independent 9-attempt bucket, multiplying allowed guesses per account. Lowercase the username before building the login_user_fail/login_user_lock keys so all casings share one bucket. (Security scan F1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FetchTextAssetCached served the unauthenticated, unrate-limited client-update
endpoint. On TTL expiry every concurrent caller missed the cache and issued its
own outbound fetch: a measured 25 requests for 25 callers. Failures were not
cached at all, so an upstream outage produced one outbound request per caller
for as long as it lasted.
Guards the refresh with singleflight so a burst issues one fetch, caches
failures for errorCacheTTL (mirroring the release cache's existing cachedErr
idiom), and evicts expired keys so the map no longer grows by one entry per
release for the process lifetime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nhooyr.io/websocket now resolves to github.com/nhooyr/websocket-old and its
README is a one-line deprecation pointing at coder/websocket. Its last three
releases (v1.8.15-17) all shipped on 2024-08-10 as the redirect; the fork has
shipped through 2026-06-15.
The version number decreases (v1.8.17 -> v1.8.15) because both paths tagged in
the same space, but the coder release is ~2 years newer. Import path only; the
9 API symbols used are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GIFs are off by default and each operator supplies their own Klipy key,
but nothing user-facing said so — README and quick-start had zero mentions,
so a fresh self-hoster had no way to learn the feature exists.
Records the decision as D12 with the rejected alternatives, so the
trade-off is not silently revisited later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GIF key now lives in server config, so no build job needs it. Leaving
the secret wired into the build env is the exact mechanism that leaked the
original key: any future re-add of import.meta.env.VITE_KLIPY_API_KEY would
silently inline it into the bundle again with the secret already present.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update the security doc's capability section with the deny list, the fact
that `http:allow-fetch` is the only URL-scoped HTTP identifier, and why
the https wildcard cannot be removed without moving the link-preview
fetch into Rust. Note under Known Limitations that narrowing the plugin
scope alone does not bound exfiltration while CSP `connect-src` allows
`https:` to any host. Add a capability row to the client architecture
doc and mark the design note implemented.
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.
Investigates whether the https wildcard on the three http:allow-fetch*
identifiers can be enumerated now that the HTTP TOFU proxy has landed.
Two findings change the answer:
- Only http:allow-fetch is URL-scoped. tauri-plugin-http validates the URL
in the `fetch` command only; `fetch_send` and `fetch_read_body` take a
ResourceId and never consult a scope, and Tauri's ACL resolver keeps a
command-declaring permission's scope as command scope. The allow blocks
on the other two identifiers are inert.
- The host set is not enumerable: api.ts, profiles.ts and attachments.ts
are loopback-only (attachment URLs are always server-generated
/api/v1/files/<id>) and media.ts hits one fixed YouTube oEmbed URL, but
embeds.ts fetches arbitrary user-posted URLs by design.
Decision: keep the https wildcard with a loopback deny list, drop the two
inert scope blocks, and record the Rust-side link-preview command as the
follow-up that would actually make the set enumerable. Residual risk
(CSP connect-src already allows https:) stated explicitly.
The closure rationale for audit finding #4 claimed in five places that
nothing in the server calls EventSink.Dispatch. That is disprovable by
grep: ws/hub.go:1034 calls Dispatch on every broadcast message, and
api/router.go:134-139 wires h.pluginSink whenever plugins are enabled.
The call site is pre-existing on main, not introduced by this branch.
Restate the closure on the claim the evidence actually supports:
Dispatch has exactly one caller outside the plugin package's tests
(ws/hub.go, on the hub's broadcast goroutine under seqMu), but its loop
body invokes no guest code and no production code calls Subscribe, so
the subscriber set is always empty and no guest code executes on the
event path. Finding #4 stays closed; the reason changes.
Also warn on Subscribe that adding the first production caller turns
Dispatch's loop live on the hub's hot path, and note in the SECURITY
GATE that the call site already exists so wiring delivery is not a new
integration.
Corrected in: plugin/host_events.go (Dispatch + Subscribe comments),
plugin/audit_closure_test.go, docs/audit-2026-04-07.md (row 4 and the
structural-mitigation paragraph), docs/audit-2026-07-19.md §1 row,
docs/plans/audit-2026-07-19-decisions.md D11.
Comments and docs only — no behaviour change.
P3 item 4. Each of the five plugin CRITICALs in audit-2026-04-07.md was
re-verified against the current Server/plugin/ code rather than the tracker:
- #1 invokeCommand timeout — CLOSED. Per-call CPU budget (manifest →
config → 100ms floor) + WithCloseOnContextDone + lazy re-instantiation.
Landed in PR #1182 (7b178ff, b13adf2); pinned by the W1-1 test.
- #2 storage key isolation — CLOSED. The premise did not hold: the namespace
is the caller's Instance.ID and plugin_kv PRIMARY KEY (plugin_id, key).
- #3 per-command ACL — CLOSED by the manifest `commands` ACL in 3d2dd19.
- #4 event rate limit — CLOSED as not reachable: EventSink.Dispatch invokes
no guest code and has zero callers; the requirement is recorded as a
SECURITY GATE at the point delivery would be wired.
- #5 HTTP exfiltration — OPEN, accepted residual risk. An allowlisted host is
by definition a permitted destination; closing it needs egress content
policy and per-plugin allowlists, i.e. a runtime redesign, out of scope
for P3.
Because #5 stays open the standing rule fires as written: plugins ship
default-disabled at the beta gate. Re-verified in config.DefaultConfig() —
Plugins.Enabled false, HTTPAllowlist empty. Also records the structural
mitigation covering #2/#4/#5: no host imports are wired into the wazero
runtime, so command_dispatch and list_commands are the only guest-reachable
entry points today.
Mirrors the outcome in the §1 carried-over row of audit-2026-07-19.md,
records decision D11 in plans/audit-2026-07-19-decisions.md, and notes in
plans/slash-commands.md which slice of its manifest design already landed.
Closes audit-2026-04-07 CRITICAL #3. Holding the `commands` capability used
to bind whatever names the guest module returned from `list_commands`, so an
admin enabling a plugin could not know which commands it would claim and a
plugin could widen its own command surface after review.
The manifest is now the authority. `plugin.json` gains a `commands` block
(`[{"name": "hello"}]`) and `RegisterCommand` refuses any undeclared name —
the single choke point both auto-registration and direct registration route
through, so no caller can bypass it. Declared names are validated to the
dispatcher's canonical lowercase form, deduplicated, and capped at 64.
The object shape matches docs/plans/slash-commands.md so the richer
per-command schema can land later without a manifest migration.
Also pins the two neighbouring CRITICALs that verification found already
closed, and adds the storage key cap host_storage.go's doc comment already
promised:
- #2 (storage key isolation): TestStorageKeysIsolatedPerPlugin — the KV
namespace is the caller's Instance.ID with no parameter to override it,
and plugin_kv PRIMARY KEY (plugin_id, key) makes the split structural.
- #4 (event rate limit): TestEventDeliveryHasNoGuestPath — EventSink.Dispatch
invokes no guest code and has no callers, so there is nothing to limit yet;
a SECURITY GATE comment requires the limiter in whatever change wires
delivery.
- #5 mitigation: TestEmptyAllowlistDeniesEveryHost — the shipped empty
http_allowlist must fail closed.
BREAKING CHANGE: a plugin declaring the `commands` capability must now list
its commands in the manifest's `commands` block; undeclared names no longer
bind. Only the in-repo `hello` example is affected and is updated here.
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 client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:
- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
comes from the new `gif.api_key` config section (koanf,
OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
can hide the picker instead of showing a broken one. Auth is checked first,
so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
the narrow struct is the allowlist, so an upstream that echoed the key
could not leak it. Upstream errors become a generic 502 and the key is
redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
traffic cannot exhaust the shared bucket used by password/TOTP endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows 1, 2, 4, 6, 7, 8, 9 were all closed in the closure table but never
struck in the section 6 backlog, making the remaining work look ~4x larger
than it is. Only rows 10 (partial) and 12 are still open.
Also drops the stale "V1/V2 dispatch" blurb from the architecture index,
which contradicted websocket.md after the V1 registry was deleted in #1196.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The suite was never red: on Node 22+ native Web Storage shadows jsdom's
localStorage, failing ~478 unrelated tests locally. Documents the workaround
where a future session will hit it, and corrects the stale KNOWN RED
assertions in docs/architecture/client.md and the ci-check skill that
contradicted the new green-and-must-stay-green rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- audit-2026-07-19.md: A-2026-07-07 and A-2026-07-09 → RESOLVED
2026-07-20 in the findings tables; §6 backlog rows 3 and 11 struck
through as DONE (D9/D10)
- plans/audit-2026-07-19-decisions.md: D9/D10 status → Implemented
- plans/channel-visibility-unification.md, v2-dispatch-migration.md:
status → implemented; v2 note records the applier-trigger shape the
voice handlers actually landed with
- architecture/server.md: WS box "V1+V2 dispatch" → "typed command
dispatch"
- architecture/websocket.md: intro + §D4c redrawn as the single typed
path (no V1 fallback)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the last three V1 message types to typed V2 handlers, then remove
the V1 registry and fallback path so handleMessage has a single dispatch
generation (audit A-2026-07-09 / backlog item 11). Server-internal only —
the envelope wire format is unchanged, no client/protocol edits.
- chat_command: ChatCommandCmd + constructor (empty/args guards) and a
V2 handler returning an ephemeral Reply plus a channel-routed
PluginBroadcastEvent gated by MessageService.CanPost; PluginDeps reads
the registry live (wired post-construction)
- voice_join/voice_leave: V2 handlers gate parse/rate-limit and hand off
to the still-hub-internal handleVoiceJoin/handleVoiceLeave routines via
new Result.JoinVoice / Result.LeaveVoice appliers (those routines are
also called un-throttled on disconnect and channel switch)
- delete HandlerRegistry.handlers/Register/Dispatch/RegisteredTypes/
IsRegisteredV1/hasV2, the MessageHandler type, and the V1-shadowing
guard; NewHub registers only V2
- tests: per-handler V2 tests + a parity guard asserting every command
constructor has a V2 handler and vice versa (locks the migration shut)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route the four "must mirror" READ_MESSAGES filters — REST
ListVisibleChannels, ws buildReady, reconnect replay
computeAllowedChannels, and hub RefreshChannelVisibility — through a
single permissions.Checker predicate so a drift can never leak a
private channel (audit A-2026-07-07 / backlog item 3).
- add permissions.Checker.VisibleChannelIDs + ChannelRef (skips dm,
fails closed, admin bypass via HasChannelPermBatch)
- delegate the three batch sites; RefreshChannelVisibility uses
HasChannelPerm instead of its inline EffectivePerms copy
- REST/WS agreement test asserting all three sites yield the identical
non-DM set across admin / member-with-deny / denied-everywhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greenlight backlog items 3 (A-2026-07-07) and 11 (A-2026-07-09) for
implementation. One design note each in docs/plans/ matching the existing
per-decision format (problem, approach, files touched, test plan, non-goals),
plus D9/D10 rows in the maintainer decisions doc dated 2026-07-20.
Design-only step of the audit-backlog PR; no code changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add decision D9 (dated 2026-07-20) to the audit decisions doc capturing the
maintainer-approved policy: best-effort audit writes, never silently
discarded, routed through db.WriteAudit. Mark carried-over finding #10 in
docs/audit-2026-07-19.md as RESOLVED with the helper adoption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Audit writes stay best-effort — a LogAudit failure must never fail or abort
the request — but a failed write must no longer be silently discarded. Add
db.WriteAudit(auditor, actor, action, targetType, targetID, detail), which
logs a failed write with actor/action/target context (never the detail
string, which may be sensitive) and never propagates the error.
The Auditor interface is satisfied structurally by both *db.DB and the
service-layer Store, so api/admin/ws/service all reach the helper without an
import cycle. Converts all ~26 call sites from `_ = LogAudit(...)` (and the
two backup handlers' inline `if err` blocks) to db.WriteAudit. Pinned by
db/audit_test.go: failure logged and not propagated, success logs nothing,
detail never leaks.
Resolves the repo-wide LogAudit policy question flagged by the D8 note.
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>
Mark the voice-and-e2ee.md §1 (store-backed voiceStatus) and §2 (visible E2EE
securing/secured indicator) gaps as implemented, noting the client runs the ECDH
key exchange before the media connect. Close the remaining voice column of the
README.md §3 connection-status table: voice controls now freeze on WS reconnect.
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>
The full vitest suite passes (verified locally, 3252/3252), but both CLAUDE.md
files, ci.yml's client-tests comment, and audit item A-2026-07-04 still called
it "KNOWN RED" pending a P2 triage. Update all four to say the suite is green
and must stay green, and close A-2026-07-04 (2026-07-20). Flipping client-tests
to blocking + the nightly Playwright gate remain tracked as backlog #10.
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>