Spec Documentation (18 files, 680KB): - Expanded all 15 existing spec files with deep detail from source code - Created 3 new specs: DM-SYSTEM, THEME-SYSTEM, RECONNECTION - Created E2E-BEST-PRACTICES spec - Audited all specs against source: fixed 50 errors Unit Tests (143 new): - Go: dm_queries_test (21), dm_handler_test (17), dm_handlers_test (18), ringbuffer_test (22) - TS: dm-store (16), disposable (14), themes security (17), ws reconnection (8), dispatcher DM (2) E2E Tests (22 mocked + 6 native specs): - New: dm-system, theme-persistence, reconnection (mocked + native) - Fixed 12 fake assertions, 18 hardcoded timeouts, 5 stale selectors - Persistent fixture: login once per run instead of per test - ensureLoggedIn with exponential backoff for rate limiting Security Fixes: - DM auth bypass: added IsDMParticipant to handleGetPins, handleSetPinned, handleSearch - LiveKit InsecureVerifier replaced with PinnedVerifier (TOFU from shared cert store) - IDOR leak: handleChatEdit/Delete now return opaque error codes - CSS injection: added deny-list for dangerous CSS functions in themes - BANNED error now triggers logout instead of infinite reconnect - CredFree leak fixed: Windows credential memory freed before parsing - Login lockout off-by-one: limit=9 so 10th failure triggers lockout Stability Fixes: - Rate limiter StartCleanup goroutine now started (prevents memory leak) - Voice mute/deafen rate limiting added (2/sec, matching camera/screenshare) - DM typing no longer echoes back to sender - Accept loop spin protection (5 consecutive error limit) - voice_config protocol drift resolved (3 missing fields added) - Login rate limit set to 60/min (spec updated, 10-failure lockout is real protection) - Hardcoded roleNameToId replaced with dynamic lookup from ready payload Co-Authored-By: claude-flow <ruv@ruv.net>
45 KiB
WebSocket Protocol Spec
All client-server communication (except file uploads, REST API
queries, and admin panel) happens over a single WebSocket connection.
Messages are JSON with a type and payload.
Related specs:
- API -- REST endpoints (message history, file uploads, etc.)
- SCHEMA -- Database tables and permission bitfields
- CLIENT-ARCHITECTURE -- Client-side dispatcher, stores, types
- VOICE-CHAT-DESIGN -- LiveKit voice/video architecture
docs/protocol-schema.json-- Machine-readable message schema
Table of Contents
- Transport Layer
- Message Envelope
- Sequence Numbers
- Authentication Flow
- Heartbeat and Connection Liveness
- Reconnection with State Recovery
- Initial State (ready)
- Chat Messages
- Reactions
- Typing Indicators
- Presence
- Channel Focus
- Channel Updates
- Member Updates
- Voice Signaling
- Direct Messages
- Server Restart
- Error Handling
- Rate Limits
- Client Dispatcher Pattern
- Message Type Reference Table
- Known Protocol Drift
Transport Layer
WebSocket Endpoint
wss://{host}/api/v1/ws
The client connects via the Tauri Rust backend's WS proxy
(ws_connect IPC command) 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.
Connection Flow
Client (TS) Rust WS Proxy Server (Go)
| | |
|-- ws_connect(url) ------>| |
| |-- TLS handshake --------->|
| | (TOFU cert pinning) |
| |<-- WebSocket upgrade ---->|
|<-- ws-state: "open" -----| |
| | |
|-- ws_send(auth JSON) --->|-- raw bytes ------------>|
| |<-- auth_ok JSON ---------|
|<-- ws-message(JSON) -----| |
Transport Limits
| Limit | Value | Source |
|---|---|---|
| Max read size | 1 MB | serve.go conn.SetReadLimit(1 << 20) |
| Max message size (client-side) | 1 MB | ws.ts DEFAULT_MAX_MESSAGE_SIZE |
| Max message content | 4000 runes | handlers.go maxMessageLen |
| HTTP request header (proxy) | 16 KB | livekit_proxy.rs |
| Write timeout | 10 seconds | serve.go writeTimeout |
| Auth deadline | 10 seconds | serve.go authDeadline |
| Send buffer per client | 256 messages | client.go sendBufSize |
| Broadcast channel | 256 messages | hub.go channel buffer |
Tauri IPC Events
The WS proxy emits these Tauri events:
| Event | Payload | When |
|---|---|---|
ws-message |
Raw JSON string | Server sends any message |
ws-state |
"open" or "closed" |
Connection opens or closes |
ws-error |
Error string | Transport error occurs |
cert-tofu |
CertTofuEvent object |
TLS certificate event |
interface CertTofuEvent {
host: string;
fingerprint: string;
status: "trusted_first_use" | "trusted" | "mismatch";
message?: string;
storedFingerprint?: string;
}
On cert-tofu with status: "mismatch", the client blocks
reconnection (certMismatchBlock = true) and shows a warning
dialog. The user can accept the new fingerprint via
ws.acceptCertFingerprint(host, fingerprint).
Message Envelope
Every WebSocket message is a JSON object with these fields:
{
"type": "message_type",
"id": "unique-request-id",
"payload": { },
"seq": 42
}
Field Definitions
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Determines how payload is interpreted. See #Message Type Reference Table |
id |
string | Client messages only | Client-generated UUID for request/response correlation. Server includes the same id in direct responses (chat_send_ok, error). |
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. NOT included on direct responses (error, chat_send_ok, auth_ok). |
Envelope Go Structs
// Inbound (client -> server)
type envelope struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// Outbound (server -> client)
type wsMsg struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Payload any `json:"payload,omitempty"`
}
TypeScript Envelope
interface WsEnvelope<T> {
readonly type: string;
readonly id?: string;
readonly payload: T;
}
The client ws.ts sends every message with a crypto.randomUUID() id.
Sequence Numbers
The sequence number system enables reconnection with state recovery by allowing the client to tell the server "replay everything I missed."
How seq Works
- The hub maintains an atomic
uint64counter (hub.seq). - Every message sent via
BroadcastToChannel()orBroadcastToAll()gets the next seq viahub.nextSeq(). - The seq is injected into the JSON using string manipulation (not re-serialization) for performance:
// wrapWithSeq: {"type":"chat_message",...}
// -> {"seq":123,"type":"chat_message",...}
func wrapWithSeq(msg []byte, seq uint64) []byte {
prefix := fmt.Sprintf(`{"seq":%d,`, seq)
result := append([]byte(prefix), msg[1:]...)
return result
}
- The wrapped message is stored in the replay ring buffer.
- The client tracks
lastSeqinws.ts:
const seq = typeof parsed.seq === "number" ? parsed.seq : 0;
if (seq > lastSeq) {
lastSeq = seq;
}
Which Messages Get seq
| Category | Has seq? | Examples |
|---|---|---|
| Channel broadcasts | Yes | chat_message, chat_edited, chat_deleted, reaction_update |
| Global broadcasts | Yes | presence, member_join, member_leave, member_update, member_ban, voice_state, voice_leave, channel_create, channel_update, channel_delete, server_restart, dm_channel_open, dm_channel_close |
| Ephemeral (no ring buffer) | No | typing (via broadcastExclude) |
| DM messages | No | DM chat_message, chat_edited, chat_deleted, reaction_update, dm_channel_open, dm_channel_close (via SendToUser) |
| Direct responses | No | auth_ok, auth_error, chat_send_ok, error, voice_config, voice_token, pong |
Message Delivery
handleMessage()
|
v
+---------+-----------+
| |
c.sendMsg(response) BroadcastToChannel(msg)
[direct, no seq] [via broadcast chan]
|
v
deliverBroadcast()
|
+-----------+-----------+
| |
nextSeq() replayBuf.Push()
wrapWithSeq() [store for replay]
|
v
h.mu.RLock()
iterate clients
c.sendMsg(msg)
Authentication Flow
Step 1: Client Sends auth
After the WebSocket connection is established (ws-state: "open"),
the client sends the first message within 10 seconds:
{
"type": "auth",
"payload": {
"token": "session-token-from-login",
"last_seq": 0
}
}
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | Session token obtained from POST /api/auth/login |
last_seq |
uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. |
Step 2: Server Authenticates
The server (authenticateConn in serve.go):
- Reads the first message with a 10-second timeout.
- Validates the envelope type is
"auth". - SHA-256 hashes the token and looks up the session.
- Checks session expiry (
auth.IsSessionExpired). - Loads the user record.
- Checks ban status (
auth.IsEffectivelyBanned). - Checks for duplicate connections (
hub.IsUserConnected).
Step 3a: Success -- auth_ok
{
"type": "auth_ok",
"payload": {
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"role": "admin"
},
"server_name": "My Server",
"motd": "Welcome!"
}
}
| Field | Type | Description |
|---|---|---|
user.id |
number | User's database ID |
user.username |
string | Display name |
user.avatar |
string or null | Avatar filename (served from /files/) |
user.role |
string | Lowercase role name ("admin", "moderator", "member") |
server_name |
string | Server display name from settings |
motd |
string | Message of the day from settings |
Settings are cached for 30 seconds (settingsCacheTTL) to avoid
per-connection DB queries.
Step 3b: Failure -- auth_error
{
"type": "auth_error",
"payload": {
"message": "Invalid or expired token"
}
}
Possible auth_error messages:
"invalid message"-- first message was not valid JSON"first message must be auth"-- wrong message type"missing token"-- token field empty"invalid token"-- session not found in DB"session expired"-- session past expiry date"user not found"-- user deleted"already connected from another client"-- duplicate login
After sending auth_error, the server closes the connection.
The client treats auth_error as non-recoverable: sets
intentionalClose = true and does not reconnect.
Step 3c: Banned -- error with BANNED code
{
"type": "error",
"payload": {
"code": "BANNED",
"message": "you are banned"
}
}
Banned users receive an error (not auth_error) and the
connection is closed.
Step 4: ready Payload
After auth_ok, the server sends a ready message containing
all initial state. See #Initial State (ready).
Step 5: Member Join + Presence
The server broadcasts to all connected clients:
{ "type": "member_join", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" } } }
{ "type": "presence", "payload": { "user_id": 1, "status": "online" } }
Full Auth Sequence Diagram
Client Server
| |
|---- WSS handshake (via Rust) ----->|
|<--- 101 Switching Protocols -------|
| |
|---- { type: "auth", payload: { |
| token: "...", |
| last_seq: 0 |
| }} --------------------------->|
| |-- validate token
| |-- check session expiry
| |-- check ban status
| |-- check duplicate conn
| |
|<--- { type: "auth_ok", ... } ------|
|<--- { type: "ready", ... } --------|
| |
| |-- broadcast member_join
| |-- broadcast presence
| |
|<--- heartbeat / message flow ----->|
Periodic Session Revalidation
Every 10 messages (SessionCheckInterval), the server re-checks
the session token hash against the database. If the session has
been revoked, expired, or the user banned, the connection is
closed immediately via kickClient.
Heartbeat and Connection Liveness
Client Ping
The client sends a JSON ping every 30 seconds
(HEARTBEAT_INTERVAL_MS = 30_000):
{ "type": "ping", "payload": {} }
Server Pong
The server responds immediately:
{ "type": "pong" }
The client silently ignores pong messages (no dispatch to listeners).
Server Stale Client Sweep
Every 30 seconds, the hub's sweepStaleClients() iterates all
clients. Any client whose lastActivity is older than 90 seconds
(staleClientTimeout) is forcibly disconnected via kickClient().
Timeline:
0s Client sends ping → lastActivity reset
30s Client sends ping → lastActivity reset
60s Client sends ping → lastActivity reset
90s Sweep runs — client OK (within 90s)
...
If client misses 3 pings (90s), next sweep kicks them.
Every incoming message (including pings) calls c.touch() which
updates lastActivity. So normal chat activity also keeps the
connection alive.
Reconnection with State Recovery
When a connection drops unexpectedly, the client automatically
reconnects with exponential backoff (1s to 30s max) and sends
last_seq in the auth message. The server replays missed
events from a 1,000-event ring buffer, or falls back to a full
ready payload if too far behind.
| 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 |
Important: DM events are not stored in the ring buffer (they
use SendToUser, not the broadcast channel). DM state is only
recoverable via the full ready payload.
See RECONNECTION for complete architecture: ring buffer internals, client reconnection logic, LiveKit auto-reconnect, TOFU certificate blocking, and edge cases.
Initial State (ready)
Sent once after auth_ok (fresh connection or replay fallback).
Server -> Client
{
"type": "ready",
"payload": {
"channels": [
{
"id": 1,
"name": "general",
"type": "text",
"category": "Main",
"position": 0,
"unread_count": 3,
"last_message_id": 1040
},
{
"id": 10,
"name": "voice-chat",
"type": "voice",
"category": "Main",
"position": 1
}
],
"dm_channels": [
{
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "",
"status": "online"
},
"last_message_id": 500,
"last_message": "Hey!",
"last_message_at": "2026-03-14T10:00:00Z",
"unread_count": 1
}
],
"members": [
{
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"role": "admin",
"status": "online"
}
],
"voice_states": [
{
"channel_id": 10,
"user_id": 2,
"muted": false,
"deafened": false
}
],
"roles": [
{
"id": 1,
"name": "Owner",
"color": "#E74C3C",
"permissions": 2147483647
},
{
"id": 2,
"name": "Admin",
"color": "#F39C12",
"permissions": 1073741823
},
{
"id": 3,
"name": "Moderator",
"color": "#3498DB",
"permissions": 1048575
},
{
"id": 4,
"name": "Member",
"color": null,
"permissions": 7779
}
],
"server_name": "My Server",
"motd": "Welcome!"
}
}
Payload Field Details
channels[]:
| Field | Type | Present On | Description |
|---|---|---|---|
id |
number | All | Channel database ID |
name |
string | All | Channel name |
type |
string | All | "text", "voice", or "announcement" |
category |
string or null | All | Category grouping |
position |
number | All | Display order |
unread_count |
number | text only | Messages unread by this user |
last_message_id |
number | text only | ID of most recent message |
dm_channels[]:
| Field | Type | Description |
|---|---|---|
channel_id |
number | DM channel database ID |
recipient |
DmRecipient | The other participant |
last_message_id |
number or null | Latest message ID |
last_message |
string | Preview of latest message |
last_message_at |
string (ISO 8601) | Timestamp of latest message |
unread_count |
number | Unread message count |
members[]: All registered users with id, username,
avatar, role (lowercase name), status.
voice_states[]: All users currently in any voice channel.
Only channel_id, user_id, muted, deafened (no
speaking/camera/screenshare — those come via live
voice_state events).
roles[]: All server roles with id, name, color, permissions (bitfield).
Chat Messages
chat_send (Client -> Server)
{
"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. Channel must exist. |
content |
string | Yes* | Max 4000 runes. HTML-sanitized (strict policy). *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/uploads. Requires ATTACH_FILES permission. |
Processing steps (server):
- Rate limit:
chat:${userId}at 10/sec. - Parse and validate
channel_id. - Fetch channel from DB (must exist).
- DM branch: If
channel.type == "dm", checkIsDMParticipantinstead of role permissions. - Non-DM branch: Check
READ_MESSAGES | SEND_MESSAGES. - Slow mode: If
ch.SlowMode > 0and user lacksMANAGE_MESSAGES, enforceslow:${userId}:${channelId}rate limit. - Sanitize content via
bluemonday.StrictPolicy(). - Validate content is non-empty (or has attachments).
- Validate content <= 4000 runes.
- If attachments present, check
ATTACH_FILESpermission. - Persist message to DB (
CreateMessage). - Link attachments to message (
LinkAttachmentsToMessage). On failure, delete the orphaned message. - Re-fetch message to get timestamp.
- Send
chat_send_okto sender. - Broadcast
chat_messageto channel (or DM participants). - DM auto-reopen: For DMs, call
OpenDMfor the recipient and senddm_channel_openif the DM was closed.
chat_send_ok (Server -> Client)
Direct response to sender (no seq). Includes original request id.
{
"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)
{
"seq": 42,
"type": "chat_message",
"payload": {
"id": 1042,
"channel_id": 5,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"role": "admin"
},
"content": "Hello everyone!",
"reply_to": null,
"timestamp": "2026-03-14T10:30:00Z",
"attachments": [{
"id": "upload-uuid-1",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/files/upload-uuid-1"
}],
"reactions": [],
"pinned": false
}
}
DM delivery: Instead of BroadcastToChannel, DM messages are
sent directly to both participants via SendToUser. This bypasses
the channel-focus subscription model, ensuring both users receive
the message regardless of which channel they're viewing.
chat_edit (Client -> Server)
{
"type": "chat_edit",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"content": "Hello everyone! (edited)"
}
}
| Field | Type | Constraints |
|---|---|---|
message_id |
number | Positive integer. Must be own message. |
content |
string | Non-empty, max 4000 runes, HTML-sanitized. |
Processing: Rate limited (10/sec). Checks DM participant or
SEND_MESSAGES permission. EditMessage verifies ownership internally.
chat_edited (Server -> Client, broadcast)
{
"seq": 43,
"type": "chat_edited",
"payload": {
"message_id": 1042,
"channel_id": 5,
"content": "Hello everyone! (edited)",
"edited_at": "2026-03-14T10:31:00Z"
}
}
chat_delete (Client -> Server)
{
"type": "chat_delete",
"id": "req-uuid",
"payload": {
"message_id": 1042
}
}
Processing: Rate limited (10/sec). For non-DM channels,
moderators with MANAGE_MESSAGES can delete others' messages.
In DM channels, users can only delete their own messages.
Generates an audit log entry.
chat_deleted (Server -> Client, broadcast)
{
"seq": 44,
"type": "chat_deleted",
"payload": {
"message_id": 1042,
"channel_id": 5
}
}
Reactions
reaction_add (Client -> Server)
{
"type": "reaction_add",
"payload": {
"message_id": 1042,
"emoji": "\ud83d\udc4d"
}
}
reaction_remove (Client -> Server)
{
"type": "reaction_remove",
"payload": {
"message_id": 1042,
"emoji": "\ud83d\udc4d"
}
}
| Field | Type | Constraints |
|---|---|---|
message_id |
number | Positive integer |
emoji |
string | Non-empty, max 32 bytes, no control characters (U+0000-U+001F, U+007F) |
Processing: Rate limited at 5/sec. DM channels check
IsDMParticipant; non-DM channels require ADD_REACTIONS
permission. Error responses use generic "reaction failed" to
prevent IDOR information leakage.
reaction_update (Server -> Client, broadcast)
{
"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)
{ "type": "typing_start", "payload": { "channel_id": 5 } }
Processing:
- Rate limited: 1 per 3 seconds per user per channel. Rate-limited messages are silently dropped (no error sent).
- Channel must exist. Unknown channels are silently dropped.
- DM channels: check
IsDMParticipant(silently drop if not). - Broadcast to channel members excluding sender (via
broadcastExclude). For DMs, broadcast to both participants viabroadcastToDMParticipants.
Typing broadcasts are ephemeral -- they are NOT stored in the
replay ring buffer (sent via broadcastExclude, not
BroadcastToChannel).
typing (Server -> Client, broadcast)
{
"type": "typing",
"payload": {
"channel_id": 5,
"user_id": 1,
"username": "alex"
}
}
Client behavior: Show typing indicator for 5 seconds. Reset timer on new typing event from same user.
Presence
presence_update (Client -> Server)
{ "type": "presence_update", "payload": { "status": "online" } }
| Field | Type | Valid Values |
|---|---|---|
status |
string | "online", "idle", "dnd", "offline" |
Rate limited: 1 per 10 seconds. Server persists to DB and broadcasts to all clients.
presence (Server -> Client, broadcast)
{
"seq": 50,
"type": "presence",
"payload": {
"user_id": 1,
"status": "online"
}
}
Automatic presence changes:
- On WS connect: server sets
"online". - On WS disconnect (
readPumpexit): server sets"offline". - Server auto-sets
"idle"after 10 minutes of no WS activity (not yet implemented -- documented as future behavior).
Channel Focus
channel_focus (Client -> Server)
{ "type": "channel_focus", "payload": { "channel_id": 5 } }
Purpose: Tells the server which channel the user is currently viewing. This affects:
- Broadcast delivery:
deliverBroadcastonly sends channel-scoped messages to clients whosechannelIDorvoiceChIDmatches the target channel. - Unread tracking: Server calls
UpdateReadStateto mark all messages in the channel as read.
Processing:
- Validate
channel_idis positive. - Fetch channel from DB (must exist).
- DM channels: check
IsDMParticipant. - Non-DM channels: check
READ_MESSAGES. - Update client's
channelIDfield (under mutex). - Get latest message ID and update read state.
There is no explicit channel_unfocus message. Focusing a new
channel implicitly unfocuses the previous one.
Channel Updates
All channel update messages are broadcast to all connected clients (not channel-scoped). These are triggered by REST API calls from admins, not by WebSocket messages.
channel_create (Server -> Client, broadcast)
{
"seq": 60,
"type": "channel_create",
"payload": {
"id": 8,
"name": "gaming",
"type": "text",
"category": "Hangout",
"topic": "",
"position": 3
}
}
channel_update (Server -> Client, broadcast)
{
"seq": 61,
"type": "channel_update",
"payload": {
"id": 8,
"name": "gaming-talk",
"type": "text",
"category": "Hangout",
"topic": "Gaming discussion",
"position": 4
}
}
Note: Server sends the full channel object (all fields), not a partial update. See #Known Protocol Drift.
channel_delete (Server -> Client, broadcast)
{
"seq": 62,
"type": "channel_delete",
"payload": { "id": 8 }
}
When a voice channel is deleted, the server calls
CleanupVoiceForChannel which removes all voice participants from
the DB, clears their client voice state, removes them from LiveKit,
and broadcasts voice_leave for each.
Channel types: "text", "voice", "announcement", "dm" (DM
channels are not included in channel_create/update/delete broadcasts).
Member Updates
All member messages are broadcast to all connected clients.
member_join (Server -> Client, broadcast)
{
"seq": 70,
"type": "member_join",
"payload": {
"user": {
"id": 5,
"username": "newuser",
"avatar": null,
"role": "member"
}
}
}
Sent when a user first connects (fresh connection, not reconnect replay).
member_leave (Server -> Client, broadcast)
{
"type": "member_leave",
"payload": { "user_id": 5 }
}
Note: member_leave is referenced in types.ts but the server
does not currently broadcast it (disconnect triggers a presence
update to "offline" instead).
member_update (Server -> Client, broadcast)
{
"seq": 71,
"type": "member_update",
"payload": {
"user_id": 5,
"role": "moderator"
}
}
Triggered when an admin changes a user's role via REST API.
member_ban (Server -> Client, broadcast)
{
"seq": 72,
"type": "member_ban",
"payload": { "user_id": 5 }
}
Triggered when a user is banned via REST API.
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. See VOICE-CHAT-DESIGN for full architecture.
voice_join (Client -> Server)
{ "type": "voice_join", "payload": { "channel_id": 10 } }
Server processing (9 steps):
- Parse and validate
channel_id. - Check
CONNECT_VOICEpermission. - Validate channel exists in DB.
- Verify LiveKit is configured and running.
- If already in same channel: send
ALREADY_JOINEDerror. - If in a different channel: call
handleVoiceLeavefirst. - Check channel capacity (
voice_max_users). - Persist join to DB (
JoinVoiceChannel). - Generate LiveKit token with permission-based publish/subscribe grants.
On success, server sends (in order):
voice_token-- LiveKit JWT + URLvoice_statebroadcast -- joiner's state to all clients- Existing
voice_statemessages -- one per existing participant (to joiner only) voice_config-- channel audio settings (to joiner only)
Rollback on failure: If token generation or state retrieval
fails after DB join, rollbackVoiceJoin clears the client's
voice channel, removes the DB row, and broadcasts voice_leave.
voice_token (Server -> Client, direct)
{
"type": "voice_token",
"payload": {
"channel_id": 10,
"token": "eyJhbGciOiJIUzI1NiIs...",
"url": "/livekit",
"direct_url": "ws://localhost:7880"
}
}
| Field | Type | Description |
|---|---|---|
channel_id |
number | Voice channel joined |
token |
string | LiveKit JWT (4-hour TTL). Contains grants: room_join, room name, can_publish (based on SPEAK_VOICE perm), can_subscribe (always true), can_publish_data. Identity: "user-{id}". Name: username. |
url |
string | Always "/livekit" -- proxy path through OwnCord's HTTPS server |
direct_url |
string | Raw LiveKit URL (e.g., "ws://localhost:7880"). Used by localhost clients to avoid TLS proxy. |
Client URL resolution:
- Localhost connections: use
direct_urldirectly. - Remote connections: start Rust-side TLS proxy
(
start_livekit_proxy), connect viaws://127.0.0.1:{proxy_port}/livekit/....
voice_token_refresh (Client -> Server)
{ "type": "voice_token_refresh", "payload": {} }
Rate limited: 1 per 60 seconds. Requires being in a voice channel.
Server generates a fresh LiveKit token and sends a new
voice_token message. The client requests this 3.5 hours into a
session (30 minutes before the 4-hour TTL expiry).
voice_config (Server -> Client, direct)
{
"type": "voice_config",
"payload": {
"channel_id": 10,
"quality": "medium",
"bitrate": 64000,
"max_users": 50
}
}
| Field | Type | Description |
|---|---|---|
channel_id |
number | Voice channel |
quality |
string | "low", "medium", or "high" |
bitrate |
number | Target audio bitrate in bits/s (32000, 64000, or 128000) |
max_users |
number | Channel capacity (0 = unlimited) |
Quality presets (Go source of truth in voice_broadcast.go):
| Preset | Bitrate |
|---|---|
low |
32,000 bps |
medium |
64,000 bps |
high |
128,000 bps |
voice_leave (Client -> Server)
{ "type": "voice_leave", "payload": {} }
Empty payload. Server clears voice channel from client, removes
from DB, broadcasts voice_leave to all, and calls
livekit.RemoveParticipant (best-effort).
Also called automatically on WS disconnect (readPump defer).
voice_leave (Server -> Client, broadcast)
{
"seq": 80,
"type": "voice_leave",
"payload": {
"channel_id": 10,
"user_id": 1
}
}
voice_state (Server -> Client, broadcast)
{
"seq": 81,
"type": "voice_state",
"payload": {
"channel_id": 10,
"user_id": 1,
"username": "alex",
"muted": false,
"deafened": false,
"speaking": false,
"camera": false,
"screenshare": false
}
}
Broadcast to ALL clients (not just channel members) on:
- Voice join (initial state)
- Mute/deafen/camera/screenshare toggle
voice_mute (Client -> Server)
{ "type": "voice_mute", "payload": { "muted": true } }
Requires being in a voice channel. Updates DB, broadcasts
voice_state.
voice_deafen (Client -> Server)
{ "type": "voice_deafen", "payload": { "deafened": true } }
Same behavior as voice_mute.
voice_camera (Client -> Server)
{ "type": "voice_camera", "payload": { "enabled": true } }
Rate limited: 2/sec. Requires USE_VIDEO permission (bit 11).
When enabling, checks voice_max_video limit from DB. On
exceeding limit, returns VIDEO_LIMIT error.
voice_screenshare (Client -> Server)
{ "type": "voice_screenshare", "payload": { "enabled": true } }
Rate limited: 2/sec. Requires SHARE_SCREEN permission (bit 12).
voice_speakers (Server -> Client) -- LEGACY
Speaker detection is now handled client-side via LiveKit SDK's
RoomEvent.ActiveSpeakersChanged. The voice_speakers message
type exists in types.ts for backward compatibility but is NOT
sent by the current server.
Migration Note (LiveKit)
The following Pion-era message types have been removed:
voice_offer/voice_answer-- replaced by LiveKit token-based joinvoice_ice-- handled internally by LiveKit SDKvoice_speakers-- client-side via LiveKit SDK eventsvoice_config.threshold_mode-- LiveKit handles mixing internallyvoice_config.top_speakers-- LiveKit handles speaker selection
Direct Messages
dm_channel_open (Server -> Client)
Sent when a DM is opened, created, or auto-reopened by an incoming message.
{
"type": "dm_channel_open",
"payload": {
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
}
}
}
Note: The recipient here is the "other user" from the
perspective of the receiving client. When user A opens a DM with
user B, user A gets recipient: B, and auto-reopen sends user B
a dm_channel_open with recipient: A.
dm_channel_close (Server -> Client)
{
"type": "dm_channel_close",
"payload": { "channel_id": 100 }
}
Sent when:
- REST
DELETE /api/v1/dms/{id}is called (REST handler sends viaSendToUserover WS)
Note: There is no dm_close WebSocket message type. DM closing
is handled exclusively via the REST endpoint, which then pushes
a dm_channel_close event to the user's WS connection.
DM channels persist in the database but are marked as "closed" per user. Messages in a closed DM trigger auto-reopen.
DM Authorization
All handlers that touch a channel check ch.Type == "dm" and
branch to IsDMParticipant instead of role-based permissions.
This applies to:
chat_send-- participant check instead ofSEND_MESSAGESchat_edit-- participant check instead ofSEND_MESSAGESchat_delete-- participant check (no mod override in DMs)reaction_add/reaction_remove-- participant check instead ofADD_REACTIONStyping_start-- participant check (silent drop if not)channel_focus-- participant check instead ofREAD_MESSAGES
DM Broadcast Pattern
DM messages bypass the channel-subscription model. Instead of
BroadcastToChannel (which only reaches clients focused on that
channel), DMs use broadcastToDMParticipants or SendToUser
which sends to all connections of each participant regardless of
their focused channel.
Server Restart
server_restart (Server -> Client, broadcast)
{
"seq": 100,
"type": "server_restart",
"payload": {
"reason": "update",
"delay_seconds": 5
}
}
| Field | Type | Description |
|---|---|---|
reason |
string | Why the server is restarting (e.g., "update", "shutdown") |
delay_seconds |
number | Seconds until actual shutdown |
Server behavior (GracefulStop):
- Broadcast
server_restartto all clients. - Stop LiveKit process (if managed).
- Wait 5 seconds for graceful disconnect.
- Close all remaining client connections.
- Stop the hub dispatch loop.
Client behavior: Display banner, auto-reconnect after delay.
Error Handling
error (Server -> Client)
All request failures return a standard error envelope:
{
"type": "error",
"id": "original-req-uuid",
"payload": {
"code": "FORBIDDEN",
"message": "No permission to post here"
}
}
If the original request had an id, the error includes it for
correlation. The id field is omitted for broadcast-triggered
errors or system errors.
Error Codes
| Code | Description | Context |
|---|---|---|
BAD_REQUEST |
Invalid payload format or field values | Any handler |
INTERNAL |
Server-side error (DB failure, etc.) | Any handler |
NOT_FOUND |
Channel or message not found | chat_send, chat_edit, chat_delete, voice_join |
FORBIDDEN |
Missing required permission | Any permissioned action |
RATE_LIMITED |
Too many requests (includes retry_after) |
Any rate-limited action |
ALREADY_JOINED |
Already in this voice channel | voice_join |
CHANNEL_FULL |
Voice channel at capacity | voice_join |
VOICE_ERROR |
Voice-specific error (not configured, not in channel) | voice_mute, voice_deafen, voice_join |
VIDEO_LIMIT |
Maximum video streams reached | voice_camera |
BANNED |
User is banned | auth, periodic session check |
INVALID_JSON |
Message is not valid JSON | envelope parse |
UNKNOWN_TYPE |
Unrecognized message type | dispatch |
SLOW_MODE |
Channel has slow mode enabled | chat_send |
CONFLICT |
Duplicate reaction or constraint violation | reaction_add |
Rate Limit Error Format
{
"type": "error",
"payload": {
"code": "RATE_LIMITED",
"message": "too many messages",
"retry_after": 1
}
}
retry_after is in seconds (float).
Invalid JSON Handling
The server tracks consecutive invalid messages per client
(invalidCount). After 10 consecutive invalid messages, the
connection is forcibly closed. The counter resets on any valid
JSON parse.
Rate Limits
All rate limits are enforced server-side using a token bucket
rate limiter (auth.RateLimiter).
| Action | Limit | Window | Key Format | Error Response |
|---|---|---|---|---|
| Chat send | 10 | 1 second | chat:{userId} |
RATE_LIMITED error |
| Chat edit | 10 | 1 second | chat_edit:{userId} |
RATE_LIMITED error |
| Chat delete | 10 | 1 second | chat_delete:{userId} |
RATE_LIMITED error |
| Typing | 1 | 3 seconds | typing:{userId}:{channelId} |
Silently dropped |
| Presence | 1 | 10 seconds | presence:{userId} |
RATE_LIMITED error |
| Reactions | 5 | 1 second | reaction:{userId} |
RATE_LIMITED error |
| Voice camera | 2 | 1 second | voice_camera:{userId} |
RATE_LIMITED error |
| Voice screenshare | 2 | 1 second | voice_screenshare:{userId} |
RATE_LIMITED error |
| Voice token refresh | 1 | 60 seconds | voice_token_refresh:{userId} |
RATE_LIMITED error |
| Slow mode | 1 | N seconds (configurable) | slow:{userId}:{channelId} |
SLOW_MODE error |
Client Dispatcher Pattern
The client uses a dispatcher pattern (lib/dispatcher.ts) to wire
WebSocket events to store updates. The wireDispatcher() function
registers listeners on the WsClient and returns a cleanup function.
Architecture
WsClient.handleMessage(raw)
|
v
parse JSON -> extract type, payload, id, seq
|
v
track lastSeq (if seq > lastSeq)
|
v
dispatch(msg) -> iterate listeners for msg.type
|
+--> dispatcher listener -> update stores
+--> livekitSession listener (voice_token)
+--> component-specific listeners
Dispatcher Mappings
| Server Message | Store Action(s) |
|---|---|
auth_ok |
setAuth(token, user, server_name, motd) |
auth_error |
setTransientError, clearAuth |
ready |
setChannels, setMembers, setVoiceStates, setDmChannels, etc. |
chat_message |
addMessage, incrementUnread, notifyIncomingMessage |
chat_send_ok |
confirmSend |
chat_edited |
editMessage |
chat_deleted |
deleteMessage |
reaction_update |
updateReaction |
typing |
setTyping |
presence |
updatePresence |
channel_create |
addChannel |
channel_update |
updateChannel |
channel_delete |
removeChannel |
voice_state |
updateVoiceState |
voice_leave |
removeVoiceUser |
voice_config |
setVoiceConfig, joinVoiceChannel |
voice_token |
handleVoiceToken (LiveKit session) |
voice_speakers |
setSpeakers |
member_join |
addMember |
member_leave |
removeMember |
member_update |
updateMemberRole |
member_ban |
removeMember |
dm_channel_open |
addDmChannel |
dm_channel_close |
removeDmChannel |
server_restart |
display restart banner |
error |
setTransientError or specific handler |
Message Type Reference Table
Client -> Server (18 types)
| Type | Payload Interface | Rate Limit | Notes |
|---|---|---|---|
auth |
AuthPayload |
N/A (first message) | Token + optional last_seq |
chat_send |
ChatSendPayload |
10/sec | + slow mode per channel |
chat_edit |
ChatEditPayload |
10/sec | Own messages only |
chat_delete |
ChatDeletePayload |
10/sec | Own or mod (non-DM) |
reaction_add |
ReactionAddPayload |
5/sec | |
reaction_remove |
ReactionRemovePayload |
5/sec | |
typing_start |
TypingStartPayload |
1/3sec/channel | Silently dropped |
channel_focus |
ChannelFocusPayload |
None | Updates read state |
presence_update |
PresenceUpdatePayload |
1/10sec | |
voice_join |
VoiceJoinPayload |
None | |
voice_leave |
{} |
None | Empty payload |
voice_mute |
VoiceMutePayload |
None | |
voice_deafen |
VoiceDeafenPayload |
None | |
voice_camera |
VoiceCameraPayload |
2/sec | Requires USE_VIDEO |
voice_screenshare |
VoiceScreensharePayload |
2/sec | Requires SHARE_SCREEN |
voice_token_refresh |
{} |
1/60sec | Must be in voice |
soundboard_play |
SoundboardPlayPayload |
N/A | TypeScript only, no Go handler |
ping |
{} |
None | Heartbeat |
Server -> Client (25 types)
| Type | Payload Interface | Has seq? | Delivery |
|---|---|---|---|
auth_ok |
AuthOkPayload |
No | Direct |
auth_error |
AuthErrorPayload |
No | Direct (then close) |
ready |
ReadyPayload |
No | Direct |
chat_message |
ChatMessagePayload |
Non-DM only | Channel (seq) or DM participants (no seq) |
chat_send_ok |
ChatSendOkPayload |
No | Direct to sender |
chat_edited |
ChatEditedPayload |
Non-DM only | Channel (seq) or DM participants (no seq) |
chat_deleted |
ChatDeletedPayload |
Non-DM only | Channel (seq) or DM participants (no seq) |
reaction_update |
ReactionUpdatePayload |
Non-DM only | Channel (seq) or DM participants (no seq) |
typing |
TypingPayload |
No | Channel (excl. sender) or DM |
presence |
PresencePayload |
Yes | All clients |
channel_create |
ChannelCreatePayload |
Yes | All clients |
channel_update |
ChannelUpdatePayload |
Yes | All clients |
channel_delete |
ChannelDeletePayload |
Yes | All clients |
voice_state |
VoiceStatePayload |
Yes | All clients |
voice_leave |
VoiceLeavePayload |
Yes | All clients |
voice_config |
VoiceConfigPayload |
No | Direct to joiner |
voice_token |
VoiceTokenPayload |
No | Direct to joiner |
voice_speakers |
VoiceSpeakersPayload |
N/A | Legacy (not sent) |
member_join |
MemberJoinPayload |
Yes | All clients |
member_leave |
MemberLeavePayload |
N/A | Defined in TS, not sent by Go |
member_update |
MemberUpdatePayload |
Yes | All clients |
member_ban |
MemberBanPayload |
Yes | All clients |
dm_channel_open |
DmChannelOpenPayload |
No | Direct to participant (via SendToUser) |
dm_channel_close |
DmChannelClosePayload |
No | Direct to participant (via SendToUser) |
server_restart |
ServerRestartPayload |
Yes | All clients |
error |
ErrorPayload |
No | Direct to requester |
pong |
(none) | No | Direct to pinger |
Delivery notes: Typing uses broadcastExclude (not
BroadcastToChannel), so it never gets a seq and is never stored
in the replay ring buffer. DM events (chat messages, edits,
deletes, reactions, open/close) are sent via SendToUser, which
also bypasses the broadcast channel -- no seq, no ring buffer
storage. This means DM events are not replayable on reconnect.
Message History (REST, not WebSocket)
Message history is fetched via REST to keep the WS connection lean:
GET /api/v1/channels/{id}/messages?before={msg_id}&limit=50
See API for full REST endpoint documentation.
Known Protocol Drift
The following discrepancies exist between the Go server
implementation and the TypeScript client types (documented in
protocol-schema.json under drift_notes):
- chat_message user.role: Go sends
user.rolebut TSChatMessagePayloadusesMessageUser(no role field). - chat_message reactions/pinned: Go sends
reactions[]andpinnedfields. TSChatMessagePayloadomits both. - channel_create topic: Go sends
topic(string) but TSChannelCreatePayloadomits it. - channel_update full object: Go sends all 6 channel
fields. TS
ChannelUpdatePayloadonly hasid, optionalname, optionalposition. - voice_config extra fields: TS
VoiceConfigPayloadhasthreshold_mode,mixing_threshold,top_speakerswhich are not present in Go'svoiceConfigPayloadstruct (legacy Pion fields). - voice_token direct_url: TS marks as optional. Go always sends it (may be empty string).
- soundboard_play: TS defines client message type but Go
has no handler for it (returns
UNKNOWN_TYPE). - dm_channel_open payload shape: Go sends
{channel_id, recipient}while TSDmChannelOpenPayloadexpects additional fields (last_message_id,last_message,last_message_at,unread_count).