J3vbandClaude Opus 5 82be103794 fix(client): resolve 94 verified defects across voice, identity, transport and UI (#1332)
* fix(client): gate every mic re-enable path on the user's mute state

Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.

These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.

Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.

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

* fix(client): stop camera/screenshare publishing after the user turns it off

enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.

Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
  voice_camera) never rolled back the published track; the dispatcher now
  correlates the error by envelope id rather than blanket-rolling-back.
- 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 on a resync (pending and failed rows
  carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
  phantom entries and stale unread counts across a resync; addMessage and
  setAroundMessages dropped offline/failed optimistic rows.

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

* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor

setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.

Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.

Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.

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

* fix(client): drop the previous server's bearer token on a host switch

api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.

Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.

Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.

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

* fix(client): guard the quick-switcher against a double-open

openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.

Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.

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

* fix(client): repair the status-picker stylesheet and a dozen UI defects

The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.

Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.

Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.

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

* fix(client): adopt the legacy identity key instead of re-minting one

Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.

When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.

A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.

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

* fix(client): surface server errors that had no dedicated handler

The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.

Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
  refetch let a live broadcast splice onto the stale around-window with a
  silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
  different cert-store pin keys; tofu::cert_store_key and ws.ts's
  normalizeHostForCertCompare both lowercase now. attachments.ts already
  did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
  core:window:allow-request-user-attention.

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

* fix(client): tear down video tiles, focus and the lightbox on leave

Four defects an earlier pass could not finish because each spanned two
files:

- closeVideoGrid only hid the grid, so remote video tiles survived a
  channel leave and reappeared on the next join. VideoGrid grew a
  clearStreams(), called from the real-leave branch of checkVideoMode
  (not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
  accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
  set on one server applied to a different user with the same id on
  another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().

Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.

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

* fix(client): close eight defects a review found in this batch

Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.

- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
  .toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
  the Rust side always emits the lowercased host and a profile stores it
  verbatim, any uppercase in the hostname broke all three guards -- worst of
  them the mismatch modal's onReject, which then skipped disconnect/clearAuth
  and left the user connected to the server whose certificate they had just
  refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
  .status-option rules without adding replacements under the names the
  component emits, so the trigger dot -- a bare div whose only style is an
  inline background -- stayed 0x0, invisible and unclickable. The picker still
  could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
  described: a PTT release routes through setMuted(true), so localMuted is
  always true there. It now takes the pttOwnsMute latch read *before* each
  call site resets it; reading the module flag from inside would always see
  false and move the bug rather than fix it.

The rest:

- dispatcher.ts statically imported @lib/screenShare, which has value imports
  from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
  own comment says is deliberately kept out of it. Now lazy, like every other
  voice call site here.
- replay detection compared payload.timestamp (server clock) against
  Date.now() (client clock). A self-hosted server without NTP made every live
  message after a reconnect look like a replay, silently killing notifications
  for the whole drift window. Both sides are now in server time via an
  observed skew estimate; latency biases it toward treat-as-live, which is the
  side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
  A missing user id would have adopted-and-deleted the real legacy key into a
  bogus host:0 account, then minted a second keypair under host:<realId> --
  published key and signing key permanently disagreeing, which is a false MITM
  warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
  when currentHost was null, which MainPage never leaves it as -- so every
  saved volume silently read as the default on upgrade. Reads now fall through
  to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
  so a missing getMessages left every window dropped with nothing to reload it.

A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.

Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.

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

* fix(client): repair the e2e cert test and four defects found verifying it

The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.

E2E:

- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
  mismatch for myserver.example:8443 while the session was authenticated
  against localhost:8443, so it asserted the pre-fix behaviour: a certificate
  rotating on ANY unrelated saved profile logs you out of the server you are
  using. That is the bug 8917c28 deliberately fixed. The test now emits for the
  live host, and a new sibling pins the guard itself -- a mismatch for another
  host must leave the session alone. Verified by defeating the guard: only the
  new test goes red, which is why the old one never noticed the change.

Defects found verifying the ledger's open items:

- logging out fired delete_credential fire-and-forget and then navigated to the
  connect page, whose auto-login immediately read the same account back. Since
  B4-3 moved the credential commands to #[tauri::command(async)] they no longer
  serialize on the IPC thread, so a read that wins that race signs the user
  straight back into the server they just left. Two fixes, because the race and
  the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
  one-operation-at-a-time property that also keeps secret_store::set's
  read-modify-write atomic, and the connect page now skips auto-login once
  after a logout that removed the credential -- mirroring the quick-switch
  sessionStorage idiom already in that file. A server_shutdown logout keeps its
  credential and deliberately does not set the flag, so restart auto-login
  still works. e2e-pinned: with the suppression defeated, the user is visibly
  back in the app after clicking Log Out.

- a post-resync history refetch that REJECTED left the active channel's window
  already invalidated but never marked errored, so MessageList fell into its
  "no messages yet" welcome branch -- rendering a failed reload as a genuinely
  empty channel, with no Retry, until the user navigated away and back. Now
  calls setChannelLoadError, reusing MessageController's existing plumbing.

- an invite deep link arriving during the connected overlay's 800ms ready
  countdown hit a gate that assumed isAuthenticated implies the router is on
  "main". It is not: clearAuth() ran without the teardown that only the
  authStore subscriber performs (and only while on "main"), so the overlay's
  timer then mounted MainPage over a nulled-out auth state, and the invite was
  dropped. Gated on the real invariant and the in-flight session is now torn
  down explicitly.

- channel mutes carried the same dead legacy-preference fallback that per-user
  volumes had -- guarded on currentHost === null, which MainPage never leaves
  it as -- so every saved mute was silently discarded on upgrade. Mutes are a
  list, where an empty saved value is real data, so this needed a presence
  probe rather than the volume fix's sentinel.

Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.

Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.

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

* fix(client): stop Tab escaping every modal, and a duplicate row after a resync

Two bugs left open by the previous round.

The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.

Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.

Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.

The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.

Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.

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

---------

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

CI Release Status Go Tauri Platforms License: AGPL-3.0

OwnCord

A self-hosted chat app I build for me and my friends — text channels, voice and video, and a server you actually own.

Alpha, and a hobby project. This is something I build for fun and run for a small group of friends. It isn't a product, there's no support, and it isn't production-ready. Expect rough edges, rapid changes, and the occasional breaking change.

Don't use it for anything sensitive.

It's a Go server plus a Tauri desktop client: real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel. Run the server on a spare box or a VPS, hand your friends an invite code, and that's the whole thing.

OwnCord Client

Login Page Admin Panel

How it's built

Most of the implementation is generated with AI tooling, with quality held up by automated checks — CI, tests, linting — and by me and my friends actually using it. That keeps iteration fast, and it also means behaviour can change quickly between releases.

What works right now

Area Status
Core chat flow Working in alpha
Voice/video Working in alpha
Admin panel Working in alpha
Security hardening Ongoing review passes; findings and their statuses are tracked in the dated audits in docs/ (see the Docs Index below)

Platform Support (Current Releases)

Component Windows x64 Linux x64 Linux ARM64
Server binary Yes Yes Not yet
Desktop client Yes (NSIS installer) Yes (AppImage, .deb) Yes (AppImage, .deb)
Docker server N/A Build from source (compose) Not yet

Start Here

Quick Start

Option A: Prebuilt binaries

  1. Download assets from Releases (binaries, checksums, signatures, and a full source snapshot per release).
  2. Run the server binary:
    • Windows: chatserver.exe
    • Linux: ./chatserver
  3. Open https://localhost:8443/admin and complete the setup wizard — it creates your Owner account and configures the server for you (settings are saved to config.yaml automatically).
  4. Generate invite codes in the admin panel and share them with friends.

Option B: Docker (Linux server)

cd Server
cp .env.example .env
cp livekit.yaml.example livekit.yaml
# Edit both files before starting
docker compose up -d

See the full setup guide in docs/deployment.md.

The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections.

What OwnCord Already Has

  • Real-time channels and direct messages over WebSocket
  • Voice/video channels via LiveKit — the LiveKit server binary is downloaded and managed for you
  • Invite-only registration and role-based permissions
  • Web admin panel with logs, backups, and update tooling
  • File uploads and inline media rendering
  • TOTP 2FA support and API rate limiting
  • Desktop client auto-update with signature verification
  • WASM plugin system (slash commands; sandboxed, default-disabled — enable via plugins.enabled and build with -tags wazero)
  • GIF picker — off by default; each server supplies its own Klipy key via gif.api_key (setup)

See deeper feature and architecture docs in docs/architecture/ and docs/protocol.md.

Architecture

Two main components:

  • Go server (REST API, WebSocket hub, SQLite, admin panel)
  • Tauri v2 desktop client (Rust backend + TypeScript frontend)
+---------------------+         +---------------------+
|   OwnCord Client    |         |   OwnCord Server    |
|   (Tauri v2)        |         |       (Go)          |
|                     |         |                     |
|  +---------------+  |  WSS    |  +---------------+  |
|  |  Chat UI      |--+------->|  |  WebSocket Hub|  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  |  HTTPS  |  +---------------+  |
|  |  REST Client  |--+------->|  |  REST API     |  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  | LiveKit |  +---------------+  |
|  |  Voice/Video  |--+------->|  |  LiveKit SFU  |  |
|  +---------------+  |         |  +---------------+  |
+---------------------+         |  +---------------+  |
                                |  |  SQLite DB    |  |
                                |  +---------------+  |
                                +---------------------+

Build and Test

Prerequisites

  • Go 1.26+
  • Node.js 20+
  • Rust stable (client builds)

Build from source

# Server (Windows)
cd Server
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.1" .

# Server (Linux)
cd Server
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.1" .

# Client
cd Client/tauri-client
npm install
npm run tauri build

Core verification commands

# Server
cd Server
go test ./...

# Client
cd Client/tauri-client
npm run typecheck
npm run lint
npm test

For the full command set, use docs/contributing.md.

Configuration

On first run, the server generates config.yaml and a local data/ directory:

data/
├── chatserver.db
├── certs/
├── uploads/
└── backups/

Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions. See docs/server-configuration.md.

Security and Vulnerability Reporting

  • For vulnerabilities, use GitHub Security Advisories (private disclosure flow).
  • Do not open public issues for security bugs.
  • Read full policy and hardening notes in docs/security.md.

Update Signing Notes (Maintainers)

Client and server update signing keys are intentionally separate.

Required Actions secrets for release signing:

  • TAURI_SIGNING_PRIVATE_KEY
  • TAURI_SIGNING_PRIVATE_KEY_PASSWORD
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD

When rotating the server updater key, update Server/updater/server_update_public_key.txt and use staged rollover for live fleets.

Docs Index

Contributing

  1. Create a branch from dev (the active development branch).
  2. Keep changes focused and tested.
  3. Open a PR targeting devdev is merged to main for releases.

See docs/contributing.md for the full process.

License

AGPL-3.0

S
Description
OwnCord is a self-hosted, open-source chat platform with text channels, voice/video chat, direct messages, and a desktop client — built for communities that want full control over their data.
Readme AGPL-3.0
111 MiB
Languages
TypeScript 48.7%
Go 44%
JavaScript 2.2%
Rust 2.1%
CSS 1.4%
Other 1.5%