UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

569 lines • 26.7 kB
"use strict"; /** * hosting/webSocketConversation — one upgraded socket, presented as a * {@link HostConversation}. * * This is the whole adapter side of the conversation port: it takes the * `'upgrade'` event a `node:http` server hands it, answers the handshake, and * turns the frames flowing over the socket into the six-member port a handler * sees. Everything protocol-shaped lives in `webSocketFrames.ts`; everything * port-shaped lives in `types.ts`; this file is the join. * * ── The laws it keeps, all of them pinned by tests ─────────────────────────── * - **A handshake the door guard refuses never gets a 101.** A WebSocket is * not bound by the same-origin policy and its browser API cannot set a * header, so there is no preflight to force: the host's guard judges Host, * Origin and Fetch metadata on the handshake itself, and the session id the * dialect read is bounded before the channel opens. * - **A path this door does not own is not touched.** `node:http` calls EVERY * `'upgrade'` listener for every upgrade, exactly as it calls every * `'request'` listener — so a caller's own protocol lives beside this one on * the same socket. Whether an unclaimed path gets an answer is the caller's * business on a server they own, and this door's only on a server it owns. * - **Frames that arrive before the handler subscribes are held, up to a * declared bound.** An `async` handler that awaits before calling `onFrame` * would otherwise lose the far side's opening frame. The bound is in BYTES * and overflow ends the conversation with a stated reason, because an * unbounded queue somebody else fills is a way to kill this process, and an * undeclared ceiling is the exact thing the declared-ceilings rule exists to * forbid. * - **`close()` ends every live conversation before the socket is released.** * Measured, not assumed: an upgraded socket keeps `server.close()` waiting * forever, so a door that let go of its conversations would hang the whole * shutdown. * - **The port's frame is the whole message.** A message the transport * delivered in fragments counts against `maxFrameBytes` in total, so * fragmentation cannot be used to walk around the ceiling. * - **Nothing in a conversation's lifecycle is ever the PROCESS's failure.** * Node calls a socket's listeners from its own stack, so a throw inside one * is uncaught and ends the container. Every listener body here that computes * is wrapped: a surprise costs THIS conversation, with a reason, and the * door keeps carrying everybody else's. * * Pattern: Adapter. Role: outer ring, one transport. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.conversationDoor = void 0; const doorGuard_js_1 = require("./doorGuard.js"); const errors_js_1 = require("./errors.js"); const headers_js_1 = require("./headers.js"); const cookie_js_1 = require("./signin/cookie.js"); const hostSignIn_js_1 = require("./signin/hostSignIn.js"); const identityVerification_js_1 = require("./identityVerification.js"); const webSocketFrames_js_1 = require("./webSocketFrames.js"); /** How long a politely-closed socket has to say goodbye before it is dropped. */ const CLOSE_GRACE_MS = 1_000; function conversationDoor(options) { const live = new Set(); return { get liveCount() { return live.size; }, handleUpgrade(request, socket) { const url = request.url ?? ''; if (url.split('?')[0] !== options.path) return false; // From here the upgrade is ours, and every exit answers it. socket.on('error', () => undefined); if (!options.accepting()) { refuse(socket, 503, `the '${options.hostName}' host is closed and is not taking new conversations`); return true; } const { headers, key: signInKey } = options.signIn === undefined ? { headers: (0, headers_js_1.lowerCasedHeaders)(request.headers), key: undefined } : (0, cookie_js_1.readSignIn)(request.headers, options.signIn.cookieName); // The door guard, before anything else about the handshake is read. A // WebSocket is not bound by the same-origin policy and its browser API // cannot set a header, so there is no preflight to force: the browser's // own Origin (and Sec-Fetch-Site) is the whole defence, and it has to be // judged here — after the 101 the channel is open. const crossSite = options.guard?.check(request); if (crossSite !== undefined) { refuseAtDoor(socket, crossSite, headers, options.onRefusal); return true; } const key = headers['sec-websocket-key']; const upgrade = (headers.upgrade ?? '').toLowerCase(); if (upgrade !== 'websocket' || headers['sec-websocket-version'] !== '13' || !key) { refuse(socket, 400, `the '${options.hostName}' host serves version-13 WebSocket conversations on ` + `${options.path}, and this upgrade did not offer one`); return true; } const facts = { headers, query: new URLSearchParams(url.split('?')[1] ?? ''), }; // A dialect is somebody else's code, and it runs on node's own stack: a // throw here is UNCAUGHT and would end the process — every other // conversation on this door and every request beside them — for one // handshake it could not read. Nothing in a request's lifecycle may ever // be the process's failure, and a handshake is the start of one. let read; try { read = options.readConversation?.(facts) ?? {}; } catch (err) { refuse(socket, 500, `the '${options.hostName}' host could not read this handshake: ${asMessage(err)}`); return true; } // The session-id bound, on whatever the dialect read — a header, a // cookie, a query parameter. Judged before the 101, because after it the // id would be on the conversation and in everything it touches. const unboundedSession = (0, doorGuard_js_1.checkSessionId)(read.sessionId, options.hostName); if (unboundedSession !== undefined) { refuseAtDoor(socket, unboundedSession, { ...headers, ...read.headers }, options.onRefusal); return true; } const admit = () => { socket.write((0, webSocketFrames_js_1.handshakeResponse)(key, read.protocol)); const watched = options.signIn?.identity.signIn; const conversation = openConversation(socket, { hostName: options.hostName, limits: options.limits, ...(read.sessionId !== undefined && { sessionId: read.sessionId }), headers: { ...headers, ...read.headers }, ...(signInKey !== undefined && { signInKey }), ...(signInKey !== undefined && watched !== undefined && { watch: { source: watched, key: signInKey } }), }); live.add(conversation); void conversation.ended.finally(() => live.delete(conversation)); // A handler that throws ends THAT conversation and nothing else: one // caller's bad frame is not an outage for everyone else on this door. void (async () => { try { await options.handler(conversation.port); } catch (err) { conversation.end(`the conversation handler threw: ${asMessage(err)}`); } })(); }; if (options.signIn === undefined) { admit(); return true; } // The handshake is a GET, and a cross-site socket can read replies: it is // verified HERE, before the 101 — the same funnel a request goes through, // so a sign-in, a bearer token and "two credentials" answer exactly as // they do at the request door. const signIn = options.signIn; void (0, hostSignIn_js_1.checkHandshake)(signIn, { ...headers, ...read.headers }, signInKey) .then((refusal) => { if (socket.destroyed || !socket.writable) return; if (refusal !== undefined) { refuseAtDoor(socket, Object.assign(refusal.error, { status: refusal.status }), headers, options.onRefusal); return; } if (!options.accepting()) { refuse(socket, 503, `the '${options.hostName}' host is closed and is not taking new conversations`); return; } admit(); }) .catch((err) => { refuse(socket, 500, `the '${options.hostName}' host could not verify this handshake: ${asMessage(err)}`); }); return true; }, async closeAll(reason) { const ending = [...live]; for (const conversation of ending) conversation.end(reason); // Waited on, not fired and forgotten: an upgraded socket that is still // open keeps `server.close()` waiting forever, so "the door is closed" // has to mean the sockets are actually gone. await Promise.allSettled(ending.map((conversation) => conversation.ended)); }, }; } exports.conversationDoor = conversationDoor; /** The statuses this door answers a pre-101 socket with. */ const REFUSAL_REASON = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 415: 'Unsupported Media Type', 421: 'Misdirected Request', 500: 'Internal Server Error', 503: 'Service Unavailable', }; /** Answer an upgrade we will not carry, in the one language a pre-101 socket speaks. */ function refuse(socket, status, message) { refuseWithText(socket, status, `[hosting] ${message}.`); } function refuseWithText(socket, status, text) { const reason = REFUSAL_REASON[status] ?? 'Bad Request'; socket.end(`HTTP/1.1 ${status} ${reason}\r\n` + `content-type: text/plain\r\n` + `content-length: ${Buffer.byteLength(text)}\r\n` + `connection: close\r\n\r\n${text}`); } /** * Refuse a handshake on the door guard's or the session bound's word — the * error's own status and sentence, which never repeat what the caller sent — * and tell the host's refusal listeners. The listener runs AFTER the answer is * written, and a listener that throws costs nothing but its own record: a * broken log must not turn a refusal into a hung socket. */ function refuseAtDoor(socket, error, headers, onRefusal) { refuseWithText(socket, error.status, error.message); if (onRefusal === undefined) return; try { onRefusal({ door: 'conversation', error, bearerPresent: (0, identityVerification_js_1.bearerToken)(headers) !== undefined, }); } catch { // The refusal is answered; a listener's fault is the listener's. } } /** * One socket, presented as a conversation. * * Written as a closure rather than a class because everything here is one * socket's state and none of it is anybody else's business — there is nothing * to subclass and nothing to inject. */ function openConversation(socket, options) { const { hostName, limits, sessionId } = options; const maxFrameBytes = limits.maxFrameBytes; const maxPendingBytes = limits.maxPendingBytes; const reader = new webSocketFrames_js_1.FrameReader(maxFrameBytes); const frameSubscribers = new Set(); const closeSubscribers = new Set(); /** Frames the far side sent before anybody was listening. Bounded (see below). */ let pending = []; let pendingBytes = 0; /** The message being assembled out of fragments, if any. */ let assembling = []; let assemblingBytes = 0; let assemblingText = false; let ending; let resolveEnded; const ended = new Promise((resolve) => { resolveEnded = resolve; }); function finish(close, wireCode, wireReason) { if (ending !== undefined) return; ending = close; // Saying goodbye is the part that touches the transport, so it is the part // that can fail — and a failure to say goodbye must not stop the handler // being TOLD, or leave `ended` unresolved and every shutdown sharing this // socket waiting on it. try { if (wireCode !== undefined && socket.writable) { socket.write((0, webSocketFrames_js_1.encodeClose)(wireCode, wireReason ?? close.reason)); } // end() flushes what is queued and then sends FIN — the polite half of // the close. The timer is the impolite half, for a peer that never // answers. socket.end(); const grace = setTimeout(() => socket.destroy(), CLOSE_GRACE_MS); if (typeof grace.unref === 'function') grace.unref(); socket.once('close', () => clearTimeout(grace)); } catch { // The socket could not be told. It is going away either way, and the // conversation is over regardless of how politely. socket.destroy(); } const subscribers = [...closeSubscribers]; closeSubscribers.clear(); for (const cb of subscribers) { try { cb(close); } catch { // A close subscriber that throws has nothing left to be told; the // conversation is already over and there is nothing to protect. } } resolveEnded(); } /** * The sign-in re-check (rule 21): a socket admitted by a sign-in hands on no * frame until the sign-in is confirmed live, in arrival order. A sign-in that * ended closes the socket with 1008 and the frame is never delivered; a store * that cannot answer closes it with 1011 — an outage, never a sign-out. * * Frames that arrive while a check runs are COALESCED behind ONE next check * (review idI57 N-13): every frame is still handed on only after a check that * began after it arrived, but a burst costs two store lookups, not one per * frame. The bytes waiting count against `maxPendingBytes`. */ let waiting = []; let checking = false; /** Bytes of frames waiting on the re-check — bounded like frames waiting for a subscriber. */ let gatedBytes = 0; function deliver(frame) { const watch = options.watch; if (watch === undefined) { deliverNow(frame); return; } gatedBytes += Buffer.byteLength(frame, 'utf8'); if (maxPendingBytes !== undefined && gatedBytes > maxPendingBytes) { finish({ by: 'host', reason: `${gatedBytes} bytes were waiting on the sign-in re-check, past the declared ` + `maxPendingBytes of ${maxPendingBytes}`, }, webSocketFrames_js_1.CLOSE_CODE.tooBig); return; } waiting.push(frame); if (!checking) void recheck(watch); } async function recheck(watch) { checking = true; try { while (waiting.length > 0 && ending === undefined) { const batch = waiting; waiting = []; const state = await (0, hostSignIn_js_1.signInStillLive)(watch.source, watch.key); for (const frame of batch) gatedBytes -= Buffer.byteLength(frame, 'utf8'); if (ending !== undefined) return; if (state === 'live') { for (const frame of batch) deliverNow(frame); } else if (state === 'ended') { signInEnded(); } else { finish({ by: 'host', reason: 'the sign-in could not be checked (its store did not answer)' }, webSocketFrames_js_1.CLOSE_CODE.internalError); } } } finally { checking = false; } } function signInEnded() { finish({ by: 'host', reason: 'the sign-in ended' }, webSocketFrames_js_1.CLOSE_CODE.policyViolation); } function deliverNow(frame) { if (ending !== undefined) return; if (frameSubscribers.size === 0) { pendingBytes += Buffer.byteLength(frame, 'utf8'); if (maxPendingBytes !== undefined && pendingBytes > maxPendingBytes) { pending = []; finish({ by: 'host', reason: `${pendingBytes} bytes arrived before onFrame(...) was subscribed, past the ` + `declared maxPendingBytes of ${maxPendingBytes}`, }, webSocketFrames_js_1.CLOSE_CODE.tooBig); return; } pending.push(frame); return; } for (const cb of [...frameSubscribers]) { try { cb(frame); } catch (err) { // Feeding more frames to a consumer whose parser already threw is how a // channel keeps looking healthy while nothing on it is being read. finish({ by: 'host', reason: `a frame subscriber threw: ${asMessage(err)}` }); return; } } } function handleFrame(frame) { if (ending !== undefined) return; if ((0, webSocketFrames_js_1.isControlFrame)(frame.opcode)) { if (frame.opcode === webSocketFrames_js_1.OPCODE.ping) { // Answered because the protocol says so, and because a peer's liveness // check is not the same thing as this port inventing a heartbeat. if (socket.writable) socket.write((0, webSocketFrames_js_1.encodeFrame)(webSocketFrames_js_1.OPCODE.pong, frame.payload)); return; } if (frame.opcode === webSocketFrames_js_1.OPCODE.pong) return; const said = (0, webSocketFrames_js_1.decodeClose)(frame.payload); finish({ by: 'far-side', ...(said.reason !== undefined && { reason: said.reason }) }, said.code === webSocketFrames_js_1.CLOSE_CODE.noStatus ? webSocketFrames_js_1.CLOSE_CODE.normal : said.code, said.reason); return; } if (frame.opcode === webSocketFrames_js_1.OPCODE.binary) { // Refused by name rather than silently stringified: this port carries // text, and binary is a capability nobody has shown evidence for yet. finish({ by: 'host', reason: 'a binary frame arrived, and this port carries text frames only — binary is a ' + 'capability that will be minted when a consumer needs it, not guessed at now', }, webSocketFrames_js_1.CLOSE_CODE.unsupportedData); return; } if (frame.opcode === webSocketFrames_js_1.OPCODE.text) { if (assemblingText) { protocolFailure(new webSocketFrames_js_1.FrameProtocolError(webSocketFrames_js_1.CLOSE_CODE.protocolError, 'a new message started before the previous fragmented one finished')); return; } assemblingText = true; } else if (!assemblingText) { protocolFailure(new webSocketFrames_js_1.FrameProtocolError(webSocketFrames_js_1.CLOSE_CODE.protocolError, 'a continuation frame arrived with no message to continue')); return; } assemblingBytes += frame.payload.length; if (maxFrameBytes !== undefined && assemblingBytes > maxFrameBytes) { // The ceiling is on the MESSAGE, so a peer cannot fragment its way past it. protocolFailure(new webSocketFrames_js_1.FrameProtocolError(webSocketFrames_js_1.CLOSE_CODE.tooBig, `a fragmented message reached ${assemblingBytes} bytes, past the declared ` + `maxFrameBytes of ${maxFrameBytes}`)); return; } assembling.push(frame.payload); if (!frame.fin) return; const whole = assembling.length === 1 ? assembling[0] : Buffer.concat(assembling); assembling = []; assemblingBytes = 0; assemblingText = false; let text; try { text = (0, webSocketFrames_js_1.decodeText)(whole); } catch (err) { protocolFailure(err); return; } deliver(text); } function protocolFailure(err) { const code = err instanceof webSocketFrames_js_1.FrameProtocolError ? err.closeCode : webSocketFrames_js_1.CLOSE_CODE.protocolError; finish({ by: 'far-side', reason: asMessage(err) }, code, asMessage(err)); } /** * One socket's version of the law the request door states: **nothing in a * conversation's lifecycle may ever be the process's failure.** An upgraded * socket is a plain stream and node calls its listeners from its own stack, * so a throw inside one is uncaught — and would end every OTHER conversation * on this door, plus the requests beside them, for one peer's bad frame. * Wrapped, it ends THIS conversation and says why. */ function contained(body) { return (...args) => { try { body(...args); } catch (err) { finish({ by: 'host', reason: `this conversation could not be read: ${asMessage(err)}` }, webSocketFrames_js_1.CLOSE_CODE.internalError); socket.destroy(); } }; } // Chunks here are always BYTES, and that is node's guarantee rather than this // door's assumption: an upgraded socket refuses `setEncoding` outright // (`ERR_HTTP_SOCKET_ENCODING`, "not allowed per RFC7230 Section 3"), so the // co-listener that can turn a REQUEST body into strings cannot do it to a // conversation. Pinned by a test, because the reason this is safe lives in // node and could change there. socket.on('data', contained((chunk) => { if (ending !== undefined) return; let frames; try { frames = reader.push(chunk); } catch (err) { protocolFailure(err); return; } for (const frame of frames) handleFrame(frame); })); socket.on('error', contained((err) => { finish({ by: 'transport', reason: err.message }); socket.destroy(); })); // BOTH halves, and `'end'` is the one that matters. An upgraded socket is // half-open: when the far side vanishes, node delivers `'end'` and then waits // for THIS side to end too — no `'error'`, and no `'close'` until we act. A // door listening only for `'close'` would never fire onClose for a caller // that walked away, and would hold a socket open that keeps every shutdown // sharing this port waiting. Measured, not assumed. socket.on('end', contained(() => { finish({ by: 'transport', reason: 'the connection ended without a close frame' }); })); socket.on('close', contained(() => { // No close frame from either side: the connection went away rather than // ended, and those are different facts to whoever is watching. finish({ by: 'transport', reason: 'the connection closed without a close frame' }); })); // Close on sign-out: told by the source when the sign-in ends, not only // found on the next frame. Feature-detected; unsubscribed when this ends. const watch = options.watch; if (watch?.source.onEnd !== undefined) { const unsubscribe = watch.source.onEnd((ended) => { if (ended === watch.key) signInEnded(); }); void ended.then(unsubscribe); } const port = { ...(sessionId !== undefined && { sessionId }), headers: options.headers, ...(options.signInKey !== undefined && { signInKey: options.signInKey }), send(frame) { if (ending !== undefined) throw new errors_js_1.ConversationClosedError(hostName, sessionId); const bytes = Buffer.byteLength(frame, 'utf8'); if (maxFrameBytes !== undefined && bytes > maxFrameBytes) { throw new errors_js_1.FrameTooLargeError(hostName, bytes, maxFrameBytes); } socket.write((0, webSocketFrames_js_1.encodeText)(frame)); }, onFrame(cb) { frameSubscribers.add(cb); if (pending.length > 0) { const held = pending; pending = []; pendingBytes = 0; for (const frame of held) cb(frame); } return () => { frameSubscribers.delete(cb); }; }, onClose(cb) { // Already over? Say so now. A subscriber that arrives late is asking the // same question as one that arrived early, and "never" is a wrong answer. if (ending !== undefined) { cb(ending); return () => undefined; } closeSubscribers.add(cb); return () => { closeSubscribers.delete(cb); }; }, close(reason) { finish({ by: 'host', ...(reason !== undefined && { reason }) }, webSocketFrames_js_1.CLOSE_CODE.normal, reason); }, }; return { port, ended, end(reason) { finish({ by: 'host', reason }, webSocketFrames_js_1.CLOSE_CODE.goingAway, reason); }, }; } function asMessage(err) { return err instanceof Error ? err.message : String(err); } //# sourceMappingURL=webSocketConversation.js.map