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:
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.*
|
||||
Reference in New Issue
Block a user