/** * T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E). * * The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the * passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never * changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3) * authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained. * * Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol` * subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query * string (which would leak the bearer credential into proxy/access logs, history, and `Referer`). * After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the * socket down otherwise (echo rule). */ import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts' import type { RelayWebConfig } from './config' import { encodeDpopSubprotocol } from './dpop' /** The seam every transport implements — plaintext in the browser, encoded by the impl. */ export interface TerminalTransport { open(): Promise send(bytes: Uint8Array): void onMessage(cb: (bytes: Uint8Array) => void): void onClose(cb: (reason: string) => void): void close(): void } /** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */ export interface WebSocketLike { binaryType: string protocol: string send(data: ArrayBufferView | ArrayBufferLike | string): void close(code?: number, reason?: string): void onopen: ((ev: unknown) => void) | null onmessage: ((ev: { data: unknown }) => void) | null onclose: ((ev: { code?: number; reason?: string }) => void) | null onerror: ((ev: unknown) => void) | null } export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike export interface PassthroughOpts { /** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */ readonly capabilityToken?: string /** * Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers * can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it * rides an extra `term.dpop.` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The * current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake * is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated). */ readonly dpopProof?: string /** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */ readonly wsCtor?: WebSocketCtor } /** Map a WS close event to a stable reason string the UI can render (no silent swallow). */ function closeReason(ev: { code?: number; reason?: string }): string { if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror if (ev.code === 4401) return 'unauthenticated' if (ev.reason && ev.reason.length > 0) return ev.reason return 'closed' } /** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */ function toBytes(data: unknown): Uint8Array { if (data instanceof Uint8Array) return data if (data instanceof ArrayBuffer) return new Uint8Array(data) if (ArrayBuffer.isView(data)) { const view = data as ArrayBufferView return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) } if (typeof data === 'string') return new TextEncoder().encode(data) return new Uint8Array(0) } export function createPassthroughTransport( cfg: RelayWebConfig, opts: PassthroughOpts = {}, ): TerminalTransport { const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor) const token = opts.capabilityToken const dpopProof = opts.dpopProof const hasBearer = token !== undefined || dpopProof !== undefined const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §) let ws: WebSocketLike | null = null let messageCb: ((bytes: Uint8Array) => void) | null = null let closeCb: ((reason: string) => void) | null = null // App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a // bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer). const protocols: readonly string[] = [ APP_SUBPROTOCOL, ...(token === undefined ? [] : [encodeTokenSubprotocol(token)]), ...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]), ] function open(): Promise { return new Promise((resolve, reject) => { const socket = new Ctor(url, protocols) socket.binaryType = 'arraybuffer' ws = socket socket.onopen = () => { // Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app // subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is // rejected (bearer-leak guard). if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) { socket.close(4400, 'bad-subprotocol') reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`)) return } resolve() } socket.onmessage = (ev) => { if (messageCb) messageCb(toBytes(ev.data)) } socket.onclose = (ev) => { if (closeCb) closeCb(closeReason(ev)) } socket.onerror = () => { // Surface as a close so callers never hang; open() rejection is handled by onclose too. reject(new Error('websocket error')) } }) } return { open, send(bytes: Uint8Array): void { if (!ws) throw new Error('transport not open') ws.send(bytes) }, onMessage(cb: (bytes: Uint8Array) => void): void { messageCb = cb }, onClose(cb: (reason: string) => void): void { closeCb = cb }, close(): void { if (ws) ws.close() }, } }