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:
Claude
2026-07-19 13:51:44 +00:00
parent 2e7a80171b
commit ca3b0fee3e
6 changed files with 532 additions and 44 deletions
+191 -9
View File
@@ -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.
+1 -2
View File
@@ -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)
+6 -2
View File
@@ -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 -2
View File
@@ -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 001015, 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
View File
@@ -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
View File
@@ -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