Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,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)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -327,7 +363,17 @@ jobs:
if: always()
runs-on: ubuntu-latest
needs:
[rust, licenses, codegen, ts-client, ts-host, playground, explorer, e2e]
[
rust,
licenses,
codegen,
ts-client,
ts-host,
ts-debugger,
playground,
explorer,
e2e,
]
steps:
- name: Check all jobs
run: |
Expand All @@ -337,6 +383,7 @@ jobs:
"${{ needs.codegen.result }}"
"${{ needs.ts-client.result }}"
"${{ needs.ts-host.result }}"
"${{ needs.ts-debugger.result }}"
"${{ needs.playground.result }}"
"${{ needs.explorer.result }}"
"${{ needs.e2e.result }}"
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ 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.
Decodes + groups the wire frames the Rust host tap
(truapi-server's DebugSink) streams out. Holds the
trace + envelope-decode engines + a runnable WS server the host
dials into (`npm run serve`, :9231) with a minimal trace
view. @parity/truapi has no debug seam. Where the app
ultimately lives is still an open decision.
playground/ Next.js interactive playground; deploys to truapi-playground.dot
hosts/dotli/ dotli submodule
docs/ design docs, RFCs, feature proposals
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
86 changes: 86 additions & 0 deletions js/packages/truapi-debugger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# @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 decodes the wire *envelope* (the
`requestId` and frame id, via `decodeWireMessage`) and groups frames into
per-operation traces. The trace view stays payload-blind — it never decodes the
frame payload. Envelope decoding lives here, in the debugger, never in the host
core, which treats frames as opaque bytes.

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 engines + the ingest that turns a wire envelope into a
> decoded frame) and a minimal *runnable app* (`server.ts`: the WS server a host
> dials into, plus a tiny trace view). 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 still an open decision for the host-protocol
> owner; in-repo now is the low-regret default and moving it later is cheap. See
> `docs/design/wire-observability-debug-host.md`.

## What's here

- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it
envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`.
- **`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 gated,
per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s
generated `WIRE_DECODE_TABLE`. A dev-only tool that decodes every frame it can,
with no sensitive special-casing.
- **`startDebugServer(...)`** (`server.ts`) — the runnable app: a Bun WS+HTTP
server. A host dials the WS and sends one text message per frame,
`{ channelId, dir, frame }` with `frame` base64-encoded; `GET /traces` returns
the grouped traces (payload-blind), `GET /frame?id=&i=` is the per-frame
drill-down (see below), `GET /` serves the view.

## Value decode (level 2 — dev-only, on by default)

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

- **On by default.** The server decodes unless
`TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value (`0`/`false`/`no`/`off`),
or `startDebugServer({ 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 renders either its decoded value
or, when it has no codec / no retained bytes / fails to decode, its byte length.
- **Never over the wire, never in `/traces`.** The host still emits opaque bytes
only; nothing about decode changes what it sends. `/traces` never serializes
raw bytes or decoded values. Decode happens only in the debugger, only in the
`/frame` drill-down.

## 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 :9231, decodes by default

# turn value decode off for a demo
TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve
```

Point a host's debugger URL at `ws://<dev-machine>:9231` (the host dials out),
open `http://localhost:9231/` for the trace view; click a frame for its
drill-down detail. The exact host↔debugger framing is provisional (envelope
spec, track T3); base64-in-JSON is what the server accepts today.
25 changes: 25 additions & 0 deletions js/packages/truapi-debugger/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"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",
"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