diff --git a/docs/api.md b/docs/api.md index eb420c84..2372b574 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 4512c066..3f52fb6d 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -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) diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index be3c73c6..0e02ebc1 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -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 | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 34d7ecd4..c0dd9d83 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index be8410db..152061cd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 | diff --git a/docs/schema.md b/docs/schema.md index 9c6283dd..ca13420e 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -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