* refactor(protocol): retire reserved voice_speakers and member_leave (S-15) Neither type was ever emitted by the server; B2-1 clears them from the schema before the epoch-1 wire fixtures are captured, so the frozen epoch does not carry two dead message types. Client: dropped the dead `ws.on(MEMBER_LEAVE)` / `ws.on(VOICE_SPEAKERS)` dispatcher handlers, the `MemberLeavePayload` type and both `ServerMessage` union members, and the tests that only exercised those WS paths. `removeMember` (member_ban) and `setSpeakers` (LiveKit ActiveSpeakers) stay live and keep their direct unit tests. * test(ws): capture the epoch-1 wire fixtures alpha.4 is the last client on the pre-epoch wire and B2-2 adds a protocol epoch to the auth handshake next, so record what epoch 1 actually looks like while it is still observable. TestEpoch1Fixtures drives eleven journeys through the ws package's in-process hub harness (full migrations, real hub, httptest WebSocket server) and compares each journey's per-connection frame sequence with a transcript under protocol/fixtures/epoch-1/: - fresh-connect, auth-failure, ping - chat-send-fanout, chat-edit-delete, reaction-add-remove - typing, mark-read, dm-send - resume-replay (last_seq + buffer-tier replay burst) - voice-join-e2ee-leave (join, both voice_state forms, announce and offer relay, leave) Volatile values are replaced by typed placeholders before both writing and comparison -- any key that is id/seq/last_seq, ends in _id (except channel_id and role_id) or _at, is timestamp/ts/last_seen, or contains token, becomes "<class:json-type>" so a field that changes type is still a diff, while everything else is compared verbatim. Regenerate with: go test ./ws -run TestEpoch1Fixtures -update Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): pin the epoch-1 auth frame contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(updater): pin the epoch-1 manifest and client-update shapes * test(ws): harden the epoch-1 fixtures against trailing frames and absent optionals Every journey now ends with the ping/pong barrier on each recorded connection, so a frame the server emits after the last read fails as `expected "pong", got "X"` instead of going unrecorded; auth-failure asserts the StatusPolicyViolation close instead, its socket being gone. alice carries a display name, avatar, about text, custom status, identity public key and an announce signature (bob carries none), so every optional field is frozen in its present form as well as its absent one — a rename or a retype of display_name or identity_public_key now moves a fixture. The typing journey focuses the channel on "a" before typing: without the subscription registerNow only makes for a focused client, its ping/pong proved nothing about excludeUserID. Comment fixes: the escaped placeholder form MarshalIndent would write, the real (headroom) reason for the raised read limit, a note that bare id and active_channel_id are normalised by design, and a .prettierignore line saying these fixtures are verified by the Go comparison, not by git diff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(protocol): document the epoch-1 fixtures * docs(protocol): match the epoch-1 wire where the fixtures contradicted the doc - voice_state: note the unsequenced relay-to-joiner form (table row + section) - auth_error: correct the example message and list the real rejection messages - member_join/presence connect example: add the seq both frames actually carry - chat_message.user: document display_name - voice_join reply order: state that the joiner's own voice_state broadcast is not ordered against the other three frames * test(ws): freeze the null forms of auth_ok and member_join user fields buildAuthOK emits display_name/about/custom_status/avatar as always-present nulls, but only alice — who has all four set — ever authenticated on a recorded connection, so the fixtures froze those fields in their populated form alone. A rename, a retype or a dropped null would have moved nothing, on the very frame B2-2 edits. fresh-connect now records bob's handshake too, on a second connection: his auth_ok carries the four nulls, his member_join carries avatar null with display_name and identity_public_key omitted, and alice — idle by then, so her reads stay in hub order — records the same pair as an already-connected observer sees it. In voice-join-e2ee-leave bob answers alice's signed announce with a legacy unsigned one, which freezes the absent form of signature next to her present one. The auth frames the test writes now carry the correlation id the real client stamps on every frame (ws.ts send()); normalisation renders it <id:string>. expectClosed also asserts the close reason ("authentication failed"), not just code 1008 — HP-2 asks for both. Comment precision, no behaviour change: the barrier guarantee now states that pong may overtake a pending LOW-priority frame (writePump) and that no journey is affected because every barrier is sent on an idle connection; the ping-budget ceiling is six connections, not four; the typing journey cites handleChannelFocusV2 rather than registerNow as the subscribe site; resume-replay's b barrier explains why moving it past the resume would be a flake, not a fix; and the header notes that normalisation hides that chat_send_ok.id echoes the request id. The client contract test's cited range for ws.ts's send() call is 441-453, not 441-454. Regenerate with: go test ./ws -run TestEpoch1Fixtures -update Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(protocol): shape-not-value fixture rule, voice_max_video default, auth close code The fixture rule read "a fixture may only change with an epoch bump", which is false for the seeded values the transcripts record verbatim: role permission masks and colours, motd, server_name, voice_max_video, the voice_config preset. A migration that changes a default mask diffs fresh-connect.json, and the README told the author to revert a change that never touched the wire. Split the rule along shape versus value. A key set, a JSON type, a key appearing or disappearing, or per-connection frame order is a protocol change and earns fixtures/epoch-<n+1>/. A seeded default value is a seed change: regenerate in the same PR and read the diff frame by frame. Normalising those values is explicitly not the answer — a placeholder over a mask or over an enum such as voice_config.threshold_mode would hide the drift the fixtures exist to catch. Two wire facts corrected against the fixtures: - voice_max_video on an unconfigured channel is 25, not 0 (migration 004 is DEFAULT 25); the doc listed it among the zero values. - auth_error is followed by a close with code 1008 (policy violation) and reason "authentication failed" (serve.go:128), which the doc left as "closes the connection". Also: voice_speakers moves from discord-parity's "still dead" list to "came off the list" — it was retired earlier on this branch. And Kick says sessions are revoked and sockets drop on the next sweep, which is what ForceLogout does (moderation.go:236); it does not cut sockets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(protocol): fixture rule covers enum vocabularies; header comment precision Re-check minors from the whole-branch review: a fixed-vocabulary value the client switches on is shape, not a seeded value; drop the false 'new default channel' clause (the seed asserts channel ids); the low-priority frame is presence_update, not connect presence. * docs(plans): record B2-1 evidence for HP-2 (PR #1435, fixture commit SHAs) * docs(protocol): additive changes stay within an epoch; bump only for what old clients cannot process Codex review on #1435: B2-2 keeps protocol_epoch = 1 while adding auth/ready/auth_error fields, which the previous wording would have called a break to revert. An epoch is a compatibility boundary, not a snapshot: additive keys regenerate in the same PR and are documented; removals, renames, retypes, dropped frames and reordering bump the epoch. The plan's B2-1 evidence records the refinement and hands B2-2/B2-4 the open questions (additive-tolerant replay of the epoch-1 transcript; epoch 0 vs 1 naming). * test(client): compare auth-frame key sets order-independently Codex review on #1435: Object.keys preserves insertion order, so a harmless property reorder in ws.ts would fail the pin. Key order has no wire meaning; the Go fixtures already compare with sorted keys. Sort both sides. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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, it comes with no support commitment, 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.
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
- New user quick path: docs/quick-start.md
- Linux Docker deployment: docs/deployment.md
- Remote access without router config: docs/tailscale.md
- Manual router/network setup: docs/port-forwarding.md
Quick Start
Option A: Prebuilt binaries
- Download assets from Releases (binaries, checksums, signatures, and a full source snapshot per release).
- Run the server binary:
- Windows:
chatserver.exe - Linux:
./chatserver
- Windows:
- Open
https://localhost:8443/adminand complete the setup wizard — it creates your Owner account and configures the server for you (settings are saved toconfig.yamlautomatically). - 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.enabledand 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 24+ (see
Client/.nvmrc) - 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.4" .
# Server (Linux)
cd Server
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.4" .
# Client
cd Client
npm install
npm run tauri build
Core verification commands
Everything CI gates on, from the repository root:
npm run check # server + client + Rust
npm run check:server # or one stack at a time
node scripts/run.mjs --list # exactly what each task runs, and where
Or run the stacks directly — the facade is a convenience, not the only path, and server work needs no Node at all:
# Server
cd Server
go test ./...
# Client
cd 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_KEYTAURI_SIGNING_PRIVATE_KEY_PASSWORDSERVER_UPDATE_SIGNING_PRIVATE_KEYSERVER_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
docs/README.md is the complete index — every document in
docs/, grouped by whether it is guidance, a reference contract, a dated audit,
or a plan. The most-used entries:
- docs/quick-start.md — get a server running
- docs/deployment.md — production deployment
- docs/contributing.md — setup, branch model, how to run the checks CI runs
- docs/security.md — reporting a vulnerability
- docs/architecture/ — system blueprints (diagrams + flows)
- docs/architecture/ux/ — client UX specification (target-state flows, per-view states, event→reaction maps)
- docs/api.md, docs/protocol.md, docs/schema.md, docs/server-configuration.md — reference contracts
- docs/plans/README.md — plan index; records each plan's state and is the authority over a plan's own header
Audits are dated snapshots and are not maintained after the fact — read them as history. docs/README.md lists all nine, newest first.
Contributing
- Create a branch from
dev(the active development branch). - Keep changes focused and tested.
- Open a PR targeting
dev—devis merged tomainfor releases.
See docs/contributing.md for the full process.
Getting Help and Reporting Problems
Nothing here is a support promise — see the note at the top — but there is a right place for each kind of message:
| Kind | Where |
|---|---|
| A reproducible bug | Issues |
| A question about setup or usage | Discussions → Q&A |
| An idea or feature suggestion | Discussions → Ideas |
| A security vulnerability | Private advisory — never an issue |
License
AGPL-3.0


