* docs: add bug-detection improvements plan
Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.
All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build: add make fuzz target and ignore mutation-test output
`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.
Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(client): pin reconnect auth-frame and replay-dedup arming
Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.
Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.
Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record two fuzz corpus traps
Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.
And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(client): enforce three prose invariants as ESLint rules
CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.
- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
write them
Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.
A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct dispatcher invariant, record Tier 2 as shipped
The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.
Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): move the status-picker dot onto the avatar corner
The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): return the saved password over IPC again
The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.
Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(client): add an auto-connect checkbox to the login form
Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.
Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.
Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.
Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(release): bump client to 1.2.0-alpha.2
The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.
Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2
PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.
Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.
Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)
26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.
The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.
Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
28 KiB
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.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.
visibilityChangeSeqcan 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) —
UpdateRoleallowed position collisions thatCreateRolealready rejected, so tied positions could read as equal rank in every hierarchy comparison; it now matchesCreateRole's validation.can_sendis 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_idauth field (#1331) restores the focused-channel subscription during the reconnect handshake itself, closing the window before the post-auth_okchannel_focusround trip lands.protocol.mdalso 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.setConfignow drops it when the host changes without a replacement. A hand-copied, un-lowercased host normalizer inmain.tsmeant an uppercase hostname's cert-mismatch reject path skippeddisconnect()/clearAuth(), leaving a user who refused a changed certificate still connected to that server — the single lowercased implementation inws.tsis 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'srestoreLocalVoiceState), each producing a hot mic while every remote UI still showed the user muted; all now route throughisMicPolicyGated(). 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 becauselivekit-client's ownreplaceTrackcall 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 replayschat_messageframes; loaded windows are now invalidated and the active channel refetched. The WS error handler only banneredRATE_LIMITEDandFORBIDDEN, so every other server error code — for example a rejectedchat_edit— was dropped in silence while the optimistic "Message edited" toast still fired. A message whosechat_send_okwas 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 predicateaddMessagealready used. Replay detection compared the server'screated_atagainst 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; seedocs/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
soundstable (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 ofprotocol-schema.jsonand 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.ymlactions 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
modalFactoryis 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_restoreaudit-log row (DC-09). The row is written before the pre-restore safety copy, so it lives inside thepre_restore_*.dbbackup — the restored database itself cannot carry it (the restore replaces the file). -
ci: the
-tags wazero/-tags otelGo 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 setsOWNCORD_CONTAINER=1, bind-mount operators can set0to opt back in). Container upgrades are image pulls;GET /admin/api/updatesnow reportscan_applyand 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-e2ejob 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.0release is superseded; versioning continues forward fromv1.1.0-alpha.Nso deployed servers and clients keep receiving updates. This release bumps the minor tov1.2.0-alpha.1to 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.
@usernameis now resolved server-side against unique usernames (address-shaped text likemail@exampleis 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/@hereare gated on a newMENTION_EVERYONEpermission (@hereskips offline and invisible users).#channelnames 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 noinnerHTML.Ctrl+B/I/Uwrap 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/purgesoft-deletes the newest N messages (MANAGE_MESSAGES), broadcasting onechat_bulk_deletedevent.
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_ROLESand 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_MEMBERScan 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
@usernamehandle 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 (2–10 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_CHANNELSholder 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 baseREAD_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".
imageDimensionsnow 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
eventstable backs the WebSocket reconnect path. When a client'slast_seqis 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 fromMAX(events.seq)at startup so row seqs and wrapped-payload seqs stay aligned across restarts. Configurable via the newevent_persistenceblock; enabled by default (see "Behavioural changes" below). - Tiered reconnect telemetry.
auth_oknow includes areplay_sourcefield ("none" | "buffer" | "db") so clients can attribute reconnection behaviour. The same tier label is exported as thews_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 onMessageService.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 oteland 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 wazerobuild 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 bothAdminIPRestrictand theadmin.RequireAdminAuthsession/permission middleware. - Plugin admin client bridge.
pluginBridge.tsmounts plugin UI tabs in sandboxed iframes with origin-validated postMessage routing.
Security
- SSRF defense for
httpcapability. Plugin outbound HTTP requests are now validated throughnet/url.Parse, suffix-matched with a dot boundary (soevil-api.example.comdoes not matchapi.example.com), and rejected for empty allowlist entries. A customTransport.DialContextre-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.Namemust 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 (viafilepath.Relcheck after join). - Plugin postMessage routing. The host bridge looks up the trusted
pluginId via
e.source -> contentWindowinstead of trusting thepluginIdfield 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/v1403s — 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 anOriginwhose host equals the request'sHostis 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 explicitallowed_originsentry). 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/ticketrequired a browser login session, so headless clients (themcp-introspectdev tool, bots) could reach every other/admin/api/*route but notserver_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
keyringcrate declares nodefaultfeature, so the previouskeyring = "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.
- Linux builds need a new system package,
event_persistence.enableddefaults totrue. Every broadcast WebSocket event is written to theeventstable, 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 toconfig.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: falseuntil 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 (
020–028) that apply automatically on first boot. They add themessage_mentions,channel_user_overrides, and emoji-supporting tables/columns, per-user profile fields (display_name,about,custom_status), channel flags (nsfw,is_group), and theserver_muted/server_deafenedvoice-state columns; a migration also seeds the newMENTION_EVERYONEpermission 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.