/** * Browser-facing WSS server for `main.ts`. Terminates HTTPS on the tenant origin and, on WS * upgrade, builds the P1 `UpgradeRequest` from the raw request headers and hands it to the REAL * relay-node (→ real `authorizeUpgrade` → P5 `onUpgrade`: Origin/CSWSH + capability verify + DPoP). * The handshake echoes ONLY `APP_SUBPROTOCOL` — never the token entry (INV15). */ import { readFileSync } from 'node:fs' import { createServer, type Server } from 'node:https' import type { IncomingMessage, ServerResponse } from 'node:http' import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws' import { APP_SUBPROTOCOL } from 'relay-contracts' import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js' import type { RelayNode } from 'term-relay/data-plane/relay-node.js' import { wsToWebSocketLike } from '../wiring/socket-pipe.js' import { serveStatic } from './static-web.js' import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js' export interface BrowserServerOptions { readonly certPath: string readonly keyPath: string readonly bindHost: string readonly bindPort: number readonly node: RelayNode readonly landingHtml: string /** * When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME * ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the * Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event). */ readonly staticRoot?: string /** * Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP * request. Return `true` to claim the request (the hook owns the response — it may finish it * asynchronously); return `false` to fall through to the static/landing behavior below. Default * (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the * `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS. */ readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean readonly onListening?: () => void readonly onError?: (e: unknown) => void } function parseCookies(header: string | undefined): Record { const out: Record = {} if (!header) return out for (const part of header.split(';')) { const idx = part.indexOf('=') if (idx === -1) continue out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim() } return out } /** * Build the P1 `UpgradeRequest` from the raw upgrade request. * * DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it), * but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra * `term.dpop.` subprotocol entry. The header WINS when both are present; otherwise the proof * falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here: * the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from * client-supplied claims — so both transports feed the SAME downstream DPoP binding. * * `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2). */ export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest { const proto = req.headers['sec-websocket-protocol'] const subprotocols = (typeof proto === 'string' ? proto.split(',') : []) .map((v) => v.trim()) .filter((v) => v.length > 0) const dpopHeader = req.headers['dpop'] const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols) return { host: req.headers.host ?? '', origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined, url: req.url ?? '/', subprotocols, cookies: parseCookies(req.headers.cookie), remoteAddr: req.socket.remoteAddress ?? '', dpop: { proof, publicKeyThumbprint: null }, activeSessionCount, } } export function startBrowserServer(opts: BrowserServerOptions): Server { const server = createServer( { cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) }, (req: IncomingMessage, res: ServerResponse) => { // Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and // must NOT fall through to static/landing (which would double-write the response). if (opts.onRequest !== undefined && opts.onRequest(req, res)) return // Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS. // Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event). if (opts.staticRoot !== undefined) { const file = serveStatic(opts.staticRoot, req.url ?? '/') if (file) { res.writeHead(file.status, file.headers) res.end(file.body) } else { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) res.end('Not Found') } return } // Phase-0 fallback: a single landing page. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) res.end(opts.landingHtml) }, ) const wss = new WebSocketServer({ server, handleProtocols: (protocols: Set) => protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false, }) // F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent // splice index PRIVATE (no public per-host stream count), so we approximate the account's "active // sessions" with the number of browser WS connections currently open to the SAME tenant origin // (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live // connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This // over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap). const liveByTenant = new Map() wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => { const tenantKey = req.headers.host ?? '' const priorCount = liveByTenant.get(tenantKey) ?? 0 liveByTenant.set(tenantKey, priorCount + 1) ws.once('close', () => { const next = (liveByTenant.get(tenantKey) ?? 1) - 1 if (next <= 0) liveByTenant.delete(tenantKey) else liveByTenant.set(tenantKey, next) }) opts.node .handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws)) .catch((e) => opts.onError?.(e)) }) server.on('error', (e) => opts.onError?.(e)) server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.()) return server }