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
95 changes: 95 additions & 0 deletions js/packages/truapi-debugger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# @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` behind a dev-only opt-in and a sensitive-method
denylist.
- **`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, off by default)

By default the debugger is **payload-blind**: it groups frames and shows byte
lengths, never their contents. A separate, opt-in **level-2** capability can
decode a single frame's payload to a plain JS value in the drill-down detail
path. Its contract:

- **Off by default.** The server enables it only when
`TRUAPI_DEBUGGER_DECODE_VALUES` is truthy (`startDebugServer({ decodeValues })`
in code). With it 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.
- **Sensitive denylist.** The generated table decodes *every* frame, including
signing and login. The security of this feature is the denylist layered on
top: the generated `SENSITIVE_FRAME_IDS` set in `@parity/truapi/wire-table`,
emitted from every method marked `#[wire(..., sensitive)]` on the Rust trait —
so sensitivity is a property of the payload type, and a codegen rename cannot
silently drop a family. It covers **signing/\*** (create-transaction, sign-raw,
sign-payload, and their legacy variants), **\*create\*proof\*** (account +
statement-store, incl. authorized), **entropy/derive**, **SSO/login +
get-user-id**, **local-storage read/write** (`clear` carries only a key name,
so it stays decodable), **payment/top-up**,
**coin-payment create-cheque/deposit/listen-for-payment**, and
**statement-store subscribe/submit**. A sensitive frame is never decoded — it
reports its byte length labelled `redacted: sensitive method`, even with the
toggle on. A fail-closed content check (any secret-named field in a decoded
value) backs it up for any secret-bearing method that was never annotated.
- **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

# opt into level-2 value decode (dev machines only)
TRUAPI_DEBUGGER_DECODE_VALUES=1 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.
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",
"view": "bun run src/cli.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.0",
"typescript": "^6.0"
},
"dependencies": {
"@parity/truapi": "file:../truapi"
}
}
122 changes: 122 additions & 0 deletions js/packages/truapi-debugger/src/cli-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: MIT
/**
* Shared client for the terminal frontends (the one-shot {@link module:cli}
* commands and the interactive {@link module:repl}). Reads a running debugger's
* HTTP endpoints and rebuilds the shared {@link TraceView} model, so both
* frontends agree with the web inspector on ops, badges, sensitivity, and what
* may be decoded - one engine, one denylist, no forks.
*
* @module
*/

import { SENSITIVE_FRAME_IDS, type FrameValueDetail } from "./decode.js";
import type { FrameRole } from "./observed-frame.js";
import {
buildTraceView,
type TraceBadge,
type TraceView,
type TraceViewInput,
} from "./trace-view.js";
import type { CliStats } from "./trace-text.js";

/** The sensitive denylist, resolved once from the generated wire-table. */
export const sensitiveIds = SENSITIVE_FRAME_IDS;

/** One frame as `/traces` serializes it (payload-blind: no bytes, no values). */
export interface TracesFrame {
direction: "out" | "in";
frameId: number;
method?: string;
role: string;
byteLength?: number;
timestamp: number;
}
/** One op as `/traces` serializes it. */
export interface TracesEntry {
channelId: string;
requestId: string;
startedAt: number;
lastAt: number;
/** Op-level badges the server computed (incl. the cross-op retry-storm). */
badges?: TraceBadge[];
frames: TracesFrame[];
}
/** One host as `/channels` reports it. */
export interface ChannelInfo {
channelId: string;
connected: boolean;
frameCount: number;
}

export type { CliStats, FrameValueDetail };

/** Rebuild the shared view model from a payload-blind `/traces` entry. */
export function toView(entry: TracesEntry): TraceView {
const input: TraceViewInput = {
requestId: entry.requestId,
channelId: entry.channelId,
startedAt: entry.startedAt,
lastAt: entry.lastAt,
// Cross-op badges (retry-storm) are computed server-side and passed through,
// so the CLI shows the same badges as the web inspector without recomputing.
extraBadges: entry.badges,
frames: entry.frames.map((f) => ({
direction: f.direction,
// `/traces` role strings come straight off the engine's FrameRole union.
role: f.role as FrameRole,
method: f.method,
frameId: f.frameId,
byteLength: f.byteLength,
timestamp: f.timestamp,
decodable: false,
sensitive: sensitiveIds.has(f.frameId),
})),
};
return buildTraceView(input);
}

export { viewMethod } from "./trace-view.js";

/** A thin HTTP client over a running debugger server. */
export interface DebuggerClient {
readonly host: string;
traces(): Promise<TracesEntry[]>;
stats(channel: string | null): Promise<CliStats>;
channels(): Promise<ChannelInfo[]>;
/**
* The gated per-frame drill-down. `reveal` is honored only when the server
* armed `TRUAPI_DEBUGGER_REVEAL_SENSITIVE`; otherwise a sensitive frame still
* comes back redacted - the guarantee lives server-side, not here.
*/
frame(
requestId: string,
seq: number,
channel: string | null,
reveal: boolean,
): Promise<FrameValueDetail>;
}

/** Build a {@link DebuggerClient} for `host` (e.g. `http://localhost:9231`). */
export function createDebuggerClient(host: string): DebuggerClient {
const getJson = async <T>(path: string): Promise<T> => {
const res = await fetch(host + path);
if (!res.ok) throw new Error(`${host}${path} → HTTP ${String(res.status)}`);
return res.json() as Promise<T>;
};
const channelQuery = (channel: string | null): string =>
channel ? `?channel=${encodeURIComponent(channel)}` : "";
return {
host,
traces: () => getJson<TracesEntry[]>("/traces"),
stats: (channel) => getJson<CliStats>(`/stats${channelQuery(channel)}`),
channels: async () =>
(await getJson<{ channels: ChannelInfo[] }>("/channels")).channels,
frame: (requestId, seq, channel, reveal) => {
const p = new URLSearchParams({ id: requestId, i: String(seq) });
if (channel) p.set("channel", channel);
if (reveal) p.set("reveal", "1");
return getJson<FrameValueDetail>(`/frame?${p.toString()}`);
},
};
}
Loading