-
Notifications
You must be signed in to change notification settings - Fork 1
SERVER SPEC
▄██▀ ▀█ ▄██▀ █▄ ▀██ ██▀ ▄██▀ ▀█ ▄██▀ █▄ █▄ ▄█
▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒ ▒▒ ▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒▒▄▒▒▒
▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒▌ ▒▒ ▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒ ▀ ▒▒
▀██▄ ▄█ ▀██▄ █▀ ▀█▄▀ ▀██▄ ▄█ ▀██▄ █▀ ▄██▄ ▄██▄
XChaCha20 · ML-KEM-768 · Ed25519 · BLAKE3 · SPQR · E2EE · ephemeral · N-party
server internals deep-dive. covers the broker's wire contract, the message handlers, validation limits, room lifecycle, configuration resolution, launch modes, and the test architecture.
Note
Installing, running, the command-line flags, the environment variables, and Docker deployment all live in USAGE. The server's trust role and the protocol narrative live in PROTOCOL. Per-message field tables and the relay payload tagging scheme live in CRYPTOGRAPHY.
- a dumb broker. The server knows room IDs and active connections, nothing else.
- zero cryptographic operations. Payloads, signatures, and claims relay opaquely.
- zero storage. No messages, no keys, no user data, no disk, no logs of content.
- Bun's native WebSocket API. No Express, Hono, or any other framework.
- no TLS. Caddy terminates TLS and proxies to the loopback listener.
- one entrypoint, two launch modes:
bun run src/index.tsand a compiled binary. Identical behavior in both; flags and env vars resolve the same way. - reads nothing from disk. Compiled binaries map source paths into an embedded
virtual filesystem, so
import.meta.diris never used to resolve anything.
src/
├── index.ts startServer(): config resolution, Bun.serve, fetch router,
│ websocket dispatch, room TTL cron, import.meta.main entrypoint
├── flags.ts pure CLI flag parser: USAGE text, strict validation, no
│ process.exit, no env reads
├── relay.ts one handler per inbound message type, plus handleClose
├── rooms.ts Room and ConnData shapes; room create, get, delete
├── types.ts the wire contract: every inbound and outbound message interface
└── version.ts generated by build.ts: VERSION, PROTOCOL_HEX. gitignored
relay.ts holds all protocol behavior. index.ts owns the runtime: it parses
config, wires the HTTP and WebSocket handlers, and dispatches each parsed
message to its handler. rooms.ts and types.ts are pure data.
All state is in memory and dies with the process.
interface ConnData {
roomId: string | null
username: string | null
ek: string | null
ratchetEk: string | null
claim: string | null // last-known identity claim for late-joiner replay
}
interface Room {
id: string
conns: Set<ServerWebSocket<ConnData>>
maxSize: number
roomSecret: string
lastActivity: number
}ConnData rides on each WebSocket via Bun's upgrade data slot and starts
all-null. identify fills username, ek, ratchetEk, and claim;
ratchet_step, ek_update, and rekey refresh the key fields so late
joiners always receive a peer's current identity in the joined snapshot.
Rooms live in a single Map<string, Room> owned by the startServer closure.
| Path | Result |
|---|---|
GET /health_check |
200 OK, body OK, Access-Control-Allow-Origin: *
|
/ws |
WebSocket upgrade with a fresh all-null ConnData; 500 Upgrade failed if the upgrade is refused |
| anything else | 404 Not found |
The server binds 127.0.0.1 by default, deliberately the explicit IPv4
loopback rather than the name localhost. Resolving localhost yields both
127.0.0.1 and ::1, and Bun.serve binds only the first address the
resolver returns, which on IPv6 hosts strands clients dialing 127.0.0.1.
Caddy proxies to 127.0.0.1, so the broker stays loopback-only unless
HOST=0.0.0.0 exposes it directly.
Every WebSocket frame is parsed as JSON and routed on strict type string
equality. The whole message handler runs inside a try/catch: a frame that
is not valid JSON, or that throws anywhere in a handler, is dropped without a
reply and without crashing the broker. Unknown type values fall through the
switch and are likewise dropped.
const msg = JSON.parse(raw as string) as InboundMsg;
switch (msg.type) {
case 'create': handleCreate(ws, msg, rooms, maxRoomSize, adminToken); break;
case 'join': handleJoin(ws, msg, rooms); break;
case 'identify': handleIdentify(ws, msg, rooms); break;
case 'relay': handleRelay(ws, msg, rooms); break;
case 'broadcast': handleBroadcast(ws, msg, rooms); break;
case 'ratchet_step': handleRatchetStep(ws, msg, rooms); break;
case 'ek_update': handleEkUpdate(ws, msg, rooms); break;
case 'rekey': handleRekey(ws, msg, rooms); break;
}server/src/types.ts is the canonical contract. All messages are JSON with a
type field. Inbound types: create, join, identify, relay,
broadcast, ratchet_step, ek_update, rekey. Outbound types:
room_created, joined, peer_joined, peer_left, relay, broadcast,
ratchet_step_fwd, ek_update_fwd, rekeyed, error.
The field-by-field tables live in CRYPTOGRAPHY § wire format; this document covers what the server does with each message. Two structural notes matter here:
-
ratchet_stepis the only fan-out-with-translation message. The client sends one frame holding a per-recipientpayloadsmap; the server emits oneratchet_step_fwdper named recipient, copying that recipient's pairwise{ kemCt, encSeed, pn }next to the sharednewEk,payload,meta,sig, andclaim. - inbound
relayandbroadcastreuse their type string on the way out, withtoreplaced byfrom. Every other outbound type has its own name.
Each handler runs its gates in source order and stops at the first failure. The lists below are exhaustive: a message passing every listed gate always produces the listed effect.
-
protocolVersion !== PROTOCOL_VERSION(including absent): senderror: version_mismatchcarryingserverVersion, close the socket. - an admin token is configured and
adminTokendoes not match: senderror: forbidden. - generate the room, reply
room_createdwithroomId,roomSecret, andserverVersion.
handleCreate does not add the creator to room.conns. The creator joins
through a normal join message after room_created, so there is exactly one
membership path.
- version gate, identical to create.
- the connection already has a
roomId:error: forbidden. One room per connection, no rebinding. - unknown
roomId:error: not_found. -
roomSecretmismatch:error: forbidden. The comparison is constant-time (leviathan'sconstantTimeEqual, routed through@covcom/lib), applied to the base64 strings byte-for-byte. Non-string input becomes an empty buffer, which fails the length check inside the comparison. -
maxSize > 0and the room is at capacity:error: room_full. - add the socket to
room.conns, setws.data.roomId, touchlastActivity, and replyjoinedwithserverVersionand amemberssnapshot of every other connection that has completedidentify.
Validation failures here close the socket without an error frame; a stripped or rewritten name would desync from the signed identity claim, so the server rejects rather than repairs.
- no
roomId: drop. - trimmed username empty or longer than 64 chars: close.
- username contains C0/C1 control chars or DEL: close.
- username contains bidi controls or zero-width format chars
(
hasUnsafeFormatCharsfrom@covcom/lib, the same code-point list the client sanitizer uses): close. -
ekorratchetEknot exactly 1580 chars (base64 of an 1184-byte ML-KEM-768 key): close. -
claimempty or over 4000 chars: close. - username already taken by another connection in the room:
error: username_taken. - store
username,ek,ratchetEk,claimon the connection, touchlastActivity, and sendpeer_joinedto every other room member.
- no
roomIdor nousername: drop. -
newEknot exactly 1580 chars: drop. -
claimempty or over 4000 chars: drop. -
sigempty or over 200 chars: drop. - update the connection's stored
ratchetEkandclaim(late joiners must see the post-step identity), touchlastActivity. - for each identified room member named in
payloads, sendratchet_step_fwdwith that member's pairwise payload. Members not named in the map receive nothing.
- no
roomIdor nousername: drop. -
claimempty or over 4000 chars: drop. - update stored
ratchetEkandclaim, touchlastActivity, and sendek_update_fwdto every other room member.
- no
roomIdor nousername: drop. - touch
lastActivity. - forward
{ type: 'relay', from, payload }to the first room member whose username equalsto. No match means the frame vanishes; the sender gets no delivery failure signal.
The payload carries a client-only one-byte tag (chain seed or file ack)
that the server never reads. See
CRYPTOGRAPHY § relay payload tagging.
- no
roomIdor nousername: drop. -
sigempty or over 200 chars: drop. - touch
lastActivity, then forward{ type: 'broadcast', from, payload, meta, sig }to every room member except the sender.payload,meta, andsigpass through untouched and unread.
- no
usernameor noroomId: drop. -
claimempty or over 4000 chars: drop. - replace the connection's stored
ek,ratchetEk, andclaim, touchlastActivity, and acknowledge withrekeyedto the sender only. No broadcast goes out; rekey exists so a lobby client can refresh identity without apeer_joinedstorm.
Runs from Bun's WebSocket close callback for every disconnect.
- no
roomId: nothing to do. - remove the socket from
room.conns. - if the connection had identified, send
peer_leftto every remaining member.
The room itself survives. Deleting rooms is the TTL sweep's job, never
handleClose's, so a transient full disconnect does not destroy the room
behind its invite.
PROTOCOL_VERSION is read from @covcom/lib (lib/src/protocol.ts), the
server's only dependency on the lib. The gate runs first in create and
join, before any admin-token, room, or username work. A missing
protocolVersion marks a pre-negotiation client and mismatches by
definition. On mismatch the server replies
{ type: 'error', reason: 'version_mismatch', serverVersion } and closes the
socket; the serverVersion field lets a newer client report exactly what it
hit. The same field rides on every room_created and joined so clients can
run the reverse check.
The version is a plaintext compatibility gate, not a security boundary. It is not bound into any signature or AEAD, and a hostile server can lie about it.
| Field | Messages | Rule | On violation |
|---|---|---|---|
username |
identify | 1-64 chars after trim; no C0/C1 or DEL; no bidi or zero-width format chars | close |
ek, ratchetEk
|
identify | exactly 1580 chars (base64 of 1184 bytes) | close |
newEk |
ratchet_step | exactly 1580 chars | drop |
claim |
identify | 1-4000 chars | close |
claim |
ratchet_step, ek_update, rekey | 1-4000 chars | drop |
sig |
broadcast, ratchet_step | 1-200 chars | drop |
roomSecret |
join | constant-time equality against the stored secret | error: forbidden |
protocolVersion |
create, join | strict equality with PROTOCOL_VERSION
|
error: version_mismatch, close |
payload and meta carry no server-side size rule of their own; they are
bounded only by the runtime's per-message WebSocket ceiling. The lib's
FILE_CHUNK_SIZE keeps file frames far below it.
The closed set, defined on ErrorMsg in types.ts. New reasons are a
flagged deviation.
| Reason | Sent when |
|---|---|
version_mismatch |
create or join with absent or wrong protocolVersion; socket closes after the frame |
forbidden |
admin token mismatch on create; join on a connection already in a room; wrong roomSecret
|
not_found |
join names a room ID that does not exist |
room_full |
join on a room at maxSize capacity |
username_taken |
identify with a username another member already holds |
Everything else fails without a reply. This is deliberate: the broker gives an unauthenticated or misbehaving sender no oracle beyond the five reasons above.
- frames that are not valid JSON, or whose handler throws
- unknown
typevalues - relay, broadcast, ratchet_step, ek_update, or rekey from a connection that has not joined and identified
- relay to a username with no matching member
- ratchet_step, ek_update, broadcast, or rekey field-validation failures
- identify validation failures close the socket instead, as listed above
Creation. createRoom draws 16 random bytes for the room ID, hex-encoded
to 32 chars, and 16 random bytes for roomSecret, base64-encoded to 24
chars, both from crypto.getRandomValues. The room starts with an empty
conns set and lastActivity = now.
Activity. Every room-touching handler refreshes lastActivity: join,
identify, ratchet_step, ek_update, relay, broadcast, and rekey.
Persistence. A room with zero connections persists. Members can drop and rejoin through the same invite for as long as the TTL allows.
Expiry. pruneRooms deletes rooms where conns.size === 0 and
now - lastActivity exceeds the TTL. It runs once at startup and then on a
Bun.cron('@hourly', ...) schedule. A TTL of 0 (or any non-positive or
unparseable value) disables pruning entirely.
Five knobs, resolved in startServer with the precedence
flag > environment variable > default:
export interface ServerConfig {
port?: number // PORT, default 1337
maxRoomSize?: number // MAX_ROOM_SIZE, default 20, 0 = unlimited
adminToken?: string | undefined // ADMIN_TOKEN, default unset
hostname?: string // HOST, default 127.0.0.1
roomTtl?: number // ROOM_TTL hours, default 24, 0 = never
}The flag parser (flags.ts) is pure: no process.exit, no env reads, no
output. It returns { config, help?, error?, version? } and the
import.meta.main block in index.ts decides what to print and how to exit
(0 for help and version, 1 for a validation error). Flags validate
strictly, so an operator typo fails loudly with the usage text; env vars keep
forgiving guards in startServer and fall back to defaults on garbage.
The parser sets only keys the user actually passed. This is load-bearing for
the admin token: startServer uses 'adminToken' in config to decide
whether a flag overrides ADMIN_TOKEN, so an untyped flag must leave the key
absent rather than set it to undefined. An admin token configured as an
empty string does not gate creation.
The operator-facing flag and env var tables, including the ps-visibility
warning for --admin-token, live in USAGE.
startServer(config) is an exported function; index.ts calls it from an
import.meta.main guard after flag parsing. The same file therefore serves
both modes:
-
source:
bun run src/index.ts, whatbun dev:serverwraps -
binary:
bun build:servercompiles per-target executables viabun build --compile; targets are data inserver/build.ts
One behavior, two launchers. There are no compiled-only or source-only code paths, and flags and env vars resolve identically in both. On boot the server prints one line:
COVCOM server v${VERSION} (protocol {PROTOCOL_HEX}) listening on {hostname}:{port}
--version prints COVCOM v{VERSION} (protocol {PROTOCOL_HEX}) from the
generated version.ts and exits. Because compiled binaries embed a virtual
filesystem, the server never reads from disk and never resolves paths through
import.meta.dir; any future config-file support must use explicit cwd or
XDG paths.
-
no payload inspection.
payload,meta,sig, andclaimforward byte-for-byte. The only checks are size bounds; nothing is parsed, verified, or logged. -
no crypto. The single cryptographic primitive in the codebase is the
constant-time string compare on
roomSecret. -
no storage. No disk, no database, no message history, no offline
queue, no replay on join. The
joinedsnapshot carries current member identities for ratchet context, never content. - no room ID proposals from clients. IDs are server-generated opaque tokens.
Any change that would have the server store, inspect, or act on payload content violates the architecture; flag it and stop.
The suite is black-box: every test talks to a real listening server over real
WebSockets. test/util.ts starts that server in one of two modes, selected
by the COVCOM_SERVER_BIN env var:
-
in-process (unset): tests call
startServer()directly and exercise the programmaticServerConfigpath -
subprocess (set to a binary path): tests spawn the compiled binary with
flags and cover the production config path;
bun test:server:bincompiles the host binary and runs the suite this way
| File | Covers |
|---|---|
test/server.test.ts |
the full protocol: create/join/identify flows, relay and broadcast delivery, ratchet_step fan-out, ek_update, rekey, peer_left, room persistence, capacity, admin token, secret validation, username validation, unidentified-sender drops, double-join, zombie-slot reclamation, version negotiation |
test/flags.test.ts |
flag parsing, strict validation, precedence over env vars, help/version output, exit codes, subprocess smoke tests |
test/http.test.ts |
/health_check, 404 routing, /ws without an upgrade |
| Document | Description |
|---|---|
| index | Project Documentation index |
| USAGE | Client and server applications development and runtime help |
| PROTOCOL | Cipher, chains, ratchet, group model, session lifecycle, server role |
| CRYPTOGRAPHY | Primitives, KDF chains, wire format, invite encoding |
| THREAT-MODEL | Principals, adversary tiers, guarantees, non-goals |
| LIB-SPEC | Shared library API, session and identity surface, invites, file transfer, and protocol manifest |
| WEB-SPEC | Web client architecture, state and session model, views, rendering, and the single-file build |
| CLI-SPEC | CLI architecture, rendering, input, widgets, views, and color system |
| TESTING | Test layers, unit and end-to-end suites, cross-client interop, and CI |