Skip to content

WEB SPEC

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

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

COVCOM Web Client Spec

web app design doc. covers the store and session architecture, the bridge, views, message rendering, the single-file build with its CSP, connection handling, and key hygiene.

Note

This is an internals deep-dive. Running the client, deployment contexts, and the interface tour all live in USAGE. The crypto the session drives comes from LIB-SPEC; the frames it speaks are specified in SERVER-SPEC and CRYPTOGRAPHY.

Table of Contents


goals

  • vanilla TypeScript with Vite. No framework; no React, Vue, Svelte, or equivalent. The DOM is built with createElement, never parsed from strings.
  • one code path for every deployment context: the Docker container, a decoupled relay, and plaintext self-host all run the same bundle. No build-time env forks in client logic.
  • a true single-file SPA. One index.html carries the script, styles, and embedded WASM, locked down by a default-src 'none' CSP. No Web Worker, no lazy chunks, no runtime fetch.
  • protocol state and UI state stay separate. A CovcomSession emits events; a small Redux-shaped store drives the views; a bridge is the only thing that connects them.
  • nothing persists. No localStorage, no sessionStorage, no cookies. Closing the tab is the session shredder.

module map

src/
├── main.ts          boot: initCrypto, one CovcomSession, wireBridge,
│                    mountShell, beforeunload teardown
├── store.ts         AppState, the Action union, reducer, subscribe/dispatch
├── session.ts       CovcomSession: ws lifecycle, handshake, messages, files,
│                    ratchet, reconnect, rekey
├── emitter.ts       hand-rolled typed event emitter (on returns unsubscribe)
├── bridge.ts        session events -> store actions; friendly error strings
├── wireTypes.ts     type-only client mirror of the server wire contract
├── wireSummary.ts   wire frames -> event-log summaries; blob redaction
├── rich.ts          RichText/Doc token rendering to DOM, stripFormatChars
├── safehtml.ts      SafeHtml brand; the single innerHTML sink
├── qr.ts            qrMatrix -> inline SVG path
├── icons.ts         bundled inline SVG icons as SafeHtml
├── util.ts          el(), clear(), formatBytes(), peerColor()
├── version.ts       generated by build.ts: VERSION, PROTOCOL_HEX. gitignored
└── views/
    ├── shell.ts       view router; everReady latch; mount/unmount lifecycle
    ├── landing.ts     main/create/join sub-screens; PendingForm persistence
    ├── join.ts        invite paste textarea + file drop + picker
    ├── joining.ts     transient "connecting" placeholder
    ├── waiting.ts     creator lobby: invite text, QR, crypto table
    ├── chat.ts        message list, three bar variants, slash commands,
    │                  drag-drop overlay
    ├── header-nav.ts  fingerprint, event-log, and system-toggle buttons
    ├── sidebar.ts     resizable host pane for the two sections
    ├── event-log.ts   expandable wire/event rows
    └── verify.ts      local and peer fingerprint display

main.ts is the whole composition root: initCrypto() (LIB-SPEC § initialization), one new CovcomSession(), wireBridge(session), mountShell(app, session), and a beforeunload listener that disposes the session, unwires the bridge, and resets the store. A failed crypto init replaces the body with an error message and mounts nothing.


store

store.ts is a single-module Redux shape: one state object, one action union, one reducer, a listener set. No middleware, no time travel, no library.

export type Screen =
	| { name: 'landing'; error?: string; prefill?: { username?: string } }
	| { name: 'joining'; room: Room; username: string }
	| { name: 'waiting'; room: Room; username: string }
	| { name: 'ready'; room: Room; username: string };

export interface AppState {
	screen:            Screen;
	peers:             Map<string, PeerView>;       // username -> fingerprint + colorIdx
	localFingerprint?: FingerprintSurface;
	messages:          ChatItem[];
	events:            EventLogEntry[];
	ui: {
		sidebarOpen:     boolean;
		activeSection:   'event-log' | 'verify' | null;
		sidebarWidthPct: number;
		hideSystem:      boolean;
	};
}

