diff --git a/README.md b/README.md index af83a1cb..61cf97f5 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ When rotating the server updater key, update [Server/updater/server_update_publi - [docs/livekit-setup.md](docs/livekit-setup.md) - [docs/port-forwarding.md](docs/port-forwarding.md) - [docs/tailscale.md](docs/tailscale.md) +- [docs/architecture/](docs/architecture/README.md) — system blueprints (diagrams + flows) +- [docs/audit-2026-07-19.md](docs/audit-2026-07-19.md) — latest architecture & spec-conformance audit - [docs/api.md](docs/api.md) - [docs/protocol.md](docs/protocol.md) - [docs/schema.md](docs/schema.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 00000000..ecd6a51d --- /dev/null +++ b/docs/architecture/README.md @@ -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. diff --git a/docs/architecture/client.md b/docs/architecture/client.md new file mode 100644 index 00000000..d7ed0f5f --- /dev/null +++ b/docs/architecture/client.md @@ -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
page orchestration, auth wiring,
appearance pre-render"] + end + + subgraph rust ["Rust (src-tauri)"] + WSP["ws_proxy.rs
WSS + TOFU cert pinning"] + LKP["livekit_proxy.rs
loopback TLS tunnel"] + CRED["credentials.rs
OS keychain"] + PTT["ptt.rs
push-to-talk polling"] + UPDC["update_commands.rs
self-hosted updater"] + SET["commands.rs
settings store (key allowlist)"] + end + + subgraph comm ["Communication layer (src/lib)"] + API["api.ts
REST client (tauri-plugin-http,
allowSelfSigned=true)"] + WSC["ws.ts
reconnect w/ backoff, seq replay,
generation counters"] + DISP["dispatcher.ts
~30 msg types → store mutators"] + LKS["livekitSession.ts (1.7k LOC)
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
profiles + health polling"] + MP["MainPage
SidebarArea / ChatArea /
controllers"] + COMP["~30 component families
message-list, settings tabs,
voice widgets, overlays"] + SOLID["components/solid/
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
(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`. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 00000000..4512c066 --- /dev/null +++ b/docs/architecture/data-model.md @@ -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). diff --git a/docs/architecture/server.md b/docs/architecture/server.md new file mode 100644 index 00000000..d36d405f --- /dev/null +++ b/docs/architecture/server.md @@ -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
config, TLS, DB open+migrate,
signal handling, maintenance loop"] + end + + subgraph http ["HTTP layer"] + API["api
router, middleware, REST handlers,
WAF, LiveKit proxy, uploads"] + ADMIN["admin
embedded SPA + admin REST,
SSE log stream"] + end + + subgraph realtime ["Real-time"] + WS["ws
Hub, pub/sub, replay,
V1+V2 dispatch, LiveKit, E2EE relay"] + end + + subgraph domain ["Domain"] + SVC["service
Message/Channel/Permission/User/
DM/Invite/Block/Moderation/Voice"] + PERM["permissions
bitfield + Checker"] + PLUGIN["plugin
wazero runtime, registry,
manifest, host APIs"] + end + + subgraph data ["Data"] + STORE["store
Store interface,
SQLiteStore, MemStore"] + DB[("db
raw SQL queries,
migration runner, models")] + DBGEN["db/dbgen
sqlc-generated"] + MIG["migrations
001–015 embedded SQL"] + end + + subgraph support ["Support"] + AUTH["auth
bcrypt, tokens, TOTP,
rate limiting, TLS"] + CFG["config
koanf: defaults→YAML→env"] + STORAGE["storage
upload files on disk"] + TEL["telemetry
OTel (no-op default)"] + UPD["updater
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
(api/router.go) + participant RT as Route mount
(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
→ telemetry → SecurityHeadersWithTLS
→ MaxBodySizeUnless(uploads exempt)
→ optional Coraza WAF + MW->>RT: routed by chi + Note over RT: AuthMiddleware(database)
+ per-route rate limits
+ 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, …)
bypasses service/store and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
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/`. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 00000000..c2297f21 --- /dev/null +++ b/docs/architecture/system-overview.md @@ -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)
UI, stores, dispatcher"] + subgraph sidecars ["Rust commands"] + WSP["ws_proxy
TOFU-pinned WSS"] + LKP["livekit_proxy
TOFU-pinned TLS tunnel"] + CRD["credentials
OS keychain"] + UPD["updater
pinned TLS + minisign"] + end + WV --- sidecars + end + + subgraph server ["Self-hosted Go server (single binary)"] + RTR["api router
REST /api/v1"] + HUB["ws Hub
real-time"] + ADM["admin SPA + REST
(IP-gated)"] + PLG["plugin runtime
(wazero, opt-in)"] + DBF[("SQLite file
WAL, single writer")] + UPS["file storage
uploads/"] + end + + LK["LiveKit server
(managed subprocess
or external)"] + REL["OwnCord-releases
(GitHub, minisign-signed)"] + + WV -->|"HTTPS REST
⚠ accepts any cert
(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
(koanf: defaults → YAML → OWNCORD_* env)"] + BIN --> DATA["data dir
SQLite DB + uploads + TLS certs"] + BIN --> LKPROC["livekit-server
(optional managed subprocess)"] + BIN --> P1[":8443 HTTPS + WSS
API, WS, admin, uploads"] + BIN -.-> P80[":80 ACME HTTP-01
(when TLS mode acme)"] + LKPROC --> P2["LiveKit ports
(WS + UDP media range)"] + end + + C1["Tauri clients"] --> P1 + C1 --> P2 + ADMB["Admin browser
(allowed CIDRs only)"] --> P1 + + subgraph constraints ["Single-instance constraints (scale-out blockers)"] + R1["in-memory rate-limiter windows
(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`. diff --git a/docs/architecture/voice-e2ee.md b/docs/architecture/voice-e2ee.md new file mode 100644 index 00000000..aecf9de1 --- /dev/null +++ b/docs/architecture/voice-e2ee.md @@ -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
(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,
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
(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
ExternalE2EEKeyProvider + Note over LKS: on participant leave,
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`. diff --git a/docs/architecture/websocket.md b/docs/architecture/websocket.md new file mode 100644 index 00000000..db148bd9 --- /dev/null +++ b/docs/architecture/websocket.md @@ -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 —
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
assign seq"] --> RB["EventRingBuffer
(1000, Tier 1)"] + EV --> EP["EventPersister
async batched → events table
(Tier 2; drops if queue full)"] + EV --> PLG["plugin EventSink"] + EV --> PS["PubSub topics
global / channel:N / voice:N / user:N
(per-topic 100 msg/s limit)"] + PS --> CH{"per-client queues"} + CH --> HI["sendHigh (64)
DMs, mentions"] + CH --> NO["send (256)
chat, reactions"] + CH --> LO["sendLow (64)
typing, presence"] + HI --> WP["writePump
drains high-first"] + NO --> WP + LO --> WP + WP -->|"high/normal full →
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`. diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md new file mode 100644 index 00000000..f5a9bd35 --- /dev/null +++ b/docs/audit-2026-07-19.md @@ -0,0 +1,177 @@ +# OwnCord — Architectural Audit & Spec-Conformance Review +**Date:** 2026-07-19 +**Branch:** claude/blueprints-architectural-audit-k927qb (audited tree: `ddc49f0` = main) +**Scope:** whole-system architecture mapping + code-by-spec conformance. Read-only — no code or spec changes ship with this audit; the companion blueprint set lives in [docs/architecture/](architecture/README.md). +**Relationship to prior audit:** successor to [audit-2026-04-07.md](audit-2026-04-07.md), which remains the closure tracker for its own findings. Carried-over items are re-verified in §1, not restated. + +--- + +## Finding closure status (maintained; update statuses in place) + +Standing rule: every HIGH below gets either a closing commit link or an explicit +accepted-risk note before the beta gate. MEDIUMs are folded into the backlog +(§6) and closed opportunistically. + +| ID | Sev | Finding | Status | +|----|-----|---------|--------| +| A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | OPEN — needs a product decision (implement or remove) | +| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | OPEN — acknowledged in code comments | +| A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | OPEN — refresh proposed as one PR (§6 item 4) | +| A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope | +| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | OPEN | +| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | OPEN | +| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN | +| A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | OPEN | +| A-2026-07-09 | MEDIUM | Dual V1+V2 WS dispatch (strangler-fig) still live; two parsers/registries to keep in sync | OPEN | +| A-2026-07-10 | MEDIUM | `api.NewRouter` god-constructor: builds services, hub, LiveKit, updater, admin, plugins; spawns goroutines; mounts everything | OPEN | +| A-2026-07-11 | MEDIUM | `ws.Hub` mega-object with post-construction `Set*` wiring ("must be called before Run") | OPEN | +| A-2026-07-12 | MEDIUM | Abandoned SolidJS beachhead still in-tree; `docs/client-architecture.md` describes the abandoned architecture | OPEN | +| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal; `audit_log` + `audit_log_v6` coexist | OPEN | +| A-2026-07-14 | LOW | Scattered client constants (`#5865F2` ×18, `localhost:8443` ×3); 64 timer call sites with manual lifecycle | OPEN | +| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | OPEN | + +--- + +## Table of Contents +1. [Carried-over items from audit-2026-04-07](#1-carried-over-items-from-audit-2026-04-07) +2. [Spec-conformance matrix](#2-spec-conformance-matrix) +3. [Server architecture findings](#3-server-architecture-findings) +4. [Client architecture findings](#4-client-architecture-findings) +5. [Process & CI findings](#5-process--ci-findings) +6. [Prioritized improvement backlog](#6-prioritized-improvement-backlog) + +--- + +## 1. Carried-over items from audit-2026-04-07 + +Re-verified in today's tree. Statuses below reflect the code, not the prior +audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md). + +| Prior # | Sev | Finding (one-line) | Re-verification (2026-07-19) | +|---------|-----|--------------------|------------------------------| +| 1–5 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | Unchanged since prior closure table; plugins still default-disabled (`plugins.enabled: false`), which is the standing mitigation | +| 6 | HIGH | `Server/store/` untested | **Still zero `_test.go` files** in `Server/store/`. The "SUPERSEDED — remove in P4" plan has not executed; the package remains the designated abstraction seam with no direct tests | +| 7 | HIGH | Client unit coverage | Suite is large (157 test files) but currently KNOWN RED and non-blocking — see A-2026-07-04 | +| 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open** — `Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` | +| 10 | MEDIUM | Audit-trail write failures silently ignored | **Confirmed open** — `Server/admin/handlers_backup.go:55` and `:139` still discard the error: `_ = database.LogAudit(...)` | +| 11 | MEDIUM | E2E not in CI | **Confirmed open** — no Playwright job exists in `.github/workflows/ci.yml` | +| W3-4 (remediation plan) | LOW | Contradictory upload cache header | **Confirmed open** — `Server/api/upload_handler.go:309` sets `private, max-age=%d, no-cache` (max-age and no-cache contradict) | + +--- + +## 2. Spec-conformance matrix + +The three reference specs were last meaningfully edited **2026-04-02** +(`git log` on each file); the code is at v1.1.0-alpha.2 with substantial July +2026 changes. Resolution column: **fix-spec** (doc catches up to code), +**fix-code** (code is wrong), **decide** (product decision needed first). + +### 2.1 `docs/api.md` + +| ID | Spec says | Code does | Evidence | Sev | Resolution | +|----|-----------|-----------|----------|-----|------------| +| B | `GET /api/v1/info` returns `{name, version}` | `version` removed (anti-fingerprinting, "C-2") | `Server/api/router.go` `infoResponse{Name}` only | MEDIUM | fix-spec | +| C | `GET /health` returns `version` | `version` removed for the same reason | `Server/api/router.go` `healthResponse{Status, Uptime, OnlineUsers}` | MEDIUM | fix-spec | +| F | — (absent) | Profile surface exists: `PATCH /api/v1/users/me`, `PUT /users/me/password`, `GET`/`DELETE /users/me/sessions` | `Server/api/profile_handler.go` (`MountProfileRoutes`) | MEDIUM | fix-spec | +| G | — (absent) | Plugin admin REST surface: `/api/v1/admin/plugins` (list/enable/disable/uninstall/install-zip) | `Server/api/router.go:250` | MEDIUM | fix-spec | + +### 2.2 `docs/protocol.md` + +| ID | Spec says | Code does | Evidence | Sev | Resolution | +|----|-----------|-----------|----------|-----|------------| +| D1 | — (voice E2EE absent) | Full E2EE signaling: client `voice_e2ee_announce`/`voice_e2ee_offer`, server broadcast/relay of both | `Server/ws/message_types.go`, `Server/ws/voice_e2ee.go`; flow in [architecture/voice-e2ee.md](architecture/voice-e2ee.md) | HIGH | fix-spec | +| D2 | — (absent) | `voice_speakers` server event | `Server/ws/message_types.go` (`MsgTypeVoiceSpeakers`) | LOW | fix-spec | +| D3 | — (absent) | `user_update` broadcast on profile change | `Server/ws/message_types.go`; wired via `MountProfileRoutes` broadcaster | LOW | fix-spec | +| D4 | `member_leave` appears only in the seq table | Fully implemented broadcast | `Server/ws/message_types.go` (`MsgTypeMemberLeave`) | LOW | fix-spec | +| E | `auth_ok` payload = `{user, server_name, motd}` | Also carries `replay_source` (`none\|buffer\|db`) | `Server/ws/serve.go` auth path; CHANGELOG Phase B | LOW | fix-spec | + +### 2.3 `docs/schema.md` + +| ID | Spec says | Code does | Evidence | Sev | Resolution | +|----|-----------|-----------|----------|-----|------------| +| A | Channel types: `text, voice, announcement, dm` (also in api.md and protocol.md; admin API offers `announcement`) | DB triggers **reject** any type outside `text\|voice\|dm` | `Server/migrations/013_channel_type_constraint.sql` vs `Server/admin/handlers_channels.go` | **HIGH** | **decide** — either implement announcement channels end-to-end or remove the type from specs + admin API | +| H | Migration history stops at "008", with wrong numbering (two `003_*` entries; DM tables labeled 008) | 15 migrations exist, `001`–`015`; real order diverges from 004 onward | `Server/migrations/` directory listing | MEDIUM | fix-spec | +| I | — (absent) | 9 tables undocumented: `login_attempts`, `settings`, `emoji`, `sounds`, `rate_lockouts`, `user_blocks`, `events`, `plugins`, `plugin_kv` | `Server/migrations/001,011,012,014,015` — see [architecture/data-model.md](architecture/data-model.md) | MEDIUM | fix-spec | +| J | attachments DDL without `uploader_id` | Column added for upload-ownership checks | `Server/migrations/010_attachment_uploader.sql` | MEDIUM | fix-spec | +| K | — (sqlc unmentioned) | `sqlc.yaml` + `Server/db/dbgen/` exist (currently dead — A-2026-07-05) | `sqlc.yaml`, `Server/db/dbgen/` | LOW | fix-spec (document whichever way A-2026-07-05 resolves) | + +**Systemic conclusion:** drift is not isolated typos — entire subsystems +(E2EE signaling, plugins, six migrations) postdate the specs. Item A is the +only case where *code and spec actively contradict at runtime*: an admin can +select a channel type the database will refuse to insert. Recommended handling: +one coherent spec-refresh PR (backlog item 4) rather than piecemeal edits, plus +a decision on A first. + +--- + +## 3. Server architecture findings + +Verdict: the intended layering (`api → service → store → db`) is sound and the +`service` layer respects it; test discipline is excellent (test LOC ≈ 1.6× +source; race + deadlock + mutation tooling). The findings are about the seams +that grew around that design. + +| ID | Sev | Area | Evidence | Finding | Recommendation | Effort | +|----|-----|------|----------|---------|----------------|--------| +| A-2026-07-05 | MEDIUM | Data layer | `Server/db/dbgen/` (~3.5k LOC), `sqlc.yaml`, CI `sqlc-verify` job | sqlc output is generated, version-pinned, CI-verified — and imported by nothing. Hand-written raw SQL in `Server/db/*_queries.go` is what runs. | Decide the Phase-A question: adopt dbgen inside `db.DB` method bodies, or delete `dbgen/` + `queries/` + the CI job. Either ends the illusion of a second data layer. | S | +| A-2026-07-06 | MEDIUM | Layering | All `Mount*Routes` signatures take `database *db.DB` alongside `svc` (`Server/api/*_handler.go`); `Server/admin` handlers take `*db.DB` | ~359 direct `database.*` calls above the store seam; three access styles coexist. The abstraction exists but cannot be relied on (e.g. for a future backend swap or for test doubles). | Consolidate incrementally: new handlers service-only; migrate one mount per PR, starting with auth (prior #9). | L | +| A-2026-07-07 | MEDIUM | Correctness risk | `Server/ws/serve.go` (`buildReady`, `computeAllowedChannels`), `Server/ws/hub.go` (`RefreshChannelVisibility`), REST `handleListChannels` | Channel-visibility filtering implemented ~4× with comments instructing they "must mirror" each other. The recent private-channel fixes (e.g. `2bfe6d6`) show this is actively churning — a drift between copies is an information-disclosure bug waiting to happen. | Extract a single `VisibleChannels(userID)` (natural home: `service.PermissionService` or the existing `permissions.Checker`), consume it from all four sites, and add a test asserting REST and WS agree. | M | +| A-2026-07-08 | MEDIUM | Protocol integrity | `Server/ws/message_types.go` header comment; `Client/…/src/lib/protocolTypes.ts` header + "Extensions (not in protocol-schema.json…)" comments | Both sides claim `docs/protocol-schema.json` is the generated single source of truth. The file does not exist; the two constant sets are maintained by hand and have already grown divergent "extension" entries. | Either commit a real `protocol-schema.json` + generator (best: also emits protocol.md tables), or delete the claim and add a cross-language equality test over the two constant sets. | M | +| A-2026-07-09 | MEDIUM | Real-time | `Server/ws/handlers.go` (`handleMessage` V2-then-V1 fallback), dual registration in `NewHub` (`Server/ws/hub.go`) | Strangler-fig V1+V2 dispatch is live: two parsers (lenient/strict), two registries, per-type duplication. | Finish the migration: port remaining V1 types to V2, then delete the V1 path. Track remaining types in an issue so the count visibly shrinks. | M/L | +| A-2026-07-10 | MEDIUM | Composition | `Server/api/router.go:34` (`NewRouter`, ~278 lines) | God-constructor builds rate limiter, TOTP key, storage, services, hub, LiveKit client+process, updater, admin + plugin handlers; spawns goroutines; returns a cleanup closure covering only one of them. Hard to test wiring in isolation; lifecycle ownership is implicit. | Split construction (a `Deps`/`App` struct built in `main.go`) from route mounting (`NewRouter(deps)`); return a composite `io.Closer`. | M | +| A-2026-07-11 | MEDIUM | Real-time | `Server/ws/hub.go` (`SetLiveKit`, `SetEventPersister`, `SetPluginRegistry`, …) | Hub is a mega-object wired post-construction via setters that "must be called before Run" — temporal coupling; a missed setter is a nil-deref at runtime, not a compile error. | Move required collaborators into `NewHub` params (or an options struct validated before `Run`). Full Hub decomposition is a separate, larger effort (backlog 12). | S (constructor) / L (decomposition) | +| — | MEDIUM | Layering | `Server/ws/hub.go:182` (`refreshSettingsLocked`) | Hub runs inline `SELECT value FROM settings WHERE key='server_name'` instead of using `SettingsStore` — the only raw SQL in the real-time layer. | Route through the store; folds into A-2026-07-06 but is a 20-minute standalone fix. | S | +| — | LOW | Scaling posture | `Server/auth/ratelimit.go` (documented), in-memory pub/sub + ring buffer, process-local TOTP replay | Single-instance coupling is structural and *documented* — this is a deliberate design, not a bug. Recorded here so the constraint stays visible ([architecture/system-overview.md D8](architecture/system-overview.md)). | No action now; revisit only if multi-instance ever becomes a goal. | — | +| A-2026-07-13 | LOW | Schema hygiene | `sounds` table (`Server/migrations/001`), `audit_log` + `audit_log_v6` (`003`) | Dead/duplicated schema: soundboard was removed but its table remains; two audit-log tables coexist after the 003 rebuild. | Add a cleanup migration (drop `sounds`, finish the audit_log consolidation) next time a migration ships anyway. | S | + +## 4. Client architecture findings + +Verdict: the client's fundamentals are strong — immutable store discipline, +TOFU pinning in Rust, keychain credentials with IPC redaction, generation +counters against stale listeners, and an unusually deep test/tooling stack +(Vitest, dual Playwright suites, Stryker, oxlint + type-checked ESLint, Knip). +Findings target structure and one security gap. + +| ID | Sev | Area | Evidence | Finding | Recommendation | Effort | +|----|-----|------|----------|---------|----------------|--------| +| A-2026-07-02 | HIGH | Security | `src/main.ts` (`allowSelfSigned: true` at API-client construction); `src-tauri` http plugin built with `dangerous-settings` | Every REST call accepts any certificate. The WS and LiveKit paths pin TOFU fingerprints in Rust; the HTTP path — which carries the auth token on every request — does not. An active MITM can capture tokens without triggering the cert-mismatch modal. | Implement the acknowledged fix: a TOFU HTTP proxy in Rust (mirror `ws_proxy.rs`), or route REST through a pinned Rust command. Until then this is the client's weakest transport link. | M | +| A-2026-07-12 | MEDIUM | Coherence | `src/components/solid/` (154 LOC), `src/lib/solidMount.ts`, `src/lib/solidAdapter.ts`, `vite-plugin-solid` config; CHANGELOG "Solid.js migration (abandoned)" | The abandoned migration's beachhead, adapters, build plugin, and test deps remain, and `docs/client-architecture.md` (2026-03-30) still describes a SolidJS client. Two mental models for contributors, one of them false. | Delete the beachhead + adapters + build plugin; replace `client-architecture.md` content with a pointer to [architecture/client.md](architecture/client.md) or a rewrite. | S | +| — | MEDIUM | Maintainability | `src/lib/livekitSession.ts` (1,719 LOC); `AccountTab.ts` (845), `SidebarArea.ts` (812), `LoginForm.ts` (699) | `livekitSession.ts` owns the connection state machine, E2EE, track management, reconnection, and diagnostics in one class. It is the highest-risk file to modify in the client. | Extract E2EE (already has `e2eeCrypto.ts` as a seam) and track management into collaborators; keep the state machine as the core. Settle for splitting the settings tabs opportunistically. | M | +| — | MEDIUM | State | `src/stores/voice.store.ts` imports members/auth stores; `auth.store.clearAuth()` reaches into `leaveVoice()` + notification cleanup; business logic in `main.ts` subscribers | Cross-store singleton coupling: teardown ordering lives implicitly in import graphs and bootstrap subscribers. | Introduce a thin session-lifecycle module (login/logout orchestration) that calls stores, so stores stop calling each other. | M | +| A-2026-07-14 | LOW | Hygiene | `#5865F2` literal ×18 across 11 files (`main.ts`, `ServerPanel.ts`, `DmSidebar.ts`, `MemberPickerModal.ts`, `SidebarArea.ts`, …); `localhost:8443` ×3; 64 `setTimeout`/`setInterval` sites; 12 `innerHTML` uses | Scattered magic values and manual timer lifecycles; `src/lib/constants.ts` holds a single constant. | Centralize into constants/tokens; adopt a tiny `managedTimer(destroyScope)` helper so `destroy()` paths can't leak intervals. | S | +| — | LOW | Error handling | `catch {}` swallows in `preferences.ts`, `ws.ts` unsubscribe cleanup, `disconnectProxy`; widespread `void`-prefixed fire-and-forget | Mostly deliberate (per lint config), but a handful of swallows hide real failures (e.g. preference persistence silently failing). | Log-at-debug in the swallow sites; keep the pattern otherwise. | S | + +## 5. Process & CI findings + +| ID | Sev | Evidence | Finding | Recommendation | Effort | +|----|-----|----------|---------|----------------|--------| +| A-2026-07-04 | HIGH | `.github/workflows/ci.yml` — `client-tests` job annotated "KNOWN RED pending reboot-plan P2 triage"; no Playwright job | The Go side is gated hard (race, deadlock tag, govulncheck, golangci-lint, sqlc-verify, 4-tag build matrix) but the client's 157-file test suite is red and non-blocking, and E2E never runs in CI. For an AI-first workflow where "quality [is] validated primarily through automated checks" (README), the client half of that promise is currently unenforced. | Triage the red suite to green, flip `client-tests` to blocking, then add at least the web Playwright suite as a nightly non-blocking job (prior #11) before promoting it to a gate. | M | +| A-2026-07-03 | HIGH | `git log` on `docs/api.md`, `protocol.md`, `schema.md` (all 2026-04-02) vs code churn through 2026-07-19 | No process keeps the reference specs current — the July burst (events table, plugins, E2EE, private channels) shipped without touching them. | After the one-time refresh (backlog 4), add a PR-checklist line (mirroring the blueprint maintenance rule in [architecture/README.md](architecture/README.md)): protocol/API/schema changes update the matching spec in the same PR. | S | +| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` W1-3/W3-5 reference `Server/store/postgres.go` (deleted) | The remediation plan predates the Postgres removal; two waves partially target dead code. | Annotate the affected items rather than rewriting the plan. | S | +| — | LOW | `ci.yml` `tauri-build` job: `if: github.event_name == 'pull_request' && github.base_ref == 'main'` | Full client build (incl. Clippy `-D warnings`, cargo audit) never runs on push to main — a merge that breaks the native build is caught only at the next PR. | Add a push-to-main trigger for `tauri-build` (or a nightly). | S | + +## 6. Prioritized improvement backlog + +Ranked by severity × effort; quick wins float within tier. S/M/L ≈ hours / days / week+. + +| # | Item | Finding | Sev | Effort | +|---|------|---------|-----|--------| +| 1 | Decide + resolve the `announcement` channel-type contradiction (implement or strip from specs/admin) | A-2026-07-01 | HIGH | S (decision) | +| 2 | Delete (or finally adopt) the dead `db/dbgen` sqlc layer; adjust the `sqlc-verify` CI job to match | A-2026-07-05 | MEDIUM | S | +| 3 | Extract the single channel-visibility function replacing the 4 "must mirror" copies; add REST/WS agreement test | A-2026-07-07 | MEDIUM | M | +| 4 | One-PR refresh of api.md / protocol.md / schema.md against §2, incl. E2EE + plugins + migrations 009–015; then enforce spec-updates-with-code via PR checklist | A-2026-07-03 | HIGH | M | +| 5 | Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly | prior #6 | HIGH | M | +| 6 | Client HTTP TOFU pinning (Rust proxy mirroring `ws_proxy.rs`) | A-2026-07-02 | HIGH | M | +| 7 | Stop discarding `LogAudit` errors in `admin/handlers_backup.go`; fix the contradictory upload `Cache-Control` | prior #10, W3-4 | MEDIUM | S | +| 8 | Remove the SolidJS beachhead + adapters; retire or rewrite `docs/client-architecture.md` | A-2026-07-12 | MEDIUM | S | +| 9 | Resolve the `protocol-schema.json` ghost: real codegen or an equality test between Go and TS constant sets | A-2026-07-08 | MEDIUM | M | +| 10 | Green + blocking client unit suite; nightly Playwright | A-2026-07-04 | HIGH | M | +| 11 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 | MEDIUM | M/L | +| 12 | Consolidate DB access behind the service layer (start with auth routes, prior #9); then Hub constructor cleanup and decomposition | A-2026-07-06, -10, -11 | MEDIUM | L | + +--- + +*Blueprints referenced throughout live in [docs/architecture/](architecture/README.md); +update a diagram and its doc in the same PR as any structural change to its +source-of-truth files. Line-number evidence in this report is a snapshot of +commit `ddc49f0`.*