Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fdfb8fb
feat(truapi-codegen): emit the debugger wire-decode surface
decrypto21 Jul 31, 2026
41be440
feat(truapi-server): payload-blind wire-debug tap and sinks
decrypto21 Jul 31, 2026
3862942
feat(truapi-host): dev-gated worker dial to the debugger
decrypto21 Aug 2, 2026
7ed8527
feat(truapi-debugger): wire trace, decode, and render engine
decrypto21 Aug 2, 2026
0ea4e59
feat(truapi-debugger): WebSocket server and web inspector
decrypto21 Aug 2, 2026
56ddb57
feat(truapi-debugger): terminal CLI and query REPL
decrypto21 Aug 2, 2026
c2de134
fix(truapi-debugger): loopback bind and bounded retention
decrypto21 Aug 4, 2026
50475d3
feat(truapi-debugger): in-app embed for host-mounted panels
decrypto21 Aug 4, 2026
51d914f
feat(truapi-debugger): decode every frame, drop the CLI
decrypto21 Aug 5, 2026
3998993
feat(truapi-debugger): render raw hex for untypable frames
decrypto21 Aug 7, 2026
cff6751
test(truapi-debugger): cover role resolution and URL gate
decrypto21 Aug 10, 2026
7ed1d1a
feat(truapi-debugger): give the embed full inspector chrome
decrypto21 Aug 12, 2026
7d4043c
fix(truapi-debugger): accept .localhost origins, show waiting ops
decrypto21 Aug 13, 2026
09fab26
fix(truapi-debugger): harden the tap, engine, and mounts
decrypto21 Aug 13, 2026
33729d9
fix(truapi-debugger): gate decode on wire identity
decrypto21 Aug 14, 2026
a728c92
test(truapi-host): guard the producer debugger link
decrypto21 Aug 14, 2026
409cd07
fix(truapi-codegen): hash payload layout and Compact width
decrypto21 Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,42 @@ jobs:
- name: Test
run: npm test --prefix js/packages/truapi-host

ts-debugger:
name: "@parity/truapi-debugger"
runs-on: ubuntu-latest
needs: codegen
env:
TRUAPI_REQUIRE_GENERATED: 1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

- name: Download codegen output
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: codegen-output

- name: Install
run: npm ci --ignore-scripts

- name: Build @parity/truapi (workspace dependency)
run: npm run build --prefix js/packages/truapi

- name: Build
run: npm run build --prefix js/packages/truapi-debugger

- name: Test
run: npm test --prefix js/packages/truapi-debugger