ChatItem is the chat transcript union: message, file (carries the decrypted Blob), system (RichText plus an optional className), and ratchet. EventLogEntry is the sidebar log row: an id, a direction (in, out, or local), a kind, a RichText summary, and a flat details record.

The action union has sixteen members: four GOTO_* navigation actions mirroring the Screen union, PEER_ADDED/PEER_REMOVED/ LOCAL_FINGERPRINT, four append actions (MESSAGE_APPENDED, FILE_APPENDED, SYSTEM_APPENDED, RATCHET_APPENDED), EVENT_LOGGED, three UI actions (SIDEBAR_TOGGLE, SIDEBAR_RESIZE, SYSTEM_TOGGLE), and RESET.

Three semantics are deliberate and load-bearing:

  • the reducer mutates in place. No spreads, no reallocation. Listeners cache the slice values they care about and self-gate; nothing compares references. subscribe(fn) returns an unsubscribe, and dispatch calls every listener after every action.
  • the event log is capped at 500 entries. Old entries shift off the front; the event-log view evicts the matching rows.
  • peer color slots. Self is index 0; peers start at 1 (state.peers.size + 1), so the first peer never collides with self's color. peerColor(colorIdx) maps the index to a var(--peer{i}) CSS variable.

SIDEBAR_TOGGLE is a three-state cycle: closed opens to the requested section, open on another section switches, open on the same section closes.


session

CovcomSession (session.ts) is the protocol half of the client: it owns the WebSocket, the @covcom/lib Session instance, and every wire interaction. One long-lived instance per tab; fatal and teardown paths return it to idle so the same instance is reusable, and dispose() exists for beforeunload.

events

It extends a hand-rolled typed Emitter. The event map is the contract the bridge consumes:

export interface SessionEvents {
	'phase':                     { phase: 'joining' | 'waiting' | 'ready'; room: Room; username: string };
	'peer-joined':               { username: string; fingerprint: FingerprintSurface };
	'peer-known':                { username: string; fingerprint: FingerprintSurface };
	'peer-left':                 { username: string };
	'local-fingerprint-changed': { fingerprint: FingerprintSurface };
	'message':                   { from: string; text: string; isSelf: boolean; epoch: number; counter: number; ts: number };
	'file':                      { from: string; filename: string; mime: string; size: number; blob: Blob; isSelf: boolean; ts: number };
	'ratchet':                   { from: string; isSelf: boolean; ts: number };
	'wire':                      { direction: 'in' | 'out'; kind: string; summary: RichText; details: Record<string, unknown> };
	'log':                       { kind: string; summary: string; details?: Record<string, unknown> };
	'info':                      { kind: string; text: string; details?: Record<string, unknown> };
	'fatal':                     { reason: string; prefill?: { username?: string } };
	'connection-lost':           { at: number };
	'connection-restored':       { at: number; downMs: number };
}

peer-joined is a first sighting, peer-known a returning peer whose fingerprint may have changed. wire fires for every frame in both directions and feeds the event log. info is a user-facing soft failure (claim rejected, decrypt failed); fatal ends the connection attempt and sends the user back to landing.

public api

async create(opts: { server: string; username: string; adminToken?: string }): Promise<void>
async join(invite: InvitePayload, username: string): Promise<void>
sendMessage(text: string): boolean
async sendFile(file: File): Promise<void>
rotate(): void
dispose(): void

create opens the socket and sends create with protocolVersion; on room_created it snapshots the room and falls into the same join path as everyone else. join takes a parsed invite, resolves the server from invite.dns ?? '127.0.0.1:1337', and sends join. sendMessage returns false when the send could not go out, so the chat view keeps the user's draft in the textarea instead of clearing it.

the joined path

