mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
docs: refresh api.md, protocol.md, schema.md against current code (D7)
One-PR spec refresh per decision D7, using the 2026-07-19 audit's
conformance matrix as the checklist:
api.md
- Remove the deleted version field from /health and /api/v1/info
(anti-fingerprinting C-2) and document the removal.
- Document the profile surface (PATCH /users/me, PUT /users/me/password,
GET/DELETE /users/me/sessions), the user-blocks surface
(GET/PUT/DELETE /api/v1/blocks), and the plugin admin surface
(/api/v1/admin/plugins).
- Correct GET /api/v1/files/{id}: auth is required and caching is
'private, no-cache' (was documented as public/immutable, unauthenticated).
- Add search (30/min) and upload (10/min) rate limits; note announcement
channel type as planned-only.
protocol.md
- Document auth_ok.replay_source and the 3-tier reconnect replay
(ring buffer -> events table -> full resync) with the visibility
watermark.
- Add the Voice End-to-End Encryption section (voice_e2ee_announce/offer
in both directions, key-holder semantics, rate limits) and
voice_token.is_key_holder.
- Document user_update; mark voice_speakers and member_leave as defined
but not currently emitted; extend voice_config fields.
- Update the reference tables (19 client->server / 30 server->client)
and point them at protocol-schema.json as the generated inventory.
schema.md
- Correct the migration history to the real 001-015 numbering.
- Document the previously missing tables: login_attempts, settings,
emoji, sounds (dead schema), rate_lockouts, user_blocks, events,
plugins, plugin_kv; add attachments.uploader_id and new indexes.
- Fix the channel-type list to text/voice/dm (013 triggers) and correct
the permission formula to (base & ~deny) | allow to match
permissions.EffectivePerms.
- Note the sqlc/dbgen layer and link the architecture data-model doc.
Also correct the audit_log_v6 claim (transient rename inside migration
003, not a coexisting table) in the audit and data-model blueprint, and
update the audit/decisions trackers (A-2026-07-03 closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
+191
-9
@@ -343,6 +343,103 @@ Disable TOTP for the authenticated user.
|
||||
|
||||
---
|
||||
|
||||
## User Profile & Sessions
|
||||
|
||||
### PATCH /api/v1/users/me
|
||||
|
||||
Update the authenticated user's profile (username and/or avatar).
|
||||
Broadcasts a `user_update` WebSocket message to all clients on success.
|
||||
|
||||
**Auth:** Required
|
||||
**Rate limit:** 10 requests/minute
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "newname",
|
||||
"avatar": "upload-uuid.png"
|
||||
}
|
||||
```
|
||||
|
||||
Both fields optional; `avatar` may be `null` to clear it.
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
Returns the updated user object (same shape as `GET /api/v1/auth/me`).
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/v1/users/me/password
|
||||
|
||||
Change the authenticated user's password. Verifies the old password, enforces
|
||||
password strength, and revokes all *other* sessions on success.
|
||||
|
||||
**Auth:** Required
|
||||
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
|
||||
repeated wrong old passwords
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"old_password": "OldPass!1",
|
||||
"new_password": "NewStr0ng!Pass"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response 204 No Content
|
||||
|
||||
Password changed and other sessions revoked. If the password change committed
|
||||
but revoking other sessions failed, the endpoint returns **200 OK** with a
|
||||
warning body instead (the new password is in effect — do not retry with the
|
||||
old one).
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
|
||||
| 403 | `FORBIDDEN` | Incorrect old password |
|
||||
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/users/me/sessions
|
||||
|
||||
List the authenticated user's active sessions.
|
||||
|
||||
**Auth:** Required
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": 12,
|
||||
"device": "Mozilla/5.0 ...",
|
||||
"ip": "192.168.1.100",
|
||||
"created_at": "2026-07-01T10:00:00Z",
|
||||
"last_used": "2026-07-19T09:00:00Z",
|
||||
"is_current": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/v1/users/me/sessions/{id}
|
||||
|
||||
Revoke one of the authenticated user's sessions by ID.
|
||||
|
||||
**Auth:** Required
|
||||
|
||||
#### Response 204 No Content
|
||||
|
||||
---
|
||||
|
||||
## Channel Endpoints
|
||||
|
||||
### GET /api/v1/channels
|
||||
@@ -372,7 +469,7 @@ List all channels the authenticated user has `READ_MESSAGES` permission for. DM
|
||||
| ----- | ---- | ----------- |
|
||||
| `id` | int64 | Channel ID |
|
||||
| `name` | string | Channel name |
|
||||
| `type` | string | `text`, `voice`, or `announcement` |
|
||||
| `type` | string | `text` or `voice` (`announcement` is planned; the DB currently rejects it) |
|
||||
| `topic` | string | Channel topic/description |
|
||||
| `category` | string | Category grouping |
|
||||
| `position` | int | Sort order within category |
|
||||
@@ -492,6 +589,7 @@ Unpin a message from a channel.
|
||||
Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.
|
||||
|
||||
**Auth:** Required
|
||||
**Rate limit:** 30 requests/minute
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
@@ -598,6 +696,47 @@ Close a DM channel for the authenticated user (hides it from their sidebar). The
|
||||
|
||||
---
|
||||
|
||||
## User Blocks
|
||||
|
||||
Blocking a user prevents DM creation and messaging in both directions
|
||||
(backed by the `user_blocks` table).
|
||||
|
||||
### GET /api/v1/blocks
|
||||
|
||||
List the IDs of users the authenticated user has blocked.
|
||||
|
||||
**Auth:** Required
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
```json
|
||||
{ "blocked_user_ids": [2, 7] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/v1/blocks/{userId}
|
||||
|
||||
Block a user.
|
||||
|
||||
**Auth:** Required
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
```json
|
||||
{ "message": "user blocked" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/v1/blocks/{userId}
|
||||
|
||||
Unblock a user.
|
||||
|
||||
**Auth:** Required
|
||||
|
||||
---
|
||||
|
||||
## Invite Endpoints
|
||||
|
||||
All invite endpoints require authentication and the `MANAGE_INVITES` permission.
|
||||
@@ -667,6 +806,7 @@ Revoke an invite by its code string.
|
||||
Upload a file as multipart form data.
|
||||
|
||||
**Auth:** Required
|
||||
**Rate limit:** 10 requests/minute
|
||||
**Body size limit:** 100 MiB
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
@@ -694,10 +834,12 @@ Files are validated against blocked magic bytes (PE executables, ELF binaries, M
|
||||
|
||||
Serve a previously uploaded file by its UUID.
|
||||
|
||||
**Auth:** None (URLs are unguessable UUIDs)
|
||||
**Caching:** `Cache-Control: public, max-age=31536000, immutable`
|
||||
**Auth:** Required (Bearer token) — downloads are access-controlled
|
||||
**Caching:** `Cache-Control: private, no-cache` (never stored by shared/proxy caches; browsers must revalidate)
|
||||
|
||||
Supports HTTP range requests and conditional requests.
|
||||
Supports HTTP range requests and conditional requests. MIME types that could
|
||||
execute under the app origin (HTML, SVG, XML, PDF) are served with
|
||||
`Content-Disposition: attachment` to force download.
|
||||
|
||||
---
|
||||
|
||||
@@ -707,12 +849,12 @@ Supports HTTP range requests and conditional requests.
|
||||
|
||||
### GET /api/v1/health
|
||||
|
||||
Public health check endpoint, no authentication required.
|
||||
Public health check endpoint, no authentication required. The server version
|
||||
is deliberately not exposed here (anti-fingerprinting hardening, C-2).
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"uptime": 86400,
|
||||
"online_users": 3
|
||||
}
|
||||
@@ -724,14 +866,14 @@ Public health check endpoint, no authentication required.
|
||||
|
||||
### GET /api/v1/info
|
||||
|
||||
Returns the server name and version.
|
||||
Returns the server name. The version field was removed from this
|
||||
unauthenticated endpoint (anti-fingerprinting hardening, C-2).
|
||||
|
||||
**Auth:** None
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My OwnCord Server",
|
||||
"version": "1.2.0"
|
||||
"name": "My OwnCord Server"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -760,6 +902,46 @@ Runtime server metrics. Restricted to admin-allowed CIDRs.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Administration
|
||||
|
||||
Manage WASM plugins. These endpoints sit behind **both** the admin IP
|
||||
restriction (allowed CIDRs) **and** admin bearer-token authentication.
|
||||
Plugin execution additionally requires a server built with `-tags wazero`
|
||||
and `plugins.enabled: true` in config.
|
||||
|
||||
### GET /api/v1/admin/plugins
|
||||
|
||||
List installed plugins.
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
Array of plugin rows: `ID`, `Name`, `Version`, `Enabled`, `ManifestJSON`,
|
||||
`InstalledAt`.
|
||||
|
||||
### POST /api/v1/admin/plugins/install
|
||||
|
||||
Install a plugin from an uploaded zip (multipart form). The archive is
|
||||
size-capped (16 MiB compressed / 64 MiB uncompressed) and hardened against
|
||||
zip-slip and symlinks; installation is staged and atomically renamed.
|
||||
|
||||
#### Response 201 Created
|
||||
|
||||
```json
|
||||
{ "name": "plugin-name" }
|
||||
```
|
||||
|
||||
### POST /api/v1/admin/plugins/{id}/enable
|
||||
|
||||
### POST /api/v1/admin/plugins/{id}/disable
|
||||
|
||||
Enable or disable an installed plugin.
|
||||
|
||||
### DELETE /api/v1/admin/plugins/{id}
|
||||
|
||||
Uninstall a plugin.
|
||||
|
||||
---
|
||||
|
||||
## LiveKit Endpoints
|
||||
|
||||
These endpoints are only registered when LiveKit voice is configured.
|
||||
|
||||
@@ -51,7 +51,6 @@ erDiagram
|
||||
events
|
||||
settings
|
||||
audit_log
|
||||
audit_log_v6
|
||||
login_attempts
|
||||
rate_lockouts
|
||||
emoji
|
||||
@@ -104,7 +103,7 @@ erDiagram
|
||||
| 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. |
|
||||
| Ops | `settings`, `audit_log`, `sounds` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. |
|
||||
|
||||
### How the schema is accessed (three coexisting styles)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
|
||||
|----|-----|---------|--------|
|
||||
| A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | DECIDED 2026-07-19 — implement end-to-end (D1) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | DECIDED 2026-07-19 — next security work: TOFU HTTP proxy in Rust (D5) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
| 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) | DECIDED 2026-07-19 — one refresh PR, after protocol codegen (D7) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
| 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) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies |
|
||||
| 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 | DECIDED 2026-07-19 — adopt sqlc as the real query layer (D2) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | DECIDED 2026-07-19 — single data layer: sqlc-backed db pkg, remove store/ (D2+D3) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
@@ -26,7 +26,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
|
||||
| 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 | DECIDED 2026-07-19 — delete beachhead + retire the stale doc (D6) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
|
||||
| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal; `audit_log` + `audit_log_v6` coexist | OPEN |
|
||||
| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal (correction 2026-07-19: `audit_log_v6` is only a transient rename inside migration 003, not a coexisting table) | 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 |
|
||||
|
||||
@@ -66,6 +66,10 @@ The three reference specs were last meaningfully edited **2026-04-02**
|
||||
2026 changes. Resolution column: **fix-spec** (doc catches up to code),
|
||||
**fix-code** (code is wrong), **decide** (product decision needed first).
|
||||
|
||||
> **Update 2026-07-19:** the spec refresh landed — every fix-spec row below
|
||||
> is resolved in the specs; evidence is retained for the record. Item A
|
||||
> remains open pending the D1 implementation.
|
||||
|
||||
### 2.1 `docs/api.md`
|
||||
|
||||
| ID | Spec says | Code does | Evidence | Sev | Resolution |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date decided:** 2026-07-19
|
||||
**Decided by:** J3vb
|
||||
**Status:** decisions recorded; work sequenced below, not yet implemented
|
||||
**Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status
|
||||
**Source:** decision points raised by [docs/audit-2026-07-19.md](../audit-2026-07-19.md)
|
||||
|
||||
This document records the maintainer's answers to the open decision points from
|
||||
@@ -20,7 +20,7 @@ here (and the audit's closure table) as items land.
|
||||
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
|
||||
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | Planned |
|
||||
| D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | Planned |
|
||||
| D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the *fresh* specs. | Planned |
|
||||
| D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the *fresh* specs. | **Implemented 2026-07-19** — all three specs refreshed against the code (incl. E2EE protocol section, migrations 001–015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. |
|
||||
| D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch** — `LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. |
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
+151
-18
@@ -25,11 +25,12 @@ All client-server real-time communication happens over a single WebSocket connec
|
||||
13. [Channel Updates](#channel-updates)
|
||||
14. [Member Updates](#member-updates)
|
||||
15. [Voice Signaling](#voice-signaling)
|
||||
16. [Direct Messages](#direct-messages)
|
||||
17. [Server Restart](#server-restart)
|
||||
18. [Error Handling](#error-handling)
|
||||
19. [Rate Limits](#rate-limits)
|
||||
20. [Message Type Reference Table](#message-type-reference-table)
|
||||
16. [Voice End-to-End Encryption](#voice-end-to-end-encryption)
|
||||
17. [Direct Messages](#direct-messages)
|
||||
18. [Server Restart](#server-restart)
|
||||
19. [Error Handling](#error-handling)
|
||||
20. [Rate Limits](#rate-limits)
|
||||
21. [Message Type Reference Table](#message-type-reference-table)
|
||||
|
||||
---
|
||||
|
||||
@@ -132,11 +133,17 @@ After the WebSocket connection is established, the client sends the first messag
|
||||
"role": "admin"
|
||||
},
|
||||
"server_name": "My Server",
|
||||
"motd": "Welcome!"
|
||||
"motd": "Welcome!",
|
||||
"replay_source": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`replay_source` reports which replay tier served this (re)connection:
|
||||
`"none"` (fresh connection / full re-sync), `"buffer"` (in-memory ring
|
||||
buffer), or `"db"` (persistent `events` table). See
|
||||
[Reconnection with State Recovery](#reconnection-with-state-recovery).
|
||||
|
||||
### Step 3: Failure -- auth_error
|
||||
|
||||
```json
|
||||
@@ -195,15 +202,22 @@ Every 30 seconds, the server checks all clients. Any client with no activity for
|
||||
|
||||
## Reconnection with State Recovery
|
||||
|
||||
When a connection drops, the client automatically reconnects with exponential backoff (1s to 30s max) and sends `last_seq` in the `auth` message.
|
||||
When a connection drops, the client automatically reconnects with exponential backoff (1s to 30s max) and sends `last_seq` in the `auth` message. The server resolves the reconnect through a **3-tier replay pipeline** (cheapest first):
|
||||
|
||||
| Condition | Server Behavior |
|
||||
|-----------|-----------------|
|
||||
| `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` |
|
||||
| `last_seq > 0` AND seq in buffer | Replay flow: `auth_ok` + missed events + `presence` (no `member_join`, no `ready`) |
|
||||
| `last_seq > 0` AND seq NOT in buffer | Full flow (fallback): same as `last_seq == 0` |
|
||||
| Tier | Condition | Server Behavior | `replay_source` |
|
||||
|------|-----------|-----------------|-----------------|
|
||||
| — | `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` | `none` |
|
||||
| 1 | seq within the in-memory ring buffer (1000 events) | Replay flow: `auth_ok` + missed events + `presence` (no `member_join`, no `ready`). Channel-scoped events are permission-filtered (fail-closed). | `buffer` |
|
||||
| 2 | seq within the persistent `events` table (max 5000 events, subject to retention) | Same replay flow, served from the cold tier | `db` |
|
||||
| 3 | seq too far behind, or channel visibility changed while away | Full flow (fallback): same as `last_seq == 0` | `none` |
|
||||
|
||||
DM events are not stored in the ring buffer and are only recoverable via the full `ready` payload.
|
||||
A visibility watermark forces the tier-3 full re-sync whenever channel
|
||||
visibility changed while the client was disconnected, so permission changes
|
||||
can never be replayed around.
|
||||
|
||||
DM events are not stored in the ring buffer; DM history persisted to the
|
||||
`events` table is replayable via tier 2, and everything is always recoverable
|
||||
via the full `ready` payload.
|
||||
|
||||
---
|
||||
|
||||
@@ -228,7 +242,7 @@ Sent once after `auth_ok` (fresh connection or replay fallback).
|
||||
|
||||
### Payload Fields
|
||||
|
||||
**channels[]:** `id`, `name`, `type` (`text`/`voice`/`announcement`), `category`, `position`, `unread_count` (text only), `last_message_id` (text only)
|
||||
**channels[]:** `id`, `name`, `type` (`text`/`voice`), `category`, `position`, `unread_count` (text only), `last_message_id` (text only)
|
||||
|
||||
**dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count`
|
||||
|
||||
@@ -548,6 +562,31 @@ Triggered when an admin changes a user's role.
|
||||
}
|
||||
```
|
||||
|
||||
### user_update (Server -> Client, broadcast)
|
||||
|
||||
Broadcast when a user changes their own profile via `PATCH /api/v1/users/me`
|
||||
(username and/or avatar).
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 73,
|
||||
"type": "user_update",
|
||||
"payload": {
|
||||
"user_id": 5,
|
||||
"username": "newname",
|
||||
"avatar": "uuid.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`avatar` may be `null` when unset.
|
||||
|
||||
### member_leave (reserved)
|
||||
|
||||
`member_leave` is a defined message type that the server does not currently
|
||||
emit (clients handle it defensively). Reserved for future member-removal
|
||||
flows.
|
||||
|
||||
---
|
||||
|
||||
## Voice Signaling
|
||||
@@ -575,11 +614,17 @@ On success, server sends (in order):
|
||||
"channel_id": 10,
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"url": "/livekit",
|
||||
"direct_url": "ws://localhost:7880"
|
||||
"direct_url": "ws://localhost:7880",
|
||||
"is_key_holder": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`is_key_holder` tells the joiner whether they are the channel's E2EE key
|
||||
holder (see [Voice End-to-End Encryption](#voice-end-to-end-encryption)).
|
||||
Tokens are 5-minute scoped JWTs whose publish sources (mic/camera/screen) are
|
||||
restricted by the user's permissions.
|
||||
|
||||
### voice_config (Server -> Client, direct)
|
||||
|
||||
```json
|
||||
@@ -589,7 +634,10 @@ On success, server sends (in order):
|
||||
"channel_id": 10,
|
||||
"quality": "medium",
|
||||
"bitrate": 64000,
|
||||
"max_users": 50
|
||||
"max_users": 50,
|
||||
"threshold_mode": "top_speakers",
|
||||
"mixing_threshold": 0,
|
||||
"top_speakers": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -621,6 +669,12 @@ Quality presets:
|
||||
}
|
||||
```
|
||||
|
||||
### voice_speakers (reserved)
|
||||
|
||||
`voice_speakers` (`{ channel_id, speakers: [user_id, ...], threshold_mode }`)
|
||||
is a defined message type that the server does not currently emit; clients
|
||||
already handle it. Reserved for active-speaker signaling.
|
||||
|
||||
### voice_state (Server -> Client, broadcast)
|
||||
|
||||
```json
|
||||
@@ -673,6 +727,72 @@ Rate limited: 1 per 60 seconds. Must be in a voice channel.
|
||||
|
||||
---
|
||||
|
||||
## Voice End-to-End Encryption
|
||||
|
||||
Voice/video media can be end-to-end encrypted. The server never holds the room
|
||||
key — it only relays the ECDH key exchange between participants and tracks who
|
||||
the **key holder** is (deterministically, the participant with the lowest user
|
||||
ID in the channel). The joiner learns whether they are the key holder from
|
||||
`voice_token.is_key_holder`. When a participant leaves, the key holder rotates
|
||||
the room key so departed members cannot decrypt future media.
|
||||
|
||||
Both E2EE message types are rate limited at 5 per second per user. Key
|
||||
material must be standard-alphabet base64 (padded or unpadded).
|
||||
|
||||
### voice_e2ee_announce (Client -> Server)
|
||||
|
||||
Announce this participant's ECDH public key to the channel.
|
||||
|
||||
```json
|
||||
{ "type": "voice_e2ee_announce", "payload": { "public_key": "base64-ecdh-pubkey" } }
|
||||
```
|
||||
|
||||
### voice_e2ee_announce (Server -> Client, broadcast to voice channel)
|
||||
|
||||
Relayed to the other participants with the sender's user ID attached:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "voice_e2ee_announce",
|
||||
"payload": {
|
||||
"user_id": 1,
|
||||
"public_key": "base64-ecdh-pubkey"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### voice_e2ee_offer (Client -> Server)
|
||||
|
||||
The key holder wraps the room key for a specific participant:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "voice_e2ee_offer",
|
||||
"payload": {
|
||||
"target_user_id": 2,
|
||||
"encrypted_key": "base64-wrapped-room-key",
|
||||
"iv": "base64-iv"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### voice_e2ee_offer (Server -> Client, relay to target)
|
||||
|
||||
Delivered only to `target_user_id`, with the sender attached:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "voice_e2ee_offer",
|
||||
"payload": {
|
||||
"from_user_id": 1,
|
||||
"encrypted_key": "base64-wrapped-room-key",
|
||||
"iv": "base64-iv"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages
|
||||
|
||||
### dm_channel_open (Server -> Client)
|
||||
@@ -779,12 +899,18 @@ All rate limits are enforced server-side using a token bucket rate limiter.
|
||||
| Voice camera | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice screenshare | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error |
|
||||
| Voice E2EE announce/offer | 5 | 1 second | `RATE_LIMITED` error |
|
||||
|
||||
---
|
||||
|
||||
## Message Type Reference Table
|
||||
|
||||
### Client -> Server (18 types)
|
||||
The authoritative type inventory is [protocol-schema.json](protocol-schema.json),
|
||||
from which the Go and TypeScript constant files are generated
|
||||
(`make protocol-generate` / verified in CI by `make protocol-verify`). The
|
||||
tables below add per-type behavioral notes.
|
||||
|
||||
### Client -> Server (19 types)
|
||||
|
||||
| Type | Rate Limit | Notes |
|
||||
|------|-----------|-------|
|
||||
@@ -804,9 +930,11 @@ All rate limits are enforced server-side using a token bucket rate limiter.
|
||||
| `voice_camera` | 2/sec | Requires USE_VIDEO |
|
||||
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
|
||||
| `voice_token_refresh` | 1/60sec | Must be in voice |
|
||||
| `voice_e2ee_announce` | 5/sec | ECDH pubkey announce |
|
||||
| `voice_e2ee_offer` | 5/sec | Wrapped room key to target |
|
||||
| `ping` | None | Heartbeat |
|
||||
|
||||
### Server -> Client (25+ types)
|
||||
### Server -> Client (30 types)
|
||||
|
||||
| Type | Has seq? | Delivery |
|
||||
|------|----------|----------|
|
||||
@@ -827,11 +955,16 @@ All rate limits are enforced server-side using a token bucket rate limiter.
|
||||
| `voice_leave` | Yes | All clients |
|
||||
| `voice_config` | No | Direct to joiner |
|
||||
| `voice_token` | No | Direct to joiner |
|
||||
| `voice_speakers` | No | Reserved — not currently emitted |
|
||||
| `member_join` | Yes | All clients |
|
||||
| `member_leave` | Yes | Reserved — not currently emitted |
|
||||
| `member_update` | Yes | All clients |
|
||||
| `user_update` | Yes | All clients (profile changes) |
|
||||
| `member_ban` | Yes | All clients |
|
||||
| `dm_channel_open` | No | Direct to participant |
|
||||
| `dm_channel_close` | No | Direct to participant |
|
||||
| `voice_e2ee_announce` | No | Voice channel (excl. sender) |
|
||||
| `voice_e2ee_offer` | No | Direct to target participant |
|
||||
| `server_restart` | Yes | All clients |
|
||||
| `error` | No | Direct to requester |
|
||||
| `pong` | No | Direct to pinger |
|
||||
|
||||
+181
-11
@@ -2,6 +2,14 @@
|
||||
|
||||
OwnCord uses a single SQLite database file (`data/chatserver.db`) with the pure-Go driver `modernc.org/sqlite` (no CGO). Migrations run automatically on startup.
|
||||
|
||||
> **Data-access layers:** queries currently run as hand-written SQL in
|
||||
> `Server/db`; an sqlc-generated layer (`Server/db/dbgen`, from
|
||||
> `Server/db/queries/`) exists and is slated to become the real query layer
|
||||
> per decision D2 in
|
||||
> [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md).
|
||||
> See [architecture/data-model.md](architecture/data-model.md) for the full
|
||||
> picture.
|
||||
|
||||
---
|
||||
|
||||
## Database Configuration
|
||||
@@ -37,13 +45,19 @@ CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
|------|-------------|
|
||||
| `001_initial_schema.sql` | All core tables, default roles and settings |
|
||||
| `002_voice_states.sql` | Adds `voice_states` table |
|
||||
| `003_audit_log.sql` | Recreates `audit_log` with renamed columns |
|
||||
| `003_voice_optimization.sql` | Adds `camera`, `screenshare` to voice_states; voice settings to channels |
|
||||
| `004_fix_member_permissions.sql` | Fixes Member role permissions |
|
||||
| `005_channel_overrides_index.sql` | Adds composite index on channel_overrides |
|
||||
| `006_member_video_permissions.sql` | Adds USE_VIDEO and SHARE_SCREEN to Member role |
|
||||
| `007_attachment_dimensions.sql` | Adds `width` and `height` to attachments |
|
||||
| `008_dm_tables.sql` | Adds `dm_participants` and `dm_open_state` tables |
|
||||
| `003_audit_log.sql` | Recreates `audit_log` with canonical column names (via a transient `audit_log_v6` rename) |
|
||||
| `004_voice_optimization.sql` | Adds `camera`, `screenshare` to voice_states; voice settings to channels |
|
||||
| `005_fix_member_permissions.sql` | Fixes Member role permissions |
|
||||
| `006_channel_overrides_index.sql` | Adds composite index on channel_overrides |
|
||||
| `007_member_video_permissions.sql` | Adds USE_VIDEO and SHARE_SCREEN to Member role |
|
||||
| `008_attachment_dimensions.sql` | Adds `width` and `height` to attachments |
|
||||
| `009_dm_tables.sql` | Adds `dm_participants` and `dm_open_state` tables |
|
||||
| `010_attachment_uploader.sql` | Adds `attachments.uploader_id` + index for upload-ownership checks |
|
||||
| `011_rate_lockouts.sql` | Adds `rate_lockouts` so rate-limit lockouts survive restarts |
|
||||
| `012_user_blocks.sql` | Adds `user_blocks` (blocks DM creation/messaging between users) |
|
||||
| `013_channel_type_constraint.sql` | INSERT/UPDATE triggers restricting `channels.type` to `text`/`voice`/`dm` |
|
||||
| `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay |
|
||||
| `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +151,10 @@ CREATE TABLE channels (
|
||||
);
|
||||
```
|
||||
|
||||
Channel types: `text`, `voice`, `announcement`, `dm`.
|
||||
Channel types: `text`, `voice`, `dm`. Migration 013 installs INSERT/UPDATE
|
||||
triggers that reject any other value at the database layer. (An `announcement`
|
||||
type is planned but not yet implemented — see the D1 decision in
|
||||
[plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md).)
|
||||
|
||||
---
|
||||
|
||||
@@ -208,11 +225,14 @@ CREATE TABLE attachments (
|
||||
size INTEGER NOT NULL,
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
width INTEGER,
|
||||
height INTEGER
|
||||
height INTEGER,
|
||||
uploader_id INTEGER REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
Uses UUID primary keys. `message_id` is NULL during upload, linked when the message is sent.
|
||||
Uses UUID primary keys. `message_id` is NULL during upload, linked when the
|
||||
message is sent. `uploader_id` (added by migration 010) records who uploaded
|
||||
the file and backs the ownership check when attaching an upload to a message.
|
||||
|
||||
---
|
||||
|
||||
@@ -324,6 +344,148 @@ CREATE TABLE dm_open_state (
|
||||
|
||||
---
|
||||
|
||||
### login_attempts
|
||||
|
||||
Login attempt log used for IP-based rate limiting and lockouts.
|
||||
|
||||
```sql
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
username TEXT,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### settings
|
||||
|
||||
Generic key/value store for server settings (`server_name`, `motd`,
|
||||
`registration_open`, …). Written by the admin API; read by the REST layer and
|
||||
the WebSocket hub (cached with a short TTL).
|
||||
|
||||
```sql
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### emoji
|
||||
|
||||
Custom emoji metadata.
|
||||
|
||||
```sql
|
||||
CREATE TABLE emoji (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
shortcode TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### sounds
|
||||
|
||||
**Dead schema.** Created by the initial schema for the soundboard feature,
|
||||
which has since been removed; the table remains but nothing reads or writes it.
|
||||
Slated for a cleanup migration (audit A-2026-07-13).
|
||||
|
||||
```sql
|
||||
CREATE TABLE sounds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### rate_lockouts
|
||||
|
||||
Persists rate-limiter lockouts (e.g. repeated failed logins) so they survive
|
||||
server restarts. Sliding-window counters themselves stay in memory.
|
||||
|
||||
```sql
|
||||
CREATE TABLE rate_lockouts (
|
||||
key TEXT PRIMARY KEY,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### user_blocks
|
||||
|
||||
User blocking (added by migration 012): a block prevents DM creation and
|
||||
messaging between the two users.
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_blocks (
|
||||
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (blocker_id, blocked_id),
|
||||
CHECK (blocker_id != blocked_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### events
|
||||
|
||||
Persistent broadcast log (migration 014) — the cold tier of the reconnect
|
||||
replay pipeline (see [protocol.md](protocol.md)). Written asynchronously by
|
||||
the event persister, pruned by retention (configurable, default 24h). The
|
||||
hub's in-memory sequence counter is seeded from `MAX(events.seq)` at startup
|
||||
so sequence numbers stay monotonic across restarts.
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
channel_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### plugins / plugin_kv
|
||||
|
||||
Plugin registry and per-plugin key/value storage (migration 015). `plugin_kv`
|
||||
is namespaced per plugin via the composite primary key.
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
version TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
manifest_json TEXT NOT NULL,
|
||||
installed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_kv (
|
||||
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value BLOB NOT NULL,
|
||||
PRIMARY KEY (plugin_id, key)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indexes
|
||||
|
||||
| Index Name | Table | Columns | Purpose |
|
||||
@@ -339,6 +501,10 @@ CREATE TABLE dm_open_state (
|
||||
| `idx_voice_states_channel` | voice_states | `(channel_id)` | All users in a voice channel |
|
||||
| `idx_channel_overrides_channel_role` | channel_overrides | `(channel_id, role_id)` | Permission lookup |
|
||||
| `idx_dm_participants_user` | dm_participants | `(user_id)` | DM channel lookup |
|
||||
| `idx_attachments_uploader` | attachments | `(uploader_id)` | Upload-ownership checks |
|
||||
| `idx_user_blocks_blocked` | user_blocks | `(blocked_id, blocker_id)` | Reverse block lookup |
|
||||
| `idx_events_channel_seq` | events | `(channel_id, seq)` | Cold-tier replay per channel |
|
||||
| `idx_events_created_at` | events | `(created_at)` | Retention pruning |
|
||||
|
||||
---
|
||||
|
||||
@@ -377,10 +543,14 @@ Bits 2-4, 7, 13-15, 21-23, 28-29, 31 are reserved.
|
||||
1. Get the user's role -> role.Permissions (base)
|
||||
2. If (base & ADMINISTRATOR) != 0 -> ALLOW everything
|
||||
3. Get channel_overrides for (channel_id, role_id) -> allow, deny
|
||||
4. effective = (base | allow) & ~deny
|
||||
4. effective = (base & ~deny) | allow
|
||||
5. Check: (effective & required_permission) != 0
|
||||
```
|
||||
|
||||
Deny is applied first (strips bits), then allow (adds bits), so allow wins
|
||||
when both target the same bit — matching Discord's channel-override semantics
|
||||
(`permissions.EffectivePerms`).
|
||||
|
||||
DM channels bypass role permissions entirely and use participant-based authorization instead.
|
||||
|
||||
### Default Role Permission Values
|
||||
|
||||
Reference in New Issue
Block a user