playground:
name: Playground (build + lint + unit)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -481,6 +517,7 @@ jobs:
ios-swift,
ts-client,
ts-host,
ts-debugger,
playground,
explorer,
e2e,
Expand All @@ -500,6 +537,7 @@ jobs:
"${{ needs.ios-swift.result }}"
"${{ needs.ts-client.result }}"
"${{ needs.ts-host.result }}"
"${{ needs.ts-debugger.result }}"
"${{ needs.playground.result }}"
"${{ needs.explorer.result }}"
"${{ needs.e2e.result }}"
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ js/packages/
`.` (shared host types), `/web` (iframe + Web
Worker), `/worker-runtime` (Worker entry).
WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm`
truapi-debugger/ @parity/truapi-debugger (private, in-repo): the debugger.
Owns all decoding of the wire frames the Rust host tap
(truapi-server's DebugSink) streams out, and decodes
every frame by default (no denylist, no reveal toggle).
Holds the trace, envelope-decode, and value-decode
engines, the shared view model + renderers, and two
mounts over them: server.ts (standalone WS+HTTP app on
127.0.0.1:9231 that hosts dial into, `npm run serve`;
endpoints /, /op-list, /op, /view, /channels, /stats,
/traces, /frame) and in-app.ts (createInAppDebugger:
same-page host, no server, no dial). @parity/truapi has
no debug seam. Where the app ultimately lives is still
an open decision.
js/container/ TS lockdown container for the iOS host web view; `npm run build`
bundles it into ios/truapi-host/Sources/TrUAPIHost/Resources/
ios/truapi-provider/ TrUAPIProvider Swift package (chain transport over UniFFI);
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions js/packages/truapi-debugger/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
153 changes: 153 additions & 0 deletions js/packages/truapi-debugger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# @parity/truapi-debugger

The debugger-side consumer for TrUAPI wire frames. **Private, in-repo, not published.**

The host taps every product↔host wire frame in its Rust core (`truapi-server`'s
`DebugSink`) and streams each one outward as a `{ channelId, dir, frame: bytes }`
envelope. This package is the other end: it owns **all** decoding — the wire
envelope (`requestId` and frame id, via `decodeWireMessage`), the grouping into
per-operation traces, and the per-frame payload decode. The host core treats
frames as opaque bytes and never decodes.

This keeps `@parity/truapi` (the product package) genuinely untouched: the tap is
in the Rust host, and the debugger's decode/trace logic lives here instead of in
the product transport.

> **Scope note.** This package holds both the debugger *library* (the trace,
> envelope-decode, and value-decode engines plus the ingest that turns a wire
> envelope into a decoded frame) and its two *mounts* — the standalone app
> (`server.ts`) and the in-app embed (`in-app.ts`). It lives in-repo because the
> debugger is coupled to the protocol this repo owns: it decodes wire frames with
> `@parity/truapi`, tracking the generated wire surface. *Where the app
> ultimately lives* (stays a truapi tool / own repo / a desktop app) is an open
> decision for the host-protocol owner; in-repo is the low-regret default and
> moving it later is cheap.

## What's here

- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it
envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`,
per-frame values from `frameDetail(...)` / `decodedFrames(...)`.
- **`createDebugIngest(sink)`** — decodes a `DebugFrameEnvelope` into an
`ObservedFrame` and forwards it. The layer that turns raw wire bytes into
something the trace engine can group.
- **`createWireDebugger(...)`** — accumulates observed frames into per-`requestId`
traces (correlates with product-sdk telemetry spans on the same id).
- **`createFrameDecoder(...)`** — the level-2 value decoder (see below): a
per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s
generated `WIRE_DECODE_TABLE`. Every frame it can decode, it does, with no
sensitive special-casing. The bare factory takes `enabled: true` to opt in; a
session turns it on for you.
- **`buildTraceView` / `wireTraceToView`, `renderOperationRow`,
`renderTraceDetail`, `renderFrameValueDetail`** — the one view model and the one
set of renderers both mounts share, so the two cannot drift apart.
- **`startDebugServer(...)`** (`server.ts`) — the standalone mount, below.
- **`createInAppDebugger(...)`** (`in-app.ts`) — the in-app mount, below.

## The two mounts

Both render the same view model with the same renderers and the same stylesheet.
They differ in where the debugger sits relative to the host:

```text
standalone: host process ──ws://127.0.0.1:9231──▶ debugger server ──HTTP──▶ browser
(host dials out; frames leave the app; one server, many channels)

in-app: host in the page ──handleFrame()──▶ InAppDebugger.mount(el)
(same page as the host; no server, no dial; frames never leave the app)
```

- **Standalone** (`startDebugServer`): a Bun WS+HTTP server bound to
`127.0.0.1` only. Hosts dial *in* and send one text message per frame,
`{ channelId, dir, frame }` with `frame` base64-encoded, plus the wire-identity
fields a versioned host stamps (`v`, `codec`, `schema`) and an optional
`dropped` count. The browser view is a thin client over server-rendered
fragments.
- **In-app** (`createInAppDebugger`): the second mount, for a host that runs in
the page. It takes the same raw SCALE frame bytes with the same
product-vantage `dir`, holds the session in-process, and renders the fragments
directly with no polling. Browser-only (uses `document`); each browser tab is
its own tenant, so there is nothing to host or scope.

## Value decode (level 2 — on by default)

This is a **dev-only tool that decodes everything**. The list views stay
payload-blind — they group frames and sum byte lengths, never their contents —
and the drill-down decodes a frame's payload to a plain JS value, for every
frame, with no "sensitive" special-casing. Its contract:

- **On by default.** The standalone server decodes unless
`TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value
(`0`/`false`/`no`/`off`), or `startDebugServer({ decodeValues: false })` /
`createInAppDebugger({ decodeValues: false })` in code — useful for a demo.
With decode off, every frame reports byte length only and no bytes are even
retained.
- **Reuses the generated table.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)`
from `@parity/truapi/wire-decode` — the same dev-only codecs the client uses.
The debugger writes none of its own.
- **No redaction, no reveal toggle.** Every frame the table can decode is
decoded, including signing, login, and payment. A developer inspecting their
own session's traffic sees the real values; there is no denylist, no reveal
escape hatch, and no `redacted` state. A frame the codec cannot type still
shows its raw payload as `<n>B · 0x…` hex — a dev-only tool hides nothing it
has the bytes for. Only a frame with no retained bytes (decode off) reads
`payload not shown`.
- **Refused on contract drift.** Decode is allowed only for a channel whose
declared `schema` fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`) and `codec` match
this debugger's; a mismatched or absent identity is refused (`/frame` answers
409) and banners in the view. Payload-blind grouping is unaffected.
- **Never over the wire, never in the list endpoints.** The host emits opaque
bytes only; nothing about decode changes what it sends. Decode happens in the
debugger, in the drill-down paths only.

## Standalone endpoints

| Endpoint | Serves |
| ------------------------------------- | --------------------------------------------------------- |
| `GET /` | The inspector page: polls the fragments below. |
| `GET /op-list?channel=&sort=` | One server-rendered row per op. `sort` is `recent`, `duration`, `frames`, or `method`; absent keeps arrival order. Payload-blind. |
| `GET /op?id=&channel=&gen=` | The selected op's drill-down, each frame's value inline. |
| `GET /view` | The drill-down as a standalone fragment, values inline. |
| `GET /channels` | Connected hosts/channels, liveness, codec-mismatch flag. |
| `GET /stats?channel=` | Aggregate roll-up: counts, bytes, durations, health, busiest methods. Payload-blind. |
| `GET /traces` | The grouped traces as JSON. Payload-blind — never serializes bytes or values. |
| `GET /frame?id=&i=&channel=` | One frame's decode as JSON (the programmatic drill-down). |

Loopback is enforced on more than the bind: a request whose `Host` header is not
a loopback name gets a 403 (DNS-rebinding guard), and a WebSocket upgrade from a
foreign browser `Origin` is refused (CSWSH).

## Run

```bash
npm install # links @parity/truapi via the workspace
npm run build # tsc -b
npm run serve # bun run src/server.ts — listens on 127.0.0.1:9231, decodes by default

# a different port, or decode off for a demo
TRUAPI_DEBUGGER_PORT=9300 npm run serve
TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve
```

Point a host's debugger URL at `ws://127.0.0.1:9231` (the host dials out) and
open `http://127.0.0.1:9231/`; click an op for its drill-down detail.

Use the literal `127.0.0.1`, not `localhost`. Both dial gates accept a `ws://`
URL on a loopback host **only** — `wss://`, certificates, and any non-loopback
target are rejected — and `localhost` passes that check but resolves `::1` first
on macOS, while the server binds `127.0.0.1` alone. A native host then dials an
address nothing is listening on and logs nothing.

For the in-app mount, feed frames straight to the session:

```ts
import { createInAppDebugger } from "@parity/truapi-debugger";

const inspector = createInAppDebugger();
const dispose = inspector.mount(document.getElementById("wire-panel")!);
// from the host's tap, per frame:
inspector.handleFrame(channelId, "out", frameBytes);
```

The exact host↔debugger framing is provisional (envelope spec, track T3);
base64-in-JSON is what the server accepts today.
26 changes: 26 additions & 0 deletions js/packages/truapi-debugger/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@parity/truapi-debugger",
"version": "0.0.0",
"private": true,
"description": "In-repo debugger consumer for TrUAPI wire frames: decodes and groups the frames the truapi-server host tap streams out",
"license": "MIT",
"author": "Parity Technologies <admin@parity.io>",
"type": "module",
"sideEffects": false,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"typecheck": "tsc -b",
"serve": "bun run src/server.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.0",
"happy-dom": "^20.11.2",
"typescript": "^6.0"
},
"dependencies": {
"@parity/truapi": "file:../truapi"
}
}
112 changes: 112 additions & 0 deletions js/packages/truapi-debugger/src/decode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";

