mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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 001–015, 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.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Data Model
|
||||
|
||||
**Verified against:** commit `ddc49f0`, 2026-07-19
|
||||
|
||||
The canonical schema is the ordered migration set `Server/migrations/001–015`
|
||||
(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).
|
||||
@@ -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/>001–015 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/`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user