mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers).
1648 lines
69 KiB
Markdown
1648 lines
69 KiB
Markdown
# WebSocket Protocol Reference
|
|
|
|
All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`.
|
|
|
|
**Related docs:**
|
|
|
|
- [api.md](api.md) -- REST endpoints (message history, file uploads, etc.)
|
|
- [schema.md](schema.md) -- Database tables and permission bitfields
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Transport Layer](#transport-layer)
|
|
2. [Message Envelope](#message-envelope)
|
|
3. [Sequence Numbers](#sequence-numbers)
|
|
4. [Authentication Flow](#authentication-flow)
|
|
5. [Heartbeat and Connection Liveness](#heartbeat-and-connection-liveness)
|
|
6. [Reconnection with State Recovery](#reconnection-with-state-recovery)
|
|
7. [Initial State (ready)](#initial-state-ready)
|
|
8. [Chat Messages](#chat-messages)
|
|
9. [Reactions](#reactions)
|
|
10. [Typing Indicators](#typing-indicators)
|
|
11. [Presence](#presence)
|
|
12. [Channel Focus and Read State](#channel-focus-and-read-state)
|
|
13. [Channel Updates](#channel-updates)
|
|
14. [Member Updates](#member-updates)
|
|
15. [Voice Signaling](#voice-signaling)
|
|
16. [Voice Moderation](#voice-moderation)
|
|
17. [Voice End-to-End Encryption](#voice-end-to-end-encryption)
|
|
18. [Direct Messages](#direct-messages)
|
|
19. [Server Restart](#server-restart)
|
|
20. [Error Handling](#error-handling)
|
|
21. [Rate Limits](#rate-limits)
|
|
22. [Message Type Reference Table](#message-type-reference-table)
|
|
|
|
---
|
|
|
|
## Transport Layer
|
|
|
|
### WebSocket Endpoint
|
|
|
|
```
|
|
wss://{host}/api/v1/ws
|
|
```
|
|
|
|
The client connects via the Tauri Rust backend's WS proxy rather than native WebView2 WebSocket. This is required because WebView2 rejects self-signed TLS certificates. The Rust proxy uses TOFU (Trust On First Use) certificate pinning.
|
|
|
|
### Transport Limits
|
|
|
|
| Limit | Value |
|
|
| ---------------------- | ------------ |
|
|
| Max read size | 1 MB |
|
|
| Max message content | 4000 runes |
|
|
| Write timeout | 10 seconds |
|
|
| Auth deadline | 10 seconds |
|
|
| Send buffer per client | 256 messages |
|
|
|
|
---
|
|
|
|
## Message Envelope
|
|
|
|
Every WebSocket message is a JSON object with these fields:
|
|
|
|
```json
|
|
{
|
|
"type": "message_type",
|
|
"id": "unique-request-id",
|
|
"payload": {},
|
|
"seq": 42
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
| --------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------------- |
|
|
| `type` | string | Yes | Determines how `payload` is interpreted |
|
|
| `id` | string | Client messages only | Client-generated UUID for request/response correlation |
|
|
| `payload` | object | Yes | Contents vary by `type`. Must be present (can be `{}`). |
|
|
| `seq` | uint64 | Broadcast messages only | Monotonically increasing sequence number. Only present on server-to-client broadcast messages. |
|
|
|
|
---
|
|
|
|
## Sequence Numbers
|
|
|
|
The sequence number system enables reconnection with state recovery.
|
|
|
|
1. The server maintains an atomic `uint64` counter.
|
|
2. Every broadcast message gets the next seq number.
|
|
3. The message is stored in a 1000-event replay ring buffer.
|
|
4. The client tracks `lastSeq` from every server broadcast.
|
|
|
|
### Which Messages Get seq
|
|
|
|
| Category | Has seq? | Examples |
|
|
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` |
|
|
| Global broadcasts | Yes | `member_join`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state` (broadcast form; see below), `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` |
|
|
| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) |
|
|
| DM chat events | Yes | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants |
|
|
| DM lifecycle | No | `dm_channel_open`, `dm_channel_close` |
|
|
| Call signalling | No | `call_incoming`, `call_declined` |
|
|
| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` |
|
|
|
|
**`presence` is split, and only one half is sequenced.** Connect and disconnect
|
|
presence is a normal sequenced global broadcast, so it replays on a warm resume.
|
|
A `presence` caused by the user changing their own status (`presence_update`) is
|
|
sent on the low-priority, droppable tier instead: it carries no `seq`, it can be
|
|
shed under send-buffer pressure for a fully connected client, and it is not
|
|
replayed — so a status change made while a client was away is not delivered when
|
|
that client resumes.
|
|
|
|
This is deliberate, not an oversight: presence is best-effort by design, and
|
|
`member_join` carries `status` precisely so a client can re-derive presence
|
|
without depending on the correction arriving. Clients must treat presence as
|
|
eventually-consistent and must not assume they have seen every transition. See
|
|
Presence for the invisible-member split.
|
|
|
|
---
|
|
|
|
## Authentication Flow
|
|
|
|
### Step 1: Client Sends auth
|
|
|
|
After the WebSocket connection is established, the client sends the first message within 10 seconds:
|
|
|
|
```json
|
|
{
|
|
"type": "auth",
|
|
"payload": {
|
|
"token": "session-token-from-login",
|
|
"last_seq": 0
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
| ------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` |
|
|
| `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. |
|
|
| `active_channel_id` | int64 | No | The channel the client had open when it disconnected. Honoured only on a resume (`last_seq > 0`) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown. |
|
|
| `epoch` | int | No | The wire epoch this client speaks (`PROTOCOL_EPOCH`, generated from `protocol/schema.json`). Absent means 0. See [Compatibility](#compatibility-protocol-epoch). |
|
|
|
|
`active_channel_id` closes a resume-only gap. The hub restores a reconnecting
|
|
client's channel subscription by copying it from the previous connection entry,
|
|
but that entry is deleted as soon as the server observes the old socket close —
|
|
which normally happens well before the client reconnects. Without the hint the
|
|
resumed socket holds no channel subscription until its post-`auth_ok`
|
|
`channel_focus` round trip completes, and everything broadcast to that channel
|
|
in the meantime reaches nobody on that connection and can never be re-requested,
|
|
since the client only ever reports `max(seq)`.
|
|
|
|
Clients should still send `channel_focus` after `auth_ok` — it remains the
|
|
fallback for servers that predate this field, and it is idempotent.
|
|
|
|
### Step 2: Success -- auth_ok
|
|
|
|
```json
|
|
{
|
|
"type": "auth_ok",
|
|
"payload": {
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "/api/v1/files/5f2c...",
|
|
"role": "admin",
|
|
"display_name": "Alex A.",
|
|
"about": "A short bio.",
|
|
"custom_status": "shipping phase 6",
|
|
"status": "invisible"
|
|
},
|
|
"server_name": "My Server",
|
|
"motd": "Welcome!",
|
|
"replay_source": "none"
|
|
}
|
|
}
|
|
```
|
|
|
|
`display_name`, `about` and `custom_status` are the signed-in user's own
|
|
profile fields, `null` when unset. `display_name` is what clients render
|
|
instead of `username`; `@mentions` still resolve against `username`, which is
|
|
the unique handle.
|
|
|
|
`status` is the user's **own true status**, `"invisible"` included. Only this
|
|
message and their own `ready` entry ever carry it — every other client is told
|
|
`"offline"` for an invisible user (see Presence). The connection comes online
|
|
as the status saved from the last session when that was `idle`, `dnd` or
|
|
`invisible`, and as `online` otherwise; a client should not re-assert a status
|
|
the server already agreed with.
|
|
|
|
`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
|
|
{
|
|
"type": "auth_error",
|
|
"payload": {
|
|
"message": "invalid token"
|
|
}
|
|
}
|
|
```
|
|
|
|
`message` is one of a fixed set the server actually sends: `invalid message`
|
|
(the first frame did not parse as JSON), `first message must be auth` (the
|
|
first frame was not type `auth`), `missing token`, `invalid token`, `session
|
|
expired`, and `user not found`.
|
|
|
|
After sending `auth_error`, the server closes the connection with close code
|
|
**1008** (policy violation) and reason `authentication failed`.
|
|
|
|
One refusal carries more than `message`. When the client's `epoch` is outside
|
|
the range the server accepts, the payload is:
|
|
|
|
```json
|
|
{
|
|
"type": "auth_error",
|
|
"payload": {
|
|
"message": "this client speaks protocol epoch 0 but the server needs 2; update the client",
|
|
"code": "protocol_epoch_unsupported",
|
|
"client_epoch": 0,
|
|
"server_epoch": 2,
|
|
"min_epoch": 2
|
|
}
|
|
}
|
|
```
|
|
|
|
`message` names which side to update; the numbers let a client decide for
|
|
itself (`server_epoch > client_epoch` — the client is the older side). The
|
|
close that follows is the same 1008.
|
|
|
|
### Compatibility (protocol epoch)
|
|
|
|
The protocol has one version number, the **epoch**, declared once as
|
|
`protocol_epoch` in `protocol/schema.json` and generated into
|
|
`ws.ProtocolEpoch` (server) and `PROTOCOL_EPOCH` (client). The client sends it
|
|
in `auth`; the server accepts an `epoch` in `[min_epoch, server_epoch]` and
|
|
refuses anything else with `protocol_epoch_unsupported`.
|
|
|
|
- **Within an epoch, changes are additive.** New optional fields; new message
|
|
types the other side may ignore. Unknown server→client types are ignored by
|
|
the client; unknown client→server types get an `error` frame. The frozen
|
|
transcripts under `protocol/fixtures/epoch-1/` replay against the server for
|
|
as long as epoch 1 is accepted — a failing fixture means "bump the epoch",
|
|
not "fix the fixture".
|
|
- **A breaking change is a new epoch.** Bump `protocol_epoch`, regenerate, and
|
|
set `minClientEpoch` (`Server/ws/messages.go`) to the new value: the server
|
|
accepts exactly one epoch by policy. Epoch 1 additionally accepts an absent
|
|
`epoch` (0), because clients up to v1.2.0-alpha.4 predate the field.
|
|
- **The server upgrades first.** A release's signed server-update manifest
|
|
carries its `protocol_epoch`, and `GET /api/v1/client-update` never
|
|
advertises a release whose epoch is newer than the server's own — a client
|
|
that auto-updated onto it would be refused at the next handshake. Releases
|
|
that do not bump the epoch (fixes, additive features) reach clients whether
|
|
or not the server has been updated.
|
|
- **A refused client can still update in place.** On
|
|
`protocol_epoch_unsupported` with a newer server, the desktop client shows
|
|
the regular update banner on the connect page.
|
|
|
|
### Step 4: ready Payload
|
|
|
|
After `auth_ok`, the server sends a `ready` message containing all initial state.
|
|
|
|
### Step 5: Member Join + Presence
|
|
|
|
The server broadcasts to all connected clients:
|
|
|
|
```json
|
|
{ "type": "member_join", "seq": 15, "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "status": "online" } }
|
|
{ "type": "presence", "seq": 16, "payload": { "user_id": 1, "status": "online", "custom_status": null } }
|
|
```
|
|
|
|
### Periodic Session Revalidation
|
|
|
|
Every 10 messages, the server re-checks the session token against the database. If the session has been revoked, expired, or the user banned, the connection is closed immediately.
|
|
|
|
---
|
|
|
|
## Heartbeat and Connection Liveness
|
|
|
|
### Client Ping
|
|
|
|
The client sends a JSON ping every 30 seconds:
|
|
|
|
```json
|
|
{ "type": "ping", "payload": {} }
|
|
```
|
|
|
|
### Server Pong
|
|
|
|
The server responds immediately:
|
|
|
|
```json
|
|
{ "type": "pong" }
|
|
```
|
|
|
|
### Server Stale Client Sweep
|
|
|
|
Every 30 seconds, the server checks all clients. Any client with no activity for 90 seconds is forcibly disconnected. Normal chat activity also keeps the connection alive.
|
|
|
|
---
|
|
|
|
## 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. The server resolves the reconnect through a **3-tier replay pipeline** (cheapest first):
|
|
|
|
| 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` |
|
|
|
|
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 chat events (`chat_message`, `chat_edited`, `chat_deleted`,
|
|
`reaction_update` in DM channels) are sequenced into the same ring buffer and
|
|
`events` table as channel broadcasts, so they replay at tiers 1 and 2 —
|
|
filtered to the DM's participants. The unsequenced DM lifecycle events
|
|
(`dm_channel_open`/`dm_channel_close`) are not replayed; that state is always
|
|
recoverable via the full `ready` payload.
|
|
|
|
---
|
|
|
|
## Initial State (ready)
|
|
|
|
Sent once after `auth_ok` (fresh connection or replay fallback).
|
|
|
|
```json
|
|
{
|
|
"type": "ready",
|
|
"payload": {
|
|
"channels": [ ... ],
|
|
"dm_channels": [ ... ],
|
|
"members": [ ... ],
|
|
"voice_states": [ ... ],
|
|
"roles": [ ... ],
|
|
"server_name": "My Server",
|
|
"motd": "Welcome!"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Payload Fields
|
|
|
|
**channels[]:** `id`, `name`, `type` (`text`/`voice`/`announcement`), `category`, `topic`, `position`, `can_send`, `slow_mode`, `nsfw`, `voice_max_users`, `voice_max_video`, `unread_count` (text + announcement), `last_message_id` (text + announcement), `mention_count` (text + announcement)
|
|
|
|
`nsfw`, `voice_max_users` and `voice_max_video` are always present, with their
|
|
column defaults on an unconfigured channel — `false`, `0`, and **`25`** for
|
|
`voice_max_video`, which is `DEFAULT 25` rather than zero (migration 004) —
|
|
never omitted, so "absent" never has to mean two different things. `nsfw` is a
|
|
label the server never acts on (see below); the two voice limits are the values
|
|
the voice-join path enforces with `CHANNEL_FULL` / `VIDEO_LIMIT`, shipped so a
|
|
client can show "3/5" and explain a refusal it could have predicted.
|
|
|
|
`mention_count` is the number of unread messages that mention this user — a
|
|
direct `@username` or an authorized `@everyone`/`@here` — in that channel. It is
|
|
raised by the send that mentions them (never by an edit) and reset to 0 by
|
|
`channel_focus` or `mark_read`.
|
|
|
|
**dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count`, `mention_count`
|
|
|
|
A DM's `mention_count` is the same `read_states.mention_count` the channel list
|
|
carries. It used to be absent here, so a DM mention badge silently reset to 0 on
|
|
every reconnect; the ready payload now ships the stored value.
|
|
|
|
**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status`, `display_name` (`null` when unset — render it instead of `username`), `custom_status` (`null` when unset), `identity_public_key` (base64 long-term E2EE identity key, omitted when the user has not published one — see voice E2EE TOFU)
|
|
|
|
`status` is per-viewer. Two rules apply, in this order, so a client never has
|
|
to reconstruct them:
|
|
|
|
1. A member with no live connection is `"offline"`, whatever status they last
|
|
chose — a chosen `idle`/`dnd`/`invisible` is preserved server-side across a
|
|
disconnect so the next connect can honour it, but it must not render as
|
|
"present" in the meantime.
|
|
2. An `"invisible"` member is `"offline"` to everyone but themselves. The
|
|
viewer's own entry carries their true status.
|
|
|
|
**voice_states[]:** All users currently in any voice channel: `channel_id`, `user_id`, `username`, `muted`, `deafened`, `server_muted`, `server_deafened`, `speaking`, `camera`, `screenshare`
|
|
|
|
**roles[]:** All server roles with `id`, `name`, `color`, `permissions` (bitfield), `position`, `is_default`
|
|
|
|
---
|
|
|
|
## Chat Messages
|
|
|
|
### chat_send (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "chat_send",
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"payload": {
|
|
"channel_id": 5,
|
|
"content": "Hello everyone!",
|
|
"reply_to": null,
|
|
"attachments": ["upload-uuid-1"]
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Constraints |
|
|
| ------------- | -------------- | -------- | ---------------------------------------------------------------------------- |
|
|
| `channel_id` | number | Yes | Positive integer |
|
|
| `content` | string | Yes* | Max 4000 runes. HTML-sanitized. *Can be empty if `attachments` is non-empty. |
|
|
| `reply_to` | number or null | No | Message ID being replied to |
|
|
| `attachments` | string[] | No | Upload IDs from `POST /api/v1/uploads`. Requires `ATTACH_FILES` permission. |
|
|
|
|
### chat_send_ok (Server -> Client)
|
|
|
|
Direct response to sender (no seq):
|
|
|
|
```json
|
|
{
|
|
"type": "chat_send_ok",
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"timestamp": "2026-03-14T10:30:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
### chat_message (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 42,
|
|
"type": "chat_message",
|
|
"payload": {
|
|
"id": 1042,
|
|
"channel_id": 5,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "alex",
|
|
"avatar": "uuid.png",
|
|
"role": "admin",
|
|
"display_name": "Alex A."
|
|
},
|
|
"content": "Hello everyone!",
|
|
"reply_to": null,
|
|
"timestamp": "2026-03-14T10:30:00Z",
|
|
"attachments": [],
|
|
"reactions": [],
|
|
"pinned": false,
|
|
"mentions": [7, 9],
|
|
"mentions_everyone": true,
|
|
"mentions_here": false
|
|
}
|
|
}
|
|
```
|
|
|
|
`user.display_name` is the author's nickname to render instead of `username`;
|
|
present only when the author has one, omitted otherwise (see `member_join`).
|
|
|
|
| Field | Type | Description |
|
|
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
| `mentions` | number[] | User IDs the server resolved from `@username` tokens. Always present; empty when nothing resolved. |
|
|
| `mentions_everyone` | bool | `true` when the message carried `@everyone` or `@here` **and** the author holds `MENTION_EVERYONE` on that channel. |
|
|
| `mentions_here` | bool | `true` when `mentions_everyone` came from `@here` rather than `@everyone` (never both). |
|
|
|
|
Mentions are resolved server-side at send time against existing usernames
|
|
(case-insensitive, whole-word, capped at 20 per message). An `@word` that
|
|
matches no username, and an `@everyone`/`@here` from an author without
|
|
`MENTION_EVERYONE`, resolve to nothing and stay plain text — clients must
|
|
highlight from these fields rather than re-parsing the content. DMs never carry
|
|
`mentions_everyone`.
|
|
|
|
`@everyone` and `@here` both raise `mention_count` for every reader except
|
|
`@here` skips a reader with no live connection at send time (the server's
|
|
`applyMentionCounts` treats that reader as unreachable, the same way a push
|
|
notification would). A client cannot tell the two tokens apart from
|
|
`mentions_everyone` alone, which is why `mentions_here` exists: a reconnecting
|
|
client that replays this frame from the gap it was disconnected for must not
|
|
raise a mention badge for a here-only mention the server never counted — there
|
|
is no `ready` in that reconnect tier to correct a wrong badge afterward. A
|
|
direct `mentions` hit is unaffected either way.
|
|
|
|
### chat_edit (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "chat_edit",
|
|
"id": "req-uuid",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"content": "Hello everyone! (edited)"
|
|
}
|
|
}
|
|
```
|
|
|
|
Own messages only. Max 4000 runes.
|
|
|
|
### chat_edited (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 43,
|
|
"type": "chat_edited",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"channel_id": 5,
|
|
"content": "Hello everyone! (edited)",
|
|
"edited_at": "2026-03-14T10:31:00Z",
|
|
"mentions": [7],
|
|
"mentions_everyone": false,
|
|
"mentions_here": false
|
|
}
|
|
}
|
|
```
|
|
|
|
`mentions`/`mentions_everyone`/`mentions_here` are re-resolved from the edited
|
|
content and replace the stored set. Editing never raises anyone's
|
|
`mention_count`: a badge is only ever raised by the original send, so
|
|
re-adding an already-counted mention cannot double-count it.
|
|
|
|
### chat_delete (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "chat_delete",
|
|
"id": "req-uuid",
|
|
"payload": {
|
|
"message_id": 1042
|
|
}
|
|
}
|
|
```
|
|
|
|
Moderators with `MANAGE_MESSAGES` can delete others' messages (non-DM channels only).
|
|
|
|
### chat_deleted (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 44,
|
|
"type": "chat_deleted",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"channel_id": 5
|
|
}
|
|
}
|
|
```
|
|
|
|
### chat_bulk_deleted (Server -> Client, broadcast)
|
|
|
|
Emitted by the REST bulk delete (`POST /api/v1/channels/{id}/messages/purge`,
|
|
gated on `READ_MESSAGES|MANAGE_MESSAGES`, non-DM channels only) instead of one
|
|
`chat_deleted` per message. `ids` is newest-first and never null; the deletes
|
|
are soft, so clients mark each id as a tombstone exactly as they do for
|
|
`chat_deleted`.
|
|
|
|
```json
|
|
{
|
|
"seq": 45,
|
|
"type": "chat_bulk_deleted",
|
|
"payload": {
|
|
"channel_id": 5,
|
|
"ids": [1042, 1041, 1040]
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Reactions
|
|
|
|
### reaction_add / reaction_remove (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "reaction_add",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"emoji": "\ud83d\udc4d"
|
|
}
|
|
}
|
|
```
|
|
|
|
Rate limited at 5/sec. Requires `ADD_REACTIONS` permission (or DM participant).
|
|
|
|
`emoji` is a free-form string, not a fixed enum: it is a unicode emoji, or the
|
|
literal `:shortcode:` of a custom emoji (see api.md). It must be non-empty, at
|
|
most **34 runes** — the longest custom shortcode (32) plus its two colons —
|
|
carry no control characters, and survive the HTML sanitizer unchanged. A
|
|
reaction whose `:shortcode:` no longer names an existing emoji stays a valid
|
|
reaction and renders as its plain text.
|
|
|
|
### reaction_update (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 45,
|
|
"type": "reaction_update",
|
|
"payload": {
|
|
"message_id": 1042,
|
|
"channel_id": 5,
|
|
"emoji": "\ud83d\udc4d",
|
|
"user_id": 1,
|
|
"action": "add"
|
|
}
|
|
}
|
|
```
|
|
|
|
`action` is `"add"` or `"remove"`.
|
|
|
|
---
|
|
|
|
## Typing Indicators
|
|
|
|
### typing_start (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "typing_start", "payload": { "channel_id": 5 } }
|
|
```
|
|
|
|
Rate limited: 1 per 3 seconds per user per channel. Silently dropped when rate limited.
|
|
|
|
### typing (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"type": "typing",
|
|
"payload": {
|
|
"channel_id": 5,
|
|
"user_id": 1,
|
|
"username": "alex"
|
|
}
|
|
}
|
|
```
|
|
|
|
Typing broadcasts are ephemeral -- they are NOT stored in the replay ring buffer.
|
|
|
|
---
|
|
|
|
## Presence
|
|
|
|
### presence_update (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "presence_update",
|
|
"payload": { "status": "invisible", "custom_status": "heads down" }
|
|
}
|
|
```
|
|
|
|
Valid `status` values: `"online"`, `"idle"`, `"dnd"`, `"invisible"`.
|
|
`"offline"` is still accepted from older clients (which used it to mean
|
|
"appear offline") and is treated as the plain offline it says. Rate limited:
|
|
1 per 10 seconds.
|
|
|
|
`custom_status` is optional and max 128 characters, HTML-sanitized and trimmed
|
|
server-side. **Omitting the field leaves the stored text alone**; sending `""`
|
|
clears it. The distinction matters because a client's auto-idle timer sends a
|
|
bare status flip several times an hour and must not blank the text the user
|
|
typed.
|
|
|
|
The chosen status is stored as chosen, `invisible` included, and persists
|
|
across reconnects (see `auth_ok`). A custom status persists too, and is
|
|
cleared on `POST /api/v1/auth/logout`.
|
|
|
|
### presence (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 50,
|
|
"type": "presence",
|
|
"payload": {
|
|
"user_id": 1,
|
|
"status": "online",
|
|
"custom_status": "shipping phase 6"
|
|
}
|
|
}
|
|
```
|
|
|
|
`custom_status` is always present (`null` when unset), so "cleared it" is
|
|
distinguishable from "this event does not mention it"; every presence
|
|
broadcast carries the current value.
|
|
|
|
**Invisible splits this message in two.** When a user's status is
|
|
`"invisible"`, everyone else receives a broadcast that says `"offline"`, and
|
|
the user themselves receives a separate, targeted `presence` carrying their
|
|
true `"invisible"`. A client must therefore not assume it sees the same
|
|
presence value for a user that everyone else does — and must not "correct" its
|
|
own status back to online on the strength of a broadcast it did not receive.
|
|
|
|
---
|
|
|
|
## Channel Focus and Read State
|
|
|
|
### channel_focus (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "channel_focus", "payload": { "channel_id": 5 } }
|
|
```
|
|
|
|
Tells the server which channel the user is currently viewing. Affects broadcast delivery and unread tracking: it advances the caller's read state to the channel's latest message and resets that channel's `mention_count` to 0.
|
|
|
|
### mark_read (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "mark_read", "payload": { "channel_id": 5 } }
|
|
```
|
|
|
|
Advances the caller's read state for `channel_id` to that channel's latest
|
|
message and resets its `mention_count` to 0 — exactly what `channel_focus` does
|
|
to unread state — **without** changing which channel the connection is focused
|
|
on. This is what backs "Mark as Read" in the channel context menu and "Mark All
|
|
as Read": marking a channel the user is _not_ looking at must not rebind the
|
|
connection's focused channel, which would misroute unread bookkeeping for the
|
|
channel actually on screen.
|
|
|
|
Same access check as `channel_focus`: `READ_MESSAGES` on the channel, or DM
|
|
participation. A denied channel answers `FORBIDDEN`; a non-positive
|
|
`channel_id` answers `BAD_REQUEST`. There is no response on success — the client
|
|
clears its local badge optimistically and the next `ready` confirms.
|
|
|
|
---
|
|
|
|
## Channel Updates
|
|
|
|
All channel update messages are broadcast to all connected clients. Triggered by REST API calls from admins.
|
|
|
|
### channel_create (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 60,
|
|
"type": "channel_create",
|
|
"payload": {
|
|
"id": 8,
|
|
"name": "gaming",
|
|
"type": "text",
|
|
"category": "Hangout",
|
|
"topic": "",
|
|
"position": 3,
|
|
"slow_mode": 0,
|
|
"nsfw": false,
|
|
"voice_max_users": 0,
|
|
"voice_max_video": 0
|
|
}
|
|
}
|
|
```
|
|
|
|
`can_send` is an **optional extra field on the targeted form only.** When a role
|
|
or channel-override edit changes who may post, `RefreshChannelVisibility` sends
|
|
each still-visible client its own `channel_create`, and that copy carries this
|
|
viewer's `can_send` — the same value `ready` ships per channel — so the composer
|
|
affordance converges without a reconnect.
|
|
|
|
The broadcast form omits it: one encoded frame is delivered to a whole audience,
|
|
and a single value would be wrong for some of them. Older servers omit it too.
|
|
**Treat an absent `can_send` as "unchanged", never as `false`** — a client that
|
|
resets on absence would disable the composer on every ordinary broadcast.
|
|
|
|
### channel_update (Server -> Client, broadcast)
|
|
|
|
Full channel object — the same payload shape as `channel_create`, built by the
|
|
same constructor so the two events can never disagree about which fields a
|
|
client is told about. Sent on every admin `PATCH`, so a client applies channel
|
|
edits (rename, topic, category move, slow mode, `nsfw`, voice limits) without
|
|
reconnecting.
|
|
|
|
`nsfw` is shipped so clients can gate or label a channel; **the server applies
|
|
no content behaviour of its own to a flagged channel** — no filtering, no age
|
|
check, no restriction on who may read or post. The desktop client shows a
|
|
one-time-per-session warning before rendering the channel and marks it in the
|
|
sidebar; a client that ignores the field behaves exactly as before it existed.
|
|
|
|
Archiving or unarchiving additionally triggers targeted `channel_create` /
|
|
`channel_delete` sends (`Hub.RefreshChannelVisibility`), because it changes who
|
|
may see the channel rather than only how it looks.
|
|
|
|
**An archived channel is read-only, and the server enforces that.** History
|
|
stays readable, but `chat_send` is refused with `FORBIDDEN` and `voice_join`
|
|
with `BAD_REQUEST`, and archiving a voice channel evicts whoever is already
|
|
connected. Hiding the channel is not on its own a protection: a caller that
|
|
still holds the id — a custom client, or a stock client racing the
|
|
`channel_delete` the archive transition sends — would otherwise keep writing
|
|
into an archive that nobody can see or moderate.
|
|
|
|
### channel_delete (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 62,
|
|
"type": "channel_delete",
|
|
"payload": { "id": 8 }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Member Updates
|
|
|
|
All member messages are broadcast to all connected clients.
|
|
|
|
### member_join (Server -> Client, broadcast)
|
|
|
|
Sent when a user first connects (fresh connection, not reconnect replay).
|
|
|
|
```json
|
|
{
|
|
"seq": 70,
|
|
"type": "member_join",
|
|
"payload": {
|
|
"user": {
|
|
"id": 5,
|
|
"username": "newuser",
|
|
"avatar": null,
|
|
"role": "member",
|
|
"display_name": "New User",
|
|
"identity_public_key": "base64-identity-pubkey"
|
|
},
|
|
"status": "online"
|
|
}
|
|
}
|
|
```
|
|
|
|
`display_name` is the nickname to render instead of `username`; omitted when
|
|
unset. `identity_public_key` is the user's long-term E2EE identity public key
|
|
(see voice E2EE TOFU); omitted when the user has not published one. The
|
|
top-level `status` is the viewer-safe presence the user comes online as (an
|
|
invisible connector reports `"offline"` here) — clients must render presence
|
|
from this field rather than assuming `"online"` because a `member_join`
|
|
arrived.
|
|
|
|
### member_update (Server -> Client, broadcast)
|
|
|
|
Triggered when an admin changes a user's role.
|
|
|
|
```json
|
|
{
|
|
"seq": 71,
|
|
"type": "member_update",
|
|
"payload": {
|
|
"user_id": 5,
|
|
"role": "moderator"
|
|
}
|
|
}
|
|
```
|
|
|
|
### member_ban (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 72,
|
|
"type": "member_ban",
|
|
"payload": { "user_id": 5 }
|
|
}
|
|
```
|
|
|
|
### roles_update (Server -> Client, broadcast)
|
|
|
|
Sent to every connected client after any role mutation (create, edit, delete or
|
|
reorder) through `/admin/api/roles`. The payload is the **whole** role list in
|
|
the same shape and order the `ready` payload uses — position descending —
|
|
rather than a delta: the client replaces `channelsStore.roles` wholesale, so a
|
|
dropped intermediate event can never leave a deleted role on screen.
|
|
|
|
```json
|
|
{
|
|
"seq": 73,
|
|
"type": "roles_update",
|
|
"payload": {
|
|
"roles": [
|
|
{
|
|
"id": 1,
|
|
"name": "Owner",
|
|
"color": "#E74C3C",
|
|
"permissions": 2147483647,
|
|
"position": 100,
|
|
"is_default": false
|
|
},
|
|
{
|
|
"id": 4,
|
|
"name": "Member",
|
|
"color": null,
|
|
"permissions": 1635,
|
|
"position": 40,
|
|
"is_default": true
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
Unfiltered on purpose — the same list already ships in every client's `ready`
|
|
payload, so it discloses nothing new. A role permission change that alters
|
|
channel visibility is delivered separately, as targeted
|
|
`channel_create`/`channel_delete` messages, and a role deletion additionally
|
|
sends one `member_update` per reassigned member.
|
|
|
|
### emoji_update (Server -> Client, broadcast)
|
|
|
|
Sent to every connected client after a custom emoji is uploaded or deleted
|
|
through `/api/v1/emoji`. Like `roles_update` this carries the **whole** set
|
|
rather than a delta — the client replaces its shortcode map wholesale, so a
|
|
dropped intermediate event can never leave a deleted emoji rendering in the
|
|
messages that name it.
|
|
|
|
```json
|
|
{
|
|
"seq": 74,
|
|
"type": "emoji_update",
|
|
"payload": {
|
|
"emoji": [
|
|
{ "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" },
|
|
{ "id": 7, "shortcode": "party_blob", "url": "/api/v1/emoji/7/image" }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
`emoji` is always an array, `[]` when the last emoji was deleted. Shortcodes
|
|
are lowercase and `[a-z0-9_]{2,32}`; `url` is a server-relative path that
|
|
requires the session token (see api.md). Ordered by shortcode.
|
|
|
|
Unfiltered on purpose, for the same reason `roles_update` is: emoji are
|
|
server-wide with no channel scope, and every client may already `GET
|
|
/api/v1/emoji` for the same list. The set is **not** in the `ready` payload —
|
|
it changes rarely and belongs to the server, not the session, so clients load
|
|
it once over REST and keep it fresh from this event.
|
|
|
|
### user_update (Server -> Client, broadcast)
|
|
|
|
Broadcast when a user changes their own profile via `PATCH /api/v1/users/me`
|
|
or `POST /api/v1/users/me/avatar` (username, avatar, display name, about
|
|
and/or identity key).
|
|
|
|
```json
|
|
{
|
|
"seq": 73,
|
|
"type": "user_update",
|
|
"payload": {
|
|
"user_id": 5,
|
|
"username": "newname",
|
|
"avatar": "/api/v1/files/5f2c...",
|
|
"display_name": "New Name",
|
|
"about": "A short bio.",
|
|
"identity_public_key": "base64-identity-pubkey"
|
|
}
|
|
}
|
|
```
|
|
|
|
The payload is a full snapshot, not a delta: it **replaces** the client's copy
|
|
of that user's profile. `avatar`, `display_name` and `about` are always present
|
|
and may be `null` (unset/cleared) — a profile edit that removes a field has to
|
|
be distinguishable from one that leaves it alone.
|
|
`identity_public_key` carries the user's current long-term E2EE identity key
|
|
and is omitted when none is published; peers that pinned a different key must
|
|
surface a TOFU mismatch.
|
|
|
|
---
|
|
|
|
## Voice Signaling
|
|
|
|
Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/state) while the actual audio/video flows through LiveKit's own WebSocket connection.
|
|
|
|
### voice_join (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_join", "payload": { "channel_id": 10 } }
|
|
```
|
|
|
|
On success, server sends:
|
|
|
|
1. `voice_token` -- LiveKit JWT + URL
|
|
2. `voice_state` broadcast -- joiner's state to all clients
|
|
3. Existing `voice_state` messages -- one per existing participant (to joiner only)
|
|
4. `voice_config` -- channel audio settings (to joiner only)
|
|
|
|
Items 1, 3 and 4 are written directly and keep that relative order; item 2
|
|
travels through the hub's broadcast queue, so its position relative to the
|
|
other three on the joiner's own socket is not guaranteed.
|
|
|
|
### voice_token (Server -> Client, direct)
|
|
|
|
```json
|
|
{
|
|
"type": "voice_token",
|
|
"payload": {
|
|
"channel_id": 10,
|
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
|
"url": "/livekit",
|
|
"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
|
|
{
|
|
"type": "voice_config",
|
|
"payload": {
|
|
"channel_id": 10,
|
|
"quality": "medium",
|
|
"bitrate": 64000,
|
|
"max_users": 50,
|
|
"threshold_mode": "top_speakers",
|
|
"mixing_threshold": 0,
|
|
"top_speakers": 5
|
|
}
|
|
}
|
|
```
|
|
|
|
Quality presets:
|
|
|
|
| Preset | Bitrate |
|
|
| -------- | ----------- |
|
|
| `low` | 32,000 bps |
|
|
| `medium` | 64,000 bps |
|
|
| `high` | 128,000 bps |
|
|
|
|
### voice_leave (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_leave", "payload": {} }
|
|
```
|
|
|
|
### voice_leave (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 80,
|
|
"type": "voice_leave",
|
|
"payload": {
|
|
"channel_id": 10,
|
|
"user_id": 1
|
|
}
|
|
}
|
|
```
|
|
|
|
### voice_state (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 81,
|
|
"type": "voice_state",
|
|
"payload": {
|
|
"channel_id": 10,
|
|
"user_id": 1,
|
|
"username": "alex",
|
|
"muted": false,
|
|
"deafened": false,
|
|
"speaking": false,
|
|
"camera": false,
|
|
"screenshare": false,
|
|
"server_muted": false,
|
|
"server_deafened": false
|
|
}
|
|
}
|
|
```
|
|
|
|
`server_muted` / `server_deafened` are moderator-imposed (see [Voice
|
|
Moderation](#voice-moderation)). `muted` / `deafened` are always set alongside
|
|
them, so a client that ignores the two new fields still renders the user as
|
|
silenced; they exist so the UI can show that the user may not lift it.
|
|
|
|
`voice_state` also arrives **unsequenced** in one case: when a client joins a
|
|
voice channel, the states of participants already in the room are relayed to
|
|
it directly, one message per participant (see the `voice_join` reply order
|
|
above) — those relayed copies carry no `seq` and are not replayed on resume.
|
|
Every other `voice_state` — a join, a leave, a mute/unmute, anything that
|
|
changes an existing participant's state — is the sequenced broadcast form
|
|
shown above.
|
|
|
|
### voice_mute / voice_deafen (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_mute", "payload": { "muted": true } }
|
|
{ "type": "voice_deafen", "payload": { "deafened": true } }
|
|
```
|
|
|
|
Rate limited: 2/sec each. While the sender is `server_muted`, an unmute
|
|
(`muted: false`) is refused with `SERVER_MUTED`; while `server_deafened`, an
|
|
undeafen is refused with `SERVER_DEAFENED`. Muting or deafening oneself is
|
|
always allowed.
|
|
|
|
### voice_camera (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_camera", "payload": { "enabled": true } }
|
|
```
|
|
|
|
Rate limited: 2/sec. Requires `USE_VIDEO` permission.
|
|
|
|
### voice_screenshare (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_screenshare", "payload": { "enabled": true } }
|
|
```
|
|
|
|
Rate limited: 2/sec. Requires `SHARE_SCREEN` permission.
|
|
|
|
### voice_token_refresh (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_token_refresh", "payload": {} }
|
|
```
|
|
|
|
Rate limited: 1 per 60 seconds. Must be in a voice channel.
|
|
|
|
---
|
|
|
|
## Voice Moderation
|
|
|
|
Four moderator commands act on another user's voice session. All four require
|
|
`MUTE_MEMBERS` on the actor's role (`ADMINISTRATOR` bypasses the bit, never the
|
|
hierarchy), the actor must strictly outrank the target by role position, and
|
|
the target must currently be in a voice channel. Each is rate limited to 5/sec
|
|
and written to the audit log (`voice_mod_mute`, `voice_mod_deafen`,
|
|
`voice_mod_move`, `voice_mod_kick`, target type `user`).
|
|
|
|
Failures: `FORBIDDEN` (missing bit, or target of equal/higher rank),
|
|
`VOICE_ERROR` (target not in voice, not in the named channel, or not
|
|
connected), `BAD_REQUEST` (self-target, non-voice destination),
|
|
`NOT_FOUND` (unknown destination channel), `CHANNEL_FULL` (destination at
|
|
capacity).
|
|
|
|
### voice_mod_mute (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_mod_mute", "payload": { "channel_id": 10, "user_id": 7, "muted": true } }
|
|
```
|
|
|
|
`channel_id` is the channel the moderator believes the target is in; the action
|
|
is refused when the target has since moved. Sets `server_muted` (and `muted`)
|
|
and mutes the target's published audio track at the SFU, then broadcasts
|
|
`voice_state`. Clearing it leaves `muted` as-is, so a user who was already
|
|
self-muted stays muted until they unmute themselves.
|
|
|
|
Server mute is scoped to the voice session: it survives a channel switch but
|
|
not a leave and re-join, because the `voice_states` row is deleted on leave.
|
|
|
|
### voice_mod_deafen (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_mod_deafen", "payload": { "channel_id": 10, "user_id": 7, "deafened": true } }
|
|
```
|
|
|
|
Sets `server_deafened` (and `deafened`) and broadcasts `voice_state`. Deafen has
|
|
no SFU equivalent — it governs what the target plays back — so it is enforced by
|
|
the target's client honoring the flag plus the server refusing their own
|
|
undeafen. Deafening also applies a server mute, so a user who cannot hear the
|
|
room cannot keep talking into it.
|
|
|
|
### voice_mod_move (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_mod_move", "payload": { "user_id": 7, "to_channel_id": 12 } }
|
|
```
|
|
|
|
The destination is checked against the TARGET's `CONNECT_VOICE` (a move must not
|
|
place someone where they could not go themselves) and against the destination's
|
|
`voice_max_users`. The server then runs its voice-leave routine for the target —
|
|
`voice_leave` is broadcast, the LiveKit participant is removed, the row deleted
|
|
— and sends the target `voice_moved`. The target's client answers with an
|
|
ordinary `voice_join` for the destination, so capacity, token minting and
|
|
key-holder election keep their single implementation.
|
|
|
|
### voice_moved (Server -> Client, direct)
|
|
|
|
```json
|
|
{ "type": "voice_moved", "payload": { "to_channel_id": 12 } }
|
|
```
|
|
|
|
Sent only to the moved user. The client tears down its LiveKit session and joins
|
|
`to_channel_id`.
|
|
|
|
### voice_mod_kick (Client -> Server)
|
|
|
|
```json
|
|
{ "type": "voice_mod_kick", "payload": { "user_id": 7 } }
|
|
```
|
|
|
|
Removes the target from the LiveKit room, deletes their `voice_states` row and
|
|
broadcasts `voice_leave`, then sends them `voice_disconnected`.
|
|
|
|
### voice_disconnected (Server -> Client, direct)
|
|
|
|
```json
|
|
{
|
|
"type": "voice_disconnected",
|
|
"payload": { "channel_id": 10, "reason": "You were disconnected from voice by a moderator" }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
`voice_e2ee_announce` is rate limited at 5 per second per user.
|
|
`voice_e2ee_offer` has a higher outer budget of 64 per second per (sender,
|
|
voice channel) — the key holder fans one offer per peer on a rotation — plus
|
|
an inner cap of 5 per second per (sender, channel, target) so no single
|
|
recipient can be flooded (the W1-2 per-victim cap). Both answer
|
|
`RATE_LIMITED` when exceeded. Key material must be standard-alphabet base64
|
|
(padded or unpadded).
|
|
|
|
**Identity keys + TOFU:** each client holds a long-term ECDSA P-256 identity
|
|
keypair, published via `PATCH /api/v1/users/me` (`identity_public_key`) and
|
|
distributed in the `ready` / `member_join` / `user_update` member payloads.
|
|
Peers pin the key on first sight (trust-on-first-use) and verify each
|
|
announce's `signature` against the pin, so a malicious server cannot swap
|
|
`user_id ↔ ephemeral pubkey` after first contact. A later key change is
|
|
surfaced to the user as a TOFU mismatch.
|
|
|
|
### voice_e2ee_announce (Client -> Server)
|
|
|
|
Announce this participant's ephemeral ECDH public key to the channel.
|
|
`signature` is the ECDSA P-256 signature by the sender's long-term identity
|
|
key over `"owncord-voice-e2ee-announce-v1" ‖ userId ‖ ephemeral-pubkey-raw`
|
|
(TOFU — see above). It is optional at the protocol level: legacy clients omit
|
|
it, and receiving clients enforce the fail-closed posture (peer has a
|
|
published identity key but the signature is missing/invalid → reject).
|
|
|
|
```json
|
|
{
|
|
"type": "voice_e2ee_announce",
|
|
"payload": {
|
|
"public_key": "base64-ecdh-pubkey",
|
|
"signature": "base64-ecdsa-signature"
|
|
}
|
|
}
|
|
```
|
|
|
|
The server validates `signature` like `public_key` (standard-alphabet base64,
|
|
max 128 chars) and stores it alongside the key, but never verifies it — only
|
|
clients hold the pinned identity keys.
|
|
|
|
### voice_e2ee_announce (Server -> Client, broadcast to voice channel)
|
|
|
|
Relayed to the other participants with the sender's user ID attached. Also
|
|
replayed to late joiners from the stored key+signature. `signature` is
|
|
omitted when the announcing client did not send one:
|
|
|
|
```json
|
|
{
|
|
"type": "voice_e2ee_announce",
|
|
"payload": {
|
|
"user_id": 1,
|
|
"public_key": "base64-ecdh-pubkey",
|
|
"signature": "base64-ecdsa-signature"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 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"
|
|
}
|
|
}
|
|
```
|
|
|
|
`encrypted_key` is opaque to the server (base64 and length checks only). Its
|
|
layout is:
|
|
|
|
```
|
|
0x01 ‖ epoch (u64 big-endian) ‖ AES-GCM ciphertext of the 32-byte room key
|
|
```
|
|
|
|
`epoch` is the holder's key-rotation counter, bound as GCM additional data so
|
|
the relay cannot change it without failing authentication. A receiver applies
|
|
an offer only if its epoch is at least the highest it has already applied from
|
|
that sender (equal is allowed: the holder re-sends the current key when a peer
|
|
re-announces); the mark resets when the sender announces a new ephemeral key.
|
|
A blob with no header (exactly 48 bytes: key + GCM tag) is the pre-epoch
|
|
format and is still accepted from holders on an older build; that
|
|
compatibility path is scheduled for removal in the next release.
|
|
|
|
### 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)
|
|
|
|
Sent when a DM is opened, created, auto-reopened by an incoming message, or has
|
|
its membership changed (a group created, renamed, or left).
|
|
|
|
The payload is the same shape as one entry of the `ready` payload's
|
|
`dm_channels` and of `GET /api/v1/dms`, so a client has exactly one DM shape to
|
|
parse. It is built **per viewer**: `recipient` and `recipients` are both defined
|
|
relative to who is reading them, and the reader never appears in their own
|
|
`recipients`.
|
|
|
|
```json
|
|
{
|
|
"type": "dm_channel_open",
|
|
"payload": {
|
|
"channel_id": 100,
|
|
"name": "Lunch crew",
|
|
"is_group": true,
|
|
"recipient": {
|
|
"id": 2,
|
|
"username": "jordan",
|
|
"display_name": "Jo",
|
|
"avatar": "/api/v1/files/uuid",
|
|
"status": "online"
|
|
},
|
|
"recipients": [
|
|
{
|
|
"id": 2,
|
|
"username": "jordan",
|
|
"display_name": "Jo",
|
|
"avatar": "/api/v1/files/uuid",
|
|
"status": "online"
|
|
},
|
|
{
|
|
"id": 3,
|
|
"username": "sam",
|
|
"display_name": "",
|
|
"avatar": "",
|
|
"status": "idle"
|
|
}
|
|
],
|
|
"last_message_id": null,
|
|
"last_message": "",
|
|
"last_message_at": "",
|
|
"unread_count": 0
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
| ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `recipient` | object | The other participant of a 1:1 DM. **Backward compatibility only** — for a group it carries the first of `recipients` so a pre-group client renders something. |
|
|
| `recipients` | array | Every participant except the reader. The field group-aware clients read. |
|
|
| `name` | string | Optional group name. `""` for a 1:1 DM and for an unnamed group. |
|
|
| `is_group` | bool | True for a group DM. Stored (`channels.is_group`), not derived from the live participant count — a group people have left stays a group. |
|
|
|
|
`status` is viewer-adjusted: an `invisible` participant reads as `offline` to
|
|
everyone but themselves.
|
|
|
|
### dm_channel_close (Server -> Client)
|
|
|
|
```json
|
|
{
|
|
"type": "dm_channel_close",
|
|
"payload": { "channel_id": 100 }
|
|
}
|
|
```
|
|
|
|
Sent to the caller of `DELETE /api/v1/dms/{id}`. For a group that is a _leave_,
|
|
and the remaining participants receive a fresh `dm_channel_open` carrying the
|
|
new membership.
|
|
|
|
### DM Authorization
|
|
|
|
All handlers that touch a channel check the channel type and branch to participant-based authorization for DMs instead of role-based permissions. This applies to: `chat_send`, `chat_edit`, `chat_delete`, `reaction_add`/`remove`, `typing_start`, `channel_focus`, `mark_read`, `call_ring`, `call_decline`.
|
|
|
|
Group DMs need no special case: `dm_participants` holds one row per
|
|
participant, and every check is a lookup on `(user_id, channel_id)`.
|
|
|
|
**Blocks are a 1:1 rule.** A block refuses DM creation and gates every
|
|
interaction sink in a two-person DM, but is _not_ consulted inside a group: a
|
|
group is a shared room, and dropping one member's messages for one other member
|
|
would leave the two of them reading different conversations under the same
|
|
name. Blocks are instead enforced when the group is created — a user may
|
|
neither add someone they have blocked nor add someone who has blocked them.
|
|
|
|
---
|
|
|
|
## DM Calls
|
|
|
|
A "call" in a DM is **not** a server-side object. It is somebody being present
|
|
in that DM's voice channel — which `voice_state` already broadcasts — and
|
|
ringing is transient signalling on top of it. There is no call id and no call
|
|
record: a persisted call would be one more thing a crashed client can leave
|
|
dangling, in exchange for information the presence already carries.
|
|
|
|
### call_ring (Client -> Server)
|
|
|
|
```json
|
|
{
|
|
"type": "call_ring",
|
|
"payload": { "channel_id": 100 }
|
|
}
|
|
```
|
|
|
|
Only a participant of the DM may ring it (`FORBIDDEN` otherwise). Rate limited
|
|
to one ring every 3 seconds per user — per _user_, not per channel, because the
|
|
abuse it prevents is spamming somebody with call banners.
|
|
|
|
The client joins the DM's voice channel **before** ringing: the ring is only
|
|
truthful once the caller is actually there.
|
|
|
|
### call_incoming (Server -> Client)
|
|
|
|
Forwarded to every other participant that is connected. An offline addressee is
|
|
a no-op by construction — a ring that arrives after the fact is worse than no
|
|
ring.
|
|
|
|
```json
|
|
{
|
|
"type": "call_incoming",
|
|
"payload": { "channel_id": 100, "from_user": 2, "username": "jordan" }
|
|
}
|
|
```
|
|
|
|
### call_decline (Client -> Server) / call_declined (Server -> Client)
|
|
|
|
```json
|
|
{
|
|
"type": "call_decline",
|
|
"payload": { "channel_id": 100 }
|
|
}
|
|
```
|
|
|
|
Answered with `call_declined` (same payload shape as `call_incoming`) to the
|
|
DM's other participants. It is addressed to all of them rather than to "the
|
|
ringer" because the server does not know who that was — no call state, by
|
|
design — and in a group more than one person may be ringing.
|
|
|
|
A declining client stops its own ring; a ringing client stops on
|
|
`call_declined`, on the ringer's `voice_leave`, or after a 30 second timeout.
|
|
A timeout deliberately sends **no** `call_decline`: it means "nobody was there",
|
|
and the ringer's own 30s window already covers it.
|
|
|
|
---
|
|
|
|
## Server Restart
|
|
|
|
### server_restart (Server -> Client, broadcast)
|
|
|
|
```json
|
|
{
|
|
"seq": 100,
|
|
"type": "server_restart",
|
|
"payload": {
|
|
"reason": "update",
|
|
"delay_seconds": 5
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### error (Server -> Client)
|
|
|
|
```json
|
|
{
|
|
"type": "error",
|
|
"id": "original-req-uuid",
|
|
"payload": {
|
|
"code": "FORBIDDEN",
|
|
"message": "No permission to post here"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Codes
|
|
|
|
| Code | Description |
|
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
| `BAD_REQUEST` | Invalid payload format or field values |
|
|
| `BAD_PAYLOAD` | Structurally valid message with a field that fails validation (E2EE announce/offer key material, signatures, targets) |
|
|
| `INTERNAL` | Server-side error |
|
|
| `NOT_FOUND` | Channel or message not found |
|
|
| `FORBIDDEN` | Missing required permission |
|
|
| `NOT_KEY_HOLDER` | `voice_e2ee_offer` sent by a participant who is not the channel's key holder |
|
|
| `RATE_LIMITED` | Too many requests (the error carries only `code` and `message`; REST 429s carry a `Retry-After` header, WS errors do not) |
|
|
| `ALREADY_JOINED` | Already in this voice channel |
|
|
| `CHANNEL_FULL` | Voice channel at capacity |
|
|
| `VOICE_ERROR` | Voice-specific error |
|
|
| `VIDEO_LIMIT` | Maximum video streams reached |
|
|
| `BANNED` | User is banned |
|
|
| `INVALID_JSON` | Message is not valid JSON |
|
|
| `UNKNOWN_TYPE` | Unrecognized message type |
|
|
| `SLOW_MODE` | Channel has slow mode enabled |
|
|
| `CONFLICT` | Duplicate reaction or constraint violation |
|
|
| `SERVER_MUTED` | Self-unmute refused: a moderator imposed the mute |
|
|
| `SERVER_DEAFENED` | Self-undeafen refused: a moderator imposed the deafen |
|
|
|
|
After 10 consecutive invalid JSON messages, the connection is forcibly closed.
|
|
|
|
---
|
|
|
|
## Rate Limits
|
|
|
|
All rate limits are enforced server-side using a token bucket rate limiter.
|
|
|
|
| Action | Limit | Window | Error Response |
|
|
| ---------------------------------------- | ----- | ------------------------------------------ | -------------------- |
|
|
| Chat send | 10 | 1 second | `RATE_LIMITED` error |
|
|
| Chat edit | 10 | 1 second | `RATE_LIMITED` error |
|
|
| Chat delete | 10 | 1 second | `RATE_LIMITED` error |
|
|
| Typing | 1 | 3 seconds | Silently dropped |
|
|
| Presence | 1 | 10 seconds | `RATE_LIMITED` error |
|
|
| Reactions | 5 | 1 second | `RATE_LIMITED` error |
|
|
| Voice join / leave | 5 | 1 second | `RATE_LIMITED` error |
|
|
| 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 | 5 | 1 second | `RATE_LIMITED` error |
|
|
| Voice E2EE offer | 64 | 1 second | `RATE_LIMITED` error |
|
|
| Voice moderation (mute/deafen/move/kick) | 5 | 1 second | `RATE_LIMITED` error |
|
|
| Call ring | 1 | 3 seconds | `RATE_LIMITED` error |
|
|
| Call decline | 1 | 3 seconds | `RATE_LIMITED` error |
|
|
| Plugin command (`chat_command`) | 5 | 1 second | `RATE_LIMITED` error |
|
|
| Channel focus | 5 | 1 second | Silently dropped |
|
|
| Mark read | 5 | 1 second (own budget, separate from focus) | Silently dropped |
|
|
| Ping | 2 | 1 second | Silently dropped |
|
|
|
|
The E2EE offer budget is deliberately higher than the announce budget: a key
|
|
rotation fires one offer per peer in a single burst, so the limit is sized to
|
|
a whole rotation rather than to a single frame. Within that outer budget an
|
|
inner cap of 5 per second per (sender, channel, target) stops any single
|
|
recipient from being flooded.
|
|
|
|
---
|
|
|
|
## Message Type Reference Table
|
|
|
|
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 (27 types)
|
|
|
|
| Type | Rate Limit | Notes |
|
|
| --------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
|
| `auth` | N/A (first message) | Token + optional last_seq |
|
|
| `chat_send` | 10/sec | + slow mode per channel |
|
|
| `chat_edit` | 10/sec | Own messages only |
|
|
| `chat_delete` | 10/sec | Own or mod (non-DM) |
|
|
| `reaction_add` | 5/sec | |
|
|
| `reaction_remove` | 5/sec | |
|
|
| `typing_start` | 1/3sec/channel | Silently dropped |
|
|
| `channel_focus` | 5/sec (silently dropped) | Updates read state |
|
|
| `mark_read` | 5/sec, own budget (silently dropped) | Updates read state without moving focus |
|
|
| `presence_update` | 1/10sec | |
|
|
| `voice_join` | 5/sec | |
|
|
| `voice_leave` | 5/sec | Empty payload |
|
|
| `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted |
|
|
| `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened |
|
|
| `voice_camera` | 2/sec | Requires USE_VIDEO |
|
|
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
|
|
| `voice_mod_mute` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
|
| `voice_mod_deafen` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
|
| `voice_mod_move` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
|
| `voice_mod_kick` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
|
| `voice_token_refresh` | 1/60sec | Must be in voice |
|
|
| `voice_e2ee_announce` | 5/sec | ECDH pubkey announce |
|
|
| `voice_e2ee_offer` | 64/sec outer, 5/sec per target | Wrapped room key to target (budgeted per key rotation) |
|
|
| `call_ring` | 1/3sec | DM participants only; fans out as `call_incoming` |
|
|
| `call_decline` | 1/3sec | DM participants only; fans out as `call_declined` |
|
|
| `chat_command` | 5/sec | Plugin slash command; max 64 args; broadcast gated by `CanPost` |
|
|
| `ping` | 2/sec (silently dropped) | Heartbeat |
|
|
|
|
### Server -> Client (39 types)
|
|
|
|
| Type | Has seq? | Delivery |
|
|
| --------------------- | -------- | ----------------------------------------------------------------------- |
|
|
| `auth_ok` | No | Direct |
|
|
| `auth_error` | No | Direct (then close) |
|
|
| `ready` | No | Direct |
|
|
| `chat_message` | Yes | Channel or DM participants |
|
|
| `chat_send_ok` | No | Direct to sender |
|
|
| `chat_edited` | Yes | Channel or DM participants |
|
|
| `chat_deleted` | Yes | Channel or DM participants |
|
|
| `chat_bulk_deleted` | Yes | Channel |
|
|
| `reaction_update` | Yes | Channel or DM participants |
|
|
| `typing` | No | Channel (excl. sender) or DM |
|
|
| `presence` | Yes | All clients |
|
|
| `channel_create` | Yes | All clients |
|
|
| `channel_update` | Yes | All clients |
|
|
| `channel_delete` | Yes | All clients |
|
|
| `voice_state` | Yes | All clients |
|
|
| `voice_leave` | Yes | All clients |
|
|
| `voice_moved` | No | Direct to moved user |
|
|
| `voice_disconnected` | No | Direct to disconnected user |
|
|
| `voice_config` | No | Direct to joiner |
|
|
| `voice_token` | No | Direct to joiner |
|
|
| `member_join` | Yes | All clients |
|
|
| `member_update` | Yes | All clients |
|
|
| `user_update` | Yes | All clients (profile changes) |
|
|
| `member_ban` | Yes | All clients |
|
|
| `roles_update` | Yes | All clients (full role list) |
|
|
| `emoji_update` | Yes | All clients (full custom-emoji set) |
|
|
| `dm_channel_open` | No | Direct to participant |
|
|
| `dm_channel_close` | No | Direct to participant |
|
|
| `call_incoming` | No | Direct to each other DM participant |
|
|
| `call_declined` | No | Direct to each other DM 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 |
|
|
| `command_reply` | No | Direct to invoking client (ephemeral plugin reply) |
|
|
| `plugin_broadcast` | Yes | Channel (plugin output posted as a broadcast; sequenced and replayable) |
|
|
|
|
### Plugin command types
|
|
|
|
Three wire types exist for the WASM plugin system. Since 2026-08-04 they are
|
|
listed in `protocol/schema.json` like every other type (closing DC-01), so
|
|
the generated constants cover them and `make protocol-verify` plus the
|
|
`ws` package's protocol-contract test gate them against drift.
|
|
|
|
| Type | Direction | Notes |
|
|
| ------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `chat_command` | Client -> Server | `{command, args[], channel_id, req_id?}`; max 64 args; unknown commands return an `error`. Rate limited at 5/sec (`RATE_LIMITED`); a channel broadcast is gated by the same `CanPost` policy as a real message send. |
|
|
| `command_reply` | Server -> Client | Ephemeral plugin reply, sent only to the invoking client; echoes `req_id`. Payload: `{text}`. |
|
|
| `plugin_broadcast` | Server -> Client | Plugin output posted to a channel. Payload: `{channel_id, user_id, command, text}`. |
|