-
Notifications
You must be signed in to change notification settings - Fork 1
WEB SPEC
▄██▀ ▀█ ▄██▀ █▄ ▀██ ██▀ ▄██▀ ▀█ ▄██▀ █▄ █▄ ▄█
▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒ ▒▒ ▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒▒▄▒▒▒
▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒▌ ▒▒ ▐▒▒▒ ▐▒▒▒ ▒▒▌ ▒▒ ▀ ▒▒
▀██▄ ▄█ ▀██▄ █▀ ▀█▄▀ ▀██▄ ▄█ ▀██▄ █▀ ▄██▄ ▄██▄
XChaCha20 · ML-KEM-768 · Ed25519 · BLAKE3 · SPQR · E2EE · ephemeral · N-party
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.
- 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.htmlcarries the script, styles, and embedded WASM, locked down by adefault-src 'none'CSP. No Web Worker, no lazy chunks, no runtime fetch. - protocol state and UI state stay separate. A
CovcomSessionemits 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.
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.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, anddispatchcalls 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 avar(--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.
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.
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.
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(): voidcreate 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.
Create and join converge on _onJoined, the single post-joined handler
(the architectural mirror of the CLI's doConnect). In order:
- 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. - emit
local-fingerprint-changed. - send
identifywith the publicek,ratchetEk, and a signed identity claim built bybuildClaim(LIB-SPEC § claims). - for each member in the
joinedsnapshot:acceptClaim(a rejected claim drops that peer, not the session), wrap a chain seed to theirekand relay it underRELAY_TAG_SEED(LIB-SPEC § chain seed handshake), cache their ratchet key, and emitpeer-joinedorpeer-known. - an empty room goes straight to
waiting. With peers, the session stays injoiningand counts inbound chain seeds; when the last expected seed arrives, it flips toreadyand fires the welcome ratchet (_doRatchetStep). That ratchet fires here and nowhere else.
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.
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.
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 everyACK_INTERVALchunks over a taggedrelay; the sender holds withinWINDOWchunks of the slowest recipient's last ack. -
socket drain. WebSocket has no drain event, so the loop polls
bufferedAmountand 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).
_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.
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.
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.
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.
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.
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.
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 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.
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.
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:
rratchets,eandvtoggle 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.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.
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:preblocks become<pre>elements, paragraphs become inline span runs. -
renderRich(target, rich)renders the inline-onlyRichTextused 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.
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.
vite.config.ts composes three plugins:
-
viteSingleFile()inlines the bundle, styles, and embedded WASM into onedist/index.html. No chunks, no external assets. -
csp(), build-only andenforce: '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.
- the
beforeunloadhandler inmain.tsrunssession.dispose(), unwires the bridge, and resets the store on tab close, reload, or navigation. -
_teardowndisposes the libSession(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. -
sendFilewipes the file key in itsfinally; 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
RESETaction.
The CLI funnels every exit through a single teardown to the same end; see CLI-SPEC § exit and teardown.
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.tsdrivesCovcomSessionagainst a scripted mock WebSocket (mock-ws.ts);store,bridge,emitter,views,rich,safehtml,markup-render,qr,util, andwireSummaryeach 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.tsandfile-stress.e2e.ts(transfer sizes and back-to-back load),csp-file-transfer.e2e.ts(transfers under the production CSP), with sharedhelpers.tsand 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.
| 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 |