import * as W from "@parity/truapi/wire-table";
import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode";

import { createFrameDecoder, type FrameValueDetail } from "./decode.js";
import type { ObservedFrame } from "./observed-frame.js";

/** A minimal observed frame for a given id/bytes; the fields decode ignores are stubbed. */
function frame(frameId: number, bytes?: Uint8Array): ObservedFrame {
return {
channelId: "myapp.dot",
direction: "out",
requestId: "p:1",
frameId,
role: "unknown",
byteLength: bytes?.length ?? 0,
timestamp: 0,
...(bytes ? { bytes } : {}),
};
}

describe("frame decoder (real table) — decodes everything, no special-casing", () => {
test("a non-sensitive frame decodes only with the toggle on", () => {
// `connection-status.subscribe` start payload is `V1(void)` = a single 0x00
// index byte: a real frame the generated table can decode.
const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start;
const bytes = new Uint8Array([0]);

const off = createFrameDecoder({ enabled: false });
const offDetail = off.detail(frame(id, bytes));
expect(offDetail.kind).toBe("bytes");
if (offDetail.kind === "bytes") expect(offDetail.byteLength).toBe(1);

const on = createFrameDecoder({ enabled: true });
const onDetail = on.detail(frame(id, bytes));
expect(onDetail.kind).toBe("decoded");
// Sanity: the id really is in the generated decode table.
expect(typeof WIRE_DECODE_TABLE[id]).toBe("function");
});

test("a formerly-'sensitive' signing frame decodes too (dev-only tool)", () => {
// No denylist any more: a signing request decodes like every other frame.
const decoder = createFrameDecoder({ enabled: true });
const detail = decoder.detail(
frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([0])),
);
// It either decodes (id has a codec + valid bytes) or, on a codec throw for
// the stub bytes, falls back to bytes — never a "redacted" state.
expect(["decoded", "bytes"]).toContain(detail.kind);
// Whatever the outcome, the kind is never the old "redacted" variant.
expect(detail.kind).not.toBe("redacted");
});

test("disabled decoder is bytes-only for every frame", () => {
const decoder = createFrameDecoder({ enabled: false });
for (const id of [
W.ACCOUNT_GET_ACCOUNT.request,
W.SIGNING_SIGN_RAW.request,
W.CHAIN_CALL_HEAD.request,
]) {
expect(decoder.detail(frame(id, new Uint8Array([9]))).kind).toBe("bytes");
}
});
});

describe("frame decoder (injected table)", () => {
const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) };

test("decodes an id when enabled and bytes present", () => {
const decoder = createFrameDecoder({ enabled: true, decodeTable: table });
const detail = decoder.detail(frame(999, new Uint8Array([1, 2])));
expect(detail).toEqual({
kind: "decoded",
value: { ok: [1, 2] },
} satisfies FrameValueDetail);
});

test("decodes a secret-named field too — no content guard withholds it", () => {
const decoder = createFrameDecoder({
enabled: true,
decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) },
});
const detail = decoder.detail(frame(999, new Uint8Array([1])));
expect(detail.kind).toBe("decoded");
if (detail.kind === "decoded") {
expect(detail.value).toEqual({ source: { sr25519SecretKey: "0xdead" } });
}
});

test("falls back to bytes when the frame retained no bytes", () => {
const decoder = createFrameDecoder({ enabled: true, decodeTable: table });
expect(decoder.detail(frame(999)).kind).toBe("bytes");
});

test("falls back to bytes when the codec throws", () => {
const decoder = createFrameDecoder({
enabled: true,
decodeTable: {
999: () => {
throw new Error("bad payload");
},
},
});
expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes");
});

test("falls back to bytes when the id has no codec", () => {
const decoder = createFrameDecoder({ enabled: true, decodeTable: table });
expect(decoder.detail(frame(1, new Uint8Array([1]))).kind).toBe("bytes");
});
});
Loading