diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json index bf7cf3aea..97cd5ff51 100644 --- a/js/packages/truapi-debugger/package.json +++ b/js/packages/truapi-debugger/package.json @@ -24,6 +24,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "typecheck:tests": "tsc -p tsconfig.test.json", + "serve": "bun run src/server.ts", "test": "bun test" }, "devDependencies": { diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts new file mode 100644 index 000000000..595bb4b93 --- /dev/null +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -0,0 +1,1384 @@ +import { expect, test } from "bun:test"; + +import { + encodeWireMessage, + TRUAPI_CODEC_VERSION, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostSignRawRequest, +} from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { WIRE_ENVELOPE_VERSION } from "./ingest.js"; +import { + decodeValuesFromEnv, + hostHeaderAllowed, + isLoopbackDebugHost, + portFromEnv, + startDebugServer, +} from "./server.js"; + +interface TraceFrameView { + direction: string; + frameId: number; + method?: string; + byteLength: number; +} +interface TraceView { + requestId: string; + frames: TraceFrameView[]; +} + +/** base64 of a wire message for `frameId` carrying `value` as its payload. */ +function encodeFrame(requestId: string, frameId: number, value: Uint8Array): string { + const encoded = encodeWireMessage({ requestId, payload: { id: frameId, value } }); + if (encoded.isErr()) throw encoded.error; + return Buffer.from(encoded.value).toString("base64"); +} + +/** + * base64 of a real, decodable sign-raw request wire message. Carries a + * recognizable `dotNsIdentifier` ("alice.dot") in its decoded value so a test + * can prove the value surfaced — this debugger decodes it like any other frame. + */ +function signFrame(requestId: string): string { + const value = VersionedHostSignRawRequest.enc({ + tag: "V1", + value: { + account: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }, + }); + const encoded = encodeWireMessage({ + requestId, + payload: { id: W.SIGNING_SIGN_RAW.request, value }, + }); + if (encoded.isErr()) throw encoded.error; + return Buffer.from(encoded.value).toString("base64"); +} + +/** Open a WS to the server, send one envelope, wait until `/traces` is non-empty. */ +async function streamFrame( + base: string, + port: number, + frame: string, + dir: "in" | "out" = "out", +): Promise { + const ws = new WebSocket(`ws://localhost:${port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir, + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + let traces: TraceView[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (traces.length === 0) await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + return traces; +} + +test("decodes and groups a frame a host streams over the WS", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const encoded = encodeWireMessage({ + requestId: "p:1", + payload: { id: W.SYSTEM_HANDSHAKE.request, value: new Uint8Array([1, 2, 3]) }, + }); + if (encoded.isErr()) throw encoded.error; + const frame = Buffer.from(encoded.value).toString("base64"); + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + + let traces: TraceView[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (traces.length === 0) await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + expect(traces).toHaveLength(1); + expect(traces[0].requestId).toBe("p:1"); + expect(traces[0].frames[0].direction).toBe("out"); + expect(traces[0].frames[0].frameId).toBe(W.SYSTEM_HANDSHAKE.request); + // The method map resolves the wire id to a dotted name for the view. + expect(typeof traces[0].frames[0].method).toBe("string"); + } finally { + server.stop(); + } +}); + +test("the inspector page is served at /", async () => { + const server = startDebugServer({ port: 0 }); + try { + const res = await fetch(`http://localhost:${server.port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("TrUAPI Wire Inspector"); + // The shell fetches the shared fragments, not a bespoke renderer. + expect(html).toContain("/op-list"); + expect(html).toContain("/op?id="); + } finally { + server.stop(); + } +}); + +test("/op-list renders one shared row per op, payload-blind", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + const html = await (await fetch(`${base}/op-list`)).text(); + expect(html).toContain("td-op"); + expect(html).toContain('data-request-id="p:1"'); + // Subscription start, no stop yet: marked live. And never a value. + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("V1"); + } finally { + server.stop(); + } +}); + +test("/op renders the drill-down for one op; unknown id degrades", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await streamFrame(base, server.port, frame); + const ok = await (await fetch(`${base}/op?id=p:1`)).text(); + expect(ok).toContain("td-trace"); + expect(ok).toContain('data-request-id="p:1"'); + const missing = await (await fetch(`${base}/op?id=nope`)).text(); + expect(missing).toContain("not found"); + } finally { + server.stop(); + } +}); + +test("/channels reports the hosts that have dialed in", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await streamFrame(base, server.port, frame); + const data = (await (await fetch(`${base}/channels`)).json()) as { + sockets: number; + channels: { + channelId: string; + firstSeen: number; + lastSeen: number; + frameCount: number; + connected: boolean; + }[]; + }; + const ch = data.channels.find((c) => c.channelId === "myapp.dot"); + expect(ch).toBeDefined(); + expect(ch?.frameCount).toBeGreaterThanOrEqual(1); + expect(ch?.connected).toBe(true); + expect(ch?.firstSeen).toBeLessThanOrEqual(ch?.lastSeen ?? 0); + } finally { + server.stop(); + } +}); + +test("/traces is byte- and value-free even with value decode on", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // A decodable, non-sensitive frame: `connection-status.subscribe` start is + // `V1(void)` = a single 0x00 byte, which the generated table decodes to a + // `{ tag: "V1" }` value - a value that must never appear in `/traces`. + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + const traces = await streamFrame(base, server.port, frame); + expect(traces).toHaveLength(1); + + const raw = await (await fetch(`${base}/traces`)).text(); + // No payload-bearing keys and no decoded content leak into the trace list. + for (const banned of ['"bytes"', '"value"', '"decoded"', '"tag"', "V1"]) { + expect(raw).not.toContain(banned); + } + } finally { + server.stop(); + } +}); + +test("/stats is byte- and value-free even with value decode on", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // The same decodable, non-sensitive frame as the /traces test: its decoded + // value is `{ tag: "V1" }`. The aggregate must report only counts - its + // `bytes` field is a summed byte *length*, never a raw or decoded payload. + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + + const raw = await (await fetch(`${base}/stats`)).text(); + // No decoded content and no raw-payload hex leaks into the aggregate. + for (const banned of ['"value"', '"decoded"', '"tag"', "V1", "0x"]) { + expect(raw).not.toContain(banned); + } + // The aggregate is present, and `bytes` is a summed length (here 1B), a count. + const stats = JSON.parse(raw) as { + ops: number; + frames: number; + bytes: number; + }; + expect(stats.ops).toBe(1); + expect(stats.frames).toBe(1); + expect(stats.bytes).toBe(1); + } finally { + server.stop(); + } +}); + +test("/frame decodes a non-sensitive frame by default; decodeValues:false reports bytes", async () => { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + + // Default (dev-only tool): decode is on, so the drill-down surfaces the value. + const on = startDebugServer({ port: 0 }); + try { + expect(on.decodeValues).toBe(true); + const baseOn = `http://localhost:${on.port}`; + await streamFrame(baseOn, on.port, frame); + const detail = await (await fetch(`${baseOn}/frame?id=p:1&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(detail.value?.tag).toBe("V1"); + } finally { + on.stop(); + } + + // `decodeValues: false` (still supported, for demos/tests): byte length only. + const off = startDebugServer({ port: 0, decodeValues: false }); + try { + expect(off.decodeValues).toBe(false); + const baseOff = `http://localhost:${off.port}`; + await streamFrame(baseOff, off.port, frame); + const detail = await (await fetch(`${baseOff}/frame?id=p:1&i=0`)).json(); + expect(detail.kind).toBe("bytes"); + expect(detail.byteLength).toBe(1); + } finally { + off.stop(); + } +}); + +test("a signing frame decodes like any other; /traces never carries its bytes", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await streamFrame(base, server.port, signFrame("p:sign")); + + // Dev-only tool: no denylist, so the frame decodes and its value surfaces. + const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // The decoded result never carries a "sensitive"/"redacted" marker any more. + expect(detail.sensitive).toBeUndefined(); + + // The payload-blind grouping invariant still holds: /traces never serializes + // the raw or decoded bytes, only the /frame drill-down does. + const raw = await (await fetch(`${base}/traces`)).text(); + expect(raw).not.toContain("deadbeef"); + expect(raw).not.toContain("alice.dot"); + } finally { + server.stop(); + } +}); + +test("/view renders the shared drill-down with decoded values by default", async () => { + // Default (dev-only tool): decode is on, so the drill-down renders each + // frame's value inline — no click-to-decode control. + const server = startDebugServer({ port: 0 }); + try { + const base = `http://localhost:${server.port}`; + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + const html = await (await fetch(`${base}/view`)).text(); + // Shared-renderer markup, not the old table. + expect(html).toContain("td-trace"); + expect(html).toContain("td-frame"); + expect(html).toContain('data-request-id="p:1"'); + // Values render inline; the click-to-decode control is gone. + expect(html).toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).not.toContain("decode payload"); + } finally { + server.stop(); + } +}); + +test("/view is payload-blind when decode is off", async () => { + const off = startDebugServer({ port: 0, decodeValues: false }); + try { + const base = `http://localhost:${off.port}`; + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, off.port, frame); + const html = await (await fetch(`${base}/view`)).text(); + expect(html).toContain('data-request-id="p:1"'); + // No payload column at all, and no decode control. + expect(html).not.toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); + } finally { + off.stop(); + } +}); + +test("/op decodes every frame inline via the real decodeTraceFrames path", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // A real sign-raw request whose decoded value carries "alice.dot". + await streamFrame(base, server.port, signFrame("p:sign")); + + // The op drill-down renders the decoded value inline — proving the + // session → decodeTraceFrames → renderer wiring, not just structural markup. + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=myapp.dot&gen=0`) + ).text(); + expect(html).toContain("td-frame-decoded"); + expect(html).toContain("alice.dot"); + // Inline, not behind a control, and nothing withheld. + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).not.toContain("redacted"); + } finally { + server.stop(); + } +}); + +test("/op refuses to decode a codec-mismatched (untrusted) channel", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // Stream a frame with a wrong wire schema hash: the channel is untrusted. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed")); + }); + ws.send( + JSON.stringify({ + channelId: "drift.dot", + dir: "out", + frame: signFrame("p:sign"), + schema: "0000000000000000", + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=drift.dot&gen=0`) + ).text(); + // Grouped and shown, but no decoded value for the untrusted channel. + expect(html).toContain('data-request-id="p:sign"'); + expect(html).not.toContain("alice.dot"); + expect(html).toContain("payload not shown"); + } finally { + server.stop(); + } +}); + +test("/frame validates its params and 404s an unknown frame", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + expect((await fetch(`${base}/frame`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=notint`)).status).toBe(400); + // Empty `?i=` must 400, not resolve frame 0 (Number("") === 0). + expect((await fetch(`${base}/frame?id=x&i=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=%20`)).status).toBe(400); + // Same coercion on `?gen=`: empty/whitespace/non-int must 400, not resolve + // generation 0 (the oldest recycled op) with a 200. + expect((await fetch(`${base}/frame?id=x&i=0&gen=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=%20`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=notint`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=missing&i=0`)).status).toBe(404); + } finally { + server.stop(); + } +}); + +test("a codec-mismatched host is banner-flagged and its frames refuse to decode", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + // Stream one frame declaring a codec this debugger can't decode against. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ v: 1, codec: 999, channelId: "old.dot", dir: "out", frame }), + ); + // Wait until the frame is grouped (payload-blind grouping still happens). + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + // /channels banners the mismatch. + const channels = await (await fetch(`${base}/channels`)).json(); + expect(channels.codecMismatch).toBe(true); + // Decode is refused (409) for that host's frames — never resolved against the + // wrong contract. + const refused = await fetch(`${base}/frame?id=p:1&i=0&channel=old.dot`); + expect(refused.status).toBe(409); + } finally { + server.stop(); + } +}); + +test("a wrong-schema or unstamped host refuses to decode, but still groups", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + const stream = async (envelope: Record): Promise => { + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + const want = ((await (await fetch(`${base}/traces`)).json()) as unknown[]) + .length; + ws.send(JSON.stringify(envelope)); + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > want) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + }; + // A frame stamping a wire schema this debugger can't decode against (the + // codec number alone is unchanged) must be refused, never resolved against + // the wrong contract - the case a coarse codec check misses. + await stream({ + channelId: "stale.dot", + dir: "out", + frame, + codec: 1, + schema: "deadbeefdeadbeef", + }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=stale.dot`)).status, + ).toBe(409); + // A host that stamps no identity at all is refused too: absent is not trusted. + await stream({ channelId: "bare.dot", dir: "out", frame }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=bare.dot`)).status, + ).toBe(409); + // Payload-blind grouping is unaffected: both ops are recorded regardless. + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + expect(traces.length).toBe(2); + } finally { + server.stop(); + } +}); + +test("isLoopbackDebugHost accepts loopback literals and .localhost subdomains", () => { + expect(isLoopbackDebugHost("127.0.0.1")).toBe(true); + expect(isLoopbackDebugHost("localhost")).toBe(true); + expect(isLoopbackDebugHost("::1")).toBe(true); + // RFC 6761 reserves `.localhost`: it always resolves to loopback and cannot be + // registered, so a sub-hostname under it is loopback too. Real hosts use this - + // dotli serves its host realm from `host.localhost`, and dials the debugger + // from that origin - so rejecting it locks the shipped host out entirely. + expect(isLoopbackDebugHost("host.localhost")).toBe(true); + expect(isLoopbackDebugHost("app.host.localhost")).toBe(true); +}); + +test("isLoopbackDebugHost rejects loopback-looking names under other domains", () => { + // The dangerous direction: a loopback-shaped label under an attacker's domain. + // Reading any of these as loopback would let a rebound page past the + // DNS-rebinding Host guard and the WS Origin gate. + for (const host of [ + "0.0.0.0", + "127.0.0.1.evil.com", + "localhost.evil.com", + // `.localhost` as a *label*, not the TLD - still an attacker domain. + "localhost.com", + "notlocalhost", + "127.0.0.2", + "[::1]", + "example.com", + ]) { + expect(isLoopbackDebugHost(host)).toBe(false); + } +}); + +test("the Host guard classifies RAW header strings, case included", async () => { + // `isLoopbackDebugHost` only ever sees a WHATWG-normalized (lowercased) + // hostname, so asserting `isLoopbackDebugHost("LOCALHOST") === false` encodes a + // belief the system does NOT have: the gate lowercases first, and `Host: + // LOCALHOST` is accepted live. Assert through the gate, with raw headers. + expect(hostHeaderAllowed("LOCALHOST")).toBe(true); + expect(hostHeaderAllowed("LocalHost:9231")).toBe(true); + expect(hostHeaderAllowed("127.0.0.1:9231")).toBe(true); + expect(hostHeaderAllowed("[::1]:9231")).toBe(true); + // Absent/empty Host: a non-browser client, allowed like a missing Origin. + expect(hostHeaderAllowed(null)).toBe(true); + expect(hostHeaderAllowed("")).toBe(true); + // Case does not launder an attacker domain either. + expect(hostHeaderAllowed("EVIL.COM")).toBe(false); + expect(hostHeaderAllowed("LOCALHOST.EVIL.COM")).toBe(false); + + // And live, through the real server, with the raw header on the wire. + const server = startDebugServer({ port: 0 }); + try { + const base = `http://localhost:${server.port}`; + const status = async (host: string): Promise => + (await fetch(`${base}/traces`, { headers: { host } })).status; + expect(await status(`LOCALHOST:${server.port}`)).toBe(200); + expect(await status(`EVIL.LOCALHOST:${server.port}`)).toBe(403); + } finally { + server.stop(); + } +}); + +test("the Host guard is narrower than the Origin gate: *.localhost is not a target", async () => { + // `.localhost` is a legitimate *origin* for a page that dials in (dotli serves + // its host realm from host.localhost), but never a legitimate *target*: this + // server binds 127.0.0.1 and answers for three names only. Accepting + // `Host: x.localhost` would only widen the rebinding surface to a + // wildcard-`*.localhost` zone, for a client that cannot exist. + expect(isLoopbackDebugHost("host.localhost")).toBe(true); + expect(hostHeaderAllowed("host.localhost")).toBe(false); + expect(hostHeaderAllowed("host.localhost:9231")).toBe(false); + const server = startDebugServer({ port: 0 }); + try { + const res = await fetch(`http://localhost:${server.port}/traces`, { + headers: { host: `host.localhost:${server.port}` }, + }); + expect(res.status).toBe(403); + } finally { + server.stop(); + } +}); + +test("an unparseable Host is a 403, not a 500 out of the route dispatcher", async () => { + const server = startDebugServer({ port: 0 }); + try { + // Bun builds `req.url` from the Host header, so an out-of-range port makes + // `new URL(req.url)` throw. The rebinding gate runs first, so the header gets + // the 403 it already earns instead of a 500 plus a stack trace per request. + for (const host of ["localhost:99999", "localhost:notaport", "["]) { + const res = await fetch(`http://localhost:${server.port}/traces`, { + headers: { host }, + }); + expect(res.status).toBe(403); + } + } finally { + server.stop(); + } +}); + +test("/frame rejects out-of-range indices (negative and huge) with 404", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + // Integer but out of range ⇒ 404 (no such frame); non-integer ⇒ 400. + expect((await fetch(`${base}/frame?id=p:1&i=-1`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=99999`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=1.5`)).status).toBe(400); + } finally { + server.stop(); + } +}); + +test("a default server decodes every frame, including formerly-sensitive ones", async () => { + // Dev-only tool: decode is on by default, so a signing frame decodes. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + expect(server.decodeValues).toBe(true); + await streamFrame(base, server.port, signFrame("p:sign")); + const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // No sensitive/redacted machinery: `?reveal=0` is just an unknown param, + // ignored, and the frame still decodes. + const still = await ( + await fetch(`${base}/frame?id=p:sign&i=0&reveal=0`) + ).json(); + expect(still.kind).toBe("decoded"); + } finally { + server.stop(); + } +}); + +test("a page with a non-loopback Host header is refused (DNS-rebinding guard)", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // A rebound evil.com -> 127.0.0.1 page's same-origin fetch still carries its + // own Host; a non-loopback (non-bind) Host must be refused with a 403. + const res = await fetch(`${base}/traces`, { + headers: { host: "evil.com" }, + }); + expect(res.status).toBe(403); + // A loopback Host is fine. + const ok = await fetch(`${base}/traces`, { + headers: { host: `127.0.0.1:${server.port}` }, + }); + expect(ok.status).toBe(200); + } finally { + server.stop(); + } +}); + +test("groups by (channel, requestId) — two hosts minting the same id do not merge", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // Per-transport counters mean both hosts mint requestId "p:1" for different + // ops. They must NOT collapse into one trace. + // Distinct byte lengths so the per-channel drill-down is distinguishable. + const a = encodeFrame("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([1])); + const b = encodeFrame( + "p:1", + W.CHAIN_GET_HEAD_HEADER.request, + new Uint8Array([2, 2, 2]), + ); + const send = async (frame: string, channelId: string) => { + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId, + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + await new Promise((r) => setTimeout(r, 40)); + ws.close(); + }; + await send(a, "hostA.dot"); + await send(b, "hostB.dot"); + + interface Ch { + channelId: string; + requestId: string; + frames: TraceFrameView[]; + } + let traces: Ch[] = []; + for (let i = 0; i < 50; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as Ch[]; + if (traces.length >= 2) break; + await new Promise((r) => setTimeout(r, 20)); + } + // Two separate traces: same requestId, distinct channels, distinct frames. + expect(traces).toHaveLength(2); + const byChannel = new Map(traces.map((t) => [t.channelId, t])); + expect(byChannel.get("hostA.dot")?.requestId).toBe("p:1"); + expect(byChannel.get("hostB.dot")?.requestId).toBe("p:1"); + expect(byChannel.get("hostA.dot")?.frames[0].frameId).toBe( + W.ACCOUNT_GET_ACCOUNT.request, + ); + expect(byChannel.get("hostB.dot")?.frames[0].frameId).toBe( + W.CHAIN_GET_HEAD_HEADER.request, + ); + + // /frame disambiguates by channel: same id "p:1" resolves to the right + // host's frame (distinct byte lengths prove it's not the other channel's). + const detailA = await ( + await fetch(`${base}/frame?id=p:1&i=0&channel=hostA.dot`) + ).json(); + const detailB = await ( + await fetch(`${base}/frame?id=p:1&i=0&channel=hostB.dot`) + ).json(); + expect(detailA.byteLength).toBe(1); + expect(detailB.byteLength).toBe(3); + expect(detailA).not.toEqual(detailB); + } finally { + server.stop(); + } +}); + +/** Open a WS, run `body`, then close it. */ +async function withSocket( + port: number, + body: (ws: WebSocket) => Promise, +): Promise { + const ws = new WebSocket(`ws://localhost:${port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + try { + await body(ws); + } finally { + ws.close(); + } +} + +/** The `/stats` fields these tests assert on. */ +interface StatsShape { + ops: number; + frames: number; + droppedByHost: number; + envelopeRejects: number; + envelopeRejectReasons: Record; + oversizedMessages: number; + abnormalCloses: number; + invalidDroppedFields: number; +} + +/** Poll `/stats` until `done` or the budget runs out; returns the last payload. */ +async function statsUntil( + base: string, + done: (s: StatsShape) => boolean, + query = "", +): Promise { + let stats = {} as StatsShape; + for (let i = 0; i < 100; i++) { + stats = (await (await fetch(`${base}/stats${query}`)).json()) as StatsShape; + if (done(stats)) return stats; + await new Promise((r) => setTimeout(r, 20)); + } + return stats; +} + +test("a matching schema with a mismatched envelope version blocks the CHANNEL-LESS decode path", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // The gap a `!identityConfirmed`-only flag leaves open: this host stamps the + // matching schema hash (so `identityConfirmed`) AND a wrong envelope version + // (so `identityMismatch`). The scoped path always refused it; the unscoped one + // must too, because `codec` is the only signal for a payload-layout drift the + // schema hash is blind to — and the shipped UI itself omits `&channel=` when + // it has no channel. + await withSocket(server.port, async (ws) => { + ws.send( + JSON.stringify({ + v: 2, + codec: 1, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId: "drift.dot", + dir: "out", + frame: signFrame("p:sign"), + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + + // Scoped by channel: refused (this already held). + expect( + (await fetch(`${base}/frame?id=p:sign&i=0&channel=drift.dot`)).status, + ).toBe(409); + // UNSCOPED: must be refused too — the hole. + expect((await fetch(`${base}/frame?id=p:sign&i=0`)).status).toBe(409); + // And the HTML drill-down the default page actually renders must not carry the + // decoded payload either. + const html = await (await fetch(`${base}/op?id=p:sign`)).text(); + expect(html).not.toContain("alice.dot"); + expect(html).toContain("payload not shown"); + // Payload-blind grouping is unaffected. + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + expect(traces.length).toBe(1); + } finally { + server.stop(); + } +}); + +test("a frame larger than the engine's per-trace budget is ingested, not killed by the WS cap", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // 1.5 MiB raw payload: base64 inflates it by 4/3, so a 1 MiB message cap would + // sit BELOW what the producers can legitimately send. Bun does not drop an + // over-cap message, it closes the socket (1006) without ever calling + // `message()`, so the whole stream would die mid-session with every counter + // untouched. + const big = encodeFrame( + "p:big", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array(1536 * 1024).fill(7), + ); + expect(Buffer.from(big, "base64").length).toBeGreaterThan(1024 * 1024); + const traces = await streamFrame(base, server.port, big); + expect(traces).toHaveLength(1); + expect(traces[0].requestId).toBe("p:big"); + const stats = (await (await fetch(`${base}/stats`)).json()) as { + oversizedMessages: number; + abnormalCloses: number; + }; + expect(stats.oversizedMessages).toBe(0); + expect(stats.abnormalCloses).toBe(0); + } finally { + server.stop(); + } +}); + +test("a socket closed for an over-cap message is counted and surfaced on /stats", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // Above MAX_INBOUND_MESSAGE_BYTES (9 MiB): Bun closes with 1006 "Received too + // big message" and never calls `message()`. Without a counter here the loss is + // literally unobservable — /stats byte-for-byte identical before and after. + await withSocket(server.port, async (ws) => { + ws.send("x".repeat(10 * 1024 * 1024)); + await new Promise((r) => setTimeout(r, 200)); + }); + const stats = await statsUntil(base, (s) => s.oversizedMessages === 1); + expect(stats.oversizedMessages).toBe(1); + expect(stats.abnormalCloses).toBe(1); + // Nothing was ingested, and no envelope reject is claimed: the message never + // reached the parser. + expect(stats.envelopeRejects).toBe(0); + expect(stats.frames).toBe(0); + } finally { + server.stop(); + } +}); + +test("envelope-level rejects are counted by reason and surfaced on /stats", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await withSocket(server.port, async (ws) => { + ws.send("{not json"); + ws.send("42"); + ws.send(JSON.stringify({ channelId: "a.dot", dir: "sideways", frame })); + // A renamed field — the shape a wire-envelope drift actually takes. + ws.send(JSON.stringify({ channel_id: "a.dot", dir: "out", frame })); + ws.send(JSON.stringify({ channelId: "a.dot", dir: "out" })); + // `""` collides with the "all channels" sentinel: that host could never be + // selected or decode-scoped, so it is refused at ingest. + ws.send(JSON.stringify({ channelId: "", dir: "out", frame })); + await new Promise((r) => setTimeout(r, 100)); + }); + const stats = await statsUntil(base, (s) => s.envelopeRejects === 6); + expect(stats.envelopeRejects).toBe(6); + expect(stats.envelopeRejectReasons).toEqual({ + "bad-json": 1, + "not-object": 1, + "bad-channel-id": 1, + "empty-channel-id": 1, + "bad-dir": 1, + "bad-frame": 1, + "ingest-threw": 0, + }); + // Six refusals and nothing ingested: /traces and /channels stay empty, which + // without the counters is indistinguishable from "the host never dialed". + expect(((await (await fetch(`${base}/traces`)).json()) as unknown[]).length).toBe(0); + const channels = (await (await fetch(`${base}/channels`)).json()) as { + channels: unknown[]; + }; + expect(channels.channels.length).toBe(0); + } finally { + server.stop(); + } +}); + +test("the inspector page renders the socket count from /channels", async () => { + const server = startDebugServer({ port: 0 }); + try { + const html = await (await fetch(`http://localhost:${server.port}/`)).text(); + // `sockets` was computed and serialized but never rendered: one socket with + // zero ops (a host that is talking and being refused) looked identical to no + // host at all. + expect(html).toContain("data.sockets"); + expect(html).toContain("socket"); + // The summary strip reports link-level loss even with zero ops. + expect(html).toContain("linkLoss"); + } finally { + server.stop(); + } +}); + +test("/channels counts an open socket", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + await withSocket(server.port, async () => { + let sockets = 0; + for (let i = 0; i < 50 && sockets === 0; i++) { + sockets = ( + (await (await fetch(`${base}/channels`)).json()) as { sockets: number } + ).sockets; + if (sockets === 0) await new Promise((r) => setTimeout(r, 20)); + } + expect(sockets).toBe(1); + }); + } finally { + server.stop(); + } +}); + +test("a non-integer `dropped` cannot poison the session's droppedByHost total", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await withSocket(server.port, async (ws) => { + // Raw text, because `JSON.stringify` would already turn Infinity into null: + // `1e999` is VALID JSON that parses to Infinity. Summed, the whole session's + // total becomes Infinity, which `JSON.stringify` emits as `null` and the UI + // renders as "0 dropped" for every channel — the declared + // `droppedByHost: number` contract broken by one envelope. + ws.send( + `{"channelId":"liar.dot","dir":"out","frame":"${frame}",` + + `"schema":"${TRUAPI_WIRE_SCHEMA_HASH}","dropped":1e999}`, + ); + // A real host's honest count, on another channel, must survive it. + ws.send( + JSON.stringify({ + channelId: "honest.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: 5, + }), + ); + // Wrong types are discarded too, and counted. + ws.send( + JSON.stringify({ + channelId: "liar.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: "5", + }), + ); + ws.send( + JSON.stringify({ + channelId: "liar.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: 1.5, + }), + ); + await new Promise((r) => setTimeout(r, 100)); + }); + const stats = await statsUntil(base, (s) => s.invalidDroppedFields === 3); + // A finite integer total, not `null` — and the honest host's 5 is intact. + expect(stats.droppedByHost).toBe(5); + expect(stats.invalidDroppedFields).toBe(3); + const scoped = await statsUntil( + base, + () => true, + "?channel=liar.dot", + ); + expect(scoped.droppedByHost).toBe(0); + // The raw JSON must not carry a `null` where a number is declared. + const raw = await (await fetch(`${base}/stats`)).text(); + expect(raw).not.toContain('"droppedByHost":null'); + } finally { + server.stop(); + } +}); + +test("/view renders a bounded window, not every trace with every payload", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const total = 25; + await withSocket(server.port, async (ws) => { + for (let i = 0; i < total; i++) { + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir: "out", + frame: signFrame(`p:${i}`), + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + } + for (let i = 0; i < 100; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length >= total) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + + const count = (html: string): number => + html.split('data-request-id="').length - 1; + // Unbounded, this endpoint renders every retained frame's decoded value into + // one string — at the engine's own caps that is hundreds of MB and seconds of + // blocked event loop for a single GET. + const first = await (await fetch(`${base}/view`)).text(); + expect(count(first)).toBe(20); + expect(first).toContain(`showing 1-20 of ${total} ops`); + // The window is addressable, so nothing is unreachable. + const rest = await (await fetch(`${base}/view?offset=20`)).text(); + expect(count(rest)).toBe(5); + expect(rest).not.toContain("showing"); + const small = await (await fetch(`${base}/view?limit=2`)).text(); + expect(count(small)).toBe(2); + // A malformed or unbounded window is a 400, never an unbounded render. + for (const q of ["?limit=0", "?limit=101", "?limit=abc", "?offset=-1", "?offset=1.5"]) { + expect((await fetch(`${base}/view${q}`)).status).toBe(400); + } + } finally { + server.stop(); + } +}); + +test("an empty ?channel= means all channels, not a channel named ''", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await streamFrame(base, server.port, signFrame("p:sign")); + // A client building the query with `?? ""` used to pin itself to a channel that + // can never exist, and got a permanent 409 "codec mismatch" that was false. + const detail = await fetch(`${base}/frame?id=p:sign&i=0&channel=`); + expect(detail.status).toBe(200); + expect(JSON.stringify(await detail.json())).toContain("alice.dot"); + const html = await (await fetch(`${base}/op?id=p:sign&channel=&gen=0`)).text(); + expect(html).toContain("alice.dot"); + // The list endpoints agree: empty means unfiltered, not "no such channel". + expect(await (await fetch(`${base}/op-list?channel=`)).text()).toContain( + 'data-request-id="p:sign"', + ); + const stats = (await (await fetch(`${base}/stats?channel=`)).json()) as { + ops: number; + }; + expect(stats.ops).toBe(1); + } finally { + server.stop(); + } +}); + +test("every numeric query param goes through the same canonical-integer parse", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0])); + await streamFrame(base, server.port, frame); + // `?i=` used to bypass this file's own `optionalInt`, so `Number()` coercion + // resolved a real frame for four spellings of "not an integer" while `?gen=` + // correctly 400'd on the same input — split-brain inside one file. + for (const i of ["-0", "0x0", "1e1", "007", "+1", " ", ""]) { + const res = await fetch(`${base}/frame?id=p:1&i=${encodeURIComponent(i)}`); + expect(res.status).toBe(400); + } + // Canonical values still resolve (or 404 out of range), unchanged. + expect((await fetch(`${base}/frame?id=p:1&i=0`)).status).toBe(200); + expect((await fetch(`${base}/frame?id=p:1&i=-1`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=0&gen=0`)).status).toBe(200); + // Same parse on `?gen=` and on `/view`'s window. + expect((await fetch(`${base}/frame?id=p:1&i=0&gen=-0`)).status).toBe(400); + expect((await fetch(`${base}/op?gen=0x0`)).status).toBe(400); + expect((await fetch(`${base}/view?limit=0x2`)).status).toBe(400); + } finally { + server.stop(); + } +}); + +test("the decode kill-switch fails closed on untrimmed env values", () => { + // The switch that stops full payload decode must not be defeated by the exact + // shapes a shell or a .env file produces. + for (const off of ["0", "false", "no", "off", "OFF", "0 ", " false", "false\n", "\tno\t"]) { + expect(decodeValuesFromEnv(off)).toBe(false); + } + // Anything else (including unset) means on: this is a dev tool that decodes. + for (const on of [undefined, "", " ", "1", "true", "yes", "0x0", "falsey"]) { + expect(decodeValuesFromEnv(on)).toBe(true); + } +}); + +test("TRUAPI_DEBUGGER_PORT is validated, not silently clamped or coerced", () => { + expect(portFromEnv("9231")).toBe(9231); + expect(portFromEnv(" 9231 ")).toBe(9231); + expect(portFromEnv(undefined)).toBe(9231); + expect(portFromEnv("")).toBe(9231); + expect(portFromEnv("65535")).toBe(65535); + expect(portFromEnv("1")).toBe(1); + // `Number.isFinite(x) && x > 0` accepted every one of these: 99999 binds a + // DIFFERENT port (the OS truncates to 65535) that no host's debug URL points + // at, and 1.5 crashes the process on port 1. A debugger listening somewhere + // else is indistinguishable from a host that never dialed. + for (const bad of ["99999", "65536", "1.5", "0", "-1", "1e4", "0x10", "abc", "9231x"]) { + expect(portFromEnv(bad)).toBeNull(); + } +}); + +test("a replayed backlog keeps the producer's clock through the real socket", async () => { + // The seam that hid the original bug: the producer stamped `observedAt` and + // ingest honoured it, but the server built its envelope without the field, so + // the fix was invisible through the only mount a host actually dials. Drive it + // end to end rather than unit-testing either half. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = ( + requestId: string, + id: number, + dir: "in" | "out", + observedAt: number, + ): string => { + const encoded = encodeWireMessage({ + requestId, + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + observedAt, + buffered: true, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + // A 900ms round trip, replayed long after the fact in one burst. + const origin = 1_700_000_000_000; + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.request, "out", origin)); + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.response, "in", origin + 900)); + + let traces: { requestId: string; startedAt: number; lastAt: number }[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + traces = (await (await fetch(`${base}/traces`)).json()) as typeof traces; + } + ws.close(); + + const op = traces.find((t) => t.requestId === "p:1"); + expect(op).toBeDefined(); + // The producer's own span, not the 0ms a flush-instant clock would report. + expect((op?.lastAt ?? 0) - (op?.startedAt ?? 0)).toBe(900); + // And the op is anchored to when it really happened, not to now. + expect(op?.startedAt).toBe(origin); + } finally { + server.stop(); + } +}); + +test("a host-terminated subscription stops counting as live on /stats", async () => { + // `interrupt` ends a subscription just as `stop` does — a chain switch or a + // revoked permission is ordinary lifecycle, not an anomaly. Testing only for + // `stop` left every such subscription "live" forever, so the tile climbed all + // session while the op list beside it showed nothing live. The two mounts + // disagreed because each had its own aggregation; both now share one. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = (id: number, dir: "in" | "out"): string => { + const encoded = encodeWireMessage({ + requestId: "p:1", + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, "out")); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.interrupt, "in")); + + let stats = { subscriptions: 0, liveSubscriptions: -1 }; + for (let i = 0; i < 50 && stats.subscriptions === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + stats = (await (await fetch(`${base}/stats`)).json()) as typeof stats; + } + ws.close(); + + expect(stats.subscriptions).toBe(1); + expect(stats.liveSubscriptions).toBe(0); + } finally { + server.stop(); + } +}); + +test("isLoopbackDebugHost refuses the product sandbox realm", () => { + // `*.app.localhost` is a PRODUCT sandbox realm: an embedding host serves + // untrusted product code from it. A page there must not be able to dial the + // debugger and inject frames or drive the decoder. Host realms under + // `.localhost` stay allowed. + expect(isLoopbackDebugHost("demo.app.localhost")).toBe(false); + expect(isLoopbackDebugHost("app.localhost")).toBe(false); + expect(isLoopbackDebugHost("host.localhost")).toBe(true); + expect(isLoopbackDebugHost("app.host.localhost")).toBe(true); +}); + +test("an unattested frame is refused even on a channel that later attests", async () => { + // The per-frame stamp, in the direction the channel gate cannot cover: frame A + // arrives with no identity, frame B on the SAME channel attests correctly. As a + // per-channel latch, B retroactively unlocked A. + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await withSocket(server.port, async (ws) => { + // Unattested first. + ws.send( + JSON.stringify({ + channelId: "latch.dot", + dir: "out", + frame: signFrame("p:a"), + }), + ); + // Then a correctly attested frame on the same channel. + ws.send( + JSON.stringify({ + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId: "latch.dot", + dir: "out", + frame: signFrame("p:b"), + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length >= 2) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + // The attested frame decodes. + const okRes = await fetch(`${base}/frame?id=p:b&i=0&channel=latch.dot`); + expect(okRes.status).toBe(200); + expect(await okRes.text()).toContain("decoded"); + // The unattested one must NOT, despite sharing the channel. + const badRes = await fetch(`${base}/frame?id=p:a&i=0&channel=latch.dot`); + const badBody = await badRes.text(); + expect(badBody).not.toContain('"kind":"decoded"'); + } finally { + server.stop(); + } +}); + +test("a matching schema with a wrong envelope version is refused", async () => { + // A host stamping the right schema hash with a wrong `v` is "confirmed AND + // mismatched", and must not decode. + // + // What this pins is the OBSERVABLE refusal, not which gate produced it: at the + // HTTP surface `decodeTrusted` (the per-channel gate) also refuses this input, + // so the per-frame stamp's own contribution cannot be isolated here. Mutating + // the stamp to the schema match alone leaves this test green. The per-frame + // gate is pinned instead by the latch test above and by decode.test.ts. + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await withSocket(server.port, async (ws) => { + // Right schema, wrong envelope version. + ws.send( + JSON.stringify({ + v: WIRE_ENVELOPE_VERSION + 5, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId: "half.dot", + dir: "out", + frame: signFrame("p:h"), + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + // Not decoded: the schema matched but the envelope version did not. + // + // Pin the STATUS too. The response here is a 409 refusal body, not a served + // frame, so `not.toContain("decoded")` alone would also be satisfied by a 404 + // or a 500 - i.e. by the endpoint being broken rather than by it refusing. + const res = await fetch(`${base}/frame?id=p:h&i=0&channel=half.dot`); + expect(res.status).toBe(409); + const body = await res.text(); + expect(body).not.toContain('"kind":"decoded"'); + expect(body).toContain("decode refused"); + } finally { + server.stop(); + } +}); diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts new file mode 100644 index 000000000..75c1ce87a --- /dev/null +++ b/js/packages/truapi-debugger/src/server.ts @@ -0,0 +1,1500 @@ +/** + * The runnable debugger app: the WS server a host dials into, plus a minimal + * trace view. + * + * A host's outward WS dial sends one text message per frame - + * `{ channelId, dir, frame }`, where `frame` is the base64 of the raw SCALE + * `ProtocolMessage` bytes (JSON can't carry binary; base64 keeps the envelope on + * one line). Each message is decoded and grouped by {@link createDebugSession}. + * `GET /traces` returns the grouped traces (payload-blind - raw bytes and + * decoded values are never serialized); `GET /op` renders one op's drill-down + * with each frame's decoded value inline; `GET /frame?id=&i=` is the same + * decode as a programmatic JSON endpoint. Value decode is on by default (a + * dev-only tool decodes everything); `GET /` serves a page that polls `/op-list`. + * + * The exact host↔debugger framing is not yet standardized (envelope spec, track + * T3); base64-in-JSON is what this server accepts today. Runs under Bun + * (`bun run src/server.ts`). + * + * @module + */ + +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; +import { + computeTraceStats, + createDebugSession, + decodeTraceFrames, +} from "./session.js"; +import { + normalizeId, + WIRE_ENVELOPE_VERSION, + type DebugFrameEnvelope, +} from "./ingest.js"; +import { wireTraceToView, type TraceView } from "./trace-view.js"; +import { renderOperationRow, renderTraceDetail } from "./trace-render.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { INSPECTOR_LAYOUT_CSS, INSPECTOR_SHELL_CSS } from "./inspector-styles.js"; +import { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** Default port the debugger listens on; a host points its debug URL here. */ +const DEFAULT_PORT = 9231; + +/** Ops one `/view` request renders when the caller names no window. */ +const VIEW_DEFAULT_LIMIT = 20; + +/** Largest `?limit=` `/view` will honour, so one request stays bounded. */ +const VIEW_MAX_LIMIT = 100; + +/** + * Cap on one inbound WS message. + * + * Deliberately ABOVE every producer's own per-message ceiling. The native + * `WsDebugSink` budgets its outbound queue at 8 MiB and refuses to enqueue a + * serialized line that alone exceeds it, so 8 MiB is the largest envelope a + * conforming host can send - and base64 inflates a frame by 4/3, so a cap set at + * the engine's 1 MiB per-trace budget would sit *below* the producer and make an + * ordinary large payload fatal. Bun does not drop an over-cap message: it CLOSES + * the connection (1006, "Received too big message") without ever invoking + * `message`, so an under-set cap silently kills the host's stream mid-session. + * The cap still bounds memory (well under Bun's 16 MiB default) for anything else + * that reaches the port. + */ +const MAX_INBOUND_MESSAGE_BYTES = 9 * 1024 * 1024; + +/** Frame roles that make an op a subscription rather than a request/response. */ +/** + * The text message a host sends per frame: the envelope with a base64 frame, + * plus the optional identity fields (`v`, `codec`) a versioned host stamps. + */ +interface WireMessage { + channelId: string; + dir: "in" | "out"; + frame: string; + /** + * When the producer *observed* the frame, as opposed to when this server + * received it. A host that buffered a backlog replays it in one burst, so + * without this every op in the flush collapses to a 0 ms span and ops that + * were seconds apart land inside the retry-storm window. + */ + observedAt?: number; + /** `true` when the producer replayed this frame out of its backlog. */ + buffered?: boolean; + /** Envelope version; see {@link WIRE_ENVELOPE_VERSION}. */ + v?: number; + /** The host's wire codec version (`TRUAPI_CODEC_VERSION`). */ + codec?: number; + /** + * The host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a hash of + * every frame id and its method leg. Unlike `codec` (the coarse handshake + * number, bumped ~never), this changes whenever a frame id is reassigned - the + * case where a frame could otherwise decode to the wrong method and value off + * this debugger's table. + */ + schema?: string; + /** Frames this host dropped (link backlog full) before this one; surfaced in stats. */ + dropped?: number; +} + +/** A parsed inbound message: the envelope plus its wire-identity verdict. */ +interface ParsedWireMessage { + envelope: DebugFrameEnvelope; + /** + * `true` when the host stamped a `v`/`codec`/`schema` that does not match this + * debugger's - the API-evolved-underneath case. Blocks the value-decode path. + */ + identityMismatch: boolean; + /** + * `true` only when the host affirmatively stamped a `schema` equal to this + * debugger's. Decode is allowed only for confirmed channels: an absent schema + * (a foreign or pre-identity host) is NOT trusted to decode, closing the + * omit-identity-to-bypass hole. Payload-blind grouping is unaffected. + */ + identityConfirmed: boolean; + /** Frames the host reported dropping before this one. */ + dropped: number; + /** + * `true` when the host sent a `dropped` that is not a finite non-negative + * integer (`Infinity`, a float, a string). The frame is still ingested and the + * bogus count is discarded, but the fact is counted so a host reporting loss in + * a shape this debugger can't sum is visible rather than read as "no loss". + */ + droppedFieldInvalid: boolean; +} + +/** + * Whether a WebSocket upgrade may proceed. Non-browser clients (the CLI, curl) + * send no Origin and are allowed; a browser sends its page Origin, which must be + * a loopback host - a cross-origin page dialing the debugger to inject frames is + * refused (CSWSH), which binding to loopback alone does not prevent. + */ +function originAllowed(origin: string | null): boolean { + if (origin === null) return true; + try { + const host = new URL(origin).hostname; + // `new URL("http://[::1]").hostname` keeps the brackets ("[::1]"), so strip + // them before classifying (a bare "::1" never occurs, but is handled too). + return isLoopbackDebugHost(host === "[::1]" ? "::1" : host); + } catch { + return false; + } +} + +/** + * Parse an optional integer query param: `undefined` if absent, `null` if + * malformed. Requires a CANONICAL decimal integer, so `""`, `" "`, `"1e3"`, + * `"0x10"`, `"1.5"`, `"+1"`, `"007"`, and `"-0"` all reject rather than silently + * coercing (`Number("") === 0`, `Number("0x10") === 16`, and `frames[-0]` is + * `frames[0]`). Every numeric query param on every route goes through this, so + * one spelling of "not an integer" can't 400 on one route and resolve a real + * record on another. + */ +function optionalInt(raw: string | null): number | null | undefined { + if (raw === null) return undefined; + const t = raw.trim(); + // No leading zeros, no signed zero: exactly one spelling per value. + if (!/^(0|-?[1-9]\d*)$/.test(t)) return null; + const n = Number(t); + return Number.isInteger(n) ? n : null; +} + +/** + * Parse an optional `?channel=` param. An empty (or whitespace-only) value means + * ABSENT, not "the channel named `''`": a client that builds the query with + * `?? ""` would otherwise pin itself to a channel that can never exist, and the + * decode gate would answer with a 409 "codec mismatch" that is simply false. + */ +function optionalChannel(raw: string | null): string | null { + if (raw === null) return null; + const t = raw.trim(); + return t === "" ? null : t; +} + +/** + * The three names this server binds and answers for. Note the case: every caller + * passes a hostname already normalized by the WHATWG URL parser, which lowercases + * it, so these literals see `LOCALHOST` as `localhost`. The classifier is never + * handed a raw header. + */ +const LOOPBACK_LITERALS = new Set(["127.0.0.1", "localhost", "::1"]); + +/** + * Whether `host` is a loopback ORIGIN this server accepts a WS upgrade from: the + * loopback literals, plus subdomains of `.localhost`. + * + * The subdomain case is not a fuzzy match: RFC 6761 reserves `.localhost` as a + * special-use TLD that always resolves to loopback and cannot be registered + * publicly, so `host.localhost` is as much loopback as `localhost` is. Real hosts + * use it - dotli serves its host realm from `host.localhost`, and dials the + * debugger from that origin - and without this they can never reach the debugger. + * The dangerous shape this must still reject is the *other* direction, a + * loopback-looking label under an attacker's domain (`127.0.0.1.evil.com`, + * `localhost.evil.com`); those do not end in `.localhost` and stay rejected. + * + * `Host` headers are NOT classified here - see {@link hostHeaderAllowed}. + * The input is a WHATWG-normalized (lowercased) hostname, so this is + * case-sensitive by design. + */ +export function isLoopbackDebugHost(host: string): boolean { + if (LOOPBACK_LITERALS.has(host)) return true; + if (!host.endsWith(".localhost")) return false; + // `*.app.localhost` is a PRODUCT sandbox realm, not a host realm. An embedding + // host serves untrusted product code from it, so a page there must not be able + // to dial the debugger and inject frames or drive the decoder. Everything else + // under `.localhost` is host-owned and stays allowed. + return !host.endsWith(".app.localhost") && host !== "app.localhost"; +} + +/** + * Whether a request's `Host` header targets an address this server is willing to + * answer for: one of the three names it can actually be reached at. + * + * This is the DNS-rebinding guard. Binding to loopback keeps off-box peers out, + * but a page served from `evil.com` whose DNS has been rebound to `127.0.0.1` + * can issue same-origin `fetch`es to the debugger and read decoded frames; those + * requests still carry `Host: evil.com`. Requiring a loopback Host rejects them + * with a 403. A `Host`-less request (a non-browser client that omits it) is + * allowed, matching the WS Origin gate's posture. + * + * Deliberately NARROWER than the Origin gate: `*.localhost` is a legitimate + * *origin* for a page that dials in, but never a legitimate *target* - this + * server binds `127.0.0.1`, and a browser that resolved `x.localhost` to + * loopback still sends `Host: x.localhost`, an address the debugger does not + * serve. Accepting it only widens the rebinding surface (a wildcard-DNS + * `*.localhost` zone under an attacker's control) for no reachable client. + */ +export function hostHeaderAllowed(hostHeader: string | null): boolean { + if (hostHeader === null || hostHeader === "") return true; + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return false; + } + // `new URL("http://[::1]").hostname` keeps the brackets; normalize to bare. + const normalized = hostname === "[::1]" ? "::1" : hostname; + return LOOPBACK_LITERALS.has(normalized); +} + +/** + * Why an inbound WS message was not ingested. Every rejection is counted under + * one of these and surfaced on `/stats`: a silently discarded envelope is + * indistinguishable from a host that never dialed, which is exactly the state a + * debugger must never leave its user guessing about. + */ +export type WireRejectReason = + | "bad-json" + | "not-object" + | "bad-channel-id" + | "empty-channel-id" + | "bad-dir" + | "bad-frame" + | "ingest-threw"; + +/** Every reject reason, so `/stats` always serializes the same key set. */ +const WIRE_REJECT_REASONS: readonly WireRejectReason[] = [ + "bad-json", + "not-object", + "bad-channel-id", + "empty-channel-id", + "bad-dir", + "bad-frame", + "ingest-threw", +]; + +/** One parse attempt: the message, or the reason it was refused. */ +type WireParseResult = + | { ok: true; value: ParsedWireMessage } + | { ok: false; reason: WireRejectReason }; + +/** Parse and validate one inbound WS text message. */ +function parseWireMessage(raw: string): WireParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, reason: "bad-json" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { ok: false, reason: "not-object" }; + } + const m = parsed as Partial; + if (typeof m.channelId !== "string") { + return { ok: false, reason: "bad-channel-id" }; + } + // `""` is the "all channels" sentinel every filtering endpoint reads as absent, + // so a host using it as its own id could never be selected, filtered, or + // decode-scoped. Refuse it at ingest rather than admit an unaddressable host. + if (m.channelId === "") return { ok: false, reason: "empty-channel-id" }; + if (m.dir !== "in" && m.dir !== "out") return { ok: false, reason: "bad-dir" }; + if (typeof m.frame !== "string") return { ok: false, reason: "bad-frame" }; + const schema = typeof m.schema === "string" ? m.schema : undefined; + const identityMismatch = + (typeof m.v === "number" && m.v !== WIRE_ENVELOPE_VERSION) || + (typeof m.codec === "number" && m.codec !== TRUAPI_CODEC_VERSION) || + (schema !== undefined && schema !== TRUAPI_WIRE_SCHEMA_HASH); + // `dropped` feeds a summed `droppedByHost: number`, so anything but a finite + // non-negative integer is not a smaller number - it poisons the whole session's + // total. `1e999` is `Infinity`, which `JSON.stringify` emits as `null` and the + // UI renders as "0 dropped" for every channel; a float or a string would break + // the declared contract just as quietly. + const droppedRaw = m.dropped; + const droppedValid = + droppedRaw === undefined || + // `isSafeInteger`, not `isInteger`: 1e308 is an integer, so it passed, and + // two channels summing to Infinity serialize as JSON `null` - the UI then + // renders "0 dropped" for the whole session with nothing counted as invalid. + (typeof droppedRaw === "number" && + Number.isSafeInteger(droppedRaw) && + droppedRaw >= 0); + return { + ok: true, + value: { + envelope: { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + // Provenance travels with the frame: ingest decides whether to trust + // `observedAt` as the timestamp, and the trace engine suppresses + // retry-storm detection for a replayed backlog. Dropping these here made + // the fix invisible through the only mount a host actually dials. + ...(typeof m.observedAt === "number" ? { observedAt: m.observedAt } : {}), + ...(m.buffered === true ? { buffered: true as const } : {}), + }, + identityMismatch, + identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, + dropped: + droppedValid && typeof droppedRaw === "number" && droppedRaw > 0 + ? droppedRaw + : 0, + droppedFieldInvalid: !droppedValid, + }, + }; +} + +/** A running debugger server. */ +export interface DebugServer { + /** The port the WS/HTTP server is listening on. */ + readonly port: number; + /** Whether level-2 value decode is enabled on the drill-down path. */ + readonly decodeValues: boolean; + /** Stop listening and drop active connections. */ + stop(): void; +} + +/** + * `JSON.stringify` that survives decoded SCALE values: `bigint` becomes a + * decimal string and `Uint8Array` a `0x…` hex string, both of which + * `JSON.stringify` otherwise throws on or renders as an index map. Only the + * drill-down detail path uses this; `/traces` never serializes decoded values. + */ +function safeStringify(value: unknown): string { + return JSON.stringify(value, (_key, val) => { + if (typeof val === "bigint") return val.toString(); + if (val instanceof Uint8Array) { + return `0x${Buffer.from(val).toString("hex")}`; + } + return val; + }); +} + +/** + * Start the debugger app: a Bun WS+HTTP server that decodes and groups every + * frame a host streams to it. `port: 0` binds an ephemeral port, read back from + * {@link DebugServer.port}. + * + * Level-2 value decode is ON unless `decodeValues: false` is passed - this is a + * dev-only tool that decodes everything (the CLI entry point derives the + * off-switch from `TRUAPI_DEBUGGER_DECODE_VALUES`). It affects all three + * drill-down paths, which render or serialize a decoded value: `/op` (the default + * page's detail pane), `/view` (the standalone fragment), and `/frame` (the JSON + * endpoint). The list-level endpoints - `/traces`, `/op-list`, `/stats` - are + * byte- and value-free either way. + */ +export function startDebugServer( + options: { + port?: number; + decodeValues?: boolean; + } = {}, +): DebugServer { + // Dev-only tool: decode everything by default. A caller can pass + // `decodeValues: false`. + const decodeValues = options.decodeValues ?? true; + const session = createDebugSession({ decodeValues }); + + /** Adapt one trace to a view with the shared method map. */ + const toView = ( + trace: ReturnType[number], + storms: ReturnType, + ): TraceView => + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []); + + /** + * Compute the cross-op retry-storm signal once over a trace set, then adapt + * every trace. The `traces() → detectRetryStorms → wireTraceToView` pipeline is + * shared by every list-level endpoint so the same aggregation runs once, not + * per endpoint. + */ + const viewsFor = ( + traces: ReturnType, + ): { trace: (typeof traces)[number]; view: TraceView }[] => { + const storms = detectRetryStorms(traces); + return traces.map((trace) => ({ trace, view: toView(trace, storms) })); + }; + + function tracesJson(): string { + // Payload-blind view: raw `bytes` and decoded values are deliberately never + // serialized here - values surface only in the `/op` and `/frame` drill-downs. + // `method` + // and `role` are public shape metadata derived from the frame id (the same + // id→name map the op list already exposes), not payload, so they are safe. + // Rendering each trace through the shared `wireTraceToView` also gives + // op-level badges (incl. the cross-op retry-storm signal), so the web and + // terminal frontends read one computed signal rather than each recomputing + // (or, for the CLI, silently omitting) it. + const out = viewsFor(session.traceEngine.traces()).map(({ trace: t, view }) => { + return { + channelId: t.channelId, + requestId: t.requestId, + generation: t.generation, + startedAt: t.startedAt, + lastAt: t.lastAt, + badges: view.badges, + frames: view.frames.map((f) => ({ + direction: f.direction, + frameId: f.frameId, + method: f.method, + role: f.role, + byteLength: f.byteLength, + timestamp: f.timestamp, + })), + }; + }); + return JSON.stringify(out); + } + + /** The `/frame?id=&i=[&channel=]` drill-down detail response. */ + function frameResponse(url: URL): Response { + const id = url.searchParams.get("id"); + const channel = optionalChannel(url.searchParams.get("channel")) ?? undefined; + // Both numeric params go through `optionalInt`: `Number("")`/`Number(" ")` are + // 0 and pass `Number.isInteger`, `Number("0x0")` is 0, and `frames[-0]` is + // `frames[0]`, so a bare `Number()` would resolve frame 0 / generation 0 (the + // oldest recycled op) with a 200 for four different spellings of "not a + // number". + const generation = optionalInt(url.searchParams.get("gen")); + const index = optionalInt(url.searchParams.get("i")); + if (id === null || index === null || index === undefined || generation === null) { + return new Response('{"error":"id and integer i required"}', { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + if (!decodeTrusted(channel)) return codecRefusal("application/json"); + const detail = session.frameDetail(id, index, channel, generation); + if (!detail) { + return new Response('{"error":"no such frame"}', { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response(safeStringify(detail), { + headers: { "content-type": "application/json" }, + }); + } + + /** + * The `/view?offset=&limit=` fragment: a WINDOW of traces rendered by the + * shared {@link renderTraceDetail}, the same renderer dotli's panel mounts. + * Each frame's value is decoded inline for a trusted channel; an untrusted + * (codec-mismatched) channel groups but shows no value. + * + * Bounded per request, and `null` for a malformed window (the caller gets a + * 400). This is the only endpoint that renders every retained frame's decoded + * value, so an unbounded response is quadratic in the session's own limits: at + * the engine's caps a single `/view` would build a multi-hundred-MB string in + * memory, block the event loop for seconds, and spike RSS by gigabytes. The + * window keeps one response proportional to what a human reads. + */ + function viewHtml(url: URL): string | null { + // `null` (malformed) must not collapse into the default the way `undefined` + // (absent) does, so the two are separated before either gets a fallback. + const rawOffset = optionalInt(url.searchParams.get("offset")); + const rawLimit = optionalInt(url.searchParams.get("limit")); + if (rawOffset === null || rawLimit === null) return null; + const offset = rawOffset ?? 0; + const limit = rawLimit ?? VIEW_DEFAULT_LIMIT; + if (offset < 0 || limit < 1 || limit > VIEW_MAX_LIMIT) return null; + const traces = session.traceEngine.traces(); + const entries = viewsFor(traces.slice(offset, offset + limit)); + if (entries.length === 0) { + return `
no frames yet
`; + } + const shown = offset + entries.length; + // Say so when the window hides ops, so a truncated read is never mistaken for + // the whole session (the failure mode the `evicted`/`dropped` tiles exist for). + const more = + shown < traces.length + ? `
showing ${offset + 1}-${shown} of ${traces.length} ops — ?offset=${shown} for more
` + : ""; + // Wrap each rendered op in `.td-drilldown` - dotli's verbatim card wrapper - + // so the standalone list gets the same per-op framing without a bespoke rule. + return ( + entries + .map( + ({ view }) => + `
` + + renderTraceDetail(view, { + offerDecode: session.decodeValues, + // Same codec/schema-drift guard the `/frame` endpoint enforces: an + // untrusted channel's frames group but never surface a decoded value. + decoded: decodeTrusted(view.channelId) + ? decodeTraceFrames(session, view) + : undefined, + }) + + `
`, + ) + .join("") + more + ); + } + + // Per-channel liveness for the inspector's host dimension. The envelope + // carries channelId; recording first/last-seen + frame count lets the UI show + // which hosts have dialed in and whether they are still active. Grouping + // traces by channel is a separate engine concern; this is only connection + // state. + // + // `connected` is RECENCY-based, not socket-based: a host counts as connected + // if it emitted a frame within the last CONNECTED_WINDOW_MS. It is NOT "has an + // open WS socket" - one WS can multiplex frames for several channelIds, so + // per-host socket liveness is not a clean fact. A host that goes quiet without + // closing its socket correctly reads as not-connected after the window. + const CONNECTED_WINDOW_MS = 5000; + // Cap the registry so a host (or anything able to reach the port) emitting + // frames under many distinct channelIds can't grow it without bound; when + // full, evict the least-recently-seen channel. + const MAX_CHANNELS = 256; + const channels = new Map< + string, + { + channelId: string; + firstSeen: number; + lastSeen: number; + frameCount: number; + // `false` once this host has sent a frame whose declared wire identity + // (`v`/`codec`/`schema`) does not match this debugger's. Sticky: a single + // mismatch marks the host untrusted for the rest of the session. + codecOk: boolean; + // `true` once this host affirmatively stamped a matching `schema`. Decode + // requires it, so a host that never declares identity is refused, not + // trusted by omission. + schemaOk: boolean; + // Frames the host reported dropping before delivery (its link backlog + // filled): a gap attributable to the link, surfaced so it is not read as + // the host "not answering". + dropped: number; + } + >(); + let openSockets = 0; + // Sticky: any host has sent an unconfirmed (mismatched or unstamped) frame this + // session. The no-channel decode path keys on this rather than scanning the live + // registry, because an untrusted host's channel record can be LRU-evicted (see + // MAX_CHANNELS) while its frames survive in the trace engine. + let sawUntrusted = false; + // Envelopes refused at ingest, by reason. Every reason is pre-seeded so the + // `/stats` key set is fixed and a client can chart a reason that is still zero. + const rejectCounts = new Map( + WIRE_REJECT_REASONS.map((r) => [r, 0]), + ); + // Sockets Bun closed abnormally (code 1006), and the subset it closed because an + // inbound message exceeded MAX_INBOUND_MESSAGE_BYTES. An over-cap message never + // reaches `message()`, so this close is the ONLY place the loss can be counted: + // without it an over-cap host's stream simply stops with every counter untouched. + let abnormalCloses = 0; + let oversizedMessages = 0; + // Frames whose `dropped` field was unusable (see `droppedFieldInvalid`). + let invalidDroppedFields = 0; + + /** Count one refused envelope under its reason. */ + function recordReject(reason: WireRejectReason): void { + rejectCounts.set(reason, (rejectCounts.get(reason) ?? 0) + 1); + } + + function recordChannel(channelId: string, parsed: ParsedWireMessage): void { + // Asymmetry with the in-app mount, recorded deliberately: that one keeps a + // `distrusted` set of channels evicted while carrying a mismatch, so a + // mismatching channel cannot buy back a clean record by being forgotten. This + // mount has no equivalent, so after MAX_CHANNELS distinct ids a mismatched + // channel re-registers as `codecOk: true` and the drift chip clears. + // + // Not ported rather than not noticed. That set is add-only and never pruned - + // unbounded for the life of the process - which a tab can absorb and a + // long-lived server should not. Bounding it without reopening the laundering + // it exists to prevent is a design question, not a copy. Frames retained from + // before the eviction keep their own `identityConfirmed: false` stamp, so this + // does not open a decode path for them; it misreports the channel's state. + // + // A host is untrusted if it did not confirm the schema OR if any declared + // identity field mismatched. Keying only on `identityConfirmed` would let the + // `matching schema + mismatched v/codec` host (confirmed AND mismatched) leave + // this flag false, and with it the whole channel-less decode path open - + // discarding the one signal that sees a payload-layout drift the schema hash + // is blind to. + if (!parsed.identityConfirmed || parsed.identityMismatch) sawUntrusted = true; + if (parsed.droppedFieldInvalid) invalidDroppedFields += 1; + const now = Date.now(); + const key = normalizeId(channelId); + const existing = channels.get(key); + if (existing) { + existing.lastSeen = now; + existing.frameCount += 1; + existing.dropped += parsed.dropped; + if (parsed.identityMismatch) existing.codecOk = false; + if (parsed.identityConfirmed) existing.schemaOk = true; + return; + } + if (channels.size >= MAX_CHANNELS) { + let oldestKey: string | undefined; + let oldestSeen = Infinity; + for (const [k, c] of channels) { + if (c.lastSeen < oldestSeen) { + oldestSeen = c.lastSeen; + oldestKey = k; + } + } + if (oldestKey !== undefined) channels.delete(oldestKey); + } + channels.set(key, { + channelId: key, + firstSeen: now, + lastSeen: now, + frameCount: 1, + codecOk: !parsed.identityMismatch, + schemaOk: parsed.identityConfirmed, + dropped: parsed.dropped, + }); + } + + /** + * Whether a decoded value may be surfaced for a channel's frames. Only bites + * when decode is on (payload-blind mode never decodes anyway). Decode is + * allowed only for a channel that affirmatively stamped a matching wire + * `schema` and never mismatched. + * + * This is a COMPATIBILITY guard against honest version drift - a host built + * against a different frame table, where an id could resolve to the wrong + * method and value off this debugger's table - not authentication: + * `TRUAPI_WIRE_SCHEMA_HASH` is a public build constant, so a deliberate local + * injector could stamp it. The WS Origin gate ({@link originAllowed}) is the + * boundary against injection; this is defence in depth on top of it. + */ + function decodeTrusted(channel: string | undefined): boolean { + if (!decodeValues) return true; + if (channel !== undefined) { + const c = channels.get(normalizeId(channel)); + return c !== undefined && c.codecOk && c.schemaOk; + } + // No channel disambiguator: refuse once any host has been untrusted this + // session (sticky, so an evicted untrusted record can't launder its surviving + // frames). An all-trusted or empty session stays true, so a missing frame + // 404s rather than being masked by a refusal. + return !sawUntrusted; + } + + /** The 409 a decode path returns when the source host's wire codec mismatches. */ + function codecRefusal(contentType: string): Response { + return new Response('{"error":"decode refused: host wire codec mismatch"}', { + status: 409, + headers: { "content-type": contentType }, + }); + } + + function channelsJson(): string { + const now = Date.now(); + const list = [...channels.values()].sort((a, b) => b.lastSeen - a.lastSeen); + return JSON.stringify({ + sockets: openSockets, + // A banner signal: at least one connected host is streaming a wire codec + // this debugger can't decode against. + codecMismatch: list.some((c) => !c.codecOk), + channels: list.map((c) => ({ + ...c, + connected: now - c.lastSeen < CONNECTED_WINDOW_MS, + })), + }); + } + + /** + * The `/stats?channel=` aggregate roll-up over the ops being listed: counts, + * byte totals, durations, health-badge tallies, the request/response split, + * and the busiest methods. Payload-blind - it sums shape and timing only and + * never serializes a byte or a decoded value. Feeds the inspector's summary + * strip (the "aggregate-level value"). + */ + function statsJson(channel: string | null): string { + /** The payload-blind aggregate shape `/stats` serializes. */ + interface StatsPayload { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + unpaired: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; + /** + * Link-level loss and liveness, SESSION-WIDE (never narrowed by + * `?channel=`): a rejected envelope has no channel to attribute it to, and a + * closed socket may have carried several. Grouped so a client can tell "the + * host is quiet" from "the host is talking and this debugger is refusing or + * losing what it says". + */ + sockets: number; + envelopeRejects: number; + envelopeRejectReasons: Record; + oversizedMessages: number; + abnormalCloses: number; + invalidDroppedFields: number; + } + const traces = + channel === null + ? session.traceEngine.traces() + : session.traceEngine.tracesForChannel(normalizeId(channel)); + // ONE aggregate for both mounts. A second implementation here is exactly how + // the two silently disagreed: this block tested `!some(role === "stop")` for + // liveness, ignoring `interrupt`, so every host-terminated subscription + // (chain switch, revoked permission) counted as live forever and the tile + // climbed all session above an op list showing nothing live. + const stats = computeTraceStats(viewsFor(traces).map(({ view }) => view)); + const evictedTraces = session.traceEngine.evictedTraces(); + const chanList = + channel === null + ? [...channels.values()] + : [...channels.values()].filter( + (c) => c.channelId === normalizeId(channel), + ); + const droppedByHost = chanList.reduce((n, c) => n + c.dropped, 0); + const codecMismatch = chanList.some((c) => !c.codecOk); + // Typed so a dropped/renamed field is a compile error, not a silent gap in + // the payload a client parses back. + const payload: StatsPayload = { + ...stats, + evictedTraces, + droppedByHost, + codecMismatch, + sockets: openSockets, + envelopeRejects: [...rejectCounts.values()].reduce((n, c) => n + c, 0), + envelopeRejectReasons: Object.fromEntries(rejectCounts), + oversizedMessages, + abnormalCloses, + invalidDroppedFields, + }; + return JSON.stringify(payload); + } + + /** The op's method for sorting: the first frame that resolves to one. */ + function traceMethod( + trace: ReturnType[number], + ): string { + for (const f of trace.frames) { + const method = session.methodNames.get(f.frameId)?.method; + if (method !== undefined) return method; + } + return ""; + } + + /** + * Order the op list for the `?sort=` control. Default (`""`) keeps arrival + * order (stable under live updates); the others are one-shot reorders the + * client's keyed diff mirrors into the DOM. + */ + function sortTraces( + traces: ReturnType, + sort: string | null, + ): ReturnType { + if (!sort) return traces; + const copy = [...traces]; + switch (sort) { + case "recent": + return copy.sort((a, b) => b.lastAt - a.lastAt); + case "duration": + return copy.sort( + (a, b) => b.lastAt - b.startedAt - (a.lastAt - a.startedAt), + ); + case "frames": + return copy.sort((a, b) => b.frames.length - a.frames.length); + case "method": + return copy.sort((a, b) => traceMethod(a).localeCompare(traceMethod(b))); + default: + return traces; + } + } + + /** + * The `/op-list?channel=&sort=` primary view: one server-rendered row per op + * (the shared {@link renderOperationRow}), payload-blind. Retry-storm is a + * cross-op signal computed here and fed to each view as an extra badge. + * `channel` filters on the trace's channelId; `sort` reorders the rows. + */ + function opListHtml(channel: string | null, sort: string | null): string { + const base = + channel === null + ? session.traceEngine.traces() + : session.traceEngine.tracesForChannel(normalizeId(channel)); + // Retry-storm is per-channel (a burst of like ops from one host), so it is + // detected over exactly the traces being listed - before any reorder, since + // the storm map is keyed by the trace object, not its position. + const storms = detectRetryStorms(base); + if (base.length === 0) { + return `
no operations yet
`; + } + const rows = sortTraces(base, sort); + // If any listed op is from a host whose wire contract differs from this + // debugger's, its method names may be wrong. Warn inline above the rows - not + // only in the global banner - so the mislabeled rows carry the caveat. + // "Unreliable" = a mismatched OR merely unconfirmed host: either way its + // method names come from this debugger's table and may be wrong, so the label + // matches the decode gate's bar rather than the narrower banner. + const mismatched = new Set( + [...channels.values()] + .filter((c) => !c.codecOk || !c.schemaOk) + .map((c) => c.channelId), + ); + const notice = + mismatched.size > 0 && + rows.some((t) => mismatched.has(normalizeId(t.channelId))) + ? `
⚠ a connected host's wire contract differs from this debugger's — method names below may be wrong
` + : ""; + return ( + notice + + rows + .map((t) => renderOperationRow(toView(t, storms), { now: Date.now() })) + .join("") + ); + } + + /** + * The `/op?id=&channel=` detail fragment: the selected op via + * {@link renderTraceDetail}. `channel` disambiguates the `requestId` when more + * than one host is connected (each mints the same `p:N` ids). + */ + function opDetailHtml( + requestId: string, + channel: string | null, + generation?: number, + ): string { + const trace = session.traceEngine.trace( + requestId, + channel ?? undefined, + generation, + ); + if (!trace) { + return `
operation not found
`; + } + const storms = detectRetryStorms( + session.traceEngine.tracesForChannel(trace.channelId), + ); + const view = toView(trace, storms); + return renderTraceDetail(view, { + offerDecode: session.decodeValues, + // Codec/schema-drift guard, matching `/frame`: refuse to decode a channel + // whose wire schema did not affirmatively match this debugger's table. + decoded: decodeTrusted(channel ?? undefined) + ? decodeTraceFrames(session, view) + : undefined, + }); + } + + const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; + + /** + * Route one request. Every throw is contained by the caller, so a malformed + * request can only ever cost its own response. + */ + function route(req: Request, srv: Bun.Server): Response | undefined { + const url = new URL(req.url); + // Reject cross-origin WebSocket upgrades (CSWSH): binding to loopback keeps + // off-box peers out, but a page open in the dev's own browser could still + // dial ws://127.0.0.1: to inject frames or drive the decoder over + // hostile bytes. A same-origin inspector and non-browser clients are + // allowed; a foreign browser Origin is not. + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (!originAllowed(req.headers.get("origin"))) { + return new Response("forbidden origin", { status: 403 }); + } + if (srv.upgrade(req)) return undefined; + } + if (url.pathname === "/traces") { + return new Response(tracesJson(), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/channels") { + return new Response(channelsJson(), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/stats") { + return new Response(statsJson(optionalChannel(url.searchParams.get("channel"))), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/op-list") { + return new Response( + opListHtml( + optionalChannel(url.searchParams.get("channel")), + url.searchParams.get("sort"), + ), + { headers: htmlHeaders }, + ); + } + if (url.pathname === "/op") { + const id = url.searchParams.get("id"); + const generation = optionalInt(url.searchParams.get("gen")); + if (generation === null) { + return new Response(`
bad request
`, { + status: 400, + headers: htmlHeaders, + }); + } + return new Response( + id === null + ? `
select an operation
` + : opDetailHtml( + id, + optionalChannel(url.searchParams.get("channel")), + generation, + ), + { headers: htmlHeaders }, + ); + } + if (url.pathname === "/view") { + const html = viewHtml(url); + return html === null + ? new Response(`
bad request
`, { + status: 400, + headers: htmlHeaders, + }) + : new Response(html, { headers: htmlHeaders }); + } + if (url.pathname === "/frame") { + return frameResponse(url); + } + return new Response(VIEW_HTML, { headers: htmlHeaders }); + } + + const server = Bun.serve({ + port: options.port ?? DEFAULT_PORT, + // Loopback only: the debugger holds every trace (and, with decode on, + // decoded values), so it must not listen on all interfaces where a LAN peer + // could read or inject. + hostname: "127.0.0.1", + fetch(req, srv) { + // DNS-rebinding guard: the request's Host must be one this server answers + // for. This blocks a rebound `evil.com -> 127.0.0.1` page from reading + // decoded frames over same-origin fetches, which binding to loopback alone + // does not prevent. + // + // FIRST, before `new URL(req.url)`: Bun builds `req.url` from the Host + // header, so an unparseable authority (`Host: localhost:99999`) throws + // inside the URL constructor. Gating first turns that into the 403 the + // header already earns, instead of a 500 plus a stack trace per request. + if (!hostHeaderAllowed(req.headers.get("host"))) { + return new Response("forbidden host", { status: 403 }); + } + // The WS handler is explicitly exception-safe; so is the route dispatcher. + // One malformed request must cost its own response and nothing else - no + // 500 with a stack trace, and no unhandled rejection taking the process + // down mid-session. + try { + return route(req, srv); + } catch { + return new Response("bad request", { status: 400 }); + } + }, + websocket: { + maxPayloadLength: MAX_INBOUND_MESSAGE_BYTES, + open() { + openSockets += 1; + }, + close(_ws, code, reason) { + openSockets = Math.max(0, openSockets - 1); + // An over-cap message is not dropped: Bun closes the socket (1006, + // "Received too big message") without invoking `message`, so this is the + // only place the loss is observable. Count the specific case, and every + // abnormal close, so a stream that dies mid-session shows up on /stats + // instead of looking like a host that simply went quiet. + if (code === 1006) { + abnormalCloses += 1; + if (/too big/i.test(reason ?? "")) oversizedMessages += 1; + } + }, + message(_ws, message) { + // Defensive: a malformed frame must never take down the socket callback. + // parseWireMessage + the Result-based ingest don't throw today, but keep + // the invariant local so a future ingest change can't propagate here. + try { + const raw = typeof message === "string" ? message : message.toString(); + const parsed = parseWireMessage(raw); + if (parsed.ok) { + recordChannel(parsed.value.envelope.channelId, parsed.value); + // Still grouped (payload-blind is safe and useful); a mismatch only + // blocks the value-decode path, via decodeTrusted. + // Stamp the frame with ITS OWN producer's verdict, so decode is gated + // per frame. See ObservedFrame.identityConfirmed. + session.handleEnvelope({ + ...parsed.value.envelope, + // BOTH halves, for symmetry with the in-app mount, which ANDs the + // same two: `identityConfirmed` is the schema match ALONE, so a + // host stamping the right hash with a wrong `v`/`codec` is + // "confirmed AND mismatched". + // + // The `!identityMismatch` half is DEFENSIVE ONLY and no test pins + // it. Reaching it needs a channel whose record says trusted while + // a retained frame says mismatched, and that cannot happen today: + // MAX_CHANNELS and the engine's `maxTraces` are both 256 and every + // channel costs at least one trace, so a channel record cannot be + // evicted and re-registered clean while its own frames survive. It + // guards the invariant, not a reachable path - `decodeTrusted` is + // what actually refuses these. + identityConfirmed: + parsed.value.identityConfirmed && + !parsed.value.identityMismatch, + }); + } else { + recordReject(parsed.reason); + } + } catch { + // Drop the frame; the observed session is worth more than one trace. + recordReject("ingest-threw"); + } + }, + }, + }); + + return { + // Always a TCP port here; the `?? 0` only satisfies Bun's unix-socket union. + port: server.port ?? 0, + decodeValues, + stop: () => server.stop(true), + }; +} + +/** + * The wire inspector: a full-screen, host-agnostic dev tool - a Network tab for + * TrUAPI wire frames. Left is the operation list (one row per op, the primary + * view); right is the selected op's frame sequence via the shared + * {@link renderTraceDetail}. A top bar switches between the hosts that have + * dialed in; a status bar shows counts and liveness. + * + * The client is a thin shell over server-rendered fragments: it polls + * `/op-list` (the shared {@link renderOperationRow}) and `/channels`, and fetches + * `/op` when an operation is selected. Every injected fragment is produced and + * escaped server-side, so `innerHTML` is safe. `/op-list` is payload-blind + * (shape/timing only); `/op` renders each frame's decoded value inline for a + * trusted channel. `td-*` classes are owned by the shared renderer. + */ +const VIEW_HTML = ` + +TrUAPI Wire Inspector + +
+ TrUAPI Wire Inspector + + + +
+
waiting for frames…
+
+
waiting for frames…
+
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open.
+
+
connecting…
+ +`; + +/** + * Whether value decode is on, from `TRUAPI_DEBUGGER_DECODE_VALUES`. + * + * On by default (dev-only tool); `0`/`false`/`no`/`off` in any case turns it off. + * TRIMMED first: this is the switch that stops full payload decode, so it must + * fail CLOSED on the shapes a shell or a `.env` file actually produces - + * `DECODE_VALUES="0 "` and `DECODE_VALUES=$'false\n'` are how a human writes + * "off", and an untrimmed match reads both as "on". + */ +export function decodeValuesFromEnv(raw: string | undefined): boolean { + return !/^(0|false|no|off)$/i.test((raw ?? "").trim()); +} + +/** + * The listen port from `TRUAPI_DEBUGGER_PORT`: the value, `DEFAULT_PORT` when + * unset/empty, or `null` when it is not a usable port. + * + * Rejects rather than coerces. `Number.isFinite(x) && x > 0` accepts `99999`, + * which the OS truncates to a DIFFERENT port (65535) that the host's debug URL + * will not be pointing at, and `1.5`, which crashes the process on port 1. A + * silently-wrong port on a debugger is indistinguishable from a host that never + * dialed - the single most expensive failure this tool can have. + */ +export function portFromEnv(raw: string | undefined): number | null { + const t = (raw ?? "").trim(); + if (t === "") return DEFAULT_PORT; + if (!/^\d+$/.test(t)) return null; + const port = Number(t); + // 0 would bind an ephemeral port nobody can predict; 65535 is the TCP ceiling. + return port >= 1 && port <= 65535 ? port : null; +} + +// Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. +// Port comes from TRUAPI_DEBUGGER_PORT, else the default. This is a DEV-ONLY, +// loopback-only tool: value decode is ON by default (set +// TRUAPI_DEBUGGER_DECODE_VALUES to 0/false/no/off to turn decode off for a demo). +if (import.meta.main) { + const port = portFromEnv(Bun.env.TRUAPI_DEBUGGER_PORT); + if (port === null) { + console.error( + `[truapi-debugger] TRUAPI_DEBUGGER_PORT must be an integer in 1-65535,` + + ` got ${JSON.stringify(Bun.env.TRUAPI_DEBUGGER_PORT)}`, + ); + process.exit(1); + } + const server = startDebugServer({ + port, + decodeValues: decodeValuesFromEnv(Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES), + }); + console.log( + `[truapi-debugger] listening on http://127.0.0.1:${server.port}` + + ` (value decode: ${server.decodeValues ? "on" : "off"})`, + ); +}