docs: add architecture blueprints and 2026-07-19 audit

Add docs/architecture/ — a curated blueprint set with 10 Mermaid diagrams
covering system context, deployment topology, server package map, REST
request lifecycle, WebSocket auth/replay/dispatch, the full data model
(migrations 001-015), voice/E2EE flow, and the client module map.

Add docs/audit-2026-07-19.md — successor to audit-2026-04-07.md:
re-verifies carried-over findings, catalogues spec-vs-code drift in
api.md/protocol.md/schema.md (incl. the announcement channel-type
contradiction and the undocumented voice-E2EE protocol surface), records
server/client/CI findings with file:line evidence, and closes with a
12-item prioritized improvement backlog.

Link both from the README docs index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 12:18:20 +00:00
parent 7f3e5d18d8
commit cf8e5aae24
9 changed files with 877 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# OwnCord Architecture Blueprints
**Verified against:** commit `ddc49f0`, 2026-07-19
**Companion audit:** [docs/audit-2026-07-19.md](../audit-2026-07-19.md)
This directory is the curated architectural map of OwnCord — the "blueprints" for
the whole system. Every diagram is a Mermaid fenced block (GitHub renders these
natively) followed by a prose explanation and a **Source of truth** file list.
## Index
| Doc | Diagrams | Covers |
|-----|----------|--------|
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, V1/V2 dispatch |
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 23 tables from migrations 001015, grouped by domain |
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars |
## Maintenance rule
These documents are **curated, not generated**. The rule that keeps them honest:
> If a PR changes the *structure* of anything listed in a diagram's
> **Source of truth** list (new package, new table, new message type, changed
> flow), that PR updates the corresponding diagram in the same change.
Diagrams reference stable identifiers (package names, table names, message-type
strings) rather than line numbers wherever possible. Line-number evidence lives
in the dated audit reports, which are point-in-time snapshots by design.
## Relationship to other docs
- `docs/api.md`, `docs/protocol.md`, `docs/schema.md` are the *reference specs*
(request/response shapes, wire formats, DDL). These blueprints describe
*structure and flow*, not payload shapes. Known drift between the specs and
the code is catalogued in [audit-2026-07-19.md §2](../audit-2026-07-19.md).
- `docs/client-architecture.md` predates the abandonment of the Solid.js
migration; [client.md](client.md) reflects the current state.
+112
View File
@@ -0,0 +1,112 @@
# Client Architecture (Tauri)
**Verified against:** commit `ddc49f0`, 2026-07-19
Desktop client built on Tauri v2: a TypeScript webview (~31.7k LOC, vanilla TS —
no UI framework) plus ~2.3k LOC of Rust commands. State lives in a hand-rolled
reactive store (`src/lib/store.ts`: immutable updates, microtask-batched
notifications, selector subscriptions). Components are factory functions
returning `{ element, mount, destroy }` built with the `@lib/dom` helpers; a
2-page state machine (`src/lib/router.ts`) switches between the Connect and
Main pages.
> `docs/client-architecture.md` still describes a SolidJS-based client. The
> Solid migration was **abandoned** (per CHANGELOG); only a 154-LOC beachhead
> remains under `src/components/solid/`. This document reflects the actual
> state.
## D7 — Module map
```mermaid
flowchart TB
subgraph boot ["Bootstrap"]
MAIN["main.ts<br/>page orchestration, auth wiring,<br/>appearance pre-render"]
end
subgraph rust ["Rust (src-tauri)"]
WSP["ws_proxy.rs<br/>WSS + TOFU cert pinning"]
LKP["livekit_proxy.rs<br/>loopback TLS tunnel"]
CRED["credentials.rs<br/>OS keychain"]
PTT["ptt.rs<br/>push-to-talk polling"]
UPDC["update_commands.rs<br/>self-hosted updater"]
SET["commands.rs<br/>settings store (key allowlist)"]
end
subgraph comm ["Communication layer (src/lib)"]
API["api.ts<br/>REST client (tauri-plugin-http,<br/>allowSelfSigned=true)"]
WSC["ws.ts<br/>reconnect w/ backoff, seq replay,<br/>generation counters"]
DISP["dispatcher.ts<br/>~30 msg types → store mutators"]
LKS["livekitSession.ts (1.7k LOC)<br/>voice state machine + E2EE"]
end
subgraph state ["Stores (8 singletons)"]
AUTH2["auth"]
CHAN["channels"]
MSG["messages"]
MEM["members"]
VOICE["voice"]
DM["dm"]
ROLES["roles"]
UIS["ui"]
end
subgraph ui ["UI (imperative DOM)"]
CP["ConnectPage<br/>profiles + health polling"]
MP["MainPage<br/>SidebarArea / ChatArea /<br/>controllers"]
COMP["~30 component families<br/>message-list, settings tabs,<br/>voice widgets, overlays"]
SOLID["components/solid/<br/>abandoned beachhead"]
end
MAIN --> CP
MAIN --> MP
MAIN --> API
MAIN --> WSC
WSC --> WSP
WSC --> DISP
DISP --> AUTH2 & CHAN & MSG & MEM & VOICE & DM & ROLES & UIS
state --> ui
LKS --> LKP
VOICE --> LKS
MP --> COMP
API -.->|"no cert pinning<br/>(unlike WS path)"| SRV["Go server"]
WSP --> SRV
CRED -.-> MAIN
UPDC -.-> MAIN
%% cross-store coupling (audit finding)
AUTH2 -.->|clearAuth → leaveVoice| VOICE
VOICE -.-> MEM
classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6
class SOLID dead
```
**What this shows.** Data flows one way in the happy path: WS frame → Rust
`ws_proxy``ws.ts``dispatcher.ts` → store mutators → subscribed components
re-render. The dashed edges mark the audit findings: the HTTP path accepts any
certificate (`allowSelfSigned` hardcoded, no TOFU pinning — unlike the WS and
LiveKit paths, which pin fingerprints in Rust), the stores cross-import each
other (auth→voice→members), and the Solid beachhead is dead weight.
### Key mechanisms
| Concern | Where | How |
|---------|-------|-----|
| Reconnect | `src/lib/ws.ts` | Exponential backoff (cap 30s), heartbeat 30s, `last_seq` replay + bounded dedup set, generation counter invalidates stale listeners |
| Cert trust | `src-tauri/src/ws_proxy.rs` | TOFU: first fingerprint pinned per host (`certs.json`); mismatch → modal (`CertMismatchModal`) |
| Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS |
| Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels |
| Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified |
| Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) *and* raw `localStorage` for UI prefs/themes |
| Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides |
### Quality tooling
157 test files (~63k LOC — about 2× the source): Vitest unit + integration,
Playwright E2E (web and native Tauri suites), Stryker mutation testing, oxlint +
type-checked ESLint, Prettier, Knip, strict `tsc`. The client unit suite is
currently marked "KNOWN RED" and non-blocking in CI — an audit finding.
**Source of truth:** `src/main.ts`, `src/lib/dispatcher.ts`, `src/lib/ws.ts`,
`src/lib/api.ts`, `src/lib/store.ts`, `src/stores/*.store.ts`,
`src-tauri/src/lib.rs`, `src-tauri/tauri.conf.json`.
+126
View File
@@ -0,0 +1,126 @@
# Data Model
**Verified against:** commit `ddc49f0`, 2026-07-19
The canonical schema is the ordered migration set `Server/migrations/001015`
(embedded via `go:embed`, applied by the custom runner in `Server/db/migrate.go`,
tracked in the `schema_versions` table). SQLite is the only supported engine —
`Server/main.go` rejects any other `database.type` at startup.
## D5 — Entity-relationship overview
All 23 application tables, grouped by domain. Junction/leaf detail columns are
elided; the goal is the relationship graph, not full DDL (see `docs/schema.md`
for DDL — note it is currently 6 migrations behind, see
[audit-2026-07-19.md §2](../audit-2026-07-19.md)).
```mermaid
erDiagram
%% ── Identity & access ──
roles ||--o{ users : "role_id"
users ||--o{ sessions : "user_id"
roles ||--o{ channel_overrides : "role_id"
channels ||--o{ channel_overrides : "channel_id"
users ||--o{ user_blocks : "blocker_id / blocked_id"
users ||--o{ invites : "created_by / redeemed_by"
%% ── Messaging ──
channels ||--o{ messages : "channel_id"
users ||--o{ messages : "user_id"
messages ||--o{ messages : "reply_to"
messages ||--o{ attachments : "message_id"
messages ||--o{ reactions : "message_id"
users ||--o{ reactions : "user_id"
users ||--o{ read_states : "user_id"
channels ||--o{ read_states : "channel_id"
%% ── Direct messages (channels with type='dm') ──
channels ||--o{ dm_participants : "channel_id"
users ||--o{ dm_participants : "user_id"
channels ||--o{ dm_open_state : "channel_id"
users ||--o{ dm_open_state : "user_id"
%% ── Voice ──
users ||--o| voice_states : "user_id (PK)"
channels ||--o{ voice_states : "channel_id"
%% ── Plugins ──
plugins ||--o{ plugin_kv : "plugin_id"
%% ── Standalone (no FK edges) ──
events
settings
audit_log
audit_log_v6
login_attempts
rate_lockouts
emoji
sounds
users {
int id PK
int role_id FK
string username
string password_hash
bool banned
}
roles {
int id PK
string name
int permissions "31-bit bitfield"
int position
}
channels {
int id PK
string name
string type "text | voice | dm (trigger-enforced)"
}
messages {
int id PK
int channel_id FK
int user_id FK
int reply_to FK
string content
}
attachments {
string id PK "UUID"
int message_id FK
int uploader_id "added by 010"
}
events {
int seq PK "AUTOINCREMENT, hub seq seeded from MAX(seq)"
string type
string payload
}
```
### Domain notes
| Domain | Tables | Notes |
|--------|--------|-------|
| Identity & access | `roles`, `users`, `sessions`, `channel_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`. `rate_lockouts` (011) persists rate-limiter lockouts across restarts. |
| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `emoji` | `channels.type` is constrained to `text \| voice \| dm` by INSERT/UPDATE triggers from migration 013 — the `announcement` type in the specs/admin UI is rejected at this layer. `attachments.uploader_id` (010) backs upload-ownership checks. |
| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). |
| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
| Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. |
| Ops | `settings`, `audit_log`, `audit_log_v6`, `sounds` | `settings` is a generic KV read by admin and (directly, via inline SQL) by the WS hub. `audit_log` + `audit_log_v6` coexist after the 003 rebuild. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. |
### How the schema is accessed (three coexisting styles)
1. **Raw SQL in `Server/db`** — hand-written queries (`*_queries.go`, ~178 call
sites). This is what actually runs, used directly by `Server/api` handlers,
`Server/admin`, and the `ws.Hub`.
2. **`store.Store` interface** (`Server/store`) — a 14-domain composed interface
with `SQLiteStore` (delegates to `db.DB`) and `MemStore` (test double).
Consumed by the `Server/service` layer only.
3. **sqlc-generated `Server/db/dbgen`** (~3.5k LOC, from `Server/db/queries/sqlite/`
per `sqlc.yaml`) — **imported by nothing**; dead code kept verified by the
`sqlc-verify` CI job.
The coexistence of all three is the largest structural finding of the audit —
see [audit-2026-07-19.md §3](../audit-2026-07-19.md).
**Source of truth:** `Server/migrations/*.sql` (schema), `Server/db/migrate.go`
(runner), `Server/db/*_queries.go` (live queries), `Server/store/store.go`
(interface), `sqlc.yaml` + `Server/db/dbgen/` (dormant generated layer).
+134
View File
@@ -0,0 +1,134 @@
# Server Architecture
**Verified against:** commit `ddc49f0`, 2026-07-19
Single Go binary (`github.com/owncord/server`, Go 1.25). Pure-Go SQLite
(`modernc.org/sqlite`, no CGO), chi router, `nhooyr.io/websocket`, LiveKit for
voice, Wazero for plugins (build-tag gated), optional OpenTelemetry
(`-tags otel`). Roughly 29k LOC of production code and 46k LOC of tests.
## D2 — Package map
```mermaid
flowchart TB
subgraph entry ["Process entry"]
MAIN["main.go<br/>config, TLS, DB open+migrate,<br/>signal handling, maintenance loop"]
end
subgraph http ["HTTP layer"]
API["api<br/>router, middleware, REST handlers,<br/>WAF, LiveKit proxy, uploads"]
ADMIN["admin<br/>embedded SPA + admin REST,<br/>SSE log stream"]
end
subgraph realtime ["Real-time"]
WS["ws<br/>Hub, pub/sub, replay,<br/>V1+V2 dispatch, LiveKit, E2EE relay"]
end
subgraph domain ["Domain"]
SVC["service<br/>Message/Channel/Permission/User/<br/>DM/Invite/Block/Moderation/Voice"]
PERM["permissions<br/>bitfield + Checker"]
PLUGIN["plugin<br/>wazero runtime, registry,<br/>manifest, host APIs"]
end
subgraph data ["Data"]
STORE["store<br/>Store interface,<br/>SQLiteStore, MemStore"]
DB[("db<br/>raw SQL queries,<br/>migration runner, models")]
DBGEN["db/dbgen<br/>sqlc-generated"]
MIG["migrations<br/>001015 embedded SQL"]
end
subgraph support ["Support"]
AUTH["auth<br/>bcrypt, tokens, TOTP,<br/>rate limiting, TLS"]
CFG["config<br/>koanf: defaults→YAML→env"]
STORAGE["storage<br/>upload files on disk"]
TEL["telemetry<br/>OTel (no-op default)"]
UPD["updater<br/>minisign-verified self-update"]
end
MAIN --> CFG
MAIN --> API
MAIN --> DB
MAIN --> STORE
API --> ADMIN
API --> WS
API --> SVC
API --> AUTH
API --> STORAGE
API --> UPD
API --> PLUGIN
SVC --> STORE
SVC --> PERM
STORE --> DB
DB --> MIG
WS --> SVC
WS --> PLUGIN
%% Layering violations (dashed red): raw *db.DB used above the store seam
API -.->|"handlers take *db.DB directly"| DB
ADMIN -.->|"raw *db.DB"| DB
WS -.->|"inline SQL for settings"| DB
DBGEN -.-|"generated but imported by nothing"| DB
classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6
class DBGEN dead
```
**What this shows.** The intended layering is
`api → service → store → db`, and the `service` layer does follow it. But three
dashed edges mark where the seam is bypassed: nearly every REST handler receives
both `svc` *and* a raw `*db.DB`; the `admin` package operates on `*db.DB`
almost exclusively; and the `ws.Hub` runs inline SQL against the `settings`
table. `db/dbgen` (sqlc output) is generated and CI-verified but referenced by
no code. In effect the server has three coexisting data-access styles — see
[data-model.md](data-model.md) and the audit for the consolidation
recommendation.
`api.NewRouter` (`Server/api/router.go`) is the composition root: it constructs
the rate limiter, TOTP key, storage, `service.New`, the `ws.Hub`, the LiveKit
client/subprocess, the updater, the admin handler, and the plugin admin handler;
spawns background goroutines; and mounts all routes. `main.go` performs only
process-level wiring (config, TLS, DB, event persistence, HTTP server,
shutdown).
**Source of truth:** `Server/main.go`, `Server/api/router.go`, package import
graph (`go list -deps`), `Server/store/store.go`, `sqlc.yaml`.
## D3 — REST request lifecycle
```mermaid
sequenceDiagram
autonumber
participant C as Client
participant MW as Global middleware<br/>(api/router.go)
participant RT as Route mount<br/>(Mount*Routes)
participant H as Handler
participant S as service.*
participant ST as store.Store
participant DB as SQLite
C->>MW: HTTPS request
Note over MW: RequestID → Recoverer → requestLogger<br/>→ telemetry → SecurityHeadersWithTLS<br/>→ MaxBodySizeUnless(uploads exempt)<br/>→ optional Coraza WAF
MW->>RT: routed by chi
Note over RT: AuthMiddleware(database)<br/>+ per-route rate limits<br/>+ RequirePermission(...) where mounted
RT->>H: authenticated request
H->>S: domain call (svc.Messages, svc.Permissions, …)
S->>ST: Store interface method
ST->>DB: SQL (via db.DB)
DB-->>C: JSON response (errorResponse envelope on failure)
rect rgba(200,120,120,0.15)
Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)<br/>bypasses service/store and queries *db.DB directly.<br/>Admin REST (Server/admin) does the same behind<br/>AdminIPRestrict + RequireAdminAuth.
end
```
**What this shows.** The global chain is assembled in `NewRouter`; note that
chi's `middleware.RealIP` is deliberately omitted — client IP is resolved via
`clientIPWithProxies` against configured trusted proxies instead, so spoofed
`X-Real-IP`/`X-Forwarded-For` headers are not trusted by default. Authentication
is bearer-token (SHA-256-hashed opaque tokens); authorization is enforced
inconsistently — sometimes as `RequirePermission` middleware at mount time,
sometimes in-handler through `svc.Permissions` (an audit finding). The shaded
region marks the two documented bypass paths of the domain layer.
**Source of truth:** `Server/api/router.go`, `Server/api/middleware.go`,
`Server/api/auth_handler.go`, `Server/admin/middleware.go`, `Server/service/`.
+99
View File
@@ -0,0 +1,99 @@
# System Overview
**Verified against:** commit `ddc49f0`, 2026-07-19
OwnCord is a self-hosted chat stack: one Go server binary per community, a Tauri
desktop client that can hold profiles for many servers (one active connection at
a time), LiveKit for voice/video media, and an embedded web admin panel.
## D1 — System context and trust boundaries
```mermaid
flowchart LR
subgraph desktop ["Desktop client (Tauri v2)"]
WV["Webview (TS)<br/>UI, stores, dispatcher"]
subgraph sidecars ["Rust commands"]
WSP["ws_proxy<br/>TOFU-pinned WSS"]
LKP["livekit_proxy<br/>TOFU-pinned TLS tunnel"]
CRD["credentials<br/>OS keychain"]
UPD["updater<br/>pinned TLS + minisign"]
end
WV --- sidecars
end
subgraph server ["Self-hosted Go server (single binary)"]
RTR["api router<br/>REST /api/v1"]
HUB["ws Hub<br/>real-time"]
ADM["admin SPA + REST<br/>(IP-gated)"]
PLG["plugin runtime<br/>(wazero, opt-in)"]
DBF[("SQLite file<br/>WAL, single writer")]
UPS["file storage<br/>uploads/"]
end
LK["LiveKit server<br/>(managed subprocess<br/>or external)"]
REL["OwnCord-releases<br/>(GitHub, minisign-signed)"]
WV -->|"HTTPS REST<br/>⚠ accepts any cert<br/>(no pinning)"| RTR
WSP -->|"WSS, fingerprint-pinned"| HUB
LKP -->|"TLS, fingerprint-pinned"| LK
WV -->|"admin panel (browser)"| ADM
HUB <-->|"webhooks + server SDK"| LK
RTR --> DBF
HUB --> DBF
RTR --> UPS
PLG -.->|"allowlisted HTTP only"| NET["external hosts"]
UPD -->|"via connected server URL"| REL
server -->|"self-update check"| REL
```
**What this shows.** Three transport paths leave the client and only two are
certificate-pinned: the app WebSocket and the LiveKit tunnel go through Rust
proxies that pin a trust-on-first-use SHA-256 fingerprint per host; the HTTP
REST path currently accepts any certificate (an acknowledged gap tracked in the
audit). The admin panel is served by the same binary but gated to configured
CIDRs (private ranges by default), with bearer admin auth on top for the plugin
endpoints. Plugins run in a WASM sandbox whose HTTP capability is allowlisted
per manifest. Both the server self-updater and the client updater verify
minisign signatures against pinned embedded public keys.
## D8 — Deployment topology
```mermaid
flowchart TB
subgraph hostbox ["Operator host (or Docker)"]
BIN["owncord server binary"]
BIN --> CFGF["config.yaml<br/>(koanf: defaults → YAML → OWNCORD_* env)"]
BIN --> DATA["data dir<br/>SQLite DB + uploads + TLS certs"]
BIN --> LKPROC["livekit-server<br/>(optional managed subprocess)"]
BIN --> P1[":8443 HTTPS + WSS<br/>API, WS, admin, uploads"]
BIN -.-> P80[":80 ACME HTTP-01<br/>(when TLS mode acme)"]
LKPROC --> P2["LiveKit ports<br/>(WS + UDP media range)"]
end
C1["Tauri clients"] --> P1
C1 --> P2
ADMB["Admin browser<br/>(allowed CIDRs only)"] --> P1
subgraph constraints ["Single-instance constraints (scale-out blockers)"]
R1["in-memory rate-limiter windows<br/>(lockouts persisted, windows not)"]
R2["in-memory pub/sub + replay ring buffer"]
R3["process-local TOTP replay store"]
R4["SQLite single-writer (MaxOpenConns=1)"]
end
BIN --- constraints
```
**What this shows.** The deployment unit is one process per community —
TLS (self-signed, custom, or ACME), the DB, uploads, the admin panel, and
optionally LiveKit are all owned by that process. The design is explicitly
single-instance: rate-limit windows, pub/sub, the replay ring buffer, and the
TOTP replay store are process-local, and SQLite runs with a single writer.
Horizontal scaling is out of scope today; the constraint boxes name exactly
what would have to move to shared infrastructure if that ever changes. A
15-minute maintenance goroutine (expired sessions, orphaned attachments, with a
circuit breaker) and graceful drain on SIGINT/SIGTERM round out the process
lifecycle.
**Source of truth:** `Server/main.go`, `Server/config/config.go`,
`Server/docker-compose.yml`, `docs/deployment.md`, `docs/server-configuration.md`,
`Client/tauri-client/src-tauri/src/lib.rs`.
+73
View File
@@ -0,0 +1,73 @@
# Voice and End-to-End Encryption
**Verified against:** commit `ddc49f0`, 2026-07-19
Voice/video runs on LiveKit. The Go server issues short-lived scoped tokens and
relays E2EE key-exchange messages; media flows client↔LiveKit directly. On the
client, everything funnels through `src/lib/livekitSession.ts`, and — because
self-hosted servers commonly use self-signed certificates — the LiveKit
connection is tunneled through a local Rust TLS proxy pinned to the same TOFU
fingerprint as the main WebSocket.
## D6 — Voice join + E2EE key exchange
```mermaid
sequenceDiagram
autonumber
participant UI as Client UI
participant LKS as livekitSession.ts
participant RP as Rust livekit_proxy<br/>(loopback TCP→TLS, TOFU-pinned)
participant WS as App WebSocket (Hub)
participant SRV as Go server
participant LK as LiveKit server
UI->>WS: voice_join {channel_id}
WS->>SRV: permission check (channel-scoped)
SRV-->>WS: voice_token (5-min JWT,<br/>CanPublishSources scoped by permission)
WS-->>LKS: voice_token payload
LKS->>RP: connect ws://127.0.0.1:{port}
RP->>LK: TLS (fingerprint-pinned)
LKS->>LK: LiveKit signaling + media (via tunnel)
rect rgba(120,160,220,0.15)
Note over LKS,WS: E2EE key exchange (relayed via app WS)
LKS->>WS: voice_e2ee_announce {ECDH pubkey}
WS-->>LKS: voice_e2ee_announce broadcast to channel
Note over SRV: Hub tracks per-channel key holder<br/>(lowest user ID)
LKS->>WS: voice_e2ee_offer {wrapped room key, target user}
WS-->>LKS: voice_e2ee_offer relayed to target
Note over LKS: unwrap room key → LiveKit<br/>ExternalE2EEKeyProvider
Note over LKS: on participant leave,<br/>key holder rotates room key
end
```
**What this shows.** The server never holds the room key — it only relays
announce/offer messages and tracks who the key holder is (deterministically the
lowest user ID in the channel). Keys are wrapped per-recipient via ECDH, and
the key holder rotates the room key when a participant leaves so departed
members cannot decrypt future media. Voice permission enforcement happens twice:
at `voice_join` (channel permission) and inside the LiveKit JWT itself
(`CanPublishSources` restricts camera/screenshare per role permission).
Supporting pieces:
- **Server:** `Server/ws/voice_e2ee.go` (relay + key-holder map),
`Server/ws/livekit.go` (token minting), `Server/ws/livekit_process.go`
(optional managed `livekit-server` subprocess),
`Server/ws/livekit_webhook.go` (webhook validated by LiveKit JWT and
admin-IP-restricted), `Server/api/livekit_proxy.go` (HTTP reverse proxy).
- **Client:** `src/lib/livekitSession.ts` (state machine: idle/connecting/
connected/reconnecting with a monotonic `joinGeneration` to discard
superseded joins), `src/lib/e2eeCrypto.ts` (ECDH, key wrap/unwrap),
`src/lib/audioPipeline.ts` + `src/lib/noise-suppression.ts` (RNNoise WASM),
`src/lib/screenShare.ts`, `src-tauri/src/livekit_proxy.rs` (tunnel),
`src-tauri/src/ptt.rs` (push-to-talk key polling).
This message flow (`voice_e2ee_announce` / `voice_e2ee_offer` /
`voice_speakers`) is currently **absent from `docs/protocol.md`** — recorded as
spec drift in [audit-2026-07-19.md §2](../audit-2026-07-19.md).
**Source of truth:** `Server/ws/voice_e2ee.go`, `Server/ws/livekit.go`,
`Client/tauri-client/src/lib/livekitSession.ts`,
`Client/tauri-client/src/lib/e2eeCrypto.ts`,
`Client/tauri-client/src-tauri/src/livekit_proxy.rs`.
+114
View File
@@ -0,0 +1,114 @@
# WebSocket / Real-time Engine
**Verified against:** commit `ddc49f0`, 2026-07-19
The `Server/ws` package (~6.9k LOC, the largest in the server) implements the
real-time engine: a single `Hub` owning all client connections, a topic-based
pub/sub, a monotonic sequence counter, a 3-tier reconnect replay pipeline, and a
dual (V1 legacy + V2 typed) command dispatch. Message-type constants live in
`Server/ws/message_types.go` (client↔server) and mirror
`Client/tauri-client/src/lib/protocolTypes.ts`.
> Both files claim to be "Generated from docs/protocol-schema.json — single
> source of truth", but **no such file exists in the repository** — the
> constants are maintained by hand on both sides. See
> [audit-2026-07-19.md §3](../audit-2026-07-19.md).
## D4a — Connect, authenticate, replay
```mermaid
sequenceDiagram
autonumber
participant C as Client (ws.ts via Rust ws_proxy)
participant S as ws.ServeWS
participant H as Hub
C->>S: WSS upgrade /api/v1/ws (Origin checked)
Note over S: no HTTP AuthMiddleware —<br/>auth is in-band, 10s deadline
C->>S: {type:"auth", payload:{token, last_seq}}
S->>S: validate token hash → session expiry → user → ban
S->>H: register (kicks previous conn of same user)
S-->>C: auth_ok {user, server_name, motd, replay_source}
alt last_seq within in-memory ring buffer (Tier 1)
H-->>C: replay EventsSinceFiltered (perm-filtered, fail-closed)
else last_seq within events table (Tier 2, max 5000)
H-->>C: replay from cold-tier EventStore
else too far behind, or channel visibility changed (Tier 3)
H-->>C: full "ready" re-sync snapshot
end
loop steady state
C->>H: chat_send / reaction_add / voice_join / …
H-->>C: seq-stamped broadcasts (chat_message, presence, …)
C->>S: ping (every 30s) → pong
end
```
**What this shows.** Auth is deliberately in-band (the WS route mounts without
`AuthMiddleware`). Every broadcast is assigned a monotonic `seq` under a
dedicated mutex; the client reports its `last_seq` on reconnect and the hub
picks the cheapest replay tier. A `visibilityChangeSeq` watermark forces a full
re-sync whenever channel visibility changed while the client was away, so
permission changes can never be replayed around. `auth_ok.replay_source`
(`none|buffer|db`) reports which tier served the reconnect and feeds the
`ws_reconnect_tier_total` metric.
## D4b — Broadcast fanout and backpressure
```mermaid
flowchart LR
EV["deliverBroadcast<br/>assign seq"] --> RB["EventRingBuffer<br/>(1000, Tier 1)"]
EV --> EP["EventPersister<br/>async batched → events table<br/>(Tier 2; drops if queue full)"]
EV --> PLG["plugin EventSink"]
EV --> PS["PubSub topics<br/>global / channel:N / voice:N / user:N<br/>(per-topic 100 msg/s limit)"]
PS --> CH{"per-client queues"}
CH --> HI["sendHigh (64)<br/>DMs, mentions"]
CH --> NO["send (256)<br/>chat, reactions"]
CH --> LO["sendLow (64)<br/>typing, presence"]
HI --> WP["writePump<br/>drains high-first"]
NO --> WP
LO --> WP
WP -->|"high/normal full →<br/>disconnect (forces replay)"| X["client"]
LO -.->|"low full → silently dropped"| X
```
**What this shows.** Overflow policy is intentional: dropping a chat message
would corrupt state, so a full normal/high queue disconnects the client and the
replay pipeline restores consistency; typing/presence are lossy by design. The
global `broadcast` channel (1024) drops with a `broadcastDrops` counter when
saturated.
## D4c — Dual dispatch (V1 → V2 strangler-fig)
```mermaid
stateDiagram-v2
[*] --> handleMessage
handleMessage --> V2: registry.hasV2(type)
handleMessage --> V1: no V2 handler
V2: V2 typed command path
V2: strict parse → Command → Result{mutations, events}
V1: V1 legacy path
V1: lenient parse → registry.Dispatch → imperative handler
V2 --> [*]
V1 --> [*]
```
**What this shows.** Both dispatch generations are registered in `NewHub` and
live simultaneously; each message type is tried against the stricter V2 typed
path first and falls back to V1. Two parsers and two handler registries must be
kept in sync until the migration completes — tracked as an audit finding.
The `Hub` also owns: stale-client sweep (90s), revoked-session sweep (30s, plus
per-connection revalidation every 10 messages), stale-voice-state sweep (60s),
panic containment on the run loop (3 panics/60s → stop), LiveKit client and
optional managed subprocess, and the voice E2EE key-holder map
([voice-e2ee.md](voice-e2ee.md)). Many collaborators are attached
post-construction via `SetLiveKit` / `SetEventPersister` /
`SetPluginRegistry` setters that "must be called before Run" — temporal
coupling noted in the audit.
**Source of truth:** `Server/ws/hub.go`, `Server/ws/serve.go`,
`Server/ws/client.go`, `Server/ws/handlers.go`, `Server/ws/command.go`,
`Server/ws/pubsub.go`, `Server/ws/ringbuffer.go`, `Server/ws/event_persister.go`,
`Server/ws/message_types.go`, `Server/migrations/014_events_table.sql`.