Create and join converge on _onJoined, the single post-joined handler (the architectural mirror of the CLI's doConnect). In order:

  1. dispose any previous lib session and build a fresh one: new Session(generateKeypair(), room.id) (LIB-SPEC § session). Fresh keys on every connect, including reconnects.
  2. emit local-fingerprint-changed.
  3. send identify with the public ek, ratchetEk, and a signed identity claim built by buildClaim (LIB-SPEC § claims).
  4. for each member in the joined snapshot: acceptClaim (a rejected claim drops that peer, not the session), wrap a chain seed to their ek and relay it under RELAY_TAG_SEED (LIB-SPEC § chain seed handshake), cache their ratchet key, and emit peer-joined or peer-known.
  5. an empty room goes straight to waiting. With peers, the session stays in joining and counts inbound chain seeds; when the last expected seed arrives, it flips to ready and fires the welcome ratchet (_doRatchetStep). That ratchet fires here and nowhere else.

phases

idle → joining → waiting ⇄ ready. Phase changes and their events are paired in one helper so they cannot drift apart; the joining emit is suppressed during reconnects so the chat view stays mounted through a drop.

messages

sendMessage seals with sealMessage (LIB-SPEC § sealing and opening), signs the ciphertext metadata with signMessage (LIB-SPEC § message signatures), and broadcasts with a MessageEnvelope meta. Crossing PROTOCOL.autoRatchetEvery with peers present triggers an automatic ratchet first. The sender self-echoes the message event immediately rather than round-tripping.

Inbound broadcast frames verify the detached signature before anything else; a bad signature is reported and the frame goes no further. A verified message envelope is decrypted via openMessage and emitted; file-begin and file-chunk envelopes route to the file path.

files

Send streams the file as one signed file-begin (the SealStream preamble is the payload, and the signature covers it) followed by one signed file-chunk broadcast per FILE_CHUNK_SIZE slice, driven by forEachChunk over File.slice (LIB-SPEC § file transfer). Two pacing mechanisms keep memory and the broker honest:

  • credit window. Recipients are snapshotted at file-begin (a peer joining mid-transfer is not a recipient). Each receiver acks every ACK_INTERVAL chunks over a tagged relay; the sender holds within WINDOW chunks of the slowest recipient's last ack.
  • socket drain. WebSocket has no drain event, so the loop polls bufferedAmount and pauses above a 4 MiB threshold. Peak memory stays O(chunkSize) regardless of file size.

The sender's own file card reuses the original File object as its blob. On any failure the stream is disposed and the file key wiped in a finally.

Receive keys in-flight transfers by ${from}|${fileId}. file-begin checks out the file key (openFileKey returns a commit/rollback handle, LIB-SPEC § file keys) and constructs an OpenStream; each chunk must arrive exactly in sequence or the whole transfer is dropped and the key checkout rolled back. The final chunk commits the handle, assembles the Blob, and emits file. In-flight transfers from a peer are disposed when that peer leaves, and all of them on disconnect or teardown. The CLI surfaces the same inbound transfers as attachment chips with an explicit download step (CLI-SPEC § renderChat).

ratchet

_doRatchetStep is the initiator side: one performRatchetStep per known peer into a payloads map, one commitRatchetStep (LIB-SPEC § ratchet step), then a single ratchet_step frame carrying the map, the new ratchetEk, a fresh claim, and a sealed sentinel message at the new epoch. The sentinel exists so receivers can verify the new chain works; it is never surfaced as a message event. Triggers: the auto-ratchet counter in sendMessage and sendFile, the rotate() command, and the welcome ratchet on reaching ready.

_onRatchetStepFwd is the receiver side: accept the claim, apply receiveRatchetStep, cache the sender's new key, broadcast its own ek_update (with a fresh claim) because its ratchet keypair rotated, then verify and open the sentinel to advance the chain. ek_update_fwd from other receivers updates the cached key the next step toward them will use.

reconnect

A mid-session socket close emits connection-lost, drops in-flight inbound transfers, disposes the lib session, and schedules a reconnect with exponential backoff: 1s doubling to a 30s ceiling, reset on success. Each attempt re-sends join for the saved room; _onJoined then rebuilds keys from scratch, peers see the return as peer-known, and connection-restored reports the downtime. A close during the initial connect (before waiting/ready) is fatal (unreachable) instead of retried.

lobby rekey

When the last peer leaves a ready session, the client does not idle on old keys: _doLobbyTransition disposes the lib session, builds a new one, and sends rekey with the new ek, ratchetEk, and claim. The phase flips to waiting only when the server's rekeyed ack arrives, so the next joiner always handshakes against the post-transition identity. The local fingerprint visibly changes.

errors and teardown

A server error frame and a failed server-version check share one path: emit fatal with the reason code and tear down. _teardown disposes inbound transfers, cancels any reconnect timer, nulls the socket handlers before closing (so an intentional close never schedules a reconnect), disposes the lib session (which wipes key material), and resets every field to the idle state.


bridge

wireBridge(session) in bridge.ts is the only writer to the store from protocol code, and views never call the session's internals; the bridge is the seam. It subscribes to every session event and returns one unsubscribe that tears down all listeners.

Mappings are mechanical: phase becomes the matching GOTO_*; peer-joined/peer-known/peer-left become PEER_ADDED/PEER_REMOVED plus a system line and a log entry; message, file, and ratchet append chat items; wire and log feed EVENT_LOGGED; connection-lost and connection-restored produce system lines with the outage duration.

fatal resets the store and lands on GOTO_LANDING with an error string from the friendly map, which translates raw reason codes (the server's error reasons plus local codes like invite_invalid, wasm_init_fail, invalid_server, and unreachable) into user-facing sentences. The session emits codes; the bridge owns the words.

wireSummary.ts builds the event-log payloads. Summaries are RichText token arrays, so user-controlled fields render as text, never HTML, and redact() collapses every cryptographic blob to its decoded length plus an 8-char prefix; full key material never enters the log.


views

Every view file exports a mount*(host, ...) function that builds its DOM, subscribes to the store, and returns a cleanup function. The shell owns when each runs.

shell

mountShell mounts the header nav once, then routes on every store change. The shell's view name is distinct from screen.name because of one latch: everReady is set the first time a screen reaches ready and cleared on landing. A waiting screen renders the waiting view before the first handshake but stays inside chat afterward, so a room that empties out keeps its transcript and shows the lobby invite in the chat bar instead of unmounting everything.

function pickView(s: AppState): View {
	switch (s.screen.name) {
	case 'landing': return 'landing';
	case 'joining': return 'joining';
	case 'waiting': return everReady ? 'chat' : 'waiting';
	case 'ready':   return 'chat';
	}
}

A view remounts when the view name changes, or when the screen object changes under a non-chat view (landing's error/prefill, waiting's room are captured at mount time). Chat never remounts on screen changes; it manages its own bar variants internally.

landing and join

Landing hosts three sub-screens: the main username form, the create form (username, server, optional admin token), and the join form. A module-scoped PendingForm survives unmount cycles, so a fatal bounce back to landing restores the same sub-screen with the user's entries intact, plus the error line. The create form's server field defaults to import.meta.env.VITE_DEFAULT_SERVER ?? location.host; the env var is set only by the bun dev launcher to point the Vite-served SPA at the separate dev relay, and production builds fall back to same-origin.

join.ts owns invite intake: a paste textarea, a drop zone, and a hidden file picker. parseArmoredInvite (LIB-SPEC § invites) runs on submit and parse errors render inline; nothing leaves the view until the invite parses. The CLI gathers the same inputs across three separate views; see CLI-SPEC § views.

waiting

The creator's lobby before the first peer: the armored invite in a <pre> with copy (navigator.clipboard.writeText) and download buttons, the QR render of the same invite, and the crypto table mapped straight from CRYPTO_TABLE (LIB-SPEC § protocol manifest), so the web table and the CLI lobby table (CLI-SPEC § renderWaiting) cannot disagree. An invite too large for QR version 10 simply omits the QR pane. Cancel disposes the session and returns to landing with the username carried forward.

chat

The transcript plus one bar slot holding one of three variants:

  • regular bar: the textarea (Enter sends, Shift+Enter newlines, fixed height with internal scroll), attach and send buttons. The node is retained when other bars swap in, so an unsent draft survives.
  • lobby bar: shown when the room is empty post-handshake; the invite with copy/download, mirroring the waiting view.
  • keys modal: the web counterpart of the CLI keys bar (CLI-SPEC § keys-display). Keyboard-only: r ratchets, e and v toggle the sidebar sections, Escape returns to the input.

Slash commands typed into the regular bar: /exit (aliases /quit, /q, /part) leaves the room, /ratchet rotates keys, /events and /verify toggle the sidebar sections, /help (alias /?) prints the list. Unknown commands get a system line instead of being sent as messages. The command set is kept in lockstep with the CLI's (CLI-SPEC § renderChat).

Messages are delta-appended (only new ChatItems render; history never reflows), scroll stays anchored to the bottom, and system lines respect the hideSystem toggle via a CSS class. On mount the view replays the store's transcript, which is what makes the shell's remount strategy safe.

File drag-drop is document-level during an active session: the overlay appears on dragenter anywhere on the page and the drop calls session.sendFile. The overlay is CSS only.

sidebar, event log, verify

sidebar.ts is the host pane: pointer-captured drag resize, +/- keyboard resize in 5% steps clamped to 10-70%, double-click reset to 30%, Escape closes and returns focus to the chat input. Width lives in the store, so it survives remounts.

event-log.ts renders each EventLogEntry as a collapsed button row that expands to its details dict on click, delta-appends new rows, evicts rows that age out of the 500 cap, and autoscrolls while visible.

verify.ts renders the local fingerprint and one row per peer: the eight swatches and the 16-hex string from FingerprintSurface (LIB-SPEC § fingerprints), for out-of-band comparison against what the peer reads aloud. Pane for pane, this sidebar trio mirrors the CLI's Sidebar widget (CLI-SPEC § Sidebar).

header-nav.ts keeps the three header buttons in sync: the fingerprint button (badge-colored from the local fingerprint) toggles the verify pane and exists in waiting/ready, while the event-log and system-toggle buttons appear only while chat is mounted.


rendering

There is no HTML parsing anywhere in the message path. parseMarkup (from @covcom/lib, specified in LIB-SPEC § markup) produces a token tree, and rich.ts walks it with createElement and textContent:

  • renderDoc(target, doc) renders message bodies: pre blocks become <pre> elements, paragraphs become inline span runs.
  • renderRich(target, rich) renders the inline-only RichText used by system messages and event-log summaries.
  • every text token passes through stripFormatChars (LIB-SPEC § sanitize) on the way in, killing bidi and zero-width display spoofing at the render boundary.

The CLI walks the same token tree into SGR sequences instead; see CLI-SPEC § ScrollView.

safehtml.ts is the single innerHTML sink in the client, gated by a branded SafeHtml type that only trustedHtml() can mint. The only callers are the bundled SVG icons in icons.ts; user-influenced strings cannot reach it by construction, and test/safehtml.test.ts plus lint keep it that way.

File items render as cards (name, formatBytes size, MIME) whose download button creates an object URL from the decrypted Blob and revokes it after the click. QR rendering (qr.ts) collapses the qrMatrix (LIB-SPEC § qr) modules into a single SVG <path>, fixed black-on-white for scannability in either theme.


connection and versioning

One rule builds every socket URL:

function wsUrl(server: string): string {
	const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
	const host = server.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/+$/, '');
	return `${scheme}://${host}/ws`;
}

The scheme follows the page's security context, not the target: an HTTPS page must speak wss (mixed-content rules and the CSP both forbid ws), a plain HTTP page speaks ws. Pasted scheme prefixes and trailing slashes are tolerated and stripped. Create connects to the form's server value; join connects to the invite's embedded dns, falling back to 127.0.0.1:1337.

Both create and join carry PROTOCOL_VERSION. The reverse check runs on room_created and joined: a serverVersion that is absent (a server predating negotiation cannot reject a newer client, so the client must catch the skew) or mismatched logs the numbers to the console and follows the standard fatal path with version_mismatch. The on-screen string stays generic.


single-file build

vite.config.ts composes three plugins:

  • viteSingleFile() inlines the bundle, styles, and embedded WASM into one dist/index.html. No chunks, no external assets.
  • csp(), build-only and enforce: 'post', so it sees the HTML after the bundle is inlined. It SHA-256-hashes every inline script and injects a CSP <meta>:
default-src 'none';
script-src 'wasm-unsafe-eval' 'sha256-...';
style-src 'unsafe-inline';
connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*;
img-src 'self' data: blob:;
font-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'

'wasm-unsafe-eval' permits WebAssembly.compile/instantiate but not eval; all crypto runs as WASM on the main thread, which is why no worker-src exists and why file transfer must not grow a pool worker. connect-src covers the three deployment shapes: 'self' for the same-origin container, wss: for a decoupled relay, and the localhost ws: entries for plaintext self-host. frame-ancestors is deliberately absent because a <meta> CSP ignores it; Caddy enforces X-Frame-Options: DENY as a header instead. The dev server is exempt (Vite injects its own HMR scripts).

  • generator() injects <meta name="generator" content="COVCOM v{VERSION} (protocol {PROTOCOL_HEX})"> so any served page is inspectable.

The config imports src/version.ts at load time, which is why codegen must run before any build (web/build.ts regenerates it, then spawns vite build as a child process, never through the JS API). The build transpiles without typechecking; bunx tsc --noEmit -p web/tsconfig.json is the separate gate.


key hygiene

  • the beforeunload handler in main.ts runs session.dispose(), unwires the bridge, and resets the store on tab close, reload, or navigation.
  • _teardown disposes the lib Session (which wipes keys per LIB-SPEC § key hygiene), rolls back every in-flight file-key checkout, and clears the room secret reference.
  • every connect builds fresh keys; a reconnect is a new identity wearing the same username, which is why peers re-verify fingerprints (peer-known) after a drop.
  • sendFile wipes the file key in its finally; failed inbound transfers roll back their key checkout.
  • nothing touches localStorage, sessionStorage, IndexedDB, or cookies. The transcript and event log live in the store and die with the tab or the RESET action.

The CLI funnels every exit through a single teardown to the same end; see CLI-SPEC § exit and teardown.


testing

Three layers, all run through the workspace scripts (see TESTING for the full architecture):

  • unit (bun test:web): bun tests with happy-dom registered as the DOM globals. session.test.ts drives CovcomSession against a scripted mock WebSocket (mock-ws.ts); store, bridge, emitter, views, rich, safehtml, markup-render, qr, util, and wireSummary each pin their module.
  • e2e (test/e2e/): Playwright against a real relay and live crypto. two-party-chat.e2e.ts (create, join, message both ways, fingerprint cross-check), commands.e2e.ts (the slash commands), file-sizes.e2e.ts and file-stress.e2e.ts (transfer sizes and back-to-back load), csp-file-transfer.e2e.ts (transfers under the production CSP), with shared helpers.ts and timing instrumentation.
  • cross-client (test/cross/, bun run test:cross): the web client and the compiled CLI binary interoperating in one room, the CLI driven over a PTY.

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
SERVER-SPEC Server wire contract, message handlers, room lifecycle, and configuration
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