J3vbandClaude Fable 5 123c0899e5 fix(b3-9): close the B3-tagged findings — OC-0345, OC-0346, OC-0376, OC-0377, OC-0378 (#1454)
* docs(b3-9): record B3-2's merge (#1450 = 75d64dd4); B3-9 in progress

Plan status line, B3-2 step-table row (DONE) and evidence block carry the
squash SHA; docs/plans/README.md B3 row updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(api): panic log carries trace_id — tracing ahead of recoverer (OC-0346)

recoverer snapshots telemetry.TraceIDFromContext before dispatch, so it
needs the otelhttp span to exist already; it was mounted two slots ahead
of telemetry.HTTPMiddleware and the trace_id attribute was always dropped.
Move the tracing middleware above it; request-id binding, security headers
and the body cap keep their relative positions.

Test (otel build only — the default build hard-wires TraceIDFromContext to
""): go test -tags otel -run TestRecoverer_PanicLogCarriesTraceID ./api/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(admin): owner gate answers 503 on a role read fault, not 403 (OC-0345)

ownerOnlyMiddleware collapsed `err != nil || role == nil` into 403 "role not
found", so a transient GetRoleByID failure told the Owner they lack the
Owner role. Split the outcomes: a store error logs and answers 503
SERVICE_UNAVAILABLE (the perimeter's contract); a genuinely missing role
still answers 403. The existing whitebox tests, which inject only the user
into the context, are unchanged.

Test: TestOwnerOnlyMiddleware_RoleLookupFailureIs503 (roles table renamed,
whitebox — through the full stack the perimeter would answer its own 503).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp reports a store fault as 500, uncounted (OC-0377)

challengeSecret folded a GetUserByID error into the same 401 "invalid or
expired two-factor challenge" an expired challenge earns, and the attempt
had already been charged to the per-user totp_fail cap. Split the outcome:
a store error logs and returns the new service.ErrTOTPUnavailable
(ErrInternal, "two-factor verification temporarily unavailable"); an unknown
user or a missing secret still answers 401. The limiter reservation moves
after the store read — the rule authenticate already applies — and still
precedes the code compare, so the check-then-act it closes stays closed.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
user lookup fails -> 500, challenge kept, attempt not counted` — after the
fault ten wrong codes still answer 401 (the tenth would be 429 had the
fault counted), then the eleventh is refused. `per-user failure cap spans
challenges` unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp keeps the verified second factor when the session insert fails (OC-0378)

VerifyTOTP consumed the partial challenge before issueSession, so a store
fault on the session insert discarded a verified second factor and sent the
user back to the password step; the code was also marked used, so an
immediate retry would have been refused as a replay.

The claim stays atomic and first (two concurrent verifies can never both
reach issueSession). On issueSession failure the challenge is restored under
the same partial token — the client still holds it — and the accepted code
is released, so the retry completes the login without another password
step. auth gains PartialAuthStore.Restore and UsedTOTPCodeStore.Unmark, each
tested in the leaf package.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
session insert fails -> 500, the challenge and the code survive` — once the
trigger is dropped the same token and the same code answer 200 with a token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): registration commits the account, the invite use and the first session together (OC-0376)

CreateUserWithInvite committed the user and burned the invite; the session
insert ran outside that transaction, so a store fault there answered 500
with a half-registered account — a retry got "invalid invite or
credentials" while a login with the same password worked. Option B from
the ledger: the session token is generated first and the session row is
inserted inside the same transaction (db.insertSession through
dbgen.Queries.WithTx; no query or migration change, so no sqlc regen). A
fault at any step rolls the whole registration back and the caller simply
retries. The H-6 cap needs no eviction for a user with no sessions.

Characterization row flipped in the same commit: `RegisterPolicyAndFailurePaths/
session insert fails -> 500, nothing committed` — user row absent, invite
use_count 0, message "registration failed — please try again". db tests
pass the three new arguments; the happy-path test asserts the session row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(service): one session-token path for Register and issueSession

OC-0376 gave Register its own auth.GenerateToken failure branch — a
duplicate of the unreachable one issueSession already carried — and the
auth slice's statement coverage dipped from 91.8% to 91.7% on that one
statement. newSessionToken generates the token and hands its hash to a
persist callback: CreateSession for login and verify-totp, the
CreateUserWithInvite transaction for registration. Behaviour identical
(the characterization file is green before and after); slice coverage
402/437 = 92.0%, service/auth.go 250/263 = 95.1%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* chore(ledger): close the five B3-tagged findings; counts re-derived (PR #1454)

OC-0346 → 775eba50, OC-0345 → fb1afb8a, OC-0377 → f7015809,
OC-0378 → be37d7ee, OC-0376 → 85d86dc7: status fixed, fix.test,
revertProof pass (hand reverse-apply per commit + verify-fixes.mjs).
Ledger 315 fixed / 59 open → 320 / 54 (3 declined, 1 duplicate, 378).

The four count-carrying documents are re-derived around every number,
not just the totals (obs #100): docs/plans/README.md, hp-0-scorecard
(54 open = 1 high / 12 medium / 41 low; three hunts; 53 of 54 resolve;
Client 33 / Server 21), repo-health-issue-register (table, "eleven of
which", OC-0345/OC-0346 rows marked fixed with this PR), b0-baseline.
OC-0323 stays open — it rides B3-8's message/read-state family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp — cap check before the store read; release the code on a lost claim (Codex P2s, #1454)

Two P2s from Codex on PR #1454, both verified against the code and fixed
test-first:

1. An exhausted totp_fail window is refused by the read-only limiter.Check
   before challengeSecret, so rotating source IPs cannot drive user reads
   and secret decryptions past the per-user cap. The atomic Allow that
   records the attempt still runs after the store read (OC-0377: an outage
   charges nothing); the cap boundary is unchanged.
   Test: api/totp_cap_before_store_test.go — budget filled through the
   limiter, users table hidden, expects 429 (RED: 500 "temporarily
   unavailable", the store was read first).

2. A verify whose claim loses at Consume releases the code it marked, so a
   winner mid-recovery (Consume → issueSession failed → Restore) is not
   left with a live token behind a dead code until the authenticator rolls
   over.
   Test: service/auth_lost_claim_test.go — forces the interleaving through
   the store's GetUserByID (RED: "the losing claim left its code marked as
   used").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-9): evidence block, status line, step-table row; README B3 row (PR #1454)

Per finding: pre-squash SHA, RED and GREEN lines, revert-proof, the two
negative controls, what changed; the ledger diff and the re-derived count
paragraphs; auth-slice coverage 402/437 = 92.0% (floor 392/427 = 91.8%),
service/auth.go 250/263 = 95.1%; the otel-tagged run. OC-0323 recorded as
riding B3-8. hp-3-scorecard untouched — the owner signs it as drafted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:56:04 +02:00
2026-08-27 21:37:40 +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, 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.

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 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_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

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:

Audits are dated snapshots and are not maintained after the fact — read them as history. docs/README.md lists all nine, newest first.

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.

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

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%