Skip to content

SERVER SPEC

xero edited this page Jun 11, 2026 · 1 revision
  ▄██▀ ▀█  ▄██▀ █▄  ▀██  ██▀  ▄██▀ ▀█  ▄██▀ █▄   █▄   ▄█
 ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒  ▒▒  ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒▒▄▒▒▒
 ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒▌ ▒▒  ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒ ▀ ▒▒
  ▀██▄ ▄█  ▀██▄ █▀    ▀█▄▀    ▀██▄ ▄█  ▀██▄ █▀  ▄██▄ ▄██▄

XChaCha20 · ML-KEM-768 · Ed25519 · BLAKE3 · SPQR · E2EE · ephemeral · N-party

COVCOM Server Spec

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.

Table of Contents


goals

  • 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.ts and 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.dir is never used to resolve anything.

module map

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.


state

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.


http surface

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.


dispatch

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;
}

wire contract

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_step is the only fan-out-with-translation message. The client sends one frame holding a per-recipient payloads map; the server emits one ratchet_step_fwd per named recipient, copying that recipient's pairwise { kemCt, encSeed, pn } next to the shared newEk, payload, meta, sig, and claim.
  • inbound relay and broadcast reuse their type string on the way out, with to replaced by from. Every other outbound type has its own name.

message handlers

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.

create

  1. protocolVersion !== PROTOCOL_VERSION (including absent): send error: version_mismatch carrying serverVersion, close the socket.
  2. an admin token is configured and adminToken does not match: send error: forbidden.
  3. generate the room, reply room_created with roomId, roomSecret, and serverVersion.

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.

join

  1. version gate, identical to create.
  2. the connection already has a roomId: error: forbidden. One room per connection, no rebinding.
  3. unknown roomId: error: not_found.
  4. roomSecret mismatch: error: forbidden. The comparison is constant-time (leviathan's constantTimeEqual, 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.
  5. maxSize > 0 and the room is at capacity: error: room_full.
  6. add the socket to room.conns, set ws.data.roomId, touch lastActivity, and reply joined with serverVersion and a members snapshot of every other connection that has completed identify.

identify

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.

  1. no roomId: drop.
  2. trimmed username empty or longer than 64 chars: close.
  3. username contains C0/C1 control chars or DEL: close.
  4. username contains bidi controls or zero-width format chars (hasUnsafeFormatChars from @covcom/lib, the same code-point list the client sanitizer uses): close.
  5. ek or ratchetEk not exactly 1580 chars (base64 of an 1184-byte ML-KEM-768 key): close.
  6. claim empty or over 4000 chars: close.
  7. username already taken by another connection in the room: error: username_taken.
  8. store username, ek, ratchetEk, claim on the connection, touch lastActivity, and send peer_joined to every other room member.

ratchet_step

  1. no roomId or no username: drop.
  2. newEk not exactly 1580 chars: drop.
  3. claim empty or over 4000 chars: drop.
  4. sig empty or over 200 chars: drop.
  5. update the connection's stored ratchetEk and claim (late joiners must see the post-step identity), touch lastActivity.
  6. for each identified room member named in payloads, send ratchet_step_fwd with that member's pairwise payload. Members not named in the map receive nothing.

ek_update

  1. no roomId or no username: drop.
  2. claim empty or over 4000 chars: drop.
  3. update stored ratchetEk and claim, touch lastActivity, and send ek_update_fwd to every other room member.

relay

  1. no roomId or no username: drop.
  2. touch lastActivity.
  3. forward { type: 'relay', from, payload } to the first room member whose username equals to. 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.

broadcast

  1. no roomId or no username: drop.
  2. sig empty or over 200 chars: drop.
  3. touch lastActivity, then forward { type: 'broadcast', from, payload, meta, sig } to every room member except the sender. payload, meta, and sig pass through untouched and unread.

rekey

  1. no username or no roomId: drop.
  2. claim empty or over 4000 chars: drop.
  3. replace the connection's stored ek, ratchetEk, and claim, touch lastActivity, and acknowledge with rekeyed to the sender only. No broadcast goes out; rekey exists so a lobby client can refresh identity without a peer_joined storm.

close

Runs from Bun's WebSocket close callback for every disconnect.

  1. no roomId: nothing to do.
  2. remove the socket from room.conns.
  3. if the connection had identified, send peer_left to 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.


version negotiation

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.


validation limits

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.


error reasons

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

silent drops

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 type values
  • 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

room lifecycle

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.


configuration

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.


launch modes

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, what bun dev:server wraps
  • binary: bun build:server compiles per-target executables via bun build --compile; targets are data in server/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.


what the server never does

  • no payload inspection. payload, meta, sig, and claim forward 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 joined snapshot 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.


testing

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 programmatic ServerConfig path
  • subprocess (set to a binary path): tests spawn the compiled binary with flags and cover the production config path; bun test:server:bin compiles 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

Cross Reference

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

Clone this wiki locally