Files
OwnCord/CHANGELOG.md
T
J3vbandClaude Opus 5 2a37f386f9 B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)
* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

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

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

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

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

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

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

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

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

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

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

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

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

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

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

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

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

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

---------

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

44 KiB
Raw Blame History

Changelog

All notable changes to OwnCord are listed here. The repository's release tooling (npm run changelog) auto-generates entries from commit messages on each release; this file is the curated counterpart that calls out behavioural changes operators must know about.

v1.2.0-alpha.3

  • fix: eight bug-hunt batches closed 199 verified defects since v1.2.0-alpha.2 — 30 in #1366/#1367, 110 in #1369#1372, 34 in #1374 and 25 in #1375 — each fixed test-first with the failing assertion watched red against the unpatched code. The behavioural consequences worth knowing about are listed below; the rest are one-line correctness fixes with no operator-visible change.
  • security(client): voice E2EE was never actually enabled (#1370). The full ECDH/HKDF/AES-GCM key exchange completed, the room key was set, and the UI showed 🔒 Secured — but createRoom never called room.setE2EEEnabled(true), so every audio and video frame reached the SFU in plaintext. It is enabled now, and a dead E2EE worker is no longer invisible to the Secured badge. Related voice-crypto fixes: a joining key holder sent its room-key offers before its own announce, so existing participants dropped them as "unknown peer" (#1370, #1374); rotation offers exceeded the server rate limit in large channels and permanently starved the same peers; both rotation paths and the reconnect-to-Secured path now carry session-generation guards; a departing peer's ephemeral key is retired on leave so a replayed pre-leave announce cannot overwrite the fresh key they rejoined with (#1372, #1374). The client also refreshed its LiveKit token every 23 hours while the server mints it with a 5-minute TTL, so auto-reconnect failed for any voice session older than five minutes (#1370).
  • security(server): access-control holes (#1369#1372, #1374, #1375) — voice_join into a 1:1 DM had no block gate, so a blocked user could enter the blocker's DM voice room; the attachment-serve admin bypass let an ADMINISTRATOR download files from private DMs they were not in; the archived-channel read-only gate covered SendMessage only, so edit, reaction, pin, purge, delete and channel_focus still mutated or subscribed to archived channels (every write sink now routes through one requireChannelWritable gate); EditMessage and handleReaction DM detection failed open on a GetChannel error, skipping the block gate; group-DM creation only block-checked the creator, letting a third party force two users who blocked each other into a shared room; an invisible user's real custom status leaked on both presence emitters; PATCH /users/{id} with banned + role_id committed and broadcast the ban before authorizing the role change; admin API-token creation accepted a negative expires_hours and minted a token that never expires; upload rejections echoed raw storage errors (absolute server paths) to any authenticated user; the GIF proxy's log redaction missed the percent-encoded API key; chat_command was the only client message type without a rate limiter while each frame ran a WASM plugin invocation; and the login and typing rate limiters built their keys from unvalidated input, letting an unauthenticated caller pin unbounded heap for six hours.
  • fix(auth): accounts whose username contains ', " or & were permanently unloggable — registration HTML-escaped the name but login did not — and a profile rename to such a name locked the user out (#1370). If you had users hit this, they can log in again with no action on your side. Also: message search returned 500 for any query containing a hyphen (the one FTS5 operator the sanitizer allowlisted); usernames with an uppercase non-ASCII letter could never be @mentioned; registration recorded the reverse-proxy address as the session IP.
  • server: WS hub, reconnect and replay (#1369, #1371, #1372, #1374, #1375) — REST DM events never bumped the visibility watermark, while every ordinary DM message re-emitted dm_channel_open and bumped the global watermark, forcing every other client's next reconnect into a full resync; the client's lastSeq was never reset by a full-ready resync and desynced permanently; cold-tier replay had no interior-gap detection, so events the persister dropped were skipped and presented as a complete resume; buildReady swallowed three DB errors and shipped an authoritative-looking empty snapshot (the client wiped its DM list, member list and unread badges) and dropped the user's own live voice room when not READ-visible; channel_focus could re-subscribe after a concurrent visibility revoke, and role demotion's live-subscription revocation was gated on a cosmetic role re-read; a failed reconnect handshake ran the full disconnect teardown twice; presence events from every source now share one ordered per-client FIFO.
  • server: voice lifecycle (#1369, #1371, #1374) — a stale join's rollback deleted voice_states by user id alone, destroying a concurrent newer membership; deleting a voice channel raced a concurrent voice_join into a permanent hub/SFU ghost no sweep could heal; the stale-state sweep could delete a just-committed join's row, leaving the client in voice with no DB row; handleVoiceJoin handed out a live 5-minute LiveKit credential after a concurrent kick/move/revocation had already torn the membership down (the token is now withheld); the participant_left webhook never told the leaver, and a transient DB read error on participant_joined ejected a legitimate participant mid-call; voice_mod_move lacked the archived-channel gate; CleanupVoiceForChannel resolved an empty voice_leave audience because both callers archive first. Camera and screenshare now draw from the same per-channel voice_max_video budget — screenshare had no cap check at all, and the camera gate did not count screensharing occupants.
  • server: DM and message fan-out (#1369, #1371, #1372, #1375) — a DM send, edit, delete or reaction survived a transient participant lookup failure by silently dropping live fan-out to everyone including the sender; emoji create/delete and group-DM creation tied their broadcasts to the request context, so an aborted request committed the mutation and skipped the event; slow mode consumed its cooldown token before content validation, so a rejected send locked the composer for the full window; an attachment-metadata read failure broadcast the message with no attachments; GET /channels/{id}/pins had no LIMIT and failed permanently past ~32k pins; pinning a soft-deleted message returned 500; LinkAttachmentsToMessage no longer claims a user's live avatar as a message attachment; PATCH /channels/{id} now rejects a blank name.
  • server: admin and plugins (#1369, #1370, #1372, #1375) — "Restore backup" wrote to a hardcoded data/chatserver.db, so it silently no-oped on any server with a configured database.path; the WAF inline engine rejected every request body ≥ 1 MiB, breaking plugin install and large avatar uploads when waf_enabled was on; self-account-deletion emitted no member_ban, so every other client kept the deleted user; the admin live log stream blanked every error attribute to {}; CheckForUpdate had no in-flight dedupe and stampeded GitHub on cache expiry; a failed self-update swap left every client counting down to a restart that never came (a corrective update_aborted is now broadcast, and deferred cleanup runs before the restart exits — on Windows that file-handle release is the reason the restart exists). Plugin enable/re-install left plugins.enabled = 1 while the runtime instance was deactivated, and uninstall reported success while the on-disk directory survived and resurrected the plugin on the next start.
  • fix(client): voice reliability (#1366, #1367, #1370#1372, #1374, #1375) — a failed voice channel-switch left the user live in the call (mic hot, audio flowing) with the voice UI hidden and no way to leave; selecting the "Default" microphone (or losing the pinned one to a hot unplug) never changed the capture device; a camera or screenshare disable that completed while the enable's publishTrack was in flight left the server and every peer believing it was on (leaveVoice and reconnect teardown now bump the same generation guard); a VIDEO_LIMIT rollback assumed the camera and tore down a working camera while leaving refused screen tracks published — it now correlates by envelope id; auto-idle's return-to-online presence_update was always swallowed by the 1-per-10s limiter, so every user showed Idle to everyone else after their first idle period; connection-quality degradation was never reported; a group-DM decline silenced every other participant's ring and never reached the caller.
  • fix(client): messaging and stores (#1366, #1367, #1369, #1372) — re-opening a channel visited earlier in the session rendered a permanently stale window (live broadcasts only cover the focused channel; the tail is now refetched); the virtual scroll window never followed the scroll position, so rows past the initial overscan rendered as blank space; a scroll-up page past the 500-row cap deleted the user's pending/failed rows, the only copy of their composed text; the scroll-to-bottom button and "Jump to Present" pill scrolled out of view exactly when they became visible; a user named exactly "System" had every message rendered as a server notice with no moderation controls; DM permalinks failed until the DM had been opened once; the reaction picker dropped the server's custom emoji; Ctrl+K was dead with CapsLock on; the composer's slow-mode cooldown was applied to whichever channel was mounted, not the one that sent.
  • fix(client): settings, session and platform (#1367, #1370#1372, #1375) — the built-in light theme overrode only 4 of ~45 tokens (composer and inputs near-invisible), the Font Size slider and High Contrast toggle were no-ops, and the tray Status menu bypassed the client's own status state so a tray-set Do Not Disturb silenced nothing; a failed TOTP verify tore down the overlay so the code could not be re-entered; channel create/edit/delete modals locked up permanently on an API failure; login to an IPv6-literal host was impossible; a host stored with an explicit :443 lost its bearer token and cert-pinned proxy on attachment fetches; one malformed stored server profile discarded all saved profiles; a banned/revoked token reconnected forever if the session ended before MainPage mounted; a previous server's block list, collapsed categories and DM notes bled into the next server; the Rust HTTP proxy tunnel's data phase had no deadline, so a remote that completed TLS then went silent parked the connection forever (bounded at 600s — loose on purpose, this path carries uploads); the autostart toggle raced its own write.
  • infra: observability, backups, guardrails and deployment hardening (#1376). /health now returns a real verdict — hub dispatch-loop liveness, a bounded DB ping and a free-disk check, answering 503 with a subsystem reason (hub, database, disk) when degraded; results are cached so the unauthenticated endpoint cannot amplify load. Point uptime monitors at it and treat any 503 as actionable. The hub's panic breaker now exits the process so a supervisor can restart it, instead of leaving broadcast delivery silently dead while clients still appear online — if you run the bare binary without a supervisor, use the new hardened deploy/owncord.service systemd unit (see "Running as a Linux Service"). Backups now actually run: backup_schedule and backup_retention had existed in the admin panel since the initial schema but were never read by any code; the 15-minute maintenance loop now enforces them, verifies each backup with PRAGMA integrity_check (and again before a restore may overwrite the live DB), and prunes by age keeping the newest. Expect backup files to start appearing and pruning for the first time. /api/v1/metrics gains reconnect-tier, backpressure, DB-writer-wait, permission-cache, ws_conn_rejects and disk_free_mb signals, and the declared-but-never-recorded OTel instruments are wired. Upload storage failures return 507 instead of blaming the client with a 400. A single-process lock beside the SQLite file makes a second server process fail fast instead of silently fighting the first. Unknown config keys now warn at startup (a typo previously kept the default silently), and startup warns when admin_allowed_cidrs is customized while trusted_proxies is empty. Shutdown now joins the pruner and maintenance loop before the DB closes, drains HTTP handlers into a live hub, and skips the 5s client-notice window when nobody is connected. Write-path work: no-op read-state UPSERTs are skipped, boot-time ANALYZE runs only when a migration applied, role-scoped override changes evict only that role's members from the permission cache, and connect/disconnect presence passes through a 300ms latest-wins coalescer (wire format and seq ordering unchanged).
  • config: new keys, all defaulting to current behaviour (#1376) — server.max_ws_connections (0 = unlimited; over the cap answers 503 + Retry-After), server.metrics_allowed_cidrs and server.livekit_webhook_allowed_cidrs (both fall back to admin_allowed_cidrs, so a central Prometheus scraper or an externally-hosted LiveKit no longer requires widening the admin perimeter), database.max_readers (0 = auto), backup.dir (data/backups), security.auth_rate_limit_multiplier (1.0; raise for shared-NAT communities), event_persistence.replay_ring_size (1000) and event_persistence.replay_cold_limit (5000 — watch reconnect_tier_full before raising). Three stored-but-inert admin settings (server_icon, max_upload_bytes, voice_quality) are now shown read-only with a pointer at the real config.yaml keys instead of pretending to apply. Documented in docs/server-configuration.md.
  • deploy: new chatserver healthcheck subcommand probes /health pinning the server's own certificate from disk (WebPKI when none exists, i.e. ACME) and is now the docker-compose healthcheck — the distroless image has no shell; plain docker compose only surfaces unhealthy, pair it with a watchdog for auto-restart. Compose gains json-file log rotation (10m × 3) on both services. release.yml now cold-boots the freshly built server binaries and Docker image and probes them healthy before anything is signed or pushed — the release feed drives signed self-updates, so a binary that compiled but died on boot would previously have shipped itself to every auto-updating instance. New "Reverse Proxy Topology" docs section (nginx snippet; only WebRTC media ports need to be directly reachable, /livekit/* is already proxied). Release binaries are built with Go 1.26.6 (stdlib CVE fixes flagged by govulncheck).
  • migrations: 031 normalizes legacy sessions.expires_at values to RFC3339-UTC and adds idx_sessions_expires_at, so the 15-minute expired- session sweep is an index lookup instead of a full-table scan on the writer. Applies automatically on first start; no operator action needed.
  • protocol: no wire changes — docs/protocol-schema.json, message_types.go and protocolTypes.ts are byte-identical to v1.2.0-alpha.2. Older clients and servers interoperate unchanged.
  • fix(ws): the LiveKit health check shared the process-wide http.DefaultTransport pool with every other user in the server; it now owns a private transport (#1356).
  • chore: bug-hunt tooling under .claude/ (fix pipeline, findings ledger, circuit breaker, single-finder hunt with graph-fed targeting — #1361#1365, #1373); dependency bumps (OTel 1.45.0, koanf, sqlite, eslint/oxlint/knip/typescript-eslint, tauri-plugin-updater, GitHub Actions; #1353#1360). No runtime impact.

v1.2.0-alpha.2

  • feat(client): the login form has an Auto connect checkbox under Remember password. Ticking it makes that server connect automatically on launch — the same setting as the auto-login button on a server card, so the two stay in sync, and as before only one server can be auto-connect at a time. Ticking it also forces Remember password on and locks it: auto-connect replays the stored token, which is only written when the password is remembered, so the two cannot be set independently without producing a setting that silently does nothing.

  • fix(client): Remember password works again. The password was saved to the OS keyring but never returned to the client over IPC, so the login form could not prefill it — the box appeared to work and did nothing.

  • fix: three bug-hunt sweeps closed 233 verified defects since v1.2.0-alpha.1 — 26 in #1328, 107 in #1331, 100 in #1332 — each fixed test-first, with the failing assertion watched red against the unpatched code before the patch landed. The behavioural consequences worth knowing about are listed in the nine entries below.

  • server: WS hub reconnect and replay hardening (#1328, #1331). Cold-tier replay used to truncate silently instead of forcing a full ready, and a retention-pruned event log was accepted outright as a complete resume — the highest-impact fix in #1331, since any client whose reconnect gap crossed the 24h retention default was permanently desynced. Resume also silently dropped the focused channel's topic subscription, stopping message delivery until the user manually switched channels; it is now restored during the handshake. visibilityChangeSeq can now only move forward across its three writers — it previously could regress and skip a required resync.

  • server: voice/E2EE key-holder election and audience gating (#1328, #1331) — three key-holder desync bugs (no client demotion path, peer keys cleared on reconnect, missing re-election on the webhook and fresh-reconnect paths), plus re-election wired into the sweep and channel-cleanup paths. Voice events were READ-filtered while membership is CONNECT-only, so participants in that gap silently missed voice_leave, stalling key-holder election and forward-secrecy rotation. Deleting a channel now evicts its voice participants first — the cleanup function existed but had zero production callers, so the FK cascade used to strand them silently. Moderator mute/deafen now survives a voice-channel switch; joins to non-voice channels are rejected; archived channels are read-only and unjoinable.

  • security(server): roles/permissions (#1328, #1331) — UpdateRole allowed position collisions that CreateRole already rejected, so tied positions could read as equal rank in every hierarchy comparison; it now matches CreateRole's validation. can_send is now recomputed per client on every role/override change, so a permission change takes effect for connected clients immediately rather than waiting on a reconnect.

  • server: attachments and admin data-safety (#1331) — migration 030 unlinks attachments on message delete instead of cascading, so a cascaded channel/DM delete no longer strands uploaded files on disk with no reclamation path. The 15-minute orphan-attachment sweep was deleting every avatar in the instance (avatars are, by design, attachments with no message link) on its first tick past the grace period, permanently 404ing every profile picture; a second bug in the same sweep collapsed the one-hour grace period to effectively zero, from a TEXT-comparison mismatch between an RFC3339 cutoff and SQLite's own timestamp format. A failed backup restore used to truncate the live database to zero bytes with no rollback, while the server kept answering requests against the now-closed DB and falsely claimed a restart was underway — it now restores the pre-restore safety copy on failure and requests the restart honestly. Also fixed: personal data is cleared on account deletion, banned users are excluded from owner lookup, the silent 1000-member roster cap is gone, and a sender's own read state now advances on send. Migration applies automatically on first start; no operator action needed.

  • protocol: a new READ-gated active_channel_id auth field (#1331) restores the focused-channel subscription during the reconnect handshake itself, closing the window before the post-auth_ok channel_focus round trip lands. protocol.md also corrects the presence table, which had incorrectly documented all presence events as sequenced. Older clients/servers are unaffected — it is a new, ignorable field.

  • security(client): identity/TOFU and transport (#1332) — an in-flight change to scope the identity keypair by host and user id would have re-minted a fresh key on every existing install, firing the TOFU "verify out-of-band" re-pin warning at the entire alpha population simultaneously, exactly the pattern that teaches users to click through the one warning meant to matter. The legacy host-only key is now adopted into the scoped name instead, saving before deleting so a partial failure cannot strand a user with neither key. Switching hosts carried the previous server's bearer token forward into the next login request; api.setConfig now drops it when the host changes without a replacement. A hand-copied, un-lowercased host normalizer in main.ts meant an uppercase hostname's cert-mismatch reject path skipped disconnect()/clearAuth(), leaving a user who refused a changed certificate still connected to that server — the single lowercased implementation in ws.ts is now shared everywhere.

  • fix(client): voice mic/camera reliability (#1331, #1332) — six separate paths could republish the microphone without checking the user's mute state (the audio-device fallback, selecting "Default" input, un-deafening, retryMicPermission, a stale PTT ownership latch, and auto-reconnect's restoreLocalVoiceState), each producing a hot mic while every remote UI still showed the user muted; all now route through isMicPolicyGated(). Camera and screenshare kept publishing to the SFU after the user turned them off during the OS device picker. Enhanced Noise Suppression silently disabled the input-volume slider and VAD gate because livekit-client's own replaceTrack call landed after ours. A key-holder promotion arriving mid voice-setup was clobbered, ejecting the joiner after a timeout only it could have resolved.

  • fix(client): messaging and store reliability (#1328, #1331, #1332) — sequenced DMs could jump the FIFO ahead of sendHigh, permanently losing an event dropped before flush. A full-ready resync left every loaded channel with a permanent hole in its history, because that tier never replays chat_message frames; loaded windows are now invalidated and the active channel refetched. The WS error handler only bannered RATE_LIMITED and FORBIDDEN, so every other server error code — for example a rejected chat_edit — was dropped in silence while the optimistic "Message edited" toast still fired. A message whose chat_send_ok was lost to the same disconnect that forced a resync could render twice; the optimistic row's id-based dedup now shares the content-based match predicate addMessage already used. Replay detection compared the server's created_at against the client's own clock, so a self-hosted server without NTP made every live message after a reconnect look like a replay and silently killed its notification; both sides now use an estimated server-time skew.

  • fix(client): UI defects (#1331, #1332) — the quick-switcher could mount a second overlay, orphaning a body-mounted backdrop that blocked all input until reload. The status-picker stylesheet targeted a root element the component never toggles; a same-branch repair then left the status dot itself 0×0 and unclickable, now fixed together with a test pinning the stylesheet to the classes the component actually emits. The attachment remove button and the failed-send Retry/Discard buttons did nothing; drag-reorder's phantom-drag latch and permission gate are fixed; keyboard Tab could escape every modal because hidden (display: none) controls were still counted as focusable.

  • fix(client): the user profile popup is styled correctly again (a308f81).

  • fix(client): Vite no longer watches src-tauri/, so a running dev server does not rebuild the frontend when Rust sources or build artifacts change (cdcfc03).

  • fix(release): the stripped Linux AppImage is signed from the environment-provided key instead of a temporary key file (9d75890) — release-pipeline only, no operator action needed.

  • docs: full documentation audit against 5630aa1 — reference docs, architecture pages, and UX specs corrected; plans and prior audits given verified statuses; see docs/audit-2026-08-04-docs-and-coverage.md.

  • security(server): closed the three 2026-08-04 review findings — the channel role-override DELETE now enforces the same hierarchy guard as PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer sees DM channels, answering 404 for their ids (A-2026-08-02); DM call rings respect blocks like every other DM interaction (A-2026-08-03). Behavioural note: deleting a channel override for a nonexistent role now returns 404 (was 204), matching PUT.

  • server: migration 029 drops the never-used sounds table (dead since the initial schema; A-2026-07-13). Applies automatically on first start; no operator action.

  • protocol: the plugin command family (chat_command, command_reply, plugin_broadcast) is now part of protocol-schema.json and the generated constants (27 client→server / 39 server→client). Wire strings are unchanged — no client or plugin impact.

  • chore(client): dead modules deleted (ServerStrip, FileUpload, reconcile, a stray worklet copy, orphan sounds API methods) and the unused tauri-typegen pipeline retired (src/generated/**, its CI steps, config block, and build-dependency).

  • ci: knip is now blocking; Playwright specs are typechecked (typecheck:e2e); three orphaned native e2e specs run again; claude.yml actions are SHA-pinned; the PR template asks for docs updates per the architecture maintenance rule.

  • tests(client): the TOFU certificate ceremony has e2e coverage (first-use + mismatch journeys), and modalFactory is fully covered.

  • security(client): the voice-E2EE identity pin lookup fails closed on keyring errors (DC-08): a transient store failure used to read as "never pinned", silently sending a pinned peer down the first-sight path and re-pinning whatever key the server delivered. An unreadable pin store now rejects the peer's announce, writes nothing, and shows a distinct amber "could not check" badge until the store recovers.

  • feat(client): accessibility pass over the modal/overlay stack (DC-13): every modal is a labelled role="dialog" with a focus trap and focus restore, Escape maps to each dialog's safe action, the settings sidebar is a keyboard-navigable tablist, the quick switcher and composer autocompletes are wired as combobox/listbox, the emoji/GIF pickers are keyboard-operable, and toasts/typing announce via polite live regions.

  • feat(client): UX polish (DC-12): deleting the active channel now says so in a toast; reactions toggle optimistically with rollback on failure; the role-change menu can no longer double-fire; a document-level listener leak in channel drag-reorder is fixed.

  • feat(admin): restoring a backup now writes a backup_restore audit-log row (DC-09). The row is written before the pre-restore safety copy, so it lives inside the pre_restore_*.db backup — the restored database itself cannot carry it (the restore replaces the file).

  • ci: the -tags wazero / -tags otel Go tests now actually run in CI (DC-06) — previously those variants were only compiled, leaving ~600 lines of plugin/telemetry tests permanently dark.

  • tests(client): e2e journeys for voice-E2EE identity verification (badge states + mismatch modal, driven through the real crypto path) and the updater (banner → progress → auto-relaunch), plus an accessibility smoke; full web suite now 291 tests.

  • server/admin: in-place self-update is refused in container deployments (503 CONTAINER_DEPLOYMENT; the shipped image sets OWNCORD_CONTAINER=1, bind-mount operators can set 0 to opt back in). Container upgrades are image pulls; GET /admin/api/updates now reports can_apply and the admin panel says so instead of offering the button.

  • ci: the full client e2e suite now blocks merges (DC-07); a new non-blocking admin-e2e job drives the admin panel against a real server (first-run wizard, channel CRUD, audit log, re-login).

  • docs: the dependency pinning/review policy is written down in docs/contributing.md, closing the last 2026-04 audit carryover that was still undecided.

v1.2.0-alpha.1 — Discord feature parity

Project reset note: OwnCord has re-entered alpha. The v1.0.0 release is superseded; versioning continues forward from v1.1.0-alpha.N so deployed servers and clients keep receiving updates. This release bumps the minor to v1.2.0-alpha.1 to mark a large feature drop. Releases are published to this repository's Releases page, including a full source snapshot with every release.

This release closes most of the feature gap against basic Discord (see docs/plans/discord-parity.md for the full gap analysis and per-item detail). The work landed as six phases plus a pre-release security and performance review.

Messaging & mentions

  • Real mentions. @username is now resolved server-side against unique usernames (address-shaped text like mail@example is rejected), stored per message, and carried on the wire — so a mention notifies, highlights the message, and drives a red per-channel mention badge distinct from the plain unread count. @everyone / @here are gated on a new MENTION_EVERYONE permission (@here skips offline and invisible users). #channel names render as clickable navigation chips, and the composer gains an @ autocomplete.
  • Markdown rendering. Messages render Discord-flavoured markdown — bold, italic, underline, strikethrough, spoilers, block quotes, headings, lists, masked links (http(s) only), and fenced code blocks with a language tag and lightweight syntax highlighting. Rendering is a strict DOM builder with no innerHTML. Ctrl+B/I/U wrap the selection in the composer.
  • Custom emoji. Server emoji can be uploaded and managed (admin panel, MANAGE_SERVER); :shortcode: renders inline in messages (jumbo when a message is emoji-only), appears in the picker and a :-autocomplete, and can be used as a reaction.
  • Message navigation. Search results, pinned messages, reply previews, and message permalinks (owncord://message/…, copyable from the hover bar) all jump to the target — fetching a window around it when it is not loaded, with a "Jump to Present" affordance. Reactions show a who-reacted tooltip on hover, video and audio attachments get inline players, and a "NEW" divider plus explicit Mark as Read / Mark All as Read round out read state.
  • Bulk delete. POST /channels/{id}/messages/purge soft-deletes the newest N messages (MANAGE_MESSAGES), broadcasting one chat_bulk_deleted event.

Roles, permissions & moderation

  • Role management. Roles are now first-class: create, edit, delete, reorder, and edit permission masks and colours from the admin panel, all gated on MANAGE_ROLES and bounded by the actor's own position (you cannot touch a role at or above your rank, nor grant a permission bit your own role lacks).
  • The permission bits are live. The six previously-decorative bits (MANAGE_CHANNELS, KICK_MEMBERS, MUTE_MEMBERS, MANAGE_ROLES, MANAGE_SERVER, VIEW_AUDIT_LOG) are now enforced per admin route group, so a Moderator role can actually moderate without being a full Administrator.
  • Per-user channel overrides. Channel permissions resolve in Discord's order — base role → role override → user override — with a tri-state override matrix editor (role or user) in the admin panel.
  • Voice moderation. Holders of MUTE_MEMBERS can server-mute, server-deafen, move, or disconnect a lower-ranked user; a server mute is enforced at the SFU.
  • Channel management from the desktop client. Topics render and are editable, plus slowmode, an NSFW flag (with a per-session age gate), and voice user/video limits. Categories are now free text (any type under any name).

Social & profiles

  • Profiles. Avatar uploads (replacing letter-initials everywhere), display names (with the @username handle preserved for mentions), an about/bio, and a custom status line.
  • Presence. Invisible is now a real status that never leaks to other users and survives a reconnect (the previous flash-online-on-connect bug is fixed); a 10-minute auto-idle that never overrides a manual status.
  • Group DMs (210 participants, name, leave), DM calls with ringing (Call button + incoming-call banner over the existing DM voice path), and per-channel notification mutes (mentions still notify; other noise is silenced).
  • Quick wins from phase 1. Block/unblock from the member menu, temporary bans, server-driven role colours, a mounted profile popup, and archived channels that actually hide.

Security & performance review (pre-release)

  • Channel-override endpoints now enforce grantability: a MANAGE_CHANNELS holder cannot grant itself or a user a permission bit its own role lacks, closing a privilege-escalation path.
  • DM voice events (voice_state/voice_leave) are delivered only to the DM's participants instead of every user with base READ_MESSAGES.
  • Voice moderation cannot reach a private DM call the actor is not part of.
  • Mention-count bookkeeping is batched (one writer exec per 500 readers instead of one per reader) and resolved against a set; the markdown parser's bracket matching is amortized-linear; video/audio attachment blobs are LRU-capped and revoked, and cleared on logout.

Test hardening (pre-release)

The hostile-input surface is now covered by Go native fuzzers and client-side property tests (mention/emoji parsing, FTS query sanitizing, permission resolution, markdown tokenizing, filename/path sanitizing, content sanitizing, credential validation, avatar URLs, LiveKit webhook identities), which found and fixed two real bugs:

  • Zero-dimension images are rejected. A GIF decoding to height 0, and a VP8 keyframe with an all-zero size field, both passed the image size guard as "small". imageDimensions now rejects non-positive dimensions centrally.
  • Upload filenames stay safe basenames. / survived sanitizing verbatim (filepath.Base("/") is "/"), and over-length names were truncated mid-rune into invalid UTF-8. Both are fixed at the sanitizer.

Also added: a full migration-chain and pre-parity (019) upgrade round-trip test, a protocol-schema/generated-constant drift test, a 200-client hub load/soak test with goleak verification, and a blocking @parity Playwright job covering the new parity features. Separately, a test-quality audit rewired tests that asserted nothing (or a tautology) to assert their claimed behaviour — no product code changed and no assertion weakened.

Phase B — Acceleration

  • Event persistence layer (Step 7). A new events table backs the WebSocket reconnect path. When a client's last_seq is too old for the in-memory ring buffer (~1000 events), the server now falls back to a SQLite query before forcing a full re-sync. The hub seeds its monotonic sequence counter from MAX(events.seq) at startup so row seqs and wrapped-payload seqs stay aligned across restarts. Configurable via the new event_persistence block; enabled by default (see "Behavioural changes" below).
  • Tiered reconnect telemetry. auth_ok now includes a replay_source field ("none" | "buffer" | "db") so clients can attribute reconnection behaviour. The same tier label is exported as the ws_reconnect_tier_total{tier} counter.
  • OpenTelemetry skeleton (Step 8). Public API + no-op default provider in Server/telemetry/. Chi router middleware mounted unconditionally. Service-layer spans on MessageService.SendMessage, PermissionService.HasChannelPerm, ChannelService.ListVisibleChannels, DMService.CreateDM, VoiceService.JoinChannel, InviteService.CreateInvite, ModerationService.BanUser, BlockService.BlockUser, UserService.UpdateProfile. The real OTel SDK is gated behind -tags otel and is currently a placeholder; completing it is deferred until after the beta reset.
  • Solid.js proof of concept (Step 6). Two leaf components migrated (Badge, ChannelListItem), Vite + JSX configured, store→signal adapter landed. The remaining vanilla components remain in place; migration is mechanical and tracked in the local TODO.

Phase C — Differentiation

  • Plugin runtime skeleton (Step 9). New Server/plugin/ package with manifest parser, on-disk loader, registry, and host capability surfaces (commands, events, storage, http, ui). Manifest format is JSON (plugin.json); the design's TOML format is gated behind the -tags wazero build and tracked locally.
  • Plugin admin REST surface. Lifecycle endpoints under /api/v1/admin/plugins: list, enable, disable, uninstall, and the new install path that accepts a multipart zip upload, validates it zip-slip safe with size + symlink rejection, and atomically installs it. Mounted under both AdminIPRestrict and the admin.RequireAdminAuth session/permission middleware.
  • Plugin admin client bridge. pluginBridge.ts mounts plugin UI tabs in sandboxed iframes with origin-validated postMessage routing.

Security

  • SSRF defense for http capability. Plugin outbound HTTP requests are now validated through net/url.Parse, suffix-matched with a dot boundary (so evil-api.example.com does not match api.example.com), and rejected for empty allowlist entries. A custom Transport.DialContext re-resolves DNS on every dial and refuses any resolved address in loopback / RFC1918 / RFC4193 / RFC6598 (CGN) / link-local / multicast / unspecified ranges. Closes the DNS-rebinding TOCTOU window. Response body is capped at 5 MiB.
  • Plugin manifest hardening. Manifest.Name must match ^[a-z0-9][a-z0-9_-]{0,63}$. Entrypoint and UI tab asset paths are rejected if absolute, non-canonical, contain .., or contain NUL bytes / backslashes.
  • Plugin asset handler. Defends against symlink escapes (rejected at install time via filepath.Walk + Lstat) and prefix-without- separator path traversal (via filepath.Rel check after join).
  • Plugin postMessage routing. The host bridge looks up the trusted pluginId via e.source -> contentWindow instead of trusting the pluginId field in the message body. Spoofed messages from any non-iframe source are dropped.

Behavioural changes operators must know about

  • Voice now works out of the box for clients that are not on the server machine. The LiveKit proxy's origin gate rejected two legitimate client shapes with /livekit/rtc/v1 403s — chat worked, voice didn't: the desktop client's fixed webview origins (http(s)://tauri.localhost, tauri://localhost) and any UI served from the server's own origin, whose WebSocket handshakes always carry that origin even though same-origin fetches omit it. Both are now recognized: first-party webview origins are always allowed, and an Origin whose host equals the request's Host is treated as same-origin — mirroring the default policy the chat WebSocket already applied, with no change to the CSRF posture (a foreign origin still needs an explicit allowed_origins entry). Rejected origins are now logged (livekit proxy: origin rejected) so the next such failure is diagnosable from the server log.
  • API tokens can use the admin log stream. POST /admin/api/logs/ticket required a browser login session, so headless clients (the mcp-introspect dev tool, bots) could reach every other /admin/api/* route but not server_logs. Tickets are now bound to whichever credential authenticated the request; revoking a token cuts an in-flight stream, exactly as session revocation always has.
  • The desktop client now actually uses the OS credential store. The keyring crate declares no default feature, so the previous keyring = "3" dependency compiled its in-memory mock store on Windows, macOS and Linux alike: saves reported success and the next read in the same process returned nothing, and no credential was ever written to Credential Manager / Keychain / Secret Service. The visible symptom was the voice-E2EE identity keypair being regenerated, so the published identity key stopped matching the key that signed the voice announce and peers rejected it as a possible MITM. The platform backends are now enabled explicitly and every write is read back before it is reported as saved. See docs/credential-storage.md.
    • Linux builds need a new system package, libdbus-1-dev, for the Secret Service backend. CI and release workflows install it already.
    • Users on an affected machine are logged in again and re-verified by their peers once, then persist normally.
  • event_persistence.enabled defaults to true. Every broadcast WebSocket event is written to the events table, retained for 24 hours by default, and pruned by a background goroutine every hour. This is a new on-disk write path that did not exist before. Disable it by adding to config.yaml:
    event_persistence:
      enabled: false
    
  • DM events are persisted under the same retention. Operators with GDPR or compliance requirements should review the retention window and consider setting event_persistence.enabled: false until a per-channel-type opt-out lands.
  • Plugin admin endpoints require admin session auth in addition to the existing IP restriction. A previous prerelease shipped with only the IP gate; that has been corrected.
  • The parity work adds nine database migrations (020028) that apply automatically on first boot. They add the message_mentions, channel_user_overrides, and emoji-supporting tables/columns, per-user profile fields (display_name, about, custom_status), channel flags (nsfw, is_group), and the server_muted/server_deafened voice-state columns; a migration also seeds the new MENTION_EVERYONE permission bit into the Owner/Admin/Moderator roles. No manual step is required, but take a backup before upgrading as usual. The release also introduces new WebSocket message types (roles_update, emoji_update, chat_bulk_deleted, voice_mod_*, voice_moved, voice_disconnected, mark_read, call_ring/call_incoming/call_decline); older clients ignore unknown types, and older servers omit the new fields (the client fails safe).

Deferred work

The project is under a feature freeze until the beta reset completes. Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK wiring, the Postgres backend (scaffolding removed pending real demand), and the slash-command dispatcher (docs/plans/slash-commands.md). The Solid.js migration was abandoned and its experiment fully removed (2026-07-19) in favor of the established vanilla component pattern.