From dab9eda99628899a0706e17455d129b4a3b714b4 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 31 Jul 2026 19:59:18 +0530 Subject: [PATCH 1/9] feat(truapi-codegen): generate #[wire(sensitive)] denylist and debugger wire-decode surface --- js/packages/truapi/README.md | 18 +- js/packages/truapi/package.json | 4 + .../truapi/scripts/ensure-generated.sh | 1 + js/packages/truapi/src/client.ts | 2 + rust/crates/truapi-codegen/src/rust.rs | 2 + rust/crates/truapi-codegen/src/rustdoc.rs | 26 +- rust/crates/truapi-codegen/src/ts.rs | 256 ++++++++++++++++++ rust/crates/truapi-macros/src/lib.rs | 40 ++- rust/crates/truapi/src/api/account.rs | 8 +- rust/crates/truapi/src/api/coin_payment.rs | 6 +- rust/crates/truapi/src/api/entropy.rs | 2 +- rust/crates/truapi/src/api/local_storage.rs | 4 +- rust/crates/truapi/src/api/payment.rs | 2 +- rust/crates/truapi/src/api/signing.rs | 12 +- rust/crates/truapi/src/api/statement_store.rs | 8 +- 15 files changed, 359 insertions(+), 32 deletions(-) diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index c8d0d8747..3842cb459 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -69,7 +69,7 @@ sub.unsubscribe(); - **Generated domain clients and types** produced from the Rust API contract. - **SCALE codec helpers** used by the generated code, also re-exported for direct use. - **Sandbox bootstrap** (`@parity/truapi/sandbox`) that detects the host environment, builds the - matching provider, and exposes a cached client — see below. + matching provider, and exposes a cached client - see below. ## Sandbox bootstrap @@ -101,6 +101,22 @@ const unsubscribe = subscribeConnectionStatus((status) => { | `getClientSync(): TrUApiClient \| null` | Cached client; `null` outside a host container. | | `subscribeConnectionStatus(cb): () => void` | Connected / disconnected status listener. | +## Observability / debugging + +The debugger does not live in this package, and the product transport carries no debug seam - +`@parity/truapi` is genuinely untouched by observability. The host taps every product↔host frame in +its Rust core (`truapi-server`'s `DebugSink`) and streams each one - as `{ channelId, dir, frame: +bytes }`, opaque bytes - to a separate debugger app, which decodes and groups them. + +- Architecture (the tap, the envelope, the host-dials-debugger topology, `wss`/cert setup): + `docs/design/wire-observability-debug-host.md`. +- The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. + +The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) +stays here, since it is generated from this package's contract. The debugger app is payload-blind +today - it decodes only the wire envelope (`requestId`, frame id) via `decodeWireMessage`, not +payloads - so this table is unused for now; it is the decode source for a future typed-value view. + ## Wire format Frames are SCALE encoded: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 33393ec00..480804234 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -39,6 +39,10 @@ "types": "./dist/generated/wire-table.d.ts", "import": "./dist/generated/wire-table.js" }, + "./wire-decode": { + "types": "./dist/generated/wire-decode.d.ts", + "import": "./dist/generated/wire-decode.js" + }, "./playground/services": { "types": "./dist/playground/codegen/services.d.ts", "import": "./dist/playground/codegen/services.js" diff --git a/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 807c07166..e32aa3561 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -9,6 +9,7 @@ codegen_required=( "js/packages/truapi/src/generated/client.ts" "js/packages/truapi/src/generated/types.ts" "js/packages/truapi/src/generated/wire-table.ts" + "js/packages/truapi/src/generated/wire-decode.ts" "js/packages/truapi/src/playground/codegen/services.ts" "js/packages/truapi/src/explorer/codegen/types.ts" "js/packages/truapi/src/explorer/versions.ts" diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index f45481f43..51ad98af7 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -152,6 +152,7 @@ export function createTransport( ): TrUApiTransport { const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; + let closedError: Error | null = null; const pending = new Map< string, @@ -212,6 +213,7 @@ export function createTransport( const decoded = decodeWireMessage(message); if (decoded.isErr()) { + // A corrupt/truncated inbound frame tears the transport down. closeWithError(decoded.error); return; } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index c78788298..0e3b23cf4 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -157,6 +157,7 @@ mod tests { stop_id: None, interrupt_id: None, receive_id: None, + sensitive: false, }, docs: None, } @@ -178,6 +179,7 @@ mod tests { stop_id: None, interrupt_id: None, receive_id: None, + sensitive: false, }, docs: None, } diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 933e23ef6..eb0b47bd3 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -106,6 +106,10 @@ pub struct WireAttrs { pub interrupt_id: Option, /// Subscription item frame discriminant. pub receive_id: Option, + /// Whether the method's payloads carry key material or bearer secrets. + /// Marked by `#[wire(..., sensitive)]`; propagated into the generated + /// `SENSITIVE_FRAME_IDS` set so the wire debugger never decodes these frames. + pub sensitive: bool, } /// Wire-shape classification of a trait method. @@ -785,6 +789,14 @@ fn extract_wire_attrs(docs: &str) -> WireAttrs { let mut attrs = WireAttrs::default(); for line in docs.lines() { let line = line.trim_start(); + if line.starts_with("@wire_sensitive=") { + attrs.sensitive = line + .trim_end() + .strip_prefix("@wire_sensitive=") + .and_then(|value| value.parse::().ok()) + .unwrap_or(false); + continue; + } for (needle, target) in [ ("@wire_request_id=", &mut attrs.request_id), ("@wire_response_id=", &mut attrs.response_id), @@ -1450,11 +1462,23 @@ mod tests { #[test] fn clean_docs_strips_wire_markers() { - let docs = "Trait summary.\n\n@wire_request_id=7\n"; + let docs = "Trait summary.\n\n@wire_request_id=7\n@wire_sensitive=true\n"; assert_eq!(clean_docs(Some(docs)).as_deref(), Some("Trait summary.")); } + #[test] + fn extract_wire_attrs_reads_sensitive_flag() { + let sensitive = extract_wire_attrs("@wire_request_id=114\n@wire_sensitive=true"); + assert_eq!(sensitive.request_id, Some(114)); + assert!(sensitive.sensitive); + + // Absent marker ⇒ not sensitive (the default for every unmarked method). + let plain = extract_wire_attrs("@wire_request_id=22"); + assert_eq!(plain.request_id, Some(22)); + assert!(!plain.sensitive); + } + #[test] fn parse_accepts_tested_format_version() { let json = format!(r#"{{ "format_version": {MIN_FORMAT_VERSION}, "index": {{}} }}"#); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 019c33f7f..df0ee2648 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -489,6 +489,12 @@ pub fn generate( let wire_table_code = generate_wire_table(api, target_version)?; fs::write(Path::new(output_dir).join("wire-table.ts"), wire_table_code)?; + let decode_table_code = generate_decode_table(api, target_version)?; + fs::write( + Path::new(output_dir).join("wire-decode.ts"), + decode_table_code, + )?; + Ok(()) } @@ -585,6 +591,8 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result = BTreeMap::new(); let mut constants: Vec<(String, ExpandedWireIds)> = Vec::new(); + // Every frame id (both legs) of a method marked `#[wire(..., sensitive)]`. + let mut sensitive_ids: BTreeSet = BTreeSet::new(); for trait_def in &api.traits { for method in &trait_def.methods { @@ -596,6 +604,9 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result>() + .join(", "); + out.push('\n'); + out.push_str(&formatdoc! {" + // Wire frame ids whose payloads carry key material or bearer secrets, + // marked `#[wire(..., sensitive)]` on the Rust trait. The wire debugger + // treats this as the authoritative denylist and never decodes these + // frames (both request/response and start/receive legs are listed). + export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([{sensitive_list}]); + "}); + Ok(out) } @@ -1051,6 +1076,172 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) Ok(out) } +/// Generates the dev-only wire decode table (`wire-decode.ts`): a map from wire +/// `frameId` to a decoder that turns a frame's SCALE payload into a plain JS +/// value. It re-derives the exact request/response/subscription codec +/// expressions the client emitter builds (via [`emit_payload`], +/// [`emit_response`], [`emit_error_response`], and +/// [`versioned_result_codec_expr`]), so a debugger decodes wire frames against +/// the same generated codecs. Subscription `start` and `receive` frames are +/// covered; `stop`/`interrupt` frames are intentionally skipped. +fn generate_decode_table(api: &ApiDefinition, target_version: u32) -> Result { + let ctx = codec_context(&[]); + let wrappers = collect_versioned_wrappers(api); + let services = public_services(api)?; + + // (wire id, emitted table line) pairs, sorted by wire id for a stable, + // wire-ordered file that matches the wire-table layout. + let mut entries: Vec<(u8, String)> = Vec::new(); + + for service in &services { + let trait_def = service.trait_def; + for method in included_methods(trait_def, &wrappers, target_version)? { + let wire_const = wire_const_name(&trait_def.name, &method.name); + let wire_version = method_wire_version(method, &wrappers, target_version)?; + let payload = emit_payload(&method.params, &wrappers, &ctx, wire_version)?; + let wire_ids = wire_ids_for_method(trait_def, method)?; + + match (&method.kind, &method.return_type) { + (MethodKind::Request, ReturnType::Result { ok, err }) => { + let ExpandedWireIds::Request { + request_id, + response_id, + } = wire_ids + else { + unreachable!("request method resolved to subscription wire ids"); + }; + let response = emit_response(ok, &wrappers, &ctx, wire_version)?; + let error = emit_error_response(err, &wrappers, &ctx, wire_version)?; + let response_codec = match wire_version { + Some(version) => versioned_result_codec_expr( + version, + &response.inner_codec_expr, + &error.inner_codec_expr, + )?, + None => format!( + "S.Result({}, {})", + response.wire_codec_expr, error.wire_codec_expr + ), + }; + let value_suffix = if wire_version.is_some() { ".value" } else { "" }; + entries.push(( + request_id, + format!( + " [W.{wire_const}.request]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + response_id, + format!( + " [W.{wire_const}.response]: (payload) => {response_codec}.dec(payload){value_suffix}," + ), + )); + } + (MethodKind::Subscription, ReturnType::Subscription(ty)) => { + let response = emit_response(ty, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (MethodKind::ResultSubscription, ReturnType::ResultSubscription { item, .. }) => { + let response = emit_response(item, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (kind, return_type) => { + bail!( + "Generator internal mismatch for method `{}`: kind {:?} does not match return type {:?}", + method.name, + kind, + return_type + ); + } + } + } + } + + entries.sort_by_key(|(id, _)| *id); + + let mut out = String::new(); + writedoc!( + out, + r#" + // Auto-generated by truapi-codegen. Do not edit. + + import * as S from '../scale.js'; + import * as T from './types.js'; + import * as W from './wire-table.js'; + + /** Dev-only: decode a wire frame's SCALE payload to a plain JS value, keyed by frameId. + * Request/response/subscription frames only; unknown ids are absent (caller falls back to bytes). */ + export const WIRE_DECODE_TABLE: Record unknown> = {{ + "# + ) + .unwrap(); + for (_, line) in &entries { + out.push_str(line); + out.push('\n'); + } + out.push_str("};\n"); + + Ok(out) +} + +/// Emits the `.start` (start payload codec) and `.receive` (item codec) decode +/// entries for a subscription method, mirroring the client's `payload` +/// encoding and `decodeItem` expression. `stop`/`interrupt` frames are skipped. +fn push_subscription_entries( + entries: &mut Vec<(u8, String)>, + wire_const: &str, + payload: &PayloadEmission, + response: &ResponseEmission, + wire_ids: ExpandedWireIds, + wire_version: Option, +) -> Result<()> { + let ExpandedWireIds::Subscription { + start_id, + receive_id, + .. + } = wire_ids + else { + unreachable!("subscription method resolved to request wire ids"); + }; + let item_value = if let Some(version) = wire_version { + versioned_value_expr( + &format!("{}.dec(payload)", response.wire_codec_expr), + &response.wire_type_ts, + &response.inner_type_ts, + version, + ) + } else { + format!("{}.dec(payload)", response.wire_codec_expr) + }; + entries.push(( + start_id, + format!( + " [W.{wire_const}.start]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + receive_id, + format!(" [W.{wire_const}.receive]: (payload) => {item_value},"), + )); + Ok(()) +} + fn write_observable_helper(out: &mut String) { writedoc!( out, @@ -2661,6 +2852,71 @@ mod tests { ); } + #[test] + fn generate_wire_table_emits_sensitive_frame_ids() { + let mut sign = request_method("sign", Some(10)); + sign.wire.sensitive = true; + let mut stream = subscription_method("stream", Some(20)); + stream.wire.sensitive = true; + let safe = request_method("safe", Some(30)); + + let source = + generate_wire_table(&api(vec![sign, stream, safe]), 2).expect("generate wire table"); + + // Every leg of a sensitive method lands in the set: both legs of a + // request, all four frames of a subscription. + assert!(source.contains( + "export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([10, 11, 20, 21, 22, 23]);" + )); + + // A non-sensitive method contributes none of its ids to the set. + let set_line = source + .lines() + .find(|line| line.contains("SENSITIVE_FRAME_IDS")) + .expect("sensitive set line"); + assert!(!set_line.contains("30")); + assert!(!set_line.contains("31")); + } + + #[test] + fn generate_wire_table_emits_empty_sensitive_set_when_none_marked() { + let source = generate_wire_table(&api(vec![request_method("safe", Some(10))]), 2) + .expect("generate wire table"); + assert!( + source.contains("export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([]);") + ); + } + + #[test] + fn generate_decode_table_emits_frame_keyed_decoders() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Example".to_string(), + module_path: Vec::new(), + methods: vec![ + request_method("feature_supported", Some(2)), + subscription_method("stream", Some(10)), + ], + docs: None, + }], + public_trait_order: vec!["Example".to_string()], + types: Vec::new(), + }; + + let source = generate_decode_table(&api, 2).expect("generate decode table"); + + assert!(source.contains("export const WIRE_DECODE_TABLE")); + assert!(source.contains("(payload: Uint8Array) => unknown")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.request]")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.response]")); + assert!(source.contains("[W.EXAMPLE_STREAM.start]")); + assert!(source.contains("[W.EXAMPLE_STREAM.receive]")); + assert!(source.contains(".dec(payload)")); + // stop/interrupt subscription frames are intentionally skipped. + assert!(!source.contains(".stop]")); + assert!(!source.contains(".interrupt]")); + } + #[test] fn generate_wire_table_rejects_duplicate_ids() { let err = generate_wire_table( diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index bc7d0dd3f..366a69dff 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -37,6 +37,7 @@ struct WireArgs { stop_id: Option, interrupt_id: Option, receive_id: Option, + sensitive: bool, } impl Parse for WireArgs { @@ -45,13 +46,24 @@ impl Parse for WireArgs { while !input.is_empty() { let key: Ident = input.parse()?; - input.parse::()?; - let lit: LitInt = input.parse()?; - let value = lit.base10_parse().map_err(|err| { - syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) - })?; - set_id(&mut args, &key, value)?; + // `sensitive` is a bare flag with no `= N` value: it marks the + // method's payloads as carrying key material or bearer secrets, so + // the wire debugger never decodes them. + if key == "sensitive" { + if args.sensitive { + return Err(syn::Error::new(key.span(), "duplicate `sensitive`")); + } + args.sensitive = true; + } else { + input.parse::()?; + let lit: LitInt = input.parse()?; + let value = lit.base10_parse().map_err(|err| { + syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) + })?; + + set_id(&mut args, &key, value)?; + } if input.is_empty() { break; @@ -83,7 +95,7 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { } else { return Err(syn::Error::new( key.span(), - "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`", + "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`, `sensitive`", )); }; @@ -102,6 +114,12 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { /// /// #[wire(start_id = 42)] /// async fn host_account_connection_status_subscribe(...) -> ...; +/// +/// // Mark a method whose payloads carry key material or bearer secrets. Its +/// // frame ids land in the generated `SENSITIVE_FRAME_IDS` set and are never +/// // decoded by the wire debugger. +/// #[wire(request_id = 114, sensitive)] +/// async fn sign_raw(...) -> ...; /// ``` /// /// Expands to the original method plus hidden doc tags that `truapi-codegen` @@ -134,7 +152,7 @@ pub fn wire(args: TokenStream, item: TokenStream) -> TokenStream { } fn wire_tags(args: &WireArgs) -> Vec { - [ + let mut tags: Vec = [ ("request_id", args.request_id), ("response_id", args.response_id), ("start_id", args.start_id), @@ -144,7 +162,11 @@ fn wire_tags(args: &WireArgs) -> Vec { ] .into_iter() .filter_map(|(name, value)| value.map(|id| format!("@wire_{name}={id}"))) - .collect() + .collect(); + if args.sensitive { + tags.push("@wire_sensitive=true".to_string()); + } + tags } /// One sequence of versioned envelope declarations passed to `versioned_type!`. diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index dd8a448c2..d1901de9d 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -116,7 +116,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "createAccountProof failed:", result); /// console.log("account proof created:", result.value); /// ``` - #[wire(request_id = 26)] + #[wire(request_id = 26, sensitive)] async fn create_account_proof( &self, _cx: &CallContext, @@ -147,7 +147,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "signVrf failed:", result); /// console.log("vrf signature:", result.value); /// ``` - #[wire(request_id = 164)] + #[wire(request_id = 164, sensitive)] async fn sign_vrf( &self, _cx: &CallContext, @@ -182,7 +182,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "getUserId failed:", result); /// console.log("user id:", result.value); /// ``` - #[wire(request_id = 110)] + #[wire(request_id = 110, sensitive)] async fn get_user_id( &self, _cx: &CallContext, @@ -203,7 +203,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "requestLogin failed:", result); /// console.log("login completed:", result.value); /// ``` - #[wire(request_id = 112)] + #[wire(request_id = 112, sensitive)] async fn request_login( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/coin_payment.rs b/rust/crates/truapi/src/api/coin_payment.rs index 5839b8e3c..90baf8417 100644 --- a/rust/crates/truapi/src/api/coin_payment.rs +++ b/rust/crates/truapi/src/api/coin_payment.rs @@ -141,7 +141,7 @@ pub trait CoinPayment: Send + Sync { /// assert(result.isOk(), "createCheque failed:", result); /// console.log("cheque created:", result.value.cheque); /// ``` - #[wire(request_id = 150)] + #[wire(request_id = 150, sensitive)] async fn create_cheque( &self, _cx: &CallContext, @@ -168,7 +168,7 @@ pub trait CoinPayment: Send + Sync { /// ); /// console.log("deposit status:", status); /// ``` - #[wire(start_id = 152)] + #[wire(start_id = 152, sensitive)] async fn deposit( &self, _cx: &CallContext, @@ -222,7 +222,7 @@ pub trait CoinPayment: Send + Sync { /// ); /// console.log("payment received:", item); /// ``` - #[wire(start_id = 160)] + #[wire(start_id = 160, sensitive)] async fn listen_for_payment( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/entropy.rs b/rust/crates/truapi/src/api/entropy.rs index 32f510b9b..36176db6c 100644 --- a/rust/crates/truapi/src/api/entropy.rs +++ b/rust/crates/truapi/src/api/entropy.rs @@ -18,7 +18,7 @@ pub trait Entropy: Send + Sync { /// assert(result.isOk(), "derive failed:", result); /// console.log("entropy derived:", result.value); /// ``` - #[wire(request_id = 108)] + #[wire(request_id = 108, sensitive)] async fn derive( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/local_storage.rs b/rust/crates/truapi/src/api/local_storage.rs index ec0bc6343..5c2057858 100644 --- a/rust/crates/truapi/src/api/local_storage.rs +++ b/rust/crates/truapi/src/api/local_storage.rs @@ -18,7 +18,7 @@ pub trait LocalStorage: Send + Sync { /// assert(result.isOk(), "read failed:", result); /// console.log("storage value read:", result.value.value); /// ``` - #[wire(request_id = 12)] + #[wire(request_id = 12, sensitive)] async fn read( &self, cx: &CallContext, @@ -35,7 +35,7 @@ pub trait LocalStorage: Send + Sync { /// assert(result.isOk(), "write failed:", result); /// console.log("storage write succeeded"); /// ``` - #[wire(request_id = 14)] + #[wire(request_id = 14, sensitive)] async fn write( &self, cx: &CallContext, diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index 107825f7f..9c6b9e4ae 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -112,7 +112,7 @@ pub trait Payment: Send + Sync { /// assert(result.isOk(), "topUp failed:", result); /// console.log("balance topped up"); /// ``` - #[wire(request_id = 122)] + #[wire(request_id = 122, sensitive)] async fn top_up( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 273e848dc..ce433e8dd 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -37,7 +37,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "createTransaction failed:", result); /// console.log("transaction created:", result.value); /// ``` - #[wire(request_id = 30)] + #[wire(request_id = 30, sensitive)] async fn create_transaction( &self, _cx: &CallContext, @@ -76,7 +76,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "createTransactionWithLegacyAccount failed:", result); /// console.log("transaction created:", result.value); /// ``` - #[wire(request_id = 32)] + #[wire(request_id = 32, sensitive)] async fn create_transaction_with_legacy_account( &self, _cx: &CallContext, @@ -104,7 +104,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signRawWithLegacyAccount failed:", result); /// console.log("raw bytes signed:", result.value); /// ``` - #[wire(request_id = 34)] + #[wire(request_id = 34, sensitive)] async fn sign_raw_with_legacy_account( &self, _cx: &CallContext, @@ -146,7 +146,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signPayloadWithLegacyAccount failed:", result); /// console.log("payload signed:", result.value); /// ``` - #[wire(request_id = 36)] + #[wire(request_id = 36, sensitive)] async fn sign_payload_with_legacy_account( &self, _cx: &CallContext, @@ -173,7 +173,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signRaw failed:", result); /// console.log("raw bytes signed:", result.value); /// ``` - #[wire(request_id = 114)] + #[wire(request_id = 114, sensitive)] async fn sign_raw( &self, _cx: &CallContext, @@ -206,7 +206,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signPayload failed:", result); /// console.log("payload signed:", result.value); /// ``` - #[wire(request_id = 116)] + #[wire(request_id = 116, sensitive)] async fn sign_payload( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index aa2415885..b864ab7dc 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -52,7 +52,7 @@ pub trait StatementStore: Send + Sync { /// ); /// console.log("subscribe received", page); /// ``` - #[wire(start_id = 56)] + #[wire(start_id = 56, sensitive)] async fn subscribe( &self, _cx: &CallContext, @@ -91,7 +91,7 @@ pub trait StatementStore: Send + Sync { /// console.log("proof created:", result.value); /// } /// ``` - #[wire(request_id = 60)] + #[wire(request_id = 60, sensitive)] async fn create_proof( &self, _cx: &CallContext, @@ -118,7 +118,7 @@ pub trait StatementStore: Send + Sync { /// assert(result.isOk(), "createProof failed:", result); /// console.log("proof created:", result.value); /// ``` - #[wire(request_id = 132)] + #[wire(request_id = 132, sensitive)] async fn create_proof_authorized( &self, _cx: &CallContext, @@ -150,7 +150,7 @@ pub trait StatementStore: Send + Sync { /// assert(result.isOk(), "submit failed:", result); /// console.log("statement submitted"); /// ``` - #[wire(request_id = 62)] + #[wire(request_id = 62, sensitive)] async fn submit( &self, _cx: &CallContext, From b81118655e49c96feb614aa33066bdb0672938e8 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 31 Jul 2026 19:59:18 +0530 Subject: [PATCH 2/9] feat(truapi-server): payload-blind wire-debug tap with loopback and wasm sinks --- Cargo.lock | 1 + rust/crates/truapi-server/Cargo.toml | 7 +- rust/crates/truapi-server/src/host_core.rs | 241 ++++++++++++- rust/crates/truapi-server/src/lib.rs | 11 +- rust/crates/truapi-server/src/native_debug.rs | 336 ++++++++++++++++++ rust/crates/truapi-server/src/wasm.rs | 41 ++- 6 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 rust/crates/truapi-server/src/native_debug.rs diff --git a/Cargo.lock b/Cargo.lock index c3635b0b2..2ef747a0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5097,6 +5097,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "async-trait", + "base64", "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 2c441c6fe..8f09ef36c 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -27,7 +27,7 @@ dwarf-debug-info = false [features] default = [] -ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand"] +ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand", "dep:base64"] [dependencies] truapi = { path = "../truapi" } @@ -64,13 +64,14 @@ verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b0 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] futures = { version = "0.3", features = ["thread-pool"] } rand = { version = "0.8", optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"], optional = true } tokio-tungstenite = { version = "0.21", default-features = false, features = ["handshake"], optional = true } uniffi = "0.29.4" subxt = { version = "0.50.2", default-features = false, features = ["native"] } subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } scale-info = { version = "2.11", default-features = false, features = ["decode"] } +base64 = { version = "0.22", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } @@ -92,7 +93,7 @@ wasm-bindgen-test = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"] } -tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect"] } +tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "handshake"] } [lints] workspace = true diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 2e6849513..269a65f0b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -42,6 +42,68 @@ pub trait FrameSink: Send + Sync { fn emit_frame(&self, frame: Vec); } +/// Dev-only sink that observes host debug events at the core's two frame choke +/// points. A host that does not enable the debugger leaves it unset and the tap +/// is inert. Fire-and-forget by construction: [`DebugSink::emit`] must not block +/// the frame path and must not fail the operation that produced the event, so a +/// slow, absent, or crashed debugger only loses the trace, never a session. +pub trait DebugSink: Send + Sync { + /// Hand one event to the sink. + /// + /// Must not block, and must not panic: `emit` is called from inside the + /// inbound and outbound frame paths, so a panic here would unwind into a + /// live dispatch. Serialize and enqueue only; never do fallible work that + /// can `unwrap`/panic on the caller's thread. + fn emit(&self, event: DebugEvent); +} + +/// Identifies which product channel on a host a debug event belongs to, so one +/// debugger app can demultiplex several channels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelId(pub String); + +/// Direction of a tapped frame relative to the host core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameDirection { + /// Product to core (inbound to the host). + In, + /// Core to product (outbound from the host). + Out, +} + +impl FrameDirection { + /// The wire direction string, from the **product's** vantage - the vantage + /// the debugger app and the design doc use: `"out"` = the frame left the + /// product, `"in"` = it arrived at the product. This is the inverse of the + /// enum's host-vantage variants (`In` = product to core, i.e. it *left* the + /// product), so every sink serializes the same product-vantage string + /// instead of re-deriving (and risking inverting) it. + pub fn wire_str(self) -> &'static str { + match self { + FrameDirection::In => "out", + FrameDirection::Out => "in", + } + } +} + +/// One observable host debug event. Frame bytes are the untouched +/// `ProtocolMessage`; the debugger decodes them, so the core never does. The +/// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, +/// so it is `#[non_exhaustive]`: adding a variant is not a breaking change. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum DebugEvent { + /// A SCALE wire frame crossing a product channel. + Frame { + /// Which product channel on this host. + channel_id: ChannelId, + /// Product to core, or core to product. + dir: FrameDirection, + /// Untouched encoded `ProtocolMessage` bytes. + bytes: Vec, + }, +} + /// Errors returned by [`ProductRuntime::receive_frame`]. #[derive(Debug, Error)] pub enum ProductRuntimeError { @@ -466,6 +528,8 @@ impl ProductRuntime { let transport = Arc::new(SinkTransport { sink, disposed: disposed.clone(), + has_debug: AtomicBool::new(false), + debug: Mutex::new(None), }); let admin = HostAdmin::new(services.clone(), authority.clone(), product); Self { @@ -493,6 +557,15 @@ impl ProductRuntime { return Ok(()); } + // Tap inbound before decode, so a corrupt frame is still observed. + if let Some((channel_id, debug)) = self.transport.debug() { + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }); + } + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { ProductRuntimeError::InvalidFrame { reason: err.to_string(), @@ -557,6 +630,13 @@ impl ProductRuntime { .await } + /// Install a dev-only [`DebugSink`] that observes every product frame in + /// both directions for `channel_id`. Absent by default and inert in + /// production; fire-and-forget, so it can never stall or fail a dispatch. + pub fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + self.transport.set_debug_sink(channel_id, sink); + } + /// Dispose this host core. Idempotent. /// /// Disposal suppresses future outgoing frames, aborts in-flight dispatch @@ -581,6 +661,33 @@ impl ProductRuntime { struct SinkTransport { sink: Arc, disposed: Arc, + /// Fast-path flag: `false` (the production default) lets the per-frame + /// `debug()` return without touching the mutex. Set once when a sink is + /// installed; a reader that races the install just misses one frame. + has_debug: AtomicBool, + debug: Mutex)>>, +} + +impl SinkTransport { + /// The installed debug sink and its channel, if any. Lock-free `None` on the + /// production path (no sink installed); only locks once one is. + fn debug(&self) -> Option<(ChannelId, Arc)> { + if !self.has_debug.load(Ordering::Relaxed) { + return None; + } + self.debug + .lock() + .expect("host core debug sink mutex poisoned") + .clone() + } + + fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + *self + .debug + .lock() + .expect("host core debug sink mutex poisoned") = Some((channel_id, sink)); + self.has_debug.store(true, Ordering::Relaxed); + } } impl Transport for SinkTransport { @@ -588,7 +695,20 @@ impl Transport for SinkTransport { if self.disposed.load(Ordering::Acquire) { return; } - self.sink.emit_frame(message.encode()); + let encoded = message.encode(); + // Forward to the product first, then tap: the debugger is in the path + // but never in the critical path. + match self.debug() { + Some((channel_id, debug)) => { + self.sink.emit_frame(encoded.clone()); + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }); + } + None => self.sink.emit_frame(encoded), + } } fn on_message( @@ -640,6 +760,125 @@ mod tests { assert_send(runtime.receive_frame(Vec::new())); } + #[derive(Default)] + struct RecordingDebugSink { + events: Mutex)>>, + } + + impl DebugSink for RecordingDebugSink { + fn emit(&self, event: DebugEvent) { + match event { + DebugEvent::Frame { + channel_id, + dir, + bytes, + } => self + .events + .lock() + .expect("debug events mutex poisoned") + .push((channel_id, dir, bytes)), + } + } + } + + #[test] + fn debug_sink_taps_frames_in_both_directions() { + let platform = Arc::new(StubPlatform::default()); + let sink = Arc::new(RecordingSink::default()); + let debug = Arc::new(RecordingDebugSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink.clone(), + ); + runtime.set_debug_sink(ChannelId("myapp.dot".to_string()), debug.clone()); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + let raw = frame.encode(); + futures::executor::block_on(runtime.receive_frame(raw.clone())).unwrap(); + + // The subscription's first item is emitted asynchronously; wait for it, + // then let the tap (which runs right after delivery in `send`) settle. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .is_empty() + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + + // Snapshot into owned vecs (never hold a lock across an assertion). + let (inbound, outbound, channels): (Vec>, Vec>, Vec) = { + let events = debug.events.lock().expect("debug events mutex poisoned"); + ( + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::In) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::Out) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events.iter().map(|(cid, _, _)| cid.clone()).collect(), + ) + }; + let delivered = sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .clone(); + + // Every event carries the installed channel id. + assert!( + channels + .iter() + .all(|c| *c == ChannelId("myapp.dot".to_string())), + "every event carries its channel id" + ); + // Inbound tapped once, untouched, before decode. + assert_eq!( + inbound, + vec![raw], + "inbound frame tapped exactly once, untouched" + ); + // Both directions fire, and every delivered outbound frame is tapped in + // order: the tap is in the path, not a fabricated side channel. + assert!( + !outbound.is_empty(), + "at least one outbound frame is tapped" + ); + assert_eq!( + outbound, delivered, + "every delivered outbound frame is tapped, in order" + ); + } + + #[test] + fn frame_direction_wire_str_is_product_vantage() { + // The wire string is product-vantage (what the debugger and design doc + // use), the inverse of the enum's host-vantage names: a frame the host + // tapped as `In` (product to core) *left the product*, so it serializes + // as `"out"`. This pins the convention so a sink can't re-invert it. + assert_eq!(FrameDirection::In.wire_str(), "out"); + assert_eq!(FrameDirection::Out.wire_str(), "in"); + } + #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index cf10b2153..7ba2338d4 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -10,6 +10,8 @@ //! native WebView hosts (Android/iOS). //! - [`native`]: UniFFI surface exposing the native host runtime + callbacks. //! - `wasm` (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. +//! - `native_debug` (non-wasm32 only): a loopback WebSocket [`DebugSink`] that +//! streams tapped frames to the `@parity/truapi-debugger` app. #![forbid(unsafe_code)] @@ -39,10 +41,15 @@ pub mod native; #[cfg(target_arch = "wasm32")] pub mod wasm; +#[cfg(all(not(target_arch = "wasm32"), feature = "ws-bridge"))] +pub mod native_debug; + pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, - SigningHostRuntime, + ChannelId, DebugEvent, DebugSink, FrameDirection, FrameSink, HostAdmin, PairingHostRuntime, + ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +#[cfg(all(not(target_arch = "wasm32"), feature = "ws-bridge"))] +pub use native_debug::{DebugSinkError, WsDebugSink}; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; diff --git a/rust/crates/truapi-server/src/native_debug.rs b/rust/crates/truapi-server/src/native_debug.rs new file mode 100644 index 000000000..89b1b1b4b --- /dev/null +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -0,0 +1,336 @@ +//! Native (non-wasm) [`DebugSink`]: streams tapped frames to a loopback +//! `@parity/truapi-debugger` over a WebSocket. +//! +//! The native counterpart of the wasm [`crate::wasm`] `WasmDebugSink`: a dumb, +//! payload-blind byte-forwarder. Each [`DebugEvent::Frame`] is serialized to the +//! debugger's wire envelope - `{channelId, dir, frame}`, where `frame` is the +//! base64 of the untouched SCALE `ProtocolMessage` bytes - and sent as one WS +//! text message. Decoding and the sensitive-frame denylist live in the debugger +//! app, never here. +//! +//! Fire-and-forget by construction, per the [`DebugSink`] contract: +//! [`WsDebugSink::emit`] never blocks and never fails a dispatch. It only +//! serializes and pushes onto a bounded queue; a background task owns the socket, +//! reconnects with capped backoff, and drops frames (counted) when the queue is +//! full. A slow, absent, or crashed debugger loses traces, never a session. +//! +//! Localhost only: the target URL must be `ws://` on a loopback host. No `wss`, +//! no certificates, no LAN. Construct via [`WsDebugSink::connect`] from within a +//! Tokio runtime and install with [`crate::ProductRuntime::set_debug_sink`]; +//! constructing one is a dev-only opt-in, so a host that never calls it leaves +//! the tap inert. + +use core::net::SocketAddr; +use core::sync::atomic::{AtomicU64, Ordering}; +use core::time::Duration; +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use futures::{SinkExt, StreamExt}; +use serde::Serialize; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio::runtime::Handle; +use tokio::sync::mpsc; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; + +use crate::host_core::{DebugEvent, DebugSink}; + +/// Bounded so a stalled or absent debugger applies backpressure as counted +/// drops, never unbounded memory growth on the observed session. +const QUEUE_CAPACITY: usize = 4096; + +/// Initial reconnect delay; doubles on each failed dial up to [`MAX_BACKOFF`]. +const INITIAL_BACKOFF: Duration = Duration::from_millis(200); + +/// Cap on the reconnect backoff. +const MAX_BACKOFF: Duration = Duration::from_secs(5); + +/// Cap on a single dial + WS handshake; a port that accepts TCP but never +/// completes the upgrade must not park the writer task forever. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Failure building a [`WsDebugSink`]. +#[derive(Debug, Error)] +pub enum DebugSinkError { + /// The debug URL did not parse. + #[error("invalid debug url: {0}")] + Url(#[from] url::ParseError), + /// The debug URL was not `ws://` on a loopback host. + #[error("debug url must be ws:// on a loopback host, got {0}")] + NotLoopback(String), + /// The debug URL host could not be resolved. + #[error("could not resolve debug url host: {0}")] + Resolve(#[from] std::io::Error), + /// `connect` was called outside a Tokio runtime. + #[error("WsDebugSink::connect must be called from within a Tokio runtime")] + NoRuntime, +} + +/// A dev-only [`DebugSink`] that forwards tapped frames to a loopback debugger +/// over a WebSocket, using the same `{channelId, dir, frame: base64}` envelope +/// the browser host sends. +pub struct WsDebugSink { + outbound: mpsc::Sender, + dropped: Arc, +} + +/// The wire envelope, matching the debugger's `parseWireMessage` / ingest +/// `DebugFrameEnvelope`: `dir` is product-vantage, `frame` is base64 SCALE bytes. +#[derive(Serialize)] +struct WireMessage<'a> { + #[serde(rename = "channelId")] + channel_id: &'a str, + dir: &'a str, + frame: String, +} + +impl WsDebugSink { + /// Build a sink targeting `url` and spawn its writer task. + /// + /// `url` must be `ws://` on `127.0.0.1`, `localhost`, or `[::1]`. Returns + /// immediately even if the debugger is not yet listening; the writer task + /// dials lazily and reconnects. Must be called from within a Tokio runtime. + pub fn connect(url: &str) -> Result, DebugSinkError> { + // Require ws://, then RESOLVE the host and require every resolved + // address to be loopback. Resolving (rather than string-matching the + // host) accepts all genuine loopback forms - 127.0.0.0/8, ::1, and a + // `localhost` that resolves to them - and rejects anything resolving + // off-loopback, closing the "validate one string, dial another" gap. + let parsed = url::Url::parse(url)?; + if parsed.scheme() != "ws" { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + // `Url::socket_addrs` resolves the host (IP literal or DNS) and handles + // IPv6 bracket-stripping and the default port; requiring every resolved + // address to be loopback accepts all genuine loopback forms (127.0.0.0/8, + // ::1, a `localhost` that resolves to them) and rejects anything that + // resolves off-loopback. + let addrs = parsed.socket_addrs(|| Some(80))?; + if !addrs.iter().all(|addr| addr.ip().is_loopback()) { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + // Capture the resolved loopback address and dial *it* directly (in + // `writer_loop`), rather than re-resolving the URL string on every dial. + // The WS handshake is therefore only ever sent to this checked loopback + // peer - closing the resolve-then-dial gap where a mid-session resolver + // change could send the handshake off-box. + let Some(addr) = addrs.first().copied() else { + return Err(DebugSinkError::NotLoopback(url.to_string())); + }; + + // Return a Result rather than panicking inside tokio::spawn when called + // outside a runtime. + if Handle::try_current().is_err() { + return Err(DebugSinkError::NoRuntime); + } + + let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); + let dropped = Arc::new(AtomicU64::new(0)); + tokio::spawn(writer_loop( + url.to_string(), + addr, + inbox, + Arc::clone(&dropped), + )); + Ok(Arc::new(Self { outbound, dropped })) + } + + /// Number of frames dropped because the outbound queue was full (debugger + /// absent or slower than the observed session). Never affects the session. + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +impl DebugSink for WsDebugSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + dir, + bytes, + } = event; + let message = WireMessage { + channel_id: &channel_id.0, + // Product-vantage string; never hand-mapped, so it cannot invert. + dir: dir.wire_str(), + frame: BASE64.encode(&bytes), + }; + let Ok(line) = serde_json::to_string(&message) else { + self.dropped.fetch_add(1, Ordering::Relaxed); + return; + }; + if self.outbound.try_send(line).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Own the socket for the sink's lifetime: dial with capped backoff, then drain +/// the queue to the wire until the sink is dropped. +async fn writer_loop( + url: String, + addr: SocketAddr, + mut inbox: mpsc::Receiver, + dropped: Arc, +) { + let mut backoff = INITIAL_BACKOFF; + loop { + // Dial the pre-validated loopback address directly, then run the WS + // handshake over that socket. The address is not re-resolved, so the + // handshake can never reach an off-box peer. The whole dial+handshake is + // bounded so a TCP-accepting but non-upgrading port can't park the task. + let dialed = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + let tcp = TcpStream::connect(addr).await.ok()?; + client_async(url.as_str(), tcp).await.ok() + }) + .await; + let stream = match dialed { + Ok(Some((stream, _response))) => Some(stream), + Ok(None) => { + debug!("truapi debug sink: dial/handshake failed, retrying"); + None + } + Err(_) => { + debug!("truapi debug sink: handshake timed out, retrying"); + None + } + }; + let Some(stream) = stream else { + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + // The sink was dropped while we were retrying: give up. + if inbox.is_closed() { + return; + } + continue; + }; + let (mut write, mut read) = stream.split(); + // Drain queued frames to the wire, and also poll the read half so + // tokio-tungstenite answers server pings and observes a Close; being + // forward-only, any inbound message is ignored. Reset backoff only on a + // *delivered* frame, so an accept-then-close server still backs off + // instead of spinning on zero-delay reconnects. + loop { + tokio::select! { + queued = inbox.recv() => match queued { + Some(line) => match write.send(Message::Text(line)).await { + Ok(()) => backoff = INITIAL_BACKOFF, + Err(_) => { + debug!("truapi debug sink: socket closed, reconnecting"); + // The in-flight line is lost across this reconnect. + dropped.fetch_add(1, Ordering::Relaxed); + break; + } + }, + // All senders dropped: the sink is gone, so is the host. Done. + None => return, + }, + inbound = read.next() => match inbound { + Some(Ok(_)) => {} // forward-only: ignore any inbound message + Some(Err(_)) | None => { + debug!("truapi debug sink: read side closed, reconnecting"); + break; + } + }, + } + } + // Reconnect after an established socket dropped: back off here too. + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + if inbox.is_closed() { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_core::{ChannelId, FrameDirection}; + + use tokio::net::TcpListener; + use tokio::sync::oneshot; + use tokio_tungstenite::accept_async; + + #[tokio::test] + async fn emits_base64_envelope_with_product_vantage_dir() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Server side: accept one connection, capture the first text message. + let (tx, rx) = oneshot::channel::(); + tokio::spawn(async move { + let (stream, _peer) = listener.accept().await.unwrap(); + let ws = accept_async(stream).await.unwrap(); + let (_write, mut read) = ws.split(); + let message = read.next().await.unwrap().unwrap(); + tx.send(message.into_text().unwrap()).unwrap(); + }); + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + // `In` = product→core, i.e. the frame *left* the product → product-vantage "out". + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::In, + bytes: vec![1, 2, 3, 4], + }); + + let text = tokio::time::timeout(Duration::from_secs(5), rx) + .await + .expect("debugger did not receive a frame") + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + + assert_eq!(value["channelId"], "myapp.dot"); + // Guard against re-inversion: In must serialize as product-vantage "out". + assert_eq!(value["dir"], FrameDirection::In.wire_str()); + assert_eq!(value["dir"], "out"); + assert_eq!(value["frame"], BASE64.encode([1, 2, 3, 4])); + } + + #[test] + fn rejects_non_loopback_and_non_ws_urls() { + // 192.0.2.1 (TEST-NET-1) is a non-loopback IP literal, so no DNS is hit. + assert!(WsDebugSink::connect("wss://127.0.0.1:9231").is_err()); + assert!(WsDebugSink::connect("ws://192.0.2.1:9231").is_err()); + assert!(WsDebugSink::connect("http://127.0.0.1:9231").is_err()); + assert!(WsDebugSink::connect("not a url").is_err()); + } + + #[tokio::test] + async fn accepts_loopback_forms() { + for url in [ + "ws://127.0.0.1:9231", + "ws://localhost:9231", + "ws://[::1]:9231", + ] { + assert!(WsDebugSink::connect(url).is_ok(), "should accept {url}"); + } + } + + #[tokio::test] + async fn emit_is_nonblocking_and_counts_drops_when_debugger_absent() { + // A loopback port with nothing listening: dials never succeed, so the + // bounded queue fills and further frames are dropped, never blocking emit. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); // free the port; nothing is listening now + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + for _ in 0..(QUEUE_CAPACITY + 50) { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: vec![1], + }); + } + assert!( + sink.dropped() > 0, + "a full queue must count drops, not block" + ); + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 74ea5998b..e3049c4d5 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -31,8 +31,8 @@ use wasm_bindgen::prelude::*; use crate::subscription::Spawner; use crate::{ - FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - ProductRuntime, + ChannelId, DebugEvent, DebugSink, FrameSink, PairingHostRuntime, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, ProductRuntime, }; mod generated_bridge; @@ -67,6 +67,33 @@ impl FrameSink for WasmFrameSink { } } +/// Streams tapped debug frames out to a JS `debugEmit(channelId, dir, frame)` +/// callback so the host worker can forward them to the debugger it dials. +/// Dev-only: installed only when the host provides the callback, and +/// fire-and-forget - a failing callback is logged, never propagated. +struct WasmDebugSink { + emit: SendWrapper, +} + +impl DebugSink for WasmDebugSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + dir, + bytes, + } = event; + let frame = Uint8Array::from(bytes.as_slice()); + if let Err(err) = self.emit.call3( + &JsValue::NULL, + &JsValue::from_str(&channel_id.0), + &JsValue::from_str(dir.wire_str()), + &frame, + ) { + web_sys::console::error_1(&err); + } + } +} + struct WasmPlatform { bridge: SendWrapper>, } @@ -705,10 +732,20 @@ impl WasmPairingHostRuntime { ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; + let debug_emit = get_optional_function(&core_callbacks, "debugEmit")?; + let channel_id = product.product_id.clone(); let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); let runtime = self.runtime.product_runtime(product, sink); + if let Some(debug_emit) = debug_emit { + runtime.set_debug_sink( + ChannelId(channel_id), + Arc::new(WasmDebugSink { + emit: SendWrapper::new(debug_emit), + }), + ); + } Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) } From c09d205ac7c0c22545458e410a12342d5a94f69e Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:50 +0530 Subject: [PATCH 3/9] feat(truapi-host): dev-gated worker dial to the wire debugger --- js/packages/truapi-host/README.md | 16 +++ .../src/web/create-worker-host-runtime.ts | 12 ++ .../src/web/worker-provider.test.ts | 1 + .../truapi-host/src/worker-protocol.ts | 9 +- js/packages/truapi-host/src/worker-runtime.ts | 116 +++++++++++++++++- playground/tests/e2e/helpers.ts | 6 +- 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 44e0cd0f9..f9d316bfc 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -55,6 +55,22 @@ const secondProvider = await runtime.createProvider({ protocol-iframe MessageChannel handshake. Host code creates one worker runtime and then opens one provider per product id. +## Debugging (dev-only) + +The worker can stream every product↔core wire frame to the wire debugger. It is +off by default and enabled purely from the host page — the product needs no +changes. Set a debugger URL in the host origin's `localStorage`, then run the +debugger (`@parity/truapi-debugger`, `npm run serve`, `:9231`): + +```js +localStorage.setItem("truapi:debugger", "ws://localhost:9231"); +``` + +On the next runtime boot the worker reads that URL, dials the debugger, and (via +the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. +Unset in production, so nothing dials and the core installs no tap. Design: +`docs/design/wire-observability-debug-host.md`. + ## Publishing This package is published by the root `Release` workflow through diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 8ea3eb864..b2951f98a 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -116,6 +116,17 @@ function readPersistedLogLevel(): LogLevel | null { return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; } +// Dev-only, host-agnostic enablement for the wire debugger: set +// `localStorage["truapi:debugger"] = "ws://:9231"` in the browser and the +// host worker dials that debugger and streams frames to it. Unset in production, +// so nothing dials and the Rust host tap stays inert. Read here (host page) and +// forwarded to the worker in `init`; no cooperation from the embedding shell. +const DEV_DEBUGGER_URL_KEY = "truapi:debugger"; + +function readPersistedDebuggerUrl(): string | null { + return globalThis.localStorage?.getItem(DEV_DEBUGGER_URL_KEY) ?? null; +} + function persistLogLevel(level: LogLevel): void { globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); } @@ -603,6 +614,7 @@ export function createWebWorkerPairingHostRuntime( kind: "init", logLevel: devLogLevelOverride ?? options.logLevel ?? "off", hostConfig: options.hostConfig, + debuggerUrl: readPersistedDebuggerUrl(), } satisfies MainToWorker); } else if (msg.kind === "ready") { cleanupInit(); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 3c75785d5..d7bd5da65 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -195,6 +195,7 @@ describe("createWebWorkerPairingHostRuntime", () => { kind: "init", logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), + debuggerUrl: null, }); worker.emit({ kind: "ready" }); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index cdb3ea586..dde2f54ee 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -55,7 +55,14 @@ export type CallbackArgs = readonly unknown[]; * host callback/subscription/chain responses requested by the worker. */ export type MainToWorker = - | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { + kind: "init"; + logLevel: LogLevel; + hostConfig: unknown; + // Dev-only: when set, the worker dials this debugger and streams tapped + // frames to it. Null in production, so the host tap stays inert. + debuggerUrl: string | null; + } | { kind: "createCore"; coreId: number; product: unknown } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index c986b9a5c..7f51f654e 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -187,8 +187,110 @@ function buildRawCallbacks() { }); } -function buildCoreCallbacks(coreId: number) { +/** Encode raw frame bytes as base64 (JSON can't carry binary over the WS). */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +/** + * Dev-only link to the debugger the host dials. Fire-and-forget by construction: + * it opens lazily, buffers a bounded backlog until the socket is up, retries a + * dropped connection, and swallows every error - a slow, absent, or crashed + * debugger only loses the trace, it can never throw into the frame path. + */ +/** + * Is `url` a WebSocket URL on a loopback host? The debug tap forwards raw frames + * (including sensitive payloads, before the debugger's denylist runs), so it is + * loopback-only: refuse to stream them off the local machine. + */ +function isLoopbackWsUrl(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol !== "ws:" && u.protocol !== "wss:") return false; + const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + host === "localhost" || + host === "::1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || + // IPv4-mapped loopback: WHATWG serializes ::ffff:127.x.y.z as ::ffff:7fxx:yyyy. + /^::ffff:7f[0-9a-f]{2}:/.test(host) + ); + } catch { + return false; + } +} + +function createDebuggerLink(url: string): { + emit(channelId: string, dir: string, frame: Uint8Array): void; +} { + // Loopback-only, dev-only: a non-loopback debugger URL yields an inert link + // rather than streaming frames across the network. + if (!isLoopbackWsUrl(url)) return { emit() {} }; + let socket: WebSocket | null = null; + let open = false; + const queue: string[] = []; + const MAX_QUEUE = 1000; + + function connect(): void { + try { + socket = new WebSocket(url); + } catch { + socket = null; + return; + } + socket.addEventListener("open", () => { + open = true; + for (const message of queue.splice(0)) send(message); + }); + socket.addEventListener("close", () => { + open = false; + socket = null; + }); + socket.addEventListener("error", () => { + // A socket that fired `error` is dead: close it explicitly (tidiness), then + // null it so `emit`'s `if (!socket) connect()` reconnects. Without the null, + // a runtime that fires `error` without a following `close` would leave + // `socket` non-null and frames would buffer then drop. + open = false; + const dead = socket; + socket = null; + try { + dead?.close(); + } catch { + // already closed / closing + } + }); + } + + function send(message: string): void { + try { + socket?.send(message); + } catch { + // A dead socket must never break the frame path. + } + } + + connect(); + return { + emit(channelId, dir, frame) { + const message = JSON.stringify({ channelId, dir, frame: toBase64(frame) }); + if (open && socket) { + send(message); + return; + } + if (queue.length < MAX_QUEUE) queue.push(message); + if (!socket) connect(); + }, + }; +} + +let debuggerLink: ReturnType | null = null; + +function buildCoreCallbacks(coreId: number) { + const callbacks = { emitFrame(frame: Uint8Array): void { postToMain({ kind: "frame", coreId, bytes: frame }); }, @@ -196,6 +298,15 @@ function buildCoreCallbacks(coreId: number) { // Main thread owns lifecycle and disposes explicitly. }, }; + if (!debuggerLink) return callbacks; + // Adding `debugEmit` is what makes the Rust host install its debug sink; when + // no debugger is configured it is absent and the tap stays inert. + return { + ...callbacks, + debugEmit(channelId: string, dir: string, frame: Uint8Array): void { + debuggerLink?.emit(channelId, dir, frame); + }, + }; } let runtime: WorkerPairingHostRuntime | null = null; @@ -231,6 +342,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { break; } wasm.setLogLevel?.(msg.logLevel); + if (msg.debuggerUrl && !debuggerLink) { + debuggerLink = createDebuggerLink(msg.debuggerUrl); + } try { runtime = new wasm.WasmPairingHostRuntime( buildRawCallbacks(), diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index c61550efe..9417f4e42 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -9,7 +9,9 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * We hand back the FrameLocator scoped to that iframe so individual specs only * need to know about playground selectors. */ -export async function openPlaygroundInDotli(page: Page): Promise { +export async function openPlaygroundInDotli( + page: Page, +): Promise { await page.addInitScript(() => { localStorage.setItem("dotli:mode", "gateway"); localStorage.setItem("dotli:chain-backend", "rpc"); @@ -24,7 +26,7 @@ export async function openPlaygroundInDotli(page: Page): Promise { window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } ).__TRUAPI_PLAYGROUND_E2E__ = true; }); - await page.goto("/localhost:3000?dotliProductId=truapi-playground.dot"); + await page.goto(`/localhost:3000?dotliProductId=truapi-playground.dot`); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. From d05080587d23aa62348e1af2b74b7f7604fee6b5 Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:51 +0530 Subject: [PATCH 4/9] feat(truapi-debugger): wire trace, decode, and render engine --- .github/workflows/ci.yml | 49 ++- CLAUDE.md | 7 + js/packages/truapi-debugger/.gitignore | 3 + js/packages/truapi-debugger/README.md | 95 +++++ js/packages/truapi-debugger/package.json | 26 ++ .../truapi-debugger/src/decode.test.ts | 396 ++++++++++++++++++ js/packages/truapi-debugger/src/decode.ts | 238 +++++++++++ js/packages/truapi-debugger/src/index.ts | 43 ++ js/packages/truapi-debugger/src/ingest.ts | 93 ++++ .../truapi-debugger/src/observed-frame.ts | 68 +++ .../truapi-debugger/src/operation-row.test.ts | 129 ++++++ .../truapi-debugger/src/retry-storm.test.ts | 150 +++++++ .../truapi-debugger/src/retry-storm.ts | 94 +++++ js/packages/truapi-debugger/src/session.ts | 142 +++++++ .../truapi-debugger/src/trace-render.test.ts | 106 +++++ .../truapi-debugger/src/trace-render.ts | 390 +++++++++++++++++ .../truapi-debugger/src/trace-styles.ts | 199 +++++++++ .../truapi-debugger/src/trace-view.test.ts | 138 ++++++ js/packages/truapi-debugger/src/trace-view.ts | 315 ++++++++++++++ .../truapi-debugger/src/wire-debugger.test.ts | 86 ++++ .../truapi-debugger/src/wire-debugger.ts | 255 +++++++++++ js/packages/truapi-debugger/tsconfig.json | 20 + package-lock.json | 30 ++ 23 files changed, 3071 insertions(+), 1 deletion(-) create mode 100644 js/packages/truapi-debugger/.gitignore create mode 100644 js/packages/truapi-debugger/README.md create mode 100644 js/packages/truapi-debugger/package.json create mode 100644 js/packages/truapi-debugger/src/decode.test.ts create mode 100644 js/packages/truapi-debugger/src/decode.ts create mode 100644 js/packages/truapi-debugger/src/index.ts create mode 100644 js/packages/truapi-debugger/src/ingest.ts create mode 100644 js/packages/truapi-debugger/src/observed-frame.ts create mode 100644 js/packages/truapi-debugger/src/operation-row.test.ts create mode 100644 js/packages/truapi-debugger/src/retry-storm.test.ts create mode 100644 js/packages/truapi-debugger/src/retry-storm.ts create mode 100644 js/packages/truapi-debugger/src/session.ts create mode 100644 js/packages/truapi-debugger/src/trace-render.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-render.ts create mode 100644 js/packages/truapi-debugger/src/trace-styles.ts create mode 100644 js/packages/truapi-debugger/src/trace-view.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-view.ts create mode 100644 js/packages/truapi-debugger/src/wire-debugger.test.ts create mode 100644 js/packages/truapi-debugger/src/wire-debugger.ts create mode 100644 js/packages/truapi-debugger/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d583e5b3d..80732cce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: | @@ -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 }}" diff --git a/CLAUDE.md b/CLAUDE.md index aef6089f1..6fa54b36d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/js/packages/truapi-debugger/.gitignore b/js/packages/truapi-debugger/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/js/packages/truapi-debugger/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md new file mode 100644 index 000000000..ade73a1c4 --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -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://: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. diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json new file mode 100644 index 000000000..f6e800693 --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -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 ", + "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" + } +} diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts new file mode 100644 index 000000000..3c7d78c38 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -0,0 +1,396 @@ +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, + SENSITIVE_FRAME_IDS, + 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("sensitive denylist from the generated wire-table", () => { + // Authoritative denylist: the generated SENSITIVE_FRAME_IDS set, emitted by + // truapi-codegen from every `#[wire(..., sensitive)]` method on the Rust trait. + const sensitive = SENSITIVE_FRAME_IDS; + + test("re-exports the generated SENSITIVE_FRAME_IDS set verbatim", () => { + expect(sensitive).toBe(W.SENSITIVE_FRAME_IDS); + }); + + // Every id of each sensitive family must be present (both request/response, + // both start/receive), so neither leg of a sensitive op can be decoded. + const mustExclude: Record> = { + "signing/create-transaction": Object.values(W.SIGNING_CREATE_TRANSACTION), + "signing/sign-raw": Object.values(W.SIGNING_SIGN_RAW), + "signing/sign-payload": Object.values(W.SIGNING_SIGN_PAYLOAD), + "signing/sign-raw-legacy": Object.values( + W.SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, + ), + "account/create-proof": Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), + "statement-store/create-proof": Object.values(W.STATEMENT_STORE_CREATE_PROOF), + "statement-store/create-proof-authorized": Object.values( + W.STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, + ), + "entropy/derive": Object.values(W.ENTROPY_DERIVE), + "account/request-login": Object.values(W.ACCOUNT_REQUEST_LOGIN), + "account/get-user-id": Object.values(W.ACCOUNT_GET_USER_ID), + "account/sign-vrf": Object.values(W.ACCOUNT_SIGN_VRF), + "local-storage/read": Object.values(W.LOCAL_STORAGE_READ), + "local-storage/write": Object.values(W.LOCAL_STORAGE_WRITE), + // Payment payloads carrying key material / bearer secrets (C1/M2). + "payment/top-up": Object.values(W.PAYMENT_TOP_UP), + "coin-payment/create-cheque": Object.values(W.COIN_PAYMENT_CREATE_CHEQUE), + "coin-payment/deposit": Object.values(W.COIN_PAYMENT_DEPOSIT), + "coin-payment/listen-for-payment": Object.values( + W.COIN_PAYMENT_LISTEN_FOR_PAYMENT, + ), + // Statement-store subscribe/submit carry SignedStatement.decryptionKey. + "statement-store/subscribe": Object.values(W.STATEMENT_STORE_SUBSCRIBE), + "statement-store/submit": Object.values(W.STATEMENT_STORE_SUBMIT), + }; + for (const [name, ids] of Object.entries(mustExclude)) { + test(`excludes ${name}`, () => { + for (const id of ids) expect(sensitive.has(id)).toBe(true); + }); + } + + // Non-sensitive families stay decodable: chain reads, account reads, payments. + // local-storage/clear is deliberately decodable — its request is just a key + // name and its response is empty, so unlike read/write it carries no secret. + const mustAllow: Record> = { + "local-storage/clear": Object.values(W.LOCAL_STORAGE_CLEAR), + "account/get-account": Object.values(W.ACCOUNT_GET_ACCOUNT), + "account/connection-status": Object.values( + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, + ), + "chain/call-head": Object.values(W.CHAIN_CALL_HEAD), + "chain/broadcast-transaction": Object.values(W.CHAIN_BROADCAST_TRANSACTION), + "payment/request": Object.values(W.PAYMENT_REQUEST), + }; + for (const [name, ids] of Object.entries(mustAllow)) { + test(`allows ${name}`, () => { + for (const id of ids) expect(sensitive.has(id)).toBe(false); + }); + } +}); + +describe("gated frame decoder (real table + denylist)", () => { + test("a signing frame does NOT decode even with the toggle on", () => { + const decoder = createFrameDecoder({ enabled: true }); + const detail = decoder.detail( + frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([1, 2, 3, 4])), + ); + expect(detail.kind).toBe("redacted"); + if (detail.kind === "redacted") { + expect(detail.reason).toBe("sensitive method"); + expect(detail.byteLength).toBe(4); + } + }); + + test("every signing family id redacts, never decodes", () => { + const decoder = createFrameDecoder({ enabled: true }); + for (const id of [ + ...Object.values(W.SIGNING_CREATE_TRANSACTION), + ...Object.values(W.SIGNING_SIGN_PAYLOAD), + ...Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), + ...Object.values(W.ENTROPY_DERIVE), + ...Object.values(W.ACCOUNT_REQUEST_LOGIN), + ]) { + const detail = decoder.detail(frame(id, new Uint8Array([0, 0]))); + expect(detail.kind).toBe("redacted"); + } + }); + + test("payment.topUp redacts (never decodes a raw private key) with toggle on (C1)", () => { + const decoder = createFrameDecoder({ enabled: true }); + for (const id of Object.values(W.PAYMENT_TOP_UP)) { + expect(decoder.detail(frame(id, new Uint8Array([0, 0]))).kind).toBe( + "redacted", + ); + } + }); + + 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, non-sensitive 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("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("gated frame decoder (injected table for gating isolation)", () => { + const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; + const sensitiveIds = new Set([7]); + + test("decodes a non-sensitive id when enabled and bytes present", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + }); + const detail = decoder.detail(frame(999, new Uint8Array([1, 2]))); + expect(detail).toEqual({ + kind: "decoded", + value: { ok: [1, 2] }, + } satisfies FrameValueDetail); + }); + + test("redacts a sensitive id before ever touching the table", () => { + let called = false; + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 7: () => ((called = true), "leaked") }, + sensitiveIds, + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2, 3]))); + expect(detail.kind).toBe("redacted"); + expect(called).toBe(false); + }); + + test("falls back to bytes when the frame retained no bytes", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + }); + 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"); + }, + }, + sensitiveIds, + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); + }); + + test("content guard redacts a decoded value carrying a secret-named field", () => { + // A non-denylisted id whose decoded payload nonetheless carries key material + // (the C1/H1 class): the fail-closed content check must redact it. + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => ({ source: { PrivateKey: { sr25519SecretKey: "0xdead" } } }), + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts encryptedSecrets (cheque bearer material)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ cheque: { encryptedSecrets: "0xbeef" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts a decryptionKey (statement key material)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => ({ statements: [{ decryptionKey: "0xc0ffee" }] }), + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts a generically-named credential field", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard still decodes a public identifier (publicKey)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ account: { publicKey: "0x01" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard allows a benign value with no secret-named field", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ account: { address: "0x01" }, amount: 5 }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard terminates on a cyclic / shared-DAG value (no blowup)", () => { + // The pre-visited-set guard hung on exactly this shape (a cycle with two + // back-edges + shared substructure). If it regresses to exponential, this + // test hangs instead of passing - which is the signal we want. + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + const a: Record = {}; + const b: Record = { a }; + a.b = b; + a.self = a; + return { a, b, both: [a, b, a, b] }; + }, + }, + sensitiveIds: new Set(), + }); + // Benign field names ⇒ decodes (and, crucially, returns promptly). + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard still redacts a secret nested inside a cyclic value", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + const a: Record = { secretKey: "0xdead" }; + const b: Record = { a }; + a.b = b; + return { a, b }; + }, + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); +}); + +describe("sensitive reveal escape hatch (dev-only, safe by default)", () => { + const table = { 7: (b: Uint8Array) => ({ secretKey: Array.from(b) }) }; + const sensitiveIds = new Set([7]); + + test("with reveal capability OFF, an explicit reveal request is ignored", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + // revealSensitive omitted → off + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { + reveal: true, + }); + expect(detail.kind).toBe("redacted"); + }); + + test("with reveal capability ON but no explicit request, sensitive still redacts", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + // Default call (no reveal) — the safe default must still hold. + expect(decoder.detail(frame(7, new Uint8Array([1, 2]))).kind).toBe( + "redacted", + ); + }); + + test("with reveal capability ON and an explicit request, a sensitive frame decodes and is marked", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { + reveal: true, + }); + expect(detail).toEqual({ + kind: "decoded", + value: { secretKey: [1, 2] }, + sensitive: true, + } satisfies FrameValueDetail); + }); + + test("an explicit reveal also bypasses the content guard for a non-denylisted frame", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, + sensitiveIds: new Set(), + }); + const detail = decoder.detail(frame(999, new Uint8Array([1])), { + reveal: true, + }); + expect(detail.kind).toBe("decoded"); + if (detail.kind === "decoded") expect(detail.sensitive).toBe(true); + }); + + test("the master gate still wins: reveal armed but decode disabled ⇒ bytes only", () => { + const decoder = createFrameDecoder({ + enabled: false, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + expect(decoder.detail(frame(7, new Uint8Array([1, 2])), { reveal: true }).kind).toBe( + "bytes", + ); + }); +}); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts new file mode 100644 index 000000000..0110493f9 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,238 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the + * drill-down detail path only, behind a dev-only opt-in. + * + * This is the one place the debugger looks *inside* a frame. Everything else - + * the trace engine, `/traces`, the host tap - is payload-blind and stays that + * way. The rules that make that safe live here: + * + * - **Off by default.** With the decoder disabled every frame reports its byte + * length and nothing else; no payload is ever inspected. + * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the + * client uses. The debugger writes no codecs 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: a sensitive frame is never decoded, even with the toggle on - it + * reports its byte length labelled `"sensitive method"`. The denylist is + * itself generated: `SENSITIVE_FRAME_IDS` in `@parity/truapi/wire-table` + * carries every frame id of a method marked `#[wire(..., sensitive)]` on the + * Rust trait, so sensitivity is a property of the payload type, not a name + * the debugger pattern-matches. + * + * Nothing here is ever serialized into `/traces`; the detail it produces is + * returned only from the explicit per-frame drill-down. + * + * @module + */ + +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; +import * as W from "@parity/truapi/wire-table"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** + * Per-frame decode result for the drill-down detail path. + * + * `"bytes"` is the safe default returned whenever the decoder is off, the frame + * carries no retained bytes, the id has no codec, or decoding throws. + * `"redacted"` is returned for a sensitive frame even when the decoder is on. + * `"decoded"` carries the plain JS value and is reachable only with the decoder + * on, for a non-sensitive frame whose id is in the table. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown; sensitive?: boolean } + | { kind: "redacted"; reason: "sensitive method"; byteLength: number } + | { kind: "bytes"; byteLength: number }; + +/** + * The set of wire `frameId`s that must never be decoded, sourced directly from + * the generated {@link W.SENSITIVE_FRAME_IDS}. That set is emitted by + * `truapi-codegen` from every method marked `#[wire(..., sensitive)]` on the + * Rust trait and carries all of the method's frame ids (request/response and + * start/stop/interrupt/receive), so both legs of a sensitive op are redacted. + * + * Sensitivity therefore lives on the Rust payload type, not on a name the + * debugger pattern-matches: a codegen rename cannot silently drop a family, and + * a newly annotated method is denylisted the moment the client is regenerated. + * The families it covers today: + * + * - signing — every method (create-transaction(+legacy), sign-raw(+legacy), + * sign-payload(+legacy)): payloads to be signed and the resulting signatures. + * - account/statement-store proof creation: cryptographic proofs bound to a + * key/identity. + * - entropy/derive: key-derivation material. + * - account request-login / get-user-id: SSO/login and the user id it resolves. + * - local-storage read/write: a read response or a write request can carry + * tokens, session state, or PII. (`clear` carries only a key name and an + * empty response, so it is intentionally *not* sensitive.) + * - payment top-up: can carry a raw sr25519 secret key (PaymentTopUpSource). + * - coin-payment create-cheque/deposit/listen-for-payment: redeemable + * `encryptedSecrets` on a CoinPaymentCheque. + * - statement-store subscribe/submit: a SignedStatement's `decryptionKey`. + * + * Deliberately decodable, because they hold no key material: chain calls + * (`CHAIN_*`) carry public on-chain data — headers, bodies, storage, runtime + * calls, and the broadcast of already-public signed transactions — and are the + * primary useful decode surface; chat, notifications, permissions, theme, + * resource-allocation, and preimage likewise carry no credentials. + * + * Because sensitivity is a property of the payload *type*, the decoder also + * applies a fail-closed content check (see {@link createFrameDecoder}) that + * redacts any decoded value carrying a secret-named field — so a secret-bearing + * method that was never annotated is still caught. + */ +export const SENSITIVE_FRAME_IDS: ReadonlySet = W.SENSITIVE_FRAME_IDS; + +/** + * Field-name pattern for the fail-closed content check: keys whose name implies + * key material or a bearer secret (`sr25519SecretKey`, `encryptedSecrets`, + * `decryptionKey`, a mnemonic, a token/credential/passphrase, …). Deliberately + * omits a bare `key` so public identifiers like `publicKey` still decode. This + * is only a backstop — the authoritative guarantee is the generated + * {@link SENSITIVE_FRAME_IDS} denylist (type-driven via `#[wire(sensitive)]`); + * the content check catches any secret-bearing method that was never annotated. + */ +const SECRET_FIELD_RE = + /secret|mnemonic|entropy|private|decrypt|token|credential|passphrase|password|apikey|bearer|seed/i; + +/** + * Does a decoded value carry a secret-named field anywhere in its structure? + * + * Sensitivity ultimately lives in the payload type, so this backs up + * {@link SENSITIVE_FRAME_IDS}: a decoded value with a secret-named key is + * redacted even if its method was not on the denylist. The `seen` set makes it + * O(nodes) - each object is visited once - so it terminates in linear time on + * cycles and shared-substructure DAGs, not just trees. Safe on arrays, tagged + * unions, and nested structs. + */ +function containsSecretField( + value: unknown, + seen: WeakSet = new WeakSet(), + depth = 0, +): boolean { + // Depth cap is generous headroom; the `seen` set is what bounds work, by + // never revisiting an object even when the graph re-references it. + if (depth > 64 || value === null || typeof value !== "object") return false; + if (seen.has(value)) return false; + seen.add(value); + for (const [key, nested] of Object.entries(value as Record)) { + if (SECRET_FIELD_RE.test(key)) return true; + if (containsSecretField(nested, seen, depth + 1)) return true; + } + return false; +} + +/** Options for {@link createFrameDecoder}. */ +export interface FrameDecoderOptions { + /** + * Master gate. `false` (the default) means the decoder never inspects a + * payload: every frame reports bytes only. This is the dev-only opt-in. + */ + enabled?: boolean; + /** + * Frame-id → decoder map. Defaults to the generated + * {@link WIRE_DECODE_TABLE}; overridable for tests. + */ + decodeTable?: Record unknown>; + /** + * Frame ids that must never be decoded. Defaults to the generated + * {@link SENSITIVE_FRAME_IDS} denylist. + */ + sensitiveIds?: ReadonlySet; + /** + * Second, independent gate that *allows* a sensitive frame to be decoded - but + * only on an explicit per-frame `reveal` request (see {@link FrameDecoder.detail}), + * never by default. Off by default and only meaningful when {@link enabled} is + * also on. This is the dev-only "reveal sensitive" escape hatch: it is wired + * from its own env gate (`TRUAPI_DEBUGGER_REVEAL_SENSITIVE`) so it is + * structurally impossible to turn on in a shipped build, and even with it on + * the safe default (redact) still holds until the operator confirms a reveal. + */ + revealSensitive?: boolean; +} + +/** Options for a single {@link FrameDecoder.detail} call. */ +export interface FrameDetailOptions { + /** + * Explicit operator request to reveal a sensitive frame's value. Honored only + * when the decoder was built with {@link FrameDecoderOptions.revealSensitive} + * (and {@link FrameDecoderOptions.enabled}); otherwise ignored and the frame + * redacts as usual. A reveal bypasses both the denylist and the content guard + * for that one frame - it is the "show me everything" dev path. + */ + reveal?: boolean; +} + +/** A gated per-frame value decoder for the drill-down detail path. */ +export interface FrameDecoder { + /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ + readonly enabled: boolean; + /** Whether the sensitive-reveal escape hatch is armed (still off by default per call). */ + readonly revealSensitive: boolean; + /** The sensitive-frame denylist in effect (redacted unless explicitly revealed). */ + readonly sensitiveIds: ReadonlySet; + /** Resolve one frame to its {@link FrameValueDetail}. */ + detail(frame: ObservedFrame, options?: FrameDetailOptions): FrameValueDetail; +} + +/** + * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. + * Even then, sensitive frames (see {@link SENSITIVE_FRAME_IDS}) are reported + * as `"redacted"`, never decoded. + */ +export function createFrameDecoder( + options: FrameDecoderOptions = {}, +): FrameDecoder { + const enabled = options.enabled ?? false; + const revealSensitive = options.revealSensitive ?? false; + const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; + const sensitiveIds = options.sensitiveIds ?? SENSITIVE_FRAME_IDS; + + const detail = ( + frame: ObservedFrame, + detailOptions: FrameDetailOptions = {}, + ): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + // The reveal escape hatch fires only when the capability is armed AND the + // operator explicitly asked for this frame. Absent either, the safe default + // (redact sensitive / content-guard) stands - so the guarantee "sensitive + // never decodes" holds by default even in a reveal-armed session. + const reveal = revealSensitive && detailOptions.reveal === true; + if (sensitiveIds.has(frame.frameId) && !reveal) { + return { + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength, + }; + } + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) { + return { kind: "bytes", byteLength: frame.byteLength }; + } + try { + const value = decode(frame.bytes); + // Fail-closed net: redact if the decoded payload carries a secret-named + // field, even though the method itself was not on the denylist - unless + // this is an explicit reveal, which is the "show me everything" path. + if (!reveal && containsSecretField(value)) { + return { + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength, + }; + } + // Mark a revealed value so the UI can style it as the danger it is. + return reveal + ? { kind: "decoded", value, sensitive: true } + : { kind: "decoded", value }; + } catch { + // A malformed or version-skewed payload must not break the drill-down; + // fall back to the byte-length view. + return { kind: "bytes", byteLength: frame.byteLength }; + } + }; + + return { enabled, revealSensitive, sensitiveIds, detail }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts new file mode 100644 index 000000000..643651ed3 --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,43 @@ +export type { + FrameDirection, + FrameRole, + ObservedFrame, + TransportObserver, +} from "./observed-frame.js"; +export { createDebugIngest } from "./ingest.js"; +export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; +export { createDebugSession } from "./session.js"; +export type { DebugSession, DebugSessionOptions } from "./session.js"; +export { createFrameDecoder, SENSITIVE_FRAME_IDS } from "./decode.js"; +export type { + FrameDecoder, + FrameDecoderOptions, + FrameValueDetail, +} from "./decode.js"; +export { createWireDebugger, createMethodNameMap } from "./wire-debugger.js"; +export type { + WireDebugger, + WireDebuggerOptions, + WireDebugSink, + WireFrameKind, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +export { buildTraceView, wireTraceToView } from "./trace-view.js"; +export type { + TraceBadge, + TraceFrameBadge, + TraceFrameInput, + TraceFrameView, + TraceView, + TraceViewInput, +} from "./trace-view.js"; +export { + renderTraceDetail, + renderFrameValueDetail, + renderOperationRow, +} from "./trace-render.js"; +export type { RenderTraceDetailOptions } from "./trace-render.js"; +export { detectRetryStorms } from "./retry-storm.js"; +export type { RetryStormOptions } from "./retry-storm.js"; +export { TRACE_DETAIL_CSS } from "./trace-styles.js"; diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..220ac61f0 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,93 @@ +/** + * Ingest: turn the host tap's wire envelopes into {@link ObservedFrame}s. + * + * The Rust host tap (`truapi-server`'s `DebugSink`) emits one envelope per + * frame - `{ channelId, dir, frame: bytes }`, raw SCALE, opaque to the core. + * The debugger decodes here: {@link decodeWireMessage} recovers the correlation + * `requestId` and the wire discriminant, which is everything the trace engine + * needs to group an op. This is the layer PG's design puts "in the debugger, not + * the core". + * + * @module + */ + +import { decodeWireMessage } from "@parity/truapi"; +import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; + +/** + * One wire frame as it crosses the host tap, matching the Rust + * `DebugEvent::Frame { channel_id, dir, bytes }`. `frame` is the untouched + * `ProtocolMessage` bytes; the debugger owns all decoding. + */ +export interface DebugFrameEnvelope { + /** Product channel the frame belongs to, e.g. `"myapp.dot"`. */ + channelId: string; + /** + * Product-vantage: `out` left the product, `in` arrived at it. The Rust host + * tap names directions host-vantage internally and flips to this convention + * on the wire (`FrameDirection::wire_str`), so both ends agree here. + */ + dir: "in" | "out"; + /** Raw SCALE `ProtocolMessage` bytes. */ + frame: Uint8Array; +} + +/** Options for {@link createDebugIngest}. */ +export interface DebugIngestOptions { + /** + * Retain each frame's raw SCALE bytes on the {@link ObservedFrame}. Off by + * default: byte length is always recorded, but the bytes themselves are the + * dev-only opt-in that level-2 decode needs. `/traces` never serializes them + * either way; retaining them only makes the drill-down decoder able to run. + */ + retainBytes?: boolean; +} + +/** + * Ingest that decodes each {@link DebugFrameEnvelope} and forwards the resulting + * {@link ObservedFrame} to `sink` (typically a {@link WireDebugger}'s `observe`). + * + * `role` is left `"unknown"`: lifecycle roles (request/response/receive/…) are + * derived from request/subscription correlation state, which lived in the client + * transport and is not carried on the wire. Reconstructing it from the observed + * request/response ordering is a follow-up; grouping by `requestId` does not need + * it. An undecodable frame is surfaced as a `"malformed"` sentinel rather than + * dropped, so the trace records the failure instead of going dark. + * + * Raw payload bytes are attached only when `retainBytes` is set - the dev-only + * byte-exposure opt-in that the level-2 decoder consumes; otherwise a frame + * carries its byte length and no payload. + */ +export function createDebugIngest( + sink: TransportObserver, + options: DebugIngestOptions = {}, +): (envelope: DebugFrameEnvelope) => void { + const retainBytes = options.retainBytes ?? false; + return (envelope) => { + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId: envelope.channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp: Date.now(), + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId: envelope.channelId, + direction: envelope.dir, + requestId, + frameId: payload.id, + role: "unknown", + byteLength: payload.value.length, + timestamp: Date.now(), + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} diff --git a/js/packages/truapi-debugger/src/observed-frame.ts b/js/packages/truapi-debugger/src/observed-frame.ts new file mode 100644 index 000000000..cea816c21 --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,68 @@ +/** + * The frame model the debugger works in. + * + * A host tap streams raw wire frames as `{ channelId, dir, frame: bytes }` + * envelopes; {@link createDebugIngest} decodes each one into an + * {@link ObservedFrame} - correlation id, wire discriminant, byte length, and + * (dev-only) the raw bytes - which the trace and host engines consume. The core + * never decodes; decoding happens here, in the debugger. + * + * @module + */ + +/** + * Direction of an observed wire frame relative to the product: `out` left the + * product, `in` arrived at it. + */ +export type FrameDirection = "out" | "in"; + +/** + * Role of an observed frame within the request/subscription lifecycle, derived + * from its wire discriminant against the method's frame ids. + */ +export type FrameRole = + | "request" + | "response" + | "start" + | "stop" + | "receive" + | "interrupt" + | "handshake" + | "malformed" + | "unknown"; + +/** + * A single decoded wire frame. Carries the correlation `requestId`, the wire + * discriminant, a best-effort lifecycle `role`, and the encoded byte length. + * The raw `bytes` are present only when byte exposure is enabled - a dev-only + * opt-in, since the raw wire can carry key material. + */ +export interface ObservedFrame { + /** + * Product channel the frame crossed, e.g. `"myapp.dot"`. Carried from the + * host tap envelope. Because `requestId` is minted per transport (each host + * mints `p:1`, `p:2`, …), it is unique only *within* a channel; grouping and + * lookups key on `(channelId, requestId)` so two hosts' ops never merge. + */ + channelId: string; + /** Whether the frame was sent by the product (`out`) or received by it (`in`). */ + direction: FrameDirection; + /** Correlation id shared by every frame of one request/subscription, within a channel. */ + requestId: string; + /** Wire-table numeric discriminant of the frame's payload. */ + frameId: number; + /** Best-effort lifecycle role inferred from the frame id. */ + role: FrameRole; + /** Encoded SCALE payload length in bytes. */ + byteLength: number; + /** Epoch ms at which the frame was observed. */ + timestamp: number; + /** The raw SCALE payload bytes, present only when byte exposure is enabled. */ + bytes?: Uint8Array; +} + +/** + * Emit-only consumer of observed frames. The trace engine's + * {@link WireDebugger.observe} is one; a host relay is another. + */ +export type TransportObserver = (frame: ObservedFrame) => void; diff --git a/js/packages/truapi-debugger/src/operation-row.test.ts b/js/packages/truapi-debugger/src/operation-row.test.ts new file mode 100644 index 000000000..75b7bf66b --- /dev/null +++ b/js/packages/truapi-debugger/src/operation-row.test.ts @@ -0,0 +1,129 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { ObservedFrame, FrameRole } from "./observed-frame.js"; +import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderOperationRow } from "./trace-render.js"; + +function frame( + role: FrameRole, + frameId: number, + timestamp: number, +): ObservedFrame { + return { + direction: role === "response" || role === "receive" ? "in" : "out", + requestId: "p:1", + frameId, + role, + byteLength: 8, + timestamp, + }; +} + +function traceOf(frames: ObservedFrame[]): WireTrace { + return { + channelId: "host-a.dot", + requestId: "p:1", + frames, + startedAt: frames[0]?.timestamp ?? 0, + lastAt: frames[frames.length - 1]?.timestamp ?? 0, + }; +} + +const methodNames: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], +]); + +describe("renderOperationRow", () => { + test("request/response op: method, frame count, duration, request glyph", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1120)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("2 frames"); + expect(html).toContain("120ms"); + expect(html).toContain("td-op-req"); + expect(html).toContain('data-request-id="p:1"'); + expect(html).not.toContain("td-op-live"); + }); + + test("subscription with no stop is marked live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("receive", 41, 1200), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).toContain("td-op-live"); + expect(html).toContain("live"); + }); + + test("subscription with a stop is not live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("stop", 42, 1300), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + }); + + test("op badges render as chips (orphaned request)", () => { + const view = wireTraceToView(traceOf([frame("request", 22, 1000)]), methodNames); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-orphaned"); + }); + + test("carries channelId as a data attribute when present", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: "host-a.dot" }; + const html = renderOperationRow(view); + expect(html).toContain('data-channel-id="host-a.dot"'); + }); + + test("omits data-channel-id when the vantage has no channel", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: undefined }; + expect(renderOperationRow(view)).not.toContain("data-channel-id"); + }); + + test("payload-blind: never emits a decoded value", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).not.toContain("decode"); + expect(html).not.toContain(" { + const base = wireTraceToView(traceOf([frame("request", 22, 1000)])); + const view = { ...base, requestId: '">' }; + const html = renderOperationRow(view); + expect(html).not.toContain(", +): string[] { + return [...map.keys()].map((t) => t.requestId).sort(); +} + +describe("detectRetryStorms", () => { + test("flags a burst of like ops in a short window", () => { + const traces = [ + trace("a", 30, 0), + trace("b", 30, 200), + trace("c", 30, 400), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.get(traces[0])).toEqual(["retry-storm"]); + }); + + test("does not flag a burst below the threshold", () => { + const storms = detectRetryStorms([trace("a", 30, 0), trace("b", 30, 100)]); + expect(storms.size).toBe(0); + }); + + test("does not flag like ops spread wider than the window", () => { + const storms = detectRetryStorms([ + trace("a", 30, 0), + trace("b", 30, 1500), + trace("c", 30, 3000), + ]); + expect(storms.size).toBe(0); + }); + + test("groups by op signature — only the bursting method storms", () => { + // Three createTransaction (id 30) inside 400ms = a storm; two getAccount + // (id 22) far apart are not, even interleaved in time. + const traces = [ + trace("sign-1", 30, 0), + trace("get-1", 22, 50), + trace("sign-2", 30, 150), + trace("get-2", 22, 5000), + trace("sign-3", 30, 300), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["sign-1", "sign-2", "sign-3"]); + }); + + test("flags only the dense sub-window within a longer sparse run", () => { + // Two early, far-apart ops then a tight burst of three: only the burst. + const traces = [ + trace("x", 30, 0), + trace("y", 30, 4000), + trace("b1", 30, 8000), + trace("b2", 30, 8300), + trace("b3", 30, 8600), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["b1", "b2", "b3"]); + }); + + test("honors custom window and burst thresholds", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 300)]; + // Default (minBurst 3) → nothing; minBurst 2 within 500ms → both. + expect(detectRetryStorms(traces).size).toBe(0); + const storms = detectRetryStorms(traces, { windowMs: 500, minBurst: 2 }); + expect(stormedIds(storms)).toEqual(["a", "b"]); + }); + + test("minBurst below 2 detects nothing", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 10)]; + expect(detectRetryStorms(traces, { minBurst: 1 }).size).toBe(0); + }); + + test("tolerates a frameless trace without throwing", () => { + const empty: WireTrace = { + channelId: "c", + requestId: "empty", + frames: [], + startedAt: 0, + lastAt: 0, + }; + const traces = [ + empty, + trace("a", 30, 0), + trace("b", 30, 100), + trace("c", 30, 200), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.has(empty)).toBe(false); + }); + + test("is per-channel — two hosts each firing once is not a storm", () => { + // Same requestId and frameId across two channels, all within the window, + // but each channel fires the op only twice (< minBurst 3): no storm, and + // the two channels are never merged into one burst. + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:1", 30, 50, "hostB"), + trace("p:2", 30, 100, "hostA"), + trace("p:2", 30, 150, "hostB"), + ]; + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("flags a per-channel burst without pulling in the other channel", () => { + // hostA hammers the op 3x in-window (storm); hostB fires it once (calm). + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:2", 30, 200, "hostA"), + trace("p:1", 30, 250, "hostB"), + trace("p:3", 30, 400, "hostA"), + ]; + const storms = detectRetryStorms(traces); + // Only hostA's three ops storm; hostB's p:1 does not, even though it shares + // requestId "p:1" with a stormed hostA op. + expect(storms.size).toBe(3); + const stormedChannels = new Set([...storms.keys()].map((t) => t.channelId)); + expect([...stormedChannels]).toEqual(["hostA"]); + }); +}); diff --git a/js/packages/truapi-debugger/src/retry-storm.ts b/js/packages/truapi-debugger/src/retry-storm.ts new file mode 100644 index 000000000..ca7e64893 --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,94 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Retry-storm detection: a *cross-op* signal the single-trace renderer cannot + * see on its own. + * + * A retry storm is a burst of like ops in a short window — a product hammering + * `signing.createTransaction` five times in 400ms because each attempt failed, + * say. Whether any one op is part of a storm depends on the *other* traces, so + * it belongs in the engine/list layer, not the per-trace renderer. This module + * computes it over the whole trace set and hands each stormed trace a + * `retry-storm` {@link TraceBadge}, which the mount feeds to `wireTraceToView`'s + * `extraBadges`. The renderer stays display-only. + * + * @module + */ + +import type { TraceBadge } from "./trace-view.js"; +import type { WireTrace } from "./wire-debugger.js"; + +/** Tuning for {@link detectRetryStorms}. */ +export interface RetryStormOptions { + /** + * The window, in ms, within which like ops count as one burst. Default 1000. + */ + windowMs?: number; + /** + * How many like ops within `windowMs` make a storm. Default 3. Values below 2 + * are meaningless (a single op is never a storm) and detect nothing. + */ + minBurst?: number; +} + +/** + * The op signature two traces must share to count as "like". A storm is one host + * hammering one method, so the signature is scoped to the channel: `channelId` + * plus the opener frame's wire `frameId` (the first frame is the `request`/`start`, + * so its id identifies the method). Same channel + same op id = the same op being + * repeated; two different hosts each firing the op once is not a storm. A trace + * with no frames has no signature and never storms. + */ +function signature(trace: WireTrace): string | undefined { + const frameId = trace.frames[0]?.frameId; + return frameId === undefined ? undefined : `${trace.channelId}\u0000${frameId}`; +} + +/** + * Find every trace that is part of a retry storm and map it to its badge. + * + * Traces are grouped by op {@link signature}; within each group, a sliding + * window over `startedAt` flags any trace that sits in a span of `minBurst` or + * more ops no wider than `windowMs`. The result is keyed by the {@link WireTrace} + * object itself (not `requestId`, which is not unique across channels): only + * stormed traces appear, each mapped to `["retry-storm"]`. Feed + * `result.get(trace) ?? []` into `wireTraceToView`'s `extraBadges`. + */ +export function detectRetryStorms( + traces: readonly WireTrace[], + options: RetryStormOptions = {}, +): ReadonlyMap { + const windowMs = options.windowMs ?? 1000; + const minBurst = options.minBurst ?? 3; + const result = new Map(); + if (minBurst < 2) return result; + + const groups = new Map(); + for (const trace of traces) { + const sig = signature(trace); + if (sig === undefined) continue; + const group = groups.get(sig); + if (group) group.push(trace); + else groups.set(sig, [trace]); + } + + for (const group of groups.values()) { + if (group.length < minBurst) continue; + const sorted = [...group].sort((a, b) => a.startedAt - b.startedAt); + let left = 0; + for (let right = 0; right < sorted.length; right++) { + while (sorted[right].startedAt - sorted[left].startedAt > windowMs) { + left++; + } + // [left, right] now spans <= windowMs, so every trace in it is within + // windowMs of every other. If that's a full burst, they all storm. + if (right - left + 1 >= minBurst) { + for (let k = left; k <= right; k++) { + result.set(sorted[k], ["retry-storm"]); + } + } + } + } + + return result; +} diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts new file mode 100644 index 000000000..466c409ce --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,142 @@ +/** + * A debug session: the trace engine wired to the ingest. + * + * A host dials the debugger and streams {@link DebugFrameEnvelope}s over a + * socket; each is handed to {@link DebugSession.handleEnvelope}, decoded, and + * grouped into per-`requestId` traces readable via {@link DebugSession.traces}. + * + * The socket itself is deliberately not here. The debugger app is a WS server + * (hosts dial outward to it), but binding the socket is a thin edge: accept a + * connection, JSON/CBOR-decode each message into a {@link DebugFrameEnvelope}, + * and call `handleEnvelope`. Keeping that edge out of this module lets the + * session compile and unit-test without a socket transport or Node types. + * + * @module + */ + +import { + createWireDebugger, + createMethodNameMap, + type WireDebugger, + type WireMethodInfo, +} from "./wire-debugger.js"; +import { createDebugIngest, type DebugFrameEnvelope } from "./ingest.js"; +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import * as W from "@parity/truapi/wire-table"; +import { createClient, createTransport } from "@parity/truapi"; + +/** A provider that sends and receives nothing; used only to enumerate service names. */ +const NOOP_PROVIDER = { + postMessage() {}, + subscribe() { + return () => {}; + }, + dispose() {}, +}; + +/** Options for {@link createDebugSession}. */ +export interface DebugSessionOptions { + /** + * Turn on level-2 value decode in the drill-down detail path. Off by default. + * When on, the session retains raw frame bytes so {@link DebugSession.frameDetail} + * can decode non-sensitive frames; `/traces` stays payload-blind regardless + * (it never reads bytes or decoded values), and sensitive frames are never + * decoded even here. When off, `frameDetail` reports byte length only. + */ + decodeValues?: boolean; + /** + * Arm the dev-only sensitive-reveal escape hatch. Off by default and only + * meaningful when {@link decodeValues} is also on. Even armed, a sensitive + * frame still redacts unless {@link DebugSession.frameDetail} is called with an + * explicit `reveal` (the operator confirms per frame). Wired from + * `TRUAPI_DEBUGGER_REVEAL_SENSITIVE`, so it cannot be set in a shipped build. + */ + revealSensitive?: boolean; +} + +/** Live debug session: feed it envelopes, read back grouped traces. */ +export interface DebugSession { + /** Handle one wire envelope from the host tap. */ + handleEnvelope(envelope: DebugFrameEnvelope): void; + /** The underlying trace engine (traces, per-id lookup, clear). */ + readonly traceEngine: WireDebugger; + /** Reverse map from wire `frameId` to method, for labelling frames in a view. */ + readonly methodNames: ReadonlyMap; + /** Whether level-2 value decode is enabled for this session. */ + readonly decodeValues: boolean; + /** Whether the dev-only sensitive-reveal escape hatch is armed for this session. */ + readonly revealSensitive: boolean; + /** + * Frame ids that are never decoded (the sensitive denylist). Exposed so a view + * can mark a frame/op as carrying redacted material *before* any decode - the + * marker is payload-blind (it reveals nothing the method name doesn't) and + * holds regardless of {@link DebugSessionOptions.decodeValues}. + */ + readonly sensitiveIds: ReadonlySet; + /** + * Drill-down: resolve one frame (by its trace `requestId` and index within + * that trace) to a {@link FrameValueDetail}. Pass `channelId` to disambiguate + * when more than one host is connected (each mints the same `p:N` ids). + * Returns `undefined` if no such frame exists. This is the *only* path that can + * surface a decoded value, and only when {@link DebugSessionOptions.decodeValues} + * is on and the frame is not sensitive; otherwise it reports byte length only. + */ + frameDetail( + requestId: string, + index: number, + channelId?: string, + reveal?: boolean, + ): FrameValueDetail | undefined; +} + +/** + * Build a {@link DebugSession}. The `frameId → method` map is derived from the + * generated wire table and client service names, so traces show + * `account.getAccount` rather than a bare `id=22`. + */ +export function createDebugSession( + options: DebugSessionOptions = {}, +): DebugSession { + const decodeValues = options.decodeValues ?? false; + // Reveal is meaningless without decode; fold the master gate in so the + // reported capability can never claim more than the session can actually do. + const revealSensitive = decodeValues && (options.revealSensitive ?? false); + const serviceNames = Object.keys(createClient(createTransport(NOOP_PROVIDER))); + const methodNames = createMethodNameMap( + W as unknown as Record, + serviceNames, + ); + // No `sink`: a session accumulates traces for the view/`/traces`; it must not + // spam the server console with a line per frame (the sink default is + // `console.debug`). Consumers read `traceEngine`, not stdout. + const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); + // Raw bytes are retained only when decode is on - they exist solely to feed + // the drill-down decoder, and `/traces` never serializes them. + const handleEnvelope = createDebugIngest(wireDebugger.observe, { + retainBytes: decodeValues, + }); + const decoder = createFrameDecoder({ + enabled: decodeValues, + revealSensitive, + }); + + const frameDetail = ( + requestId: string, + index: number, + channelId?: string, + reveal?: boolean, + ): FrameValueDetail | undefined => { + const frame = wireDebugger.trace(requestId, channelId)?.frames[index]; + return frame ? decoder.detail(frame, { reveal }) : undefined; + }; + + return { + handleEnvelope, + traceEngine: wireDebugger, + methodNames, + decodeValues, + revealSensitive: decoder.revealSensitive, + sensitiveIds: decoder.sensitiveIds, + frameDetail, + }; +} diff --git a/js/packages/truapi-debugger/src/trace-render.test.ts b/js/packages/truapi-debugger/src/trace-render.test.ts new file mode 100644 index 000000000..28c1f829b --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,106 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { FrameValueDetail } from "./decode.js"; +import type { TraceView } from "./trace-view.js"; +import { renderFrameValueDetail, renderTraceDetail } from "./trace-render.js"; + +const view: TraceView = { + requestId: "req-1", + startedAt: 1000, + lastAt: 1150, + durationMs: 150, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 1000, + latencyFromStartMs: 0, + badges: [], + decodable: true, + }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 40, + timestamp: 1150, + latencyFromStartMs: 150, + roundTripMs: 150, + badges: [], + decodable: true, + }, + ], + badges: [], +}; + +describe("renderTraceDetail", () => { + test("renders the frame sequence with method, bytes, and round-trip", () => { + const html = renderTraceDetail(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("40B"); + expect(html).toContain("150ms"); + expect(html).toContain('data-seq="1"'); + }); + + test("is payload-blind by default: no decode control", () => { + const html = renderTraceDetail(view); + expect(html).not.toContain("decode payload"); + }); + + test("offers a decode control per decodable frame when opted in", () => { + const html = renderTraceDetail(view, { offerDecode: true }); + expect(html).toContain("td-frame-decode-btn"); + expect(html).toContain("decode payload"); + }); + + test("renders a resolved decoded value in place of the control", () => { + const decoded = new Map([ + [1, { kind: "decoded", value: { free: 42 } }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain(""free": 42"); + }); + + test("a sensitive frame renders a redacted state, never the value", () => { + const decoded = new Map([ + [0, { kind: "redacted", reason: "sensitive method", byteLength: 96 }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain("redacted"); + expect(html).toContain("96B withheld"); + expect(html).not.toContain("free"); + }); + + test("escapes wire-sourced strings", () => { + const evil: TraceView = { + ...view, + requestId: '', + frames: [], + }; + const html = renderTraceDetail(evil); + expect(html).not.toContain(" { + const html = renderTraceDetail({ ...view, badges: ["orphaned", "retry-storm"] }); + expect(html).toContain("td-badge-orphaned"); + expect(html).toContain("retry storm"); + }); +}); + +describe("renderFrameValueDetail", () => { + test("bytes-only never shows a payload", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts new file mode 100644 index 000000000..78b02ab7f --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,390 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The one drill-down renderer, mounted in both the standalone app and dotli's + * panel. + * + * "One level deeper": given a selected op, render its frame sequence - + * request→response, or subscribe→receive×N→stop - with method, direction, byte + * length, latency, and orphaned/malformed/retry-storm badges. It is a pure + * `TraceView → HTML` function so the two mounts render identically; each mount + * supplies the {@link TraceView} through its own adapter (see {@link + * wireTraceToView} for the wire vantage). + * + * Payload-blind by default. Level-2 value decode is offered only when a mount + * opts in (`offerDecode`) and passes decode results back in (`decoded`); the + * renderer never touches bytes itself. Decode results come from the Core + + * Decode thread's {@link FrameValueDetail}, so a sensitive frame renders a + * redacted state and never its value. + * + * The renderer emits HTML strings (both mounts assign `innerHTML`) using `td-*` + * classes so one stylesheet covers both. Every interpolated string that came + * off the wire (`requestId`, `method`) is escaped. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import type { + TraceBadge, + TraceFrameBadge, + TraceFrameView, + TraceView, +} from "./trace-view.js"; + +/** Options controlling a single drill-down render. */ +export interface RenderTraceDetailOptions { + /** + * Offer the per-frame level-2 decode affordance for decodable frames. Off by + * default: the view stays payload-blind and shows no decode control. + */ + offerDecode?: boolean; + /** + * Decode results already resolved for this op, keyed by frame `seq`. The mount + * fills this after a user acts on a frame (calling the Core session's + * `frameDetail(requestId, seq)`) and re-renders. Frames absent from the map + * show only their decode control, never a value. + */ + decoded?: ReadonlyMap; + /** + * Offer the dev-only "reveal" affordance on *sensitive* frames (the escape + * hatch). Off by default: a sensitive frame then shows its redacted state + * upfront with no control. Only a mount whose session armed the reveal gate + * sets this; the reveal itself is still an explicit, confirmed per-frame action. + */ + offerReveal?: boolean; +} + +/** HTML-escape a wire-sourced string before it touches `innerHTML`. */ +function esc(value: string): string { + return value.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** `1234` → `1.23s`, `42` → `42ms`, for compact latency display. */ +function formatMs(ms: number): string { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +const DIRECTION_GLYPH: Record = { + out: "▶", + in: "◀", +}; + +/** + * Render the drill-down detail for one op. Returns an HTML fragment for a + * mount's detail pane (`.td-detail` in dotli, the detail column in the app). + */ +export function renderTraceDetail( + view: TraceView, + options: RenderTraceDetailOptions = {}, +): string { + const offerDecode = options.offerDecode ?? false; + const offerReveal = options.offerReveal ?? false; + const decoded = options.decoded; + + const header = renderHeader(view); + const rows = view.frames + .map((frame) => + renderFrameRow(frame, offerDecode, offerReveal, decoded?.get(frame.seq)), + ) + .join(""); + + return ( + `
` + + header + + `
${rows}
` + + `
` + ); +} + +function renderHeader(view: TraceView): string { + const badges = view.badges.map(renderOpBadge).join(""); + const frameCount = view.frames.length; + return ( + `
` + + `${esc(view.requestId)}` + + `${String(frameCount)} frame${frameCount === 1 ? "" : "s"} · ${formatMs(view.durationMs)}` + + (badges === "" ? "" : `${badges}`) + + `
` + ); +} + +const OP_BADGE_LABEL: Record = { + orphaned: "orphaned", + malformed: "malformed", + "retry-storm": "retry storm", +}; + +function renderOpBadge(badge: TraceBadge): string { + return `${esc(OP_BADGE_LABEL[badge])}`; +} + +function badgeTitle(badge: TraceBadge): string { + switch (badge) { + case "orphaned": + return "An opening frame has no matching close, or a close has no opener"; + case "malformed": + return "A frame failed to decode on the wire"; + case "retry-storm": + return "This op is one of a burst of like ops in a short window"; + } +} + +const FRAME_BADGE_LABEL: Record = { + malformed: "malformed", + orphaned: "orphaned", +}; + +function renderFrameRow( + frame: TraceFrameView, + offerDecode: boolean, + offerReveal: boolean, + detail: FrameValueDetail | undefined, +): string { + const glyph = DIRECTION_GLYPH[frame.direction]; + const method = + frame.method === undefined + ? `id ${String(frame.frameId ?? "?")}` + : `${esc(frame.method)}`; + const role = `${esc(frame.role)}`; + // Privacy marker, shown before any decode: this frame carries material the + // denylist keeps redacted. Reveals nothing the method name doesn't. + const lock = frame.sensitive + ? `🔒` + : ""; + const size = + frame.byteLength === undefined + ? "" + : `${String(frame.byteLength)}B`; + const latency = renderLatency(frame); + const badges = frame.badges + .map( + (b) => + `${esc(FRAME_BADGE_LABEL[b])}`, + ) + .join(""); + + // The frame's meta (direction, role, method, size, latency, badges) is one + // grouped cell so a mount can pin the level-2 payload into a fixed second + // column beside it - every frame's decoded box then opens in the same aligned + // space rather than trailing variable-width meta. + const meta = + `
` + + `${glyph}` + + role + + method + + lock + + size + + latency + + (badges === "" ? "" : `${badges}`) + + `
`; + + const payload = + offerDecode && frame.decodable + ? `
${renderDecodeBlock(frame, offerReveal, detail)}
` + : ""; + + return ( + `
` + + meta + + payload + + `
` + ); +} + +function renderLatency(frame: TraceFrameView): string { + // A closing frame that answers an opener shows its round-trip; everything + // else shows its offset from the op's first frame. + if (frame.roundTripMs !== undefined) { + return `⟳ ${formatMs(frame.roundTripMs)}`; + } + if (frame.latencyFromStartMs === 0) { + return `+0`; + } + return `+${formatMs(frame.latencyFromStartMs)}`; +} + +/** + * The level-2 slot for one frame: a decode control plus, once resolved, the + * decoded / redacted / bytes-only outcome. Rendered only when the mount offers + * decode and the frame retained bytes. + */ +function renderDecodeBlock( + frame: TraceFrameView, + offerReveal: boolean, + detail: FrameValueDetail | undefined, +): string { + if (detail !== undefined) { + return `
${renderFrameValueDetail(detail)}
`; + } + const size = + frame.byteLength === undefined ? "" : ` · ${String(frame.byteLength)}B`; + if (frame.sensitive) { + // A sensitive frame stays redacted by default - so show that upfront rather + // than a decode control that would only ever redact. When the dev reveal + // gate is armed, offer a distinct, explicit reveal control instead (guarded + // by a per-frame confirm on the client); it is NOT a `td-frame-decode-btn`, + // so "Decode all" never sweeps it in. + if (offerReveal) { + return ( + `` + ); + } + return `
${renderFrameValueDetail({ + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength ?? 0, + })}
`; + } + // Non-sensitive pre-decode state: a blurred placeholder standing in for the + // encoded payload. It carries NO real bytes - the renderer is payload-blind + // and never sees them, so the blocks are decorative, sized only by byte + // length. The button is the decode trigger; the value is fetched on demand. + return ( + `` + ); +} + +/** + * A capped run of block glyphs for the pre-decode blur: it conveys "an encoded + * payload lives here" and roughly how large, without ever carrying the real + * bytes. Purely decorative (aria-hidden); the byte length is the only input. + */ +function encodedGlyphs(byteLength: number | undefined): string { + const n = + byteLength === undefined + ? 10 + : Math.max(8, Math.min(40, Math.ceil(byteLength / 2))); + return "▓".repeat(n); +} + +/** + * Render a Core-thread {@link FrameValueDetail}. Shared by both mounts so the + * redacted state is identical everywhere: a sensitive frame shows a clear + * "redacted" label and its byte length, never its value. + */ +export function renderFrameValueDetail(detail: FrameValueDetail): string { + switch (detail.kind) { + case "redacted": + return ( + `
` + + `redacted ` + + `${esc(detail.reason)} · ${String(detail.byteLength)}B withheld` + + `
` + ); + case "bytes": + return `
${String(detail.byteLength)}B · payload not shown
`; + case "decoded": + // A revealed sensitive value is flagged so the mount can style it as the + // danger it is (dev-only escape hatch); an ordinary decode is plain. + return detail.sensitive === true + ? `
${esc(stringifyValue(detail.value))}
` + : `
${esc(stringifyValue(detail.value))}
`; + } +} + +/** Pretty-print a decoded value for a `
`, tolerating cyclic/bigint inputs. */
+function stringifyValue(value: unknown): string {
+  try {
+    return JSON.stringify(
+      value,
+      (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v),
+      2,
+    );
+  } catch {
+    return String(value);
+  }
+}
+
+/** Roles that mark an op as a subscription rather than a request/response. */
+const SUBSCRIPTION_ROLES: ReadonlySet = new Set([
+  "start",
+  "receive",
+  "stop",
+  "interrupt",
+]);
+
+/** The op's method: the first opening frame's method, else the first known one. */
+function operationMethod(view: TraceView): string | undefined {
+  const opener = view.frames.find(
+    (f) => f.role === "request" || f.role === "start",
+  );
+  if (opener?.method !== undefined) {
+    return opener.method;
+  }
+  return view.frames.find((f) => f.method !== undefined)?.method;
+}
+
+/** Whether the op is a subscription (has a start/receive/stop/interrupt frame). */
+function isSubscription(view: TraceView): boolean {
+  return view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role));
+}
+
+/**
+ * Render one operation-list row: the primary view's unit, one per op. Shows the
+ * method, a request/subscription glyph, op-level badges, frame count, and
+ * duration. A subscription with no `stop` frame is marked live.
+ *
+ * Pure and stateless: the mount toggles `.selected` and manages the keyed diff.
+ * `data-request-id` (+ `data-channel-id` when known) identify the row for
+ * selection and channel filtering. Payload-blind: only shape and timing here.
+ */
+export function renderOperationRow(view: TraceView): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  const live = sub && !view.frames.some((f) => f.role === "stop");
+  const kindGlyph = sub ? "⟳" : "▶";
+  const kindClass = sub ? "td-op-sub" : "td-op-req";
+
+  const methodHtml =
+    method === undefined
+      ? `(unknown)`
+      : `${esc(method)}`;
+  const badges = view.badges.map(renderOpBadge).join("");
+  const count = view.frames.length;
+  const meta =
+    `${String(count)} frame${count === 1 ? "" : "s"} · ` +
+    (live ? `live · ${formatMs(view.durationMs)}` : formatMs(view.durationMs));
+
+  const channelAttr =
+    view.channelId === undefined
+      ? ""
+      : ` data-channel-id="${esc(view.channelId)}"`;
+  // Op-row privacy marker + a filterable attribute: this op touches a method
+  // whose payload stays redacted by default.
+  const sensitiveAttr = view.sensitive ? ` data-sensitive="1"` : "";
+  const lock = view.sensitive
+    ? ``
+    : "";
+
+  return (
+    `
` + + `` + + methodHtml + + lock + + (badges === "" ? "" : `${badges}`) + + `${meta}` + + `
` + ); +} diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts new file mode 100644 index 000000000..44de74d6e --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -0,0 +1,199 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Canonical styling for the shared drill-down renderer's `td-*` classes + * ({@link renderTraceDetail} / {@link renderFrameValueDetail}), co-located with + * the class emitter. + * + * These rules are lifted VERBATIM from dotli's debug-panel stylesheet + * (`hosts/dotli/packages/truapi-debug/src/styles.css`, the drill-down section) + * so the standalone app and dotli render the frame sequence identically, with + * zero drift. dotli keeps its own copy for now and converges onto this one once + * the build-graph seam lets it import `@parity/truapi-debugger`. Keep the two in + * sync until then; do not hand-edit these rules here. + * + * Note the vendored `hosts/dotli` submodule is the stale pre-port copy, so most + * of these drill-down classes are NOT yet byte-comparable against it - this file + * is the source of truth for them, and the dotli-community port picks them up at + * convergence. App-level layout (grid, the summary strip, `--payload-w`, etc.) + * deliberately lives OUTSIDE this file, as overrides after `TRACE_DETAIL_CSS` in + * the standalone shell, so it never contaminates the shared rules. + */ + +/** Verbatim `td-*` drill-down rules; inline into a ` +
+ TrUAPI Wire Inspector + + + + + + + + + decode: __DECODE_STATE__ +
+
waiting for frames…
+
+
waiting for frames…
+
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open, d to decode a frame.
+
+
connecting…
+ +`; + +// Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. +// Port comes from TRUAPI_DEBUGGER_PORT, else the default. Level-2 value decode +// is off unless TRUAPI_DEBUGGER_DECODE_VALUES is truthy (1/true/yes/on). +if (import.meta.main) { + const envPort = Number(Bun.env.TRUAPI_DEBUGGER_PORT); + const decodeValues = /^(1|true|yes|on)$/i.test( + Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES ?? "", + ); + const revealSensitive = /^(1|true|yes|on)$/i.test( + Bun.env.TRUAPI_DEBUGGER_REVEAL_SENSITIVE ?? "", + ); + const server = startDebugServer({ + port: Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT, + decodeValues, + revealSensitive, + }); + console.log( + `[truapi-debugger] listening on http://localhost:${server.port}` + + ` (value decode: ${server.decodeValues ? "on" : "off"}` + + `${server.revealSensitive ? ", sensitive reveal: ARMED" : ""})`, + ); +} From 79d24d43374091137f349da27e2c140580dcf425 Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:51 +0530 Subject: [PATCH 6/9] feat(truapi-debugger): terminal CLI and query REPL --- js/packages/truapi-debugger/src/cli-client.ts | 122 +++++++ js/packages/truapi-debugger/src/cli.ts | 182 +++++++++++ js/packages/truapi-debugger/src/repl.ts | 309 ++++++++++++++++++ .../truapi-debugger/src/trace-text.test.ts | 99 ++++++ js/packages/truapi-debugger/src/trace-text.ts | 159 +++++++++ 5 files changed, 871 insertions(+) create mode 100644 js/packages/truapi-debugger/src/cli-client.ts create mode 100644 js/packages/truapi-debugger/src/cli.ts create mode 100644 js/packages/truapi-debugger/src/repl.ts create mode 100644 js/packages/truapi-debugger/src/trace-text.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-text.ts diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts new file mode 100644 index 000000000..f71e23aa4 --- /dev/null +++ b/js/packages/truapi-debugger/src/cli-client.ts @@ -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; + stats(channel: string | null): Promise; + channels(): Promise; + /** + * 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; +} + +/** Build a {@link DebuggerClient} for `host` (e.g. `http://localhost:9231`). */ +export function createDebuggerClient(host: string): DebuggerClient { + const getJson = async (path: string): Promise => { + const res = await fetch(host + path); + if (!res.ok) throw new Error(`${host}${path} → HTTP ${String(res.status)}`); + return res.json() as Promise; + }; + const channelQuery = (channel: string | null): string => + channel ? `?channel=${encodeURIComponent(channel)}` : ""; + return { + host, + traces: () => getJson("/traces"), + stats: (channel) => getJson(`/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(`/frame?${p.toString()}`); + }, + }; +} diff --git a/js/packages/truapi-debugger/src/cli.ts b/js/packages/truapi-debugger/src/cli.ts new file mode 100644 index 000000000..766f553d0 --- /dev/null +++ b/js/packages/truapi-debugger/src/cli.ts @@ -0,0 +1,182 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * `truapi-debugger` terminal frontend: look at wire traces from a shell, for + * headless / SSH / CI workflows where the web inspector isn't reachable. + * + * Two frontends over one running debugger (`:9231` by default), sharing the same + * {@link module:cli-client} engine and the same sensitive denylist as the web + * inspector - no forked engine, no forked denylist: + * + * - `ui` / `repl` (default in a terminal): the interactive query {@link module:repl} + * - a prompt you keep querying: ls, filter, sort, use , show, reveal. + * - `ls` / `stats` / `show` / `tail`: one-shot commands for scripting + piping. + * + * Usage (from js/packages/truapi-debugger): + * bun run src/cli.ts # interactive query REPL + * bun run src/cli.ts ls # ops + aggregate summary + * bun run src/cli.ts stats # just the aggregate line + * bun run src/cli.ts show p:4 --reveal # one op's frames + decoded values + * bun run src/cli.ts tail # live view, refreshes each second + * Flags: --host http://localhost:9231 · --channel · --reveal · --interval + * + * @module + */ + +import { + createDebuggerClient, + toView, + type FrameValueDetail, + type TracesEntry, +} from "./cli-client.js"; +import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; +import { runRepl } from "./repl.js"; + +interface ParsedArgs { + cmd: string; + positional: string[]; + flags: Record; +} + +/** Flags that take a following value; everything else is a boolean flag. */ +const VALUE_FLAGS = new Set(["host", "channel", "interval"]); + +function parseArgs(argv: string[]): ParsedArgs { + const flags: Record = {}; + const positional: string[] = []; + let cmd = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + // Only value-flags consume the next token; a boolean flag (e.g. --reveal) + // leaves it as a positional, so `show --reveal p:4` parses correctly. + if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else if (cmd === "") { + cmd = a; + } else { + positional.push(a); + } + } + // No command in an interactive terminal → the query REPL; otherwise the list. + if (cmd === "") cmd = process.stdout.isTTY ? "ui" : "ls"; + return { cmd, positional, flags }; +} + +const args = parseArgs(process.argv.slice(2)); +// A bare `--host`/`--channel` (no value) parses as boolean `true`; take only a +// real string value as provided, otherwise fall back rather than coerce garbage. +const flagValue = (v: string | boolean | undefined): string | undefined => + typeof v === "string" ? v : undefined; +const host = + flagValue(args.flags.host) ?? + process.env.TRUAPI_DEBUGGER_HTTP ?? + "http://localhost:9231"; +const channel = flagValue(args.flags.channel) ?? null; +const reveal = args.flags.reveal === true || args.flags.reveal === "1"; +const client = createDebuggerClient(host); + +async function traces(): Promise { + const all = await client.traces(); + return channel === null ? all : all.filter((t) => t.channelId === channel); +} + +async function cmdStats(): Promise { + console.log(formatStats(await client.stats(channel))); +} + +async function cmdLs(): Promise { + const [stats, entries] = await Promise.all([client.stats(channel), traces()]); + console.log(formatStats(stats)); + console.log(""); + if (entries.length === 0) console.log(" (no operations yet)"); + // Unscoped view: show the channel so same-id ops from two hosts are distinct. + for (const t of entries) console.log(formatOpRow(toView(t), channel === null)); +} + +async function cmdShow(): Promise { + const id = args.positional[0]; + if (id === undefined) { + console.error("usage: show [--reveal] [--channel ]"); + process.exit(1); + } + const entry = (await traces()).find((t) => t.requestId === id); + if (entry === undefined) { + console.error(`no operation with requestId ${id}`); + process.exit(1); + } + const view = toView(entry); + if (reveal) { + // The one-shot reveal is a deliberate, non-interactive scripting path (the + // interactive REPL uses a typed `reveal ` + `yes` confirm instead). Warn + // up front as the REPL does; the server still only honors reveal when armed. + console.error( + "\x1b[31m⚠ revealing SENSITIVE payloads\x1b[0m\x1b[2m — output may contain a private key, signature, or credential; do NOT run this while screen-sharing or recording. Honored only on a server armed with TRUAPI_DEBUGGER_REVEAL_SENSITIVE.\x1b[0m", + ); + } + const decoded = new Map(); + for (const f of view.frames) { + try { + decoded.set( + f.seq, + await client.frame(entry.requestId, f.seq, entry.channelId, reveal), + ); + } catch { + // Leave the frame value-less; the row still renders. + } + } + console.log(formatOpDetail(view, decoded)); +} + +async function cmdTail(): Promise { + const interval = Number(args.flags.interval ?? 1000); + const render = async (): Promise => { + const [stats, entries] = await Promise.all([client.stats(channel), traces()]); + process.stdout.write("\x1b[2J\x1b[H"); + console.log(formatStats(stats)); + console.log(""); + for (const t of entries.slice(-40)) console.log(formatOpRow(toView(t))); + console.log( + `\n\x1b[2mwatching ${host}${channel ? ` · ${channel}` : ""} — Ctrl-C to stop\x1b[0m`, + ); + }; + await render(); + setInterval(() => { + render().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : String(e)); + }); + }, interval); +} + +async function cmdUi(): Promise { + await runRepl(client, channel); +} + +const commands: Record Promise> = { + ui: cmdUi, + repl: cmdUi, + stats: cmdStats, + ls: cmdLs, + ops: cmdLs, + show: cmdShow, + tail: cmdTail, + watch: cmdTail, +}; + +const run = commands[args.cmd]; +if (run === undefined) { + console.error( + `unknown command: ${args.cmd}\ncommands: ui · stats · ls · show · tail`, + ); + process.exit(1); +} +run().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); +}); diff --git a/js/packages/truapi-debugger/src/repl.ts b/js/packages/truapi-debugger/src/repl.ts new file mode 100644 index 000000000..2c5319053 --- /dev/null +++ b/js/packages/truapi-debugger/src/repl.ts @@ -0,0 +1,309 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Interactive query REPL for the wire debugger - a prompt you keep talking to, + * rather than a full-screen app. Line-based (via `node:readline`, so history and + * line editing come for free), over a running debugger, reusing the same + * {@link buildTraceView} engine and denylist as the web inspector. + * + * Session scope (channel / filter / sort / sensitive-only) persists across + * queries, so `ls` reflects the state you set. The sensitive-reveal escape hatch + * is a two-step, in-loop confirm (`reveal ` then `yes`) - no nested prompt, + * and the reveal is honored only when the server is armed. + * + * @module + */ + +import readline from "node:readline"; + +import { + toView, + viewMethod, + type DebuggerClient, + type FrameValueDetail, +} from "./cli-client.js"; +import type { TraceView } from "./trace-view.js"; +import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; + +const COLOR = + process.env.NO_COLOR === undefined && process.stdout.isTTY === true; +function c(code: string, s: string): string { + return COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; +} +const bold = (s: string): string => c("1", s); +const dim = (s: string): string => c("2", s); +const red = (s: string): string => c("31", s); +const green = (s: string): string => c("32", s); +const cyan = (s: string): string => c("36", s); + +const SORTS = ["arrival", "recent", "method", "duration", "frames"]; + +interface ReplState { + channel: string | null; + filter: string; + sort: string; + sensOnly: boolean; + /** A reveal awaiting the next line's `yes` confirmation. */ + pendingReveal: { requestId: string; seq?: number } | null; +} + +const HELP = [ + bold("commands"), + ` ${cyan("ls")} [text] list ops (aggregate + rows); optional inline method filter`, + ` ${cyan("stats")} just the aggregate summary line`, + ` ${cyan("show")} an op's frames, decoding non-sensitive values`, + ` ${cyan("decode")} alias for show`, + ` ${cyan("reveal")} [seq] reveal sensitive frame(s) — asks to confirm (dev, armed server only)`, + ` ${cyan("channels")} hosts that have dialed in`, + ` ${cyan("use")} scope every query to one channel`, + ` ${cyan("filter")} [text] persistent method filter (empty clears)`, + ` ${cyan("sort")} ${SORTS.join(" | ")}`, + ` ${cyan("sensitive")} [on|off] show only ops with a sensitive method`, + ` ${cyan("clear")} clear the screen`, + ` ${cyan("help")} · ${cyan("quit")}`, +].join("\n"); + +/** Run the query REPL against `client`. Resolves when the user quits. */ +export async function runRepl( + client: DebuggerClient, + channel: string | null, +): Promise { + const state: ReplState = { + channel, + filter: "", + sort: "arrival", + sensOnly: false, + pendingReveal: null, + }; + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + historySize: 200, + terminal: process.stdin.isTTY === true, + }); + + console.log(`${bold("TrUAPI wire debugger")}${dim(` — ${client.host}`)}`); + console.log(dim("type `help` for commands, `quit` to exit")); + + const promptStr = (): string => { + const bits = [state.channel ?? "all"]; + if (state.filter) bits.push(cyan(`/${state.filter}`)); + if (state.sort !== "arrival") bits.push(`sort:${state.sort}`); + if (state.sensOnly) bits.push(red("\u{1f512}")); + return `${green("truapi")} ${dim(bits.join(" "))} ${bold("▸")} `; + }; + + function sortViews(views: TraceView[]): TraceView[] { + if (state.sort === "arrival") return views; + return [...views].sort((a, b) => { + switch (state.sort) { + case "recent": + return b.lastAt - a.lastAt; + case "duration": + return b.durationMs - a.durationMs; + case "frames": + return b.frames.length - a.frames.length; + case "method": + return viewMethod(a).localeCompare(viewMethod(b)); + default: + return 0; + } + }); + } + + async function views(inlineFilter?: string): Promise { + const all = await client.traces(); + let vs = all + .filter((t) => state.channel === null || t.channelId === state.channel) + .map(toView); + const f = (inlineFilter ?? state.filter).toLowerCase(); + if (f) vs = vs.filter((v) => viewMethod(v).toLowerCase().includes(f)); + if (state.sensOnly) vs = vs.filter((v) => v.sensitive === true); + return sortViews(vs); + } + + async function doList(inlineFilter?: string): Promise { + const [stats, vs] = await Promise.all([ + client.stats(state.channel), + views(inlineFilter), + ]); + console.log(formatStats(stats)); + console.log(""); + if (vs.length === 0) console.log(dim(" (no operations match)")); + // Unscoped view: show the channel so same-id ops from two hosts are distinct. + for (const v of vs) console.log(formatOpRow(v, state.channel === null)); + } + + async function doChannels(): Promise { + const chs = await client.channels(); + if (chs.length === 0) { + console.log(dim(" (no hosts have dialed in yet)")); + return; + } + for (const ch of chs) { + console.log( + `${ch.connected ? green("●") : dim("○")} ${ch.channelId} ${dim(`(${String(ch.frameCount)} frames)`)}${ch.channelId === state.channel ? cyan(" ← scoped") : ""}`, + ); + } + } + + async function findOp(id: string) { + return (await client.traces()).find( + (t) => + t.requestId === id && + (state.channel === null || t.channelId === state.channel), + ); + } + + async function doShow(id: string, revealSeqs?: Set): Promise { + const entry = await findOp(id); + if (entry === undefined) { + console.log(red(`no operation with requestId ${id}`)); + return; + } + const view = toView(entry); + const decoded = new Map(); + for (const f of view.frames) { + const reveal = revealSeqs?.has(f.seq) ?? false; + try { + decoded.set( + f.seq, + await client.frame(entry.requestId, f.seq, entry.channelId, reveal), + ); + } catch { + // Leave the frame value-less; the row still renders. + } + } + console.log(formatOpDetail(view, decoded)); + } + + async function startReveal(id: string, seqArg?: string): Promise { + const entry = await findOp(id); + if (entry === undefined) { + console.log(red(`no operation with requestId ${id}`)); + return; + } + const view = toView(entry); + const seq = seqArg === undefined ? undefined : Number(seqArg); + const targets = + seq === undefined + ? view.frames.filter((f) => f.sensitive === true) + : view.frames.filter((f) => f.seq === seq); + if (targets.length === 0) { + console.log(dim(" (no sensitive frame to reveal here)")); + return; + } + state.pendingReveal = { requestId: id, seq }; + console.log( + red("⚠ reveal SENSITIVE payload") + + dim(" — may contain a private key/credential; not while screen-sharing.\n") + + ` type ${bold("yes")} to confirm (anything else cancels)`, + ); + } + + async function handle(line: string): Promise { + // A pending reveal consumes this line as its confirmation. + if (state.pendingReveal) { + const { requestId, seq } = state.pendingReveal; + state.pendingReveal = null; + if (line.toLowerCase() !== "yes" && line.toLowerCase() !== "y") { + console.log(dim(" (reveal cancelled)")); + return; + } + const entry = await findOp(requestId); + if (entry === undefined) { + console.log(red(`no operation with requestId ${requestId}`)); + return; + } + const view = toView(entry); + // A specific seq reveals just that frame; otherwise every sensitive frame. + const revealSeqs = + seq === undefined + ? new Set(view.frames.filter((f) => f.sensitive === true).map((f) => f.seq)) + : new Set([seq]); + await doShow(requestId, revealSeqs); + return; + } + + const [cmd, ...rest] = line.split(/\s+/).filter(Boolean); + if (cmd === undefined) return; + const pos = rest.filter((a) => !a.startsWith("--")); + const arg = pos[0]; + switch (cmd) { + case "help": + case "?": + console.log(HELP); + return; + case "ls": + case "ops": + return doList(arg); + case "stats": + console.log(formatStats(await client.stats(state.channel))); + return; + case "channels": + return doChannels(); + case "show": + case "decode": + if (arg === undefined) { + console.log(dim("usage: show ")); + return; + } + return doShow(arg); + case "reveal": + if (arg === undefined) { + console.log(dim("usage: reveal [seq]")); + return; + } + return startReveal(arg, pos[1]); + case "use": + case "channel": + state.channel = arg === undefined || arg === "all" ? null : arg; + return; + case "filter": + state.filter = rest.filter((a) => !a.startsWith("--")).join(" "); + return; + case "sort": + if (arg !== undefined && SORTS.includes(arg)) state.sort = arg; + else console.log(dim(`sort: ${SORTS.join(" | ")}`)); + return; + case "sensitive": + case "sens": + state.sensOnly = arg === undefined ? !state.sensOnly : arg === "on"; + return; + case "clear": + console.clear(); + return; + case "quit": + case "exit": + case "q": + rl.close(); + return; + default: + console.log(dim(`unknown command: ${cmd} — try \`help\``)); + } + } + + const prompt = (): void => { + rl.setPrompt(promptStr()); + rl.prompt(); + }; + + // Serialize line handling so piped input and in-flight fetches never interleave. + let chain: Promise = Promise.resolve(); + prompt(); + rl.on("line", (line) => { + chain = chain + .then(() => handle(line.trim())) + .catch((e: unknown) => { + console.error(red(e instanceof Error ? e.message : String(e))); + }) + .then(() => prompt()); + }); + + await new Promise((resolve) => { + rl.on("close", () => { + console.log(dim("bye")); + resolve(); + }); + }); +} diff --git a/js/packages/truapi-debugger/src/trace-text.test.ts b/js/packages/truapi-debugger/src/trace-text.test.ts new file mode 100644 index 000000000..f1c1d6c74 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-text.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; + +import { buildTraceView } from "./trace-view.js"; +import { + formatFrameValue, + formatOpRow, + formatStats, + type CliStats, +} from "./trace-text.js"; + +const stats: CliStats = { + ops: 2, + frames: 5, + bytes: 40, + subscriptions: 1, + liveSubscriptions: 1, + malformed: 0, + orphaned: 1, + retryStorms: 0, + sensitive: 1, + out: 3, + in: 2, + avgDurationMs: 12, + maxDurationMs: 30, + topMethods: [], +}; + +describe("formatStats", () => { + test("renders counts and surfaces sensitive + orphaned", () => { + const s = formatStats(stats); + expect(s).toContain("ops"); + expect(s).toContain("sensitive"); + expect(s).toContain("orphaned"); + }); +}); + +describe("formatOpRow", () => { + test("shows method, requestId, and a lock for a sensitive op", () => { + const view = buildTraceView({ + requestId: "p:1", + startedAt: 0, + lastAt: 12, + frames: [ + { + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 20, + timestamp: 0, + decodable: false, + sensitive: true, + }, + { + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 20, + timestamp: 12, + decodable: false, + sensitive: true, + }, + ], + }); + const row = formatOpRow(view); + expect(row).toContain("account.getAccount"); + expect(row).toContain("p:1"); + expect(row).toContain("\u{1f512}"); + }); +}); + +describe("formatFrameValue", () => { + test("redacted never shows a value", () => { + expect( + formatFrameValue({ + kind: "redacted", + reason: "sensitive method", + byteLength: 64, + }), + ).toContain("redacted"); + }); + + test("a revealed sensitive value is flagged dev-only, and still shows content", () => { + const out = formatFrameValue({ + kind: "decoded", + value: { free: 42 }, + sensitive: true, + }); + expect(out).toContain("revealed sensitive material"); + expect(out).toContain("42"); + }); + + test("bytes-only shows no payload", () => { + expect(formatFrameValue({ kind: "bytes", byteLength: 8 })).toContain( + "payload not shown", + ); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-text.ts b/js/packages/truapi-debugger/src/trace-text.ts new file mode 100644 index 000000000..b775bd119 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-text.ts @@ -0,0 +1,159 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Terminal renderer for the drill-down, the text counterpart of the HTML + * {@link renderTraceDetail}. Same {@link TraceView} input, so the terminal + * viewer ({@link module:cli}) and the web inspector show the same ops, badges, + * redaction, and decoded values off one engine - no forked formatter, no forked + * denylist. Pure `TraceView → string`; the CLI supplies the view and the decode + * results, exactly as the web mount does. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import { viewMethod, type TraceFrameView, type TraceView } from "./trace-view.js"; + +// Color only on an interactive terminal, and never when NO_COLOR is set. +const USE_COLOR = + process.env.NO_COLOR === undefined && process.stdout.isTTY === true; + +function paint(code: string, s: string): string { + return USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; +} +const bold = (s: string): string => paint("1", s); +const dim = (s: string): string => paint("2", s); +const red = (s: string): string => paint("31", s); +const green = (s: string): string => paint("32", s); +const yellow = (s: string): string => paint("33", s); +const magenta = (s: string): string => paint("35", s); +const gray = (s: string): string => paint("90", s); + +function fmtMs(ms: number): string { + return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(2)}s`; +} +function fmtBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +/** The payload-blind aggregate `/stats` returns, mirrored for the CLI. */ +export interface CliStats { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + sensitive: number; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; +} + +/** One-line aggregate summary, the terminal form of the inspector's summary strip. */ +export function formatStats(s: CliStats): string { + const parts = [ + `${bold(String(s.ops))} ${dim("ops")}`, + `${bold(String(s.frames))} ${dim(`frames (${String(s.out)}▶ ${String(s.in)}◀)`)}`, + `${bold(fmtBytes(s.bytes))} ${dim("data")}`, + `${bold(String(s.subscriptions))} ${dim("subs")}${s.liveSubscriptions ? ` ${green(`(${String(s.liveSubscriptions)} live)`)}` : ""}`, + `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)})`)}`, + s.sensitive + ? red(`\u{1f512} ${String(s.sensitive)} sensitive`) + : dim("\u{1f512} 0 sensitive"), + ]; + if (s.malformed) parts.push(red(`${String(s.malformed)} malformed`)); + if (s.orphaned) parts.push(yellow(`${String(s.orphaned)} orphaned`)); + if (s.retryStorms) parts.push(yellow(`${String(s.retryStorms)} retry-storms`)); + return parts.join(dim(" · ")); +} + +const SUBSCRIPTION_ROLES = new Set(["start", "receive", "stop", "interrupt"]); + +/** + * One op as a single row: the terminal form of an op-list row. When + * `showChannel` is set (an unscoped, multi-host view), the channel is shown so + * two hosts minting the same `requestId` are distinguishable. + */ +export function formatOpRow(view: TraceView, showChannel = false): string { + const sub = view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role)); + const live = sub && !view.frames.some((f) => f.role === "stop"); + const kind = sub ? magenta("⟳") : yellow("▶"); + const method = bold(viewMethod(view).padEnd(38).slice(0, 38)); + const lock = view.sensitive ? red(" \u{1f512}") : " "; + const badges = view.badges + .map((b) => (b === "malformed" ? red(`[${b}]`) : yellow(`[${b}]`))) + .join(" "); + const meta = dim( + `${String(view.frames.length)}f · ${live ? green("live ") : ""}${fmtMs(view.durationMs)}`, + ); + const chan = + showChannel && view.channelId !== undefined + ? gray(`[${view.channelId}] `) + : ""; + return `${kind} ${method}${lock} ${meta}${badges ? ` ${badges}` : ""} ${chan}${gray(view.requestId)}`; +} + +/** One op's full frame sequence + any resolved decode values (drill-down). */ +export function formatOpDetail( + view: TraceView, + decoded: ReadonlyMap, +): string { + const lines: string[] = []; + lines.push( + `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)}`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, + ); + for (const f of view.frames) { + lines.push(formatFrameRow(f)); + const detail = decoded.get(f.seq); + if (detail) lines.push(indent(formatFrameValue(detail))); + } + return lines.join("\n"); +} + +function formatFrameRow(f: TraceFrameView): string { + const glyph = f.direction === "out" ? yellow("▶") : green("◀"); + const role = dim(f.role.padEnd(8).slice(0, 8)); + const method = f.method ?? `id ${String(f.frameId ?? "?")}`; + const size = f.byteLength === undefined ? "" : dim(`${String(f.byteLength)}B`); + const lat = + f.roundTripMs !== undefined + ? dim(`⟳${fmtMs(f.roundTripMs)}`) + : dim(`+${fmtMs(f.latencyFromStartMs)}`); + return ` ${glyph} ${role} ${method} ${size} ${lat}`; +} + +/** Render one {@link FrameValueDetail}; a revealed sensitive value is flagged. */ +export function formatFrameValue(detail: FrameValueDetail): string { + switch (detail.kind) { + case "redacted": + return red( + `redacted · ${detail.reason} · ${String(detail.byteLength)}B withheld`, + ); + case "bytes": + return dim(`${String(detail.byteLength)}B · payload not shown`); + case "decoded": { + const body = JSON.stringify( + detail.value, + (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v), + 2, + ); + return detail.sensitive === true + ? `${red("⚠ revealed sensitive material — dev only")}\n${body}` + : body; + } + } +} + +function indent(s: string): string { + return s + .split("\n") + .map((l) => ` ${l}`) + .join("\n"); +} From 4cca7a3726f6409c81f660621a55e37d69cf658d Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 4 Aug 2026 13:29:54 +0530 Subject: [PATCH 7/9] fix(truapi): loopback bind, versioned wire envelope, bounded retention --- js/packages/truapi-debugger/src/cli-client.ts | 3 + js/packages/truapi-debugger/src/ingest.ts | 50 +- .../truapi-debugger/src/server.test.ts | 126 ++++- js/packages/truapi-debugger/src/server.ts | 452 ++++++++++++++---- js/packages/truapi-debugger/src/session.ts | 11 +- .../truapi-debugger/src/trace-render.ts | 12 +- .../truapi-debugger/src/trace-styles.ts | 5 + .../truapi-debugger/src/trace-text.test.ts | 4 + js/packages/truapi-debugger/src/trace-text.ts | 20 +- js/packages/truapi-debugger/src/trace-view.ts | 33 +- .../truapi-debugger/src/wire-debugger.test.ts | 113 ++++- .../truapi-debugger/src/wire-debugger.ts | 158 +++++- .../truapi-host/src/worker-runtime.test.ts | 24 + js/packages/truapi-host/src/worker-runtime.ts | 109 ++++- js/packages/truapi/README.md | 9 +- rust/crates/truapi-codegen/src/main.rs | 11 +- rust/crates/truapi-codegen/src/rust.rs | 29 +- .../truapi-codegen/src/rust/wire_table.rs | 22 +- rust/crates/truapi-codegen/src/ts.rs | 56 +++ .../truapi-codegen/tests/golden/wire_table.rs | 6 + .../truapi-server/src/generated/wire_table.rs | 6 + rust/crates/truapi-server/src/host_core.rs | 84 +++- rust/crates/truapi-server/src/native_debug.rs | 108 ++++- 23 files changed, 1257 insertions(+), 194 deletions(-) create mode 100644 js/packages/truapi-host/src/worker-runtime.test.ts diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts index f71e23aa4..aa08ee543 100644 --- a/js/packages/truapi-debugger/src/cli-client.ts +++ b/js/packages/truapi-debugger/src/cli-client.ts @@ -36,6 +36,8 @@ export interface TracesFrame { export interface TracesEntry { channelId: string; requestId: string; + /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ + generation?: number; startedAt: number; lastAt: number; /** Op-level badges the server computed (incl. the cross-op retry-storm). */ @@ -56,6 +58,7 @@ export function toView(entry: TracesEntry): TraceView { const input: TraceViewInput = { requestId: entry.requestId, channelId: entry.channelId, + generation: entry.generation, startedAt: entry.startedAt, lastAt: entry.lastAt, // Cross-op badges (retry-storm) are computed server-side and passed through, diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts index 220ac61f0..482dd0e45 100644 --- a/js/packages/truapi-debugger/src/ingest.ts +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -13,6 +13,25 @@ import { decodeWireMessage } from "@parity/truapi"; import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; +import type { WireMethodInfo } from "./wire-debugger.js"; + +/** + * Version of the host→debugger wire envelope (`{ channelId, dir, frame }`). + * Bumped when the envelope shape changes. Producers (the Rust `WsDebugSink`, the + * web host's debugger link) stamp it alongside a codec identity so the debugger + * can refuse to decode a frame against a wire contract that isn't its own - + * frame ids are `u8` discriminants that get reassigned as the API evolves, so an + * unversioned envelope from an older host would resolve to the wrong method, the + * wrong value, and worst case decode a frame the host's build marks sensitive. + */ +export const WIRE_ENVELOPE_VERSION = 1; + +/** + * Default cap on retained `channelId` / `requestId` length. Shared so the + * debugger server's channel registry clamps to the same bound as ingest and the + * two keys stay equal (the UI filters by the clamped key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; /** * One wire frame as it crosses the host tap, matching the Rust @@ -41,6 +60,21 @@ export interface DebugIngestOptions { * either way; retaining them only makes the drill-down decoder able to run. */ retainBytes?: boolean; + /** + * Reverse map from wire `frameId` to method info (build one with + * {@link createMethodNameMap}). When set, each frame's lifecycle `role` is + * resolved here from the frame id's wire-table `kind`, so *every* consumer - + * the default console sink, the `forward` hook, and the trace engine - sees the + * real role. Without it, `role` is left `"unknown"` and only the view adapter + * recovers it. + */ + methodNames?: ReadonlyMap; + /** + * Cap on retained `channelId` / `requestId` length. Anything able to reach the + * host tap could otherwise send 200k-char ids, one copy per frame; real ids are + * short (`myapp.dot`, `p:1`). Default 256. + */ + maxIdChars?: number; } /** @@ -63,11 +97,16 @@ export function createDebugIngest( options: DebugIngestOptions = {}, ): (envelope: DebugFrameEnvelope) => void { const retainBytes = options.retainBytes ?? false; + const methodNames = options.methodNames; + const maxIdChars = options.maxIdChars ?? DEFAULT_MAX_ID_CHARS; + const clampId = (id: string): string => + id.length > maxIdChars ? id.slice(0, maxIdChars) : id; return (envelope) => { + const channelId = clampId(envelope.channelId); const decoded = decodeWireMessage(envelope.frame); if (decoded.isErr()) { sink({ - channelId: envelope.channelId, + channelId, direction: envelope.dir, requestId: "malformed", frameId: -1, @@ -79,11 +118,14 @@ export function createDebugIngest( } const { requestId, payload } = decoded.value; const frame: ObservedFrame = { - channelId: envelope.channelId, + channelId, direction: envelope.dir, - requestId, + requestId: clampId(requestId), frameId: payload.id, - role: "unknown", + // Resolve the lifecycle role from the frame id's wire-table kind (the same + // kind wireTraceToView falls back to). Left "unknown" when no map is given + // or the id is off-table. + role: methodNames?.get(payload.id)?.kind ?? "unknown", byteLength: payload.value.length, timestamp: Date.now(), ...(retainBytes ? { bytes: payload.value } : {}), diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index cff93a89b..0b6f84d91 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { encodeWireMessage } from "@parity/truapi"; +import { encodeWireMessage, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; import { startDebugServer } from "./server.js"; @@ -35,7 +35,14 @@ async function streamFrame( ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId: "myapp.dot", dir, frame })); + 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[]; @@ -61,7 +68,14 @@ test("decodes and groups a frame a host streams over the WS", async () => { ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId: "myapp.dot", dir: "out", frame })); + 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++) { @@ -340,12 +354,109 @@ test("/frame validates its params and 404s an unknown frame", async () => { 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("/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}`; @@ -459,7 +570,14 @@ test("groups by (channel, requestId) — two hosts minting the same id do not me ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId, dir: "out", frame })); + ws.send( + JSON.stringify({ + channelId, + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); await new Promise((r) => setTimeout(r, 40)); ws.close(); }; diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index dda336483..4186e08d3 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -19,9 +19,15 @@ * @module */ +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import { createDebugSession } from "./session.js"; -import type { DebugFrameEnvelope } from "./ingest.js"; -import { wireTraceToView } from "./trace-view.js"; +import { + DEFAULT_MAX_ID_CHARS, + WIRE_ENVELOPE_VERSION, + type DebugFrameEnvelope, +} from "./ingest.js"; +import { wireTraceToView, type TraceView } from "./trace-view.js"; +import type { CliStats } from "./trace-text.js"; import { renderFrameValueDetail, renderOperationRow, @@ -41,15 +47,87 @@ const SUBSCRIPTION_ROLES = new Set([ "interrupt", ]); -/** The text message a host sends per frame: the envelope with a base64 frame. */ +/** + * 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; + /** 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, its method leg, and its sensitivity. Unlike `codec` (the + * coarse handshake number, bumped ~never), this changes whenever a frame id is + * reassigned or a `#[wire(sensitive)]` flag flips - the case where a + * host-sensitive frame could otherwise decode off this debugger's denylist. + */ + 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; } -/** Parse and validate one inbound WS text message into an envelope, or `null`. */ -function parseWireMessage(raw: string): DebugFrameEnvelope | null { +/** + * 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 match + // that form (a bare "::1" never occurs, but accept it defensively). + return ( + host === "127.0.0.1" || + host === "localhost" || + host === "[::1]" || + host === "::1" + ); + } catch { + return false; + } +} + +/** + * Parse an optional integer query param: `undefined` if absent, `null` if + * malformed. Requires a canonical integer so `""`, `" "`, `"1e3"`, `"0x10"`, + * `"1.5"`, and `"+1"` all reject rather than silently coercing (`Number("")===0`). + */ +function optionalInt(raw: string | null): number | null | undefined { + if (raw === null) return undefined; + const t = raw.trim(); + if (!/^-?\d+$/.test(t)) return null; + const n = Number(t); + return Number.isInteger(n) ? n : null; +} + +/** Parse and validate one inbound WS text message, or `null`. */ +function parseWireMessage(raw: string): ParsedWireMessage | null { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -61,10 +139,20 @@ function parseWireMessage(raw: string): DebugFrameEnvelope | null { if (typeof m.channelId !== "string") return null; if (m.dir !== "in" && m.dir !== "out") return null; if (typeof m.frame !== "string") return null; + 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); return { - channelId: m.channelId, - dir: m.dir, - frame: new Uint8Array(Buffer.from(m.frame, "base64")), + envelope: { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + }, + identityMismatch, + identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, + dropped: typeof m.dropped === "number" && m.dropped > 0 ? m.dropped : 0, }; } @@ -118,6 +206,31 @@ export function startDebugServer( const revealSensitive = decodeValues && (options.revealSensitive ?? false); const session = createDebugSession({ decodeValues, revealSensitive }); + /** Adapt one trace to a view with the shared method map + denylist. */ + const toView = ( + trace: ReturnType[number], + storms: ReturnType, + ): TraceView => + wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + session.sensitiveIds, + ); + + /** + * 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 - decode lives only on the `/frame` drill-down. `method` @@ -127,18 +240,11 @@ export function startDebugServer( // 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 traces = session.traceEngine.traces(); - const storms = detectRetryStorms(traces); - const out = traces.map((t) => { - const view = wireTraceToView( - t, - session.methodNames, - storms.get(t) ?? [], - session.sensitiveIds, - ); + 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, @@ -161,14 +267,25 @@ export function startDebugServer( const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; const reveal = url.searchParams.get("reveal") === "1"; + // `Number("")`/`Number(" ")` are both 0 and pass Number.isInteger, so an + // empty or whitespace `?i=` or `?gen=` would otherwise resolve frame 0 / + // generation 0 (the oldest recycled op) with a 200; optionalInt rejects them. + const generation = optionalInt(url.searchParams.get("gen")); const index = Number(rawIndex); - if (id === null || rawIndex === null || !Number.isInteger(index)) { + if ( + id === null || + rawIndex === null || + rawIndex.trim() === "" || + !Number.isInteger(index) || + generation === null + ) { return new Response('{"error":"id and integer i required"}', { status: 400, headers: { "content-type": "application/json" }, }); } - const detail = session.frameDetail(id, index, channel, reveal); + if (!decodeTrusted(channel)) return codecRefusal("application/json"); + const detail = session.frameDetail(id, index, channel, reveal, generation); if (!detail) { return new Response('{"error":"no such frame"}', { status: 404, @@ -186,31 +303,20 @@ export function startDebugServer( * No payloads here; decode controls appear per frame only when level-2 is on. */ function viewHtml(): string { - const traces = session.traceEngine.traces(); - if (traces.length === 0) { + const entries = viewsFor(session.traceEngine.traces()); + if (entries.length === 0) { return `
no frames yet
`; } - // Retry-storm is a cross-op signal computed here in the list layer and fed - // to the view as extra op badges; the renderer stays display-only. - const storms = detectRetryStorms(traces); // 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 traces + return entries .map( - (t) => + ({ view }) => `
` + - renderTraceDetail( - wireTraceToView( - t, - session.methodNames, - storms.get(t) ?? [], - session.sensitiveIds, - ), - { - offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, - }, - ) + + renderTraceDetail(view, { + offerDecode: session.decodeValues, + offerReveal: session.revealSensitive, + }) + `
`, ) .join(""); @@ -227,14 +333,27 @@ export function startDebugServer( const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; const reveal = url.searchParams.get("reveal") === "1"; + const generation = optionalInt(url.searchParams.get("gen")); const index = Number(rawIndex); - if (id === null || rawIndex === null || !Number.isInteger(index)) { + if ( + id === null || + rawIndex === null || + rawIndex.trim() === "" || + !Number.isInteger(index) || + generation === null + ) { return new Response(`
bad request
`, { status: 400, headers: htmlHeaders, }); } - const detail = session.frameDetail(id, index, channel, reveal); + if (!decodeTrusted(channel)) { + return new Response( + `
decode refused — host wire codec mismatch
`, + { status: 409, headers: htmlHeaders }, + ); + } + const detail = session.frameDetail(id, index, channel, reveal, generation); if (!detail) { return new Response(`
no such frame
`, { status: 404, @@ -262,46 +381,120 @@ export function startDebugServer( // frames under many distinct channelIds can't grow it without bound; when // full, evict the least-recently-seen channel. const MAX_CHANNELS = 256; + // Clamp channelId to the same bound ingest uses so this registry's key matches + // the trace-engine key the UI filters by, and an over-long attacker-chosen id + // can't bloat the map (256 entries * an unbounded key would otherwise grow it). + const clampChannelId = (id: string): string => + id.length > DEFAULT_MAX_ID_CHARS ? id.slice(0, DEFAULT_MAX_ID_CHARS) : id; const channels = new Map< string, - { channelId: string; firstSeen: number; lastSeen: number; frameCount: number } + { + 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; - function recordChannel(channelId: string): void { + function recordChannel(channelId: string, parsed: ParsedWireMessage): void { + if (!parsed.identityConfirmed) sawUntrusted = true; const now = Date.now(); - const existing = channels.get(channelId); + const key = clampChannelId(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 [key, c] of channels) { + for (const [k, c] of channels) { if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; - oldestKey = key; + oldestKey = k; } } if (oldestKey !== undefined) channels.delete(oldestKey); } - channels.set(channelId, { - channelId, + 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 a host-sensitive id could resolve off + * this debugger's `SENSITIVE_FRAME_IDS` - 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(clampChannelId(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, - channels: [...channels.values()] - .sort((a, b) => b.lastSeen - a.lastSeen) - .map((c) => ({ ...c, connected: now - c.lastSeen < CONNECTED_WINDOW_MS })), + // 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, + })), }); } @@ -316,8 +509,7 @@ export function startDebugServer( const traces = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(channel); - const storms = detectRetryStorms(traces); + : session.traceEngine.tracesForChannel(clampChannelId(channel)); let frames = 0; let bytes = 0; let subscriptions = 0; @@ -325,20 +517,21 @@ export function startDebugServer( let malformed = 0; let orphaned = 0; let retryStorms = 0; + let truncated = 0; let sensitive = 0; let out = 0; let inbound = 0; let durationTotal = 0; let durationMax = 0; const methodCounts = new Map(); - for (const t of traces) { - const view = wireTraceToView(t, session.methodNames, storms.get(t) ?? [], session.sensitiveIds); + for (const { view } of viewsFor(traces)) { frames += view.frames.length; durationTotal += view.durationMs; if (view.durationMs > durationMax) durationMax = view.durationMs; if (view.badges.includes("malformed")) malformed += 1; if (view.badges.includes("orphaned")) orphaned += 1; if (view.badges.includes("retry-storm")) retryStorms += 1; + if (view.badges.includes("truncated")) truncated += 1; if (view.sensitive) sensitive += 1; if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { subscriptions += 1; @@ -362,7 +555,22 @@ export function startDebugServer( .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([method, count]) => ({ method, count })); - return JSON.stringify({ + // Whole-op eviction (session-wide) and host-reported drops are loss the ops + // list can't show: `ops` counts only the survivors, so without these a + // 10k-op session that kept 256 reads as "256 ops" with no sign the rest were + // dropped. `codecMismatch` flags a host whose wire contract differs. + const evictedTraces = session.traceEngine.evictedTraces(); + const chanList = + channel === null + ? [...channels.values()] + : [...channels.values()].filter( + (c) => c.channelId === clampChannelId(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 the CLI parses back as CliStats. + const payload: CliStats = { ops, frames, bytes, @@ -371,13 +579,18 @@ export function startDebugServer( malformed, orphaned, retryStorms, + truncated, + evictedTraces, + droppedByHost, + codecMismatch, sensitive, out, in: inbound, avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), maxDurationMs: Math.round(durationMax), topMethods, - }); + }; + return JSON.stringify(payload); } /** The op's method for sorting: the first frame that resolves to one. */ @@ -428,7 +641,7 @@ export function startDebugServer( const base = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(channel); + : session.traceEngine.tracesForChannel(clampChannelId(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. @@ -436,13 +649,26 @@ export function startDebugServer( if (base.length === 0) { return `
no operations yet
`; } - return sortTraces(base, sort) - .map((t) => - renderOperationRow( - wireTraceToView(t, session.methodNames, storms.get(t) ?? [], session.sensitiveIds), - ), - ) - .join(""); + 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(clampChannelId(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))).join("") + ); } /** @@ -450,21 +676,23 @@ export function startDebugServer( * {@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): string { - const trace = session.traceEngine.trace(requestId, channel ?? undefined); + 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 = wireTraceToView( - trace, - session.methodNames, - storms.get(trace) ?? [], - session.sensitiveIds, - ); - return renderTraceDetail(view, { + return renderTraceDetail(toView(trace, storms), { offerDecode: session.decodeValues, offerReveal: session.revealSensitive, }); @@ -472,8 +700,23 @@ export function startDebugServer( 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 them or inject frames. The CLI and same-origin inspector both target + // localhost, so nothing else changes. + hostname: "127.0.0.1", fetch(req, srv) { - if (srv.upgrade(req)) return undefined; + // Reject cross-origin WebSocket upgrades (CSWSH): binding to 127.0.0.1 + // 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; + } const url = new URL(req.url); const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; if (url.pathname === "/traces") { @@ -502,10 +745,17 @@ export function startDebugServer( } 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, url.searchParams.get("channel")), + : opDetailHtml(id, url.searchParams.get("channel"), generation), { headers: htmlHeaders }, ); } @@ -536,10 +786,12 @@ export function startDebugServer( // the invariant local so a future ingest change can't propagate here. try { const raw = typeof message === "string" ? message : message.toString(); - const envelope = parseWireMessage(raw); - if (envelope) { - recordChannel(envelope.channelId); - session.handleEnvelope(envelope); + const parsed = parseWireMessage(raw); + if (parsed) { + recordChannel(parsed.envelope.channelId, parsed); + // Still grouped (payload-blind is safe and useful); a mismatch only + // blocks the value-decode path, via decodeTrusted. + session.handleEnvelope(parsed.envelope); } } catch { // Drop the frame; the observed session is worth more than one trace. @@ -707,6 +959,7 @@ ${TRACE_DETAIL_CSS} .ins-status { display: flex; gap: 16px; padding: 4px 12px; color: #6b7280; border-top: 1px solid rgba(255,255,255,.08); } .ins-status .live { color: #4ade80; } + .ins-status .mismatch { color: #f87171; }
TrUAPI Wire Inspector @@ -752,6 +1005,7 @@ ${TRACE_DETAIL_CSS} var selectedId = null; // requestId of the open op var selectedChannel = null; // channelId of the open op (disambiguates requestId across hosts) + var selectedGen = null; // generation of the open op (disambiguates a recycled requestId) var channel = null; // channelId filter, null = all var lastListHtml = ""; // skip rebuilds when the op list is unchanged var lastDetailHtml = ""; // skip detail refresh when the open op is unchanged @@ -798,13 +1052,13 @@ ${TRACE_DETAIL_CSS} function get(url) { return fetch(url).then(function (r) { return r.text(); }); } function keyOf(el) { - return el.getAttribute("data-request-id") + "\\0" + (el.getAttribute("data-channel-id") || ""); + return el.getAttribute("data-request-id") + "\\0" + (el.getAttribute("data-channel-id") || "") + "\\0" + (el.getAttribute("data-generation") || "0"); } // The selected op's identity is (requestId, channelId), not requestId alone - // two hosts on the "all" view mint the same p:N, so selection, the keyed diff, // and keyboard nav must all match on the composite key. function selKey() { - return selectedId === null ? null : selectedId + "\\0" + (selectedChannel || ""); + return selectedId === null ? null : selectedId + "\\0" + (selectedChannel || "") + "\\0" + (selectedGen || "0"); } function visibleRows() { return rows().filter(function (r) { return !r.classList.contains("filtered-out"); }); @@ -860,9 +1114,10 @@ ${TRACE_DETAIL_CSS} function rows() { return Array.prototype.slice.call(listEl.querySelectorAll(".td-op")); } - function selectOp(id, chan) { + function selectOp(id, chan, gen) { selectedId = id; selectedChannel = chan || null; + selectedGen = gen == null ? "0" : String(gen); var want = selKey(); var row = null; rows().forEach(function (r) { @@ -872,7 +1127,8 @@ ${TRACE_DETAIL_CSS} }); cursor = -1; get("/op?id=" + encodeURIComponent(id) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen)) .then(function (frag) { lastDetailHtml = frag; detailEl.innerHTML = frag; @@ -890,7 +1146,7 @@ ${TRACE_DETAIL_CSS} if (rs.length === 0) return; var key = selKey(); var idx = rs.findIndex(function (r) { return keyOf(r) === key; }); - function pick(r) { selectOp(r.getAttribute("data-request-id"), r.getAttribute("data-channel-id")); } + function pick(r) { selectOp(r.getAttribute("data-request-id"), r.getAttribute("data-channel-id"), r.getAttribute("data-generation")); } if (e.key === "ArrowDown") { e.preventDefault(); var n = idx < 0 ? 0 : Math.min(idx + 1, rs.length - 1); @@ -913,7 +1169,7 @@ ${TRACE_DETAIL_CSS} }); listEl.addEventListener("click", function (e) { var row = e.target.closest && e.target.closest(".td-op"); - if (row) { listEl.focus(); selectOp(row.getAttribute("data-request-id"), row.getAttribute("data-channel-id")); } + if (row) { listEl.focus(); selectOp(row.getAttribute("data-request-id"), row.getAttribute("data-channel-id"), row.getAttribute("data-generation")); } }); // Detail keyboard: move a frame cursor, decode the cursored frame. @@ -947,7 +1203,8 @@ ${TRACE_DETAIL_CSS} if (!id || seq === null) return; btn.disabled = true; get("/frame-html?id=" + encodeURIComponent(id) + "&i=" + encodeURIComponent(seq) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { btn.outerHTML = frag; }) .catch(function () { btn.disabled = false; }); } @@ -973,7 +1230,8 @@ ${TRACE_DETAIL_CSS} if (!id || seq === null) return; btn.disabled = true; get("/frame-html?id=" + encodeURIComponent(id) + "&i=" + encodeURIComponent(seq) + "&reveal=1" + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { btn.outerHTML = frag; }) .catch(function () { btn.disabled = false; }); } @@ -993,7 +1251,8 @@ ${TRACE_DETAIL_CSS} // placeholder (the server offers controls, not values). cursor = -1; // the re-render clears .cursor; keep the index in step get("/op?id=" + encodeURIComponent(selectedId) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { detailEl.innerHTML = frag; }); } var decodeAllBtn = document.getElementById("decodeAll"); @@ -1020,8 +1279,13 @@ ${TRACE_DETAIL_CSS} }); chanEl.innerHTML = html; var hosts = (data.channels || []).length; + // A host streaming a wire codec this debugger can't decode against: value + // decode is refused for it (payload-blind grouping still works). Banner it. + var codecWarn = data.codecMismatch + ? ' · ⚠ codec mismatch' + : ""; statusEl.innerHTML = rows().length + " ops · " + hosts + " host" + (hosts === 1 ? "" : "s") + - " · " + (live > 0 ? '' + live + " live" : "idle"); + " · " + (live > 0 ? '' + live + " live" : "idle") + codecWarn; } function escHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { @@ -1059,12 +1323,15 @@ ${TRACE_DETAIL_CSS} statTile(s.frames, "frames", s.out + "▶ " + s["in"] + "◀") + statTile(fmtBytes(s.bytes), "data") + statTile(s.subscriptions, "subs", s.liveSubscriptions > 0 ? s.liveSubscriptions + " live" : "") + - statTile(fmtMs(s.avgDurationMs), "avg op", "max " + fmtMs(s.maxDurationMs)) + + statTile(fmtMs(s.avgDurationMs), "avg op", "max " + fmtMs(s.maxDurationMs) + ", observed") + '
' + (s.sensitive || 0) + '🔒 sensitive
' + warnTile(s.malformed, "malformed") + warnTile(s.orphaned, "orphaned") + - warnTile(s.retryStorms, "retry storms"); + warnTile(s.retryStorms, "retry storms") + + warnTile(s.truncated || 0, "truncated") + + warnTile(s.evictedTraces || 0, "evicted") + + warnTile(s.droppedByHost || 0, "dropped"); if (s.topMethods && s.topMethods.length) { var m = '
'; s.topMethods.forEach(function (t) { @@ -1113,7 +1380,8 @@ ${TRACE_DETAIL_CSS} // every second; sticky Decode-all re-applies when it does change. if (selectedId) { get("/op?id=" + encodeURIComponent(selectedId) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { if (frag === lastDetailHtml) return; lastDetailHtml = frag; diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts index 466c409ce..628d5b337 100644 --- a/js/packages/truapi-debugger/src/session.ts +++ b/js/packages/truapi-debugger/src/session.ts @@ -86,6 +86,7 @@ export interface DebugSession { index: number, channelId?: string, reveal?: boolean, + generation?: number, ): FrameValueDetail | undefined; } @@ -111,9 +112,12 @@ export function createDebugSession( // `console.debug`). Consumers read `traceEngine`, not stdout. const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); // Raw bytes are retained only when decode is on - they exist solely to feed - // the drill-down decoder, and `/traces` never serializes them. + // the drill-down decoder, and `/traces` never serializes them. `methodNames` + // resolves each frame's role at ingest, so the engine and any forward hook see + // the real role rather than "unknown". const handleEnvelope = createDebugIngest(wireDebugger.observe, { retainBytes: decodeValues, + methodNames, }); const decoder = createFrameDecoder({ enabled: decodeValues, @@ -125,8 +129,11 @@ export function createDebugSession( index: number, channelId?: string, reveal?: boolean, + generation?: number, ): FrameValueDetail | undefined => { - const frame = wireDebugger.trace(requestId, channelId)?.frames[index]; + const frame = wireDebugger.trace(requestId, channelId, generation)?.frames[ + index + ]; return frame ? decoder.detail(frame, { reveal }) : undefined; }; diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts index 78b02ab7f..f94c78b70 100644 --- a/js/packages/truapi-debugger/src/trace-render.ts +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -127,6 +127,7 @@ const OP_BADGE_LABEL: Record = { orphaned: "orphaned", malformed: "malformed", "retry-storm": "retry storm", + truncated: "truncated", }; function renderOpBadge(badge: TraceBadge): string { @@ -141,6 +142,8 @@ function badgeTitle(badge: TraceBadge): string { return "A frame failed to decode on the wire"; case "retry-storm": return "This op is one of a burst of like ops in a short window"; + case "truncated": + return "Older frames were dropped to stay under the frame/byte cap"; } } @@ -210,12 +213,12 @@ function renderLatency(frame: TraceFrameView): string { // A closing frame that answers an opener shows its round-trip; everything // else shows its offset from the op's first frame. if (frame.roundTripMs !== undefined) { - return `⟳ ${formatMs(frame.roundTripMs)}`; + return `⟳ ${formatMs(frame.roundTripMs)}`; } if (frame.latencyFromStartMs === 0) { return `+0`; } - return `+${formatMs(frame.latencyFromStartMs)}`; + return `+${formatMs(frame.latencyFromStartMs)}`; } /** @@ -373,13 +376,16 @@ export function renderOperationRow(view: TraceView): string { // Op-row privacy marker + a filterable attribute: this op touches a method // whose payload stays redacted by default. const sensitiveAttr = view.sensitive ? ` data-sensitive="1"` : ""; + // Generation disambiguates ops that recycle a `(channelId, requestId)`; the + // client keys rows and the drill-down on it so reused ids stay distinct. + const genAttr = ` data-generation="${String(view.generation ?? 0)}"`; const lock = view.sensitive ? `` : ""; return ( `
` + + `data-request-id="${esc(view.requestId)}"${channelAttr}${genAttr}${sensitiveAttr} role="listitem" tabindex="-1">` + `` + methodHtml + lock + diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts index 44de74d6e..78f9cf968 100644 --- a/js/packages/truapi-debugger/src/trace-styles.ts +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -169,6 +169,11 @@ export const TRACE_DETAIL_CSS = String.raw` background: rgba(251, 146, 60, 0.12); border-color: rgba(251, 146, 60, 0.3); } +.td-badge-truncated { + color: #94a3b8; + background: rgba(148, 163, 184, 0.12); + border-color: rgba(148, 163, 184, 0.3); +} /* Level-2 decode affordance (standalone app vantage; dotli keeps bytes off). */ .td-frame-decode-btn { font: inherit; diff --git a/js/packages/truapi-debugger/src/trace-text.test.ts b/js/packages/truapi-debugger/src/trace-text.test.ts index f1c1d6c74..85f6b1c7c 100644 --- a/js/packages/truapi-debugger/src/trace-text.test.ts +++ b/js/packages/truapi-debugger/src/trace-text.test.ts @@ -17,6 +17,10 @@ const stats: CliStats = { malformed: 0, orphaned: 1, retryStorms: 0, + truncated: 0, + evictedTraces: 0, + droppedByHost: 0, + codecMismatch: false, sensitive: 1, out: 3, in: 2, diff --git a/js/packages/truapi-debugger/src/trace-text.ts b/js/packages/truapi-debugger/src/trace-text.ts index b775bd119..84c806fda 100644 --- a/js/packages/truapi-debugger/src/trace-text.ts +++ b/js/packages/truapi-debugger/src/trace-text.ts @@ -48,6 +48,10 @@ export interface CliStats { malformed: number; orphaned: number; retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; sensitive: number; out: number; in: number; @@ -63,7 +67,9 @@ export function formatStats(s: CliStats): string { `${bold(String(s.frames))} ${dim(`frames (${String(s.out)}▶ ${String(s.in)}◀)`)}`, `${bold(fmtBytes(s.bytes))} ${dim("data")}`, `${bold(String(s.subscriptions))} ${dim("subs")}${s.liveSubscriptions ? ` ${green(`(${String(s.liveSubscriptions)} live)`)}` : ""}`, - `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)})`)}`, + // "observed": these times are the debugger's own WS-arrival clock, so they + // include transport + queueing delay and are not the true host call latency. + `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)}, observed)`)}`, s.sensitive ? red(`\u{1f512} ${String(s.sensitive)} sensitive`) : dim("\u{1f512} 0 sensitive"), @@ -71,6 +77,13 @@ export function formatStats(s: CliStats): string { if (s.malformed) parts.push(red(`${String(s.malformed)} malformed`)); if (s.orphaned) parts.push(yellow(`${String(s.orphaned)} orphaned`)); if (s.retryStorms) parts.push(yellow(`${String(s.retryStorms)} retry-storms`)); + // Loss the op list can't show: frames dropped within a kept op, whole ops + // evicted, and frames the host dropped before delivery. A codec mismatch means + // a connected host's wire contract differs, so its method names may be wrong. + if (s.truncated) parts.push(yellow(`${String(s.truncated)} truncated`)); + if (s.evictedTraces) parts.push(yellow(`${String(s.evictedTraces)} evicted`)); + if (s.droppedByHost) parts.push(yellow(`${String(s.droppedByHost)} dropped`)); + if (s.codecMismatch) parts.push(red("⚠ codec mismatch")); return parts.join(dim(" · ")); } @@ -106,8 +119,11 @@ export function formatOpDetail( decoded: ReadonlyMap, ): string { const lines: string[] = []; + // Durations here (and the per-frame ⟳/+ below) are the debugger's own + // WS-arrival clock — transport + queueing included — so label them "observed" + // rather than let a Network-tab-shaped readout imply true host call latency. lines.push( - `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)}`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, + `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)} observed`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, ); for (const f of view.frames) { lines.push(formatFrameRow(f)); diff --git a/js/packages/truapi-debugger/src/trace-view.ts b/js/packages/truapi-debugger/src/trace-view.ts index ecb970691..d5af0e532 100644 --- a/js/packages/truapi-debugger/src/trace-view.ts +++ b/js/packages/truapi-debugger/src/trace-view.ts @@ -38,8 +38,10 @@ import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; * This is a *cross-op* signal the single-trace renderer cannot see on its * own, so it is supplied by the caller (the list/engine layer) rather than * derived here. Left as a follow-up for the engine to compute. + * - `truncated`: older frames of this op were dropped to stay under the engine's + * frame/byte cap, so the sequence shown is not the whole op. */ -export type TraceBadge = "orphaned" | "malformed" | "retry-storm"; +export type TraceBadge = "orphaned" | "malformed" | "retry-storm" | "truncated"; /** A per-frame badge, surfaced against a single row in the frame sequence. */ export type TraceFrameBadge = "malformed" | "orphaned"; @@ -63,11 +65,17 @@ export interface TraceFrameView { byteLength?: number; /** Epoch ms the frame was observed. */ timestamp: number; - /** Offset in ms from the trace's first frame. */ + /** + * Offset in ms from the trace's first frame. Debugger-observed: measured from + * the debugger's envelope-arrival clock, so it includes WS transport and + * queueing delay. Reliable for ordering and presence, not a host-side latency. + */ latencyFromStartMs: number; /** * Round-trip in ms from this frame back to the opening frame it answers, - * present only on a closing frame that has a matched opener. + * present only on a closing frame that has a matched opener. Debugger-observed + * (see {@link latencyFromStartMs}): it includes transport/queueing, so it is + * not the host's "this call took N ms". */ roundTripMs?: number; /** Badges for this frame alone. */ @@ -97,6 +105,12 @@ export interface TraceView { * so the op list keys and filters on `(channelId, requestId)`. */ channelId?: string; + /** + * Which reuse of `(channelId, requestId)` this op is, from `0`. A product may + * recycle a requestId for a later call; this lets the op list and drill-down + * address the right op instead of merging or masking one. + */ + generation?: number; /** Epoch ms of the first frame. */ startedAt: number; /** Epoch ms of the most recent frame. */ @@ -150,6 +164,8 @@ export interface TraceViewInput { requestId: string; /** Channel/host the op belongs to, when the vantage supplies it. */ channelId?: string; + /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ + generation?: number; startedAt: number; lastAt: number; frames: readonly TraceFrameInput[]; @@ -187,6 +203,7 @@ export function buildTraceView(input: TraceViewInput): TraceView { return { requestId: input.requestId, channelId: input.channelId, + generation: input.generation, startedAt: input.startedAt, lastAt: input.lastAt, durationMs: input.lastAt - input.startedAt, @@ -222,13 +239,15 @@ export function wireTraceToView( return buildTraceView({ requestId: trace.requestId, channelId: trace.channelId, + generation: trace.generation, startedAt: trace.startedAt, lastAt: trace.lastAt, - extraBadges, + // Surface engine-level frame/byte-cap eviction as an op badge. + extraBadges: trace.truncated ? [...extraBadges, "truncated"] : extraBadges, frames: trace.frames.map((frame): TraceFrameInput => { - // The wire ingest leaves `role` as `"unknown"` (lifecycle is not on the - // wire); the frameId's wire-table `kind` is the lifecycle role, so use it - // when the frame has no better one. A `"malformed"` sentinel is kept. + // A frame may still arrive `role: "unknown"` (a vantage with no wire + // frameId, or an off-table id); the frameId's wire-table `kind` is the + // lifecycle role, so use it as the fallback. A `"malformed"` sentinel is kept. const info = methodNames?.get(frame.frameId); const role = frame.role === "unknown" && info !== undefined ? info.kind : frame.role; diff --git a/js/packages/truapi-debugger/src/wire-debugger.test.ts b/js/packages/truapi-debugger/src/wire-debugger.test.ts index cc34a1c12..2b1b4d2f3 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.test.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { createWireDebugger } from "./wire-debugger.js"; -import type { ObservedFrame } from "./observed-frame.js"; +import { createWireDebugger, type WireMethodInfo } from "./wire-debugger.js"; +import type { FrameRole, ObservedFrame } from "./observed-frame.js"; /** A minimal observed frame; only the fields the trace engine keys/groups on matter. */ function frame( @@ -9,13 +9,14 @@ function frame( requestId: string, frameId: number, timestamp: number, + role: FrameRole = "unknown", ): ObservedFrame { return { channelId, direction: "out", requestId, frameId, - role: "unknown", + role, byteLength: 1, timestamp, }; @@ -83,4 +84,110 @@ describe("createWireDebugger grouping", () => { expect(wd.tracesForChannel("hostB.dot")).toHaveLength(1); expect(wd.tracesForChannel("absent.dot")).toHaveLength(0); }); + + test("counts whole-op evictions so ops aren't silently under-reported", () => { + const wd = createWireDebugger({ sink: () => {}, maxTraces: 2 }); + // Four distinct ops under a cap of 2: the two oldest whole ops are evicted. + // traces() shows only survivors, so evictedTraces() is the only signal that + // the other two happened. + wd.observe(frame("app.dot", "p:1", 22, 1)); + wd.observe(frame("app.dot", "p:2", 22, 2)); + wd.observe(frame("app.dot", "p:3", 22, 3)); + wd.observe(frame("app.dot", "p:4", 22, 4)); + expect(wd.traces().length).toBe(2); + expect(wd.evictedTraces()).toBe(2); + wd.clear(); + expect(wd.evictedTraces()).toBe(0); + }); + + test("a recycled requestId opens a new op instead of merging (generation)", () => { + // Regression for real dotli traffic: a product recycles `p:5` for an unrelated + // later call. Mirror real ingest — frames arrive role "unknown" and the opener + // is resolved from the frameId's wire-table kind — so the split must still fire. + const methodNames = new Map([ + [40, { method: "chat.createRoom", kind: "request" }], + [41, { method: "chat.createRoom", kind: "response" }], + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + ]); + const wd = createWireDebugger({ sink: () => {}, methodNames }); + wd.observe(frame("app.dot", "p:5", 40, 1)); // op 0: chat.createRoom (role "unknown") + wd.observe(frame("app.dot", "p:5", 41, 2)); + wd.observe(frame("app.dot", "p:5", 22, 3_600_000)); // id reused: account.getAccount + wd.observe(frame("app.dot", "p:5", 23, 3_600_002)); + + const traces = wd.traces(); + expect(traces).toHaveLength(2); + expect(traces.map((t) => t.frames.map((f) => f.frameId))).toEqual([ + [40, 41], + [22, 23], + ]); + expect(traces.map((t) => t.generation)).toEqual([0, 1]); + // Durations stay honest — neither op spans the hour-long gap between them. + expect(traces[0].lastAt - traces[0].startedAt).toBe(1); + expect(traces[1].lastAt - traces[1].startedAt).toBe(2); + // trace() resolves to the latest generation. + expect(wd.trace("p:5", "app.dot")?.frames[0].frameId).toBe(22); + }); + + test("the frame cap evicts from index 1, keeping the opener (frames[0])", () => { + // Regression: evicting the oldest frame drops the subscription's `start`, so + // pairing would falsely flag the live sub `orphaned`. The opener must survive. + const wd = createWireDebugger({ sink: () => {}, maxFramesPerTrace: 3 }); + wd.observe(frame("app.dot", "s:7", 18, 1, "start")); // opener + for (let i = 0; i < 10; i++) { + wd.observe(frame("app.dot", "s:7", 21, 2 + i, "receive")); + } + const [trace] = wd.traces(); + expect(trace.frames).toHaveLength(3); + // frames[0] is still the start (id 18), not a mid-stream receive. + expect(trace.frames[0].frameId).toBe(18); + expect(trace.frames[0].role).toBe("start"); + expect(trace.truncated).toBe(true); + }); + + test("an un-truncated trace is not marked truncated", () => { + const wd = createWireDebugger({ sink: () => {}, maxFramesPerTrace: 100 }); + wd.observe(frame("app.dot", "p:1", 22, 1)); + wd.observe(frame("app.dot", "p:1", 23, 2)); + expect(wd.traces()[0].truncated).toBe(false); + }); + + test("the byte cap evicts payload frames but keeps the opener", () => { + const withBytes = ( + requestId: string, + frameId: number, + timestamp: number, + bytes: number, + role: FrameRole = "unknown", + ): ObservedFrame => ({ + ...frame("app.dot", requestId, frameId, timestamp, role), + byteLength: bytes, + bytes: new Uint8Array(bytes), + }); + const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); + wd.observe(withBytes("s:9", 18, 1, 10, "start")); // opener, 10B + for (let i = 0; i < 20; i++) { + wd.observe(withBytes("s:9", 21, 2 + i, 40, "receive")); // 40B each + } + const [trace] = wd.traces(); + const retained = trace.frames.reduce((n, f) => n + (f.bytes?.length ?? 0), 0); + expect(retained).toBeLessThanOrEqual(100); + expect(trace.frames[0].frameId).toBe(18); // opener kept + expect(trace.truncated).toBe(true); + }); + + test("receives never rotate; a re-subscribe (second start) opens a new op", () => { + const wd = createWireDebugger({ sink: () => {} }); + wd.observe(frame("app.dot", "s:1", 18, 1, "start")); + wd.observe(frame("app.dot", "s:1", 21, 2, "receive")); + wd.observe(frame("app.dot", "s:1", 21, 3, "receive")); + expect(wd.traces()).toHaveLength(1); // one live sub — receives append, no rotate + + wd.observe(frame("app.dot", "s:1", 18, 100, "start")); // id recycled for a new sub + const traces = wd.traces(); + expect(traces).toHaveLength(2); + expect(traces.map((t) => t.frames.length)).toEqual([3, 1]); + expect(traces.map((t) => t.generation)).toEqual([0, 1]); + }); }); diff --git a/js/packages/truapi-debugger/src/wire-debugger.ts b/js/packages/truapi-debugger/src/wire-debugger.ts index 348124896..5d09ac42c 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.ts @@ -29,7 +29,11 @@ import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; export interface WireTrace { /** Product channel this op belongs to, e.g. `"myapp.dot"`. */ channelId: string; - /** Correlation id shared by every frame in this trace (unique within the channel). */ + /** + * Correlation id shared by every frame in this trace. A product may recycle it + * for a later, unrelated call; {@link WireTrace.generation} disambiguates the + * successive ops that then share it. + */ requestId: string; /** Frames observed for this id, in the order they crossed the transport. */ frames: ObservedFrame[]; @@ -37,6 +41,18 @@ export interface WireTrace { startedAt: number; /** Epoch ms of the most recent frame. */ lastAt: number; + /** + * Which reuse of `(channelId, requestId)` this op is, from `0`. A fresh opener + * (`request`/`start`) arriving after the id's current op already opened starts + * the next generation, so a recycled id never merges two unrelated calls. + */ + generation: number; + /** + * Whether older frames were dropped from this trace to stay under the frame or + * byte cap. Surfaced as a `truncated` op badge so the operator can tell "older + * frames dropped" from a genuinely short op. + */ + truncated: boolean; } /** Sink for fully-formatted debug lines (defaults to `console.debug`). */ @@ -135,6 +151,15 @@ export interface WireDebuggerOptions { * all of them. */ maxFramesPerTrace?: number; + /** + * Cap on total retained payload bytes within a single trace. Only bites when + * the ingest retains bytes (level-2 decode); with decode off, frames carry no + * bytes and this never triggers. Without it, a burst of large payloads sharing + * one long-lived `requestId` grows memory unbounded even under + * {@link maxFramesPerTrace} (count-capped, not byte-capped). Oldest non-opener + * frames are evicted until the trace is under budget. Default 1 MiB. + */ + maxBytesPerTrace?: number; /** * Reverse map from wire `frameId` to method name (build one with * {@link createMethodNameMap}). When set, formatted lines carry @@ -150,14 +175,25 @@ export interface WireDebugger { /** All retained traces across all channels, most-recently-active last. */ traces(): WireTrace[]; /** - * The trace for a specific `requestId`. Pass `channelId` to disambiguate when - * more than one host is connected (each mints the same `p:N` ids); without it, - * the first trace matching `requestId` in activity order is returned - fine for - * a single-host session or product-span (`correlationId`) correlation. + * The current (latest-generation) trace for a `requestId`. Pass `channelId` to + * disambiguate when more than one host is connected (each mints the same `p:N` + * ids); without it, the most-recently-active op matching `requestId` is returned + * - fine for a single-host session or product-span (`correlationId`) correlation. */ - trace(requestId: string, channelId?: string): WireTrace | undefined; + trace( + requestId: string, + channelId?: string, + generation?: number, + ): WireTrace | undefined; /** All retained traces for one channel, most-recently-active last. */ tracesForChannel(channelId: string): WireTrace[]; + /** + * Count of whole operations LRU-evicted since the last {@link clear}. Distinct + * from per-op frame truncation ({@link WireTrace.truncated}): whole-op eviction + * is otherwise invisible because {@link traces} shows only survivors, so this + * is how a consumer tells "kept 256 of 10k" from "only 256 ever happened". + */ + evictedTraces(): number; /** Drop all retained traces. */ clear(): void; } @@ -184,6 +220,7 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug const forward = options.forward; const maxTraces = options.maxTraces ?? 256; const maxFramesPerTrace = options.maxFramesPerTrace ?? 1024; + const maxBytesPerTrace = options.maxBytesPerTrace ?? 1024 * 1024; const methodNames = options.methodNames; // Insertion-ordered; re-inserting on activity keeps the map LRU-ordered. // Keyed by `(channelId, requestId)` since requestId is per-channel only. @@ -191,36 +228,89 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug const keyOf = (channelId: string, requestId: string): string => `${channelId}\u0000${requestId}`; + // `(channelId, requestId)` -> the gen-key of that id's current (latest) op. + const current = new Map(); + // Whole operations LRU-evicted since the last clear(). Surfaced so a session + // that overflowed maxTraces doesn't silently under-report its op count. + let evictedCount = 0; + // A frame's lifecycle role. The ingest leaves it "unknown" (lifecycle isn't on + // the wire), so fall back to the frameId's wire-table kind — the same resolution + // wireTraceToView uses — otherwise no real frame ever reads as an opener. + const roleOf = (f: ObservedFrame): string | undefined => + f.role !== "unknown" ? f.role : methodNames?.get(f.frameId)?.kind; + // A frame that begins an operation: a unary request or a subscription start. + const isOpener = (f: ObservedFrame): boolean => { + const r = roleOf(f); + return r === "request" || r === "start"; + }; + const observe: TransportObserver = (frame) => { - const key = keyOf(frame.channelId, frame.requestId); - let trace = traces.get(key); - if (trace) { - traces.delete(key); + const baseKey = keyOf(frame.channelId, frame.requestId); + const curKey = current.get(baseKey); + const cur = curKey !== undefined ? traces.get(curKey) : undefined; + + // A fresh opener for an id whose current op already opened means the product + // recycled the requestId: rotate to a new generation so the two never merge. + const rotate = + cur !== undefined && + isOpener(frame) && + cur.frames.some((f) => isOpener(f)); + + let trace: WireTrace; + let key: string; + if (curKey !== undefined && cur !== undefined && !rotate) { + traces.delete(curKey); // re-insert below to keep the map LRU-ordered + trace = cur; + key = curKey; } else { + const generation = cur === undefined ? 0 : cur.generation + 1; + key = `${baseKey}${String(generation)}`; trace = { channelId: frame.channelId, requestId: frame.requestId, + generation, frames: [], startedAt: frame.timestamp, lastAt: frame.timestamp, + truncated: false, }; } trace.frames.push(frame); if (trace.frames.length > maxFramesPerTrace) { - // Evict oldest to keep an exact hard cap. This is O(cap) per frame once - // the cap is reached; kept deliberately simple over an O(1) ring buffer - // because `frames` is a plain in-order array read directly by consumers, - // and this runs only on the dev-only observe path where the cost (a bounded - // memmove of <=maxFramesPerTrace references) is immaterial. - trace.frames.splice(0, trace.frames.length - maxFramesPerTrace); + // Evict oldest to keep an exact hard cap, but NEVER the opener (frames[0]): + // it is the request/start the pairing (`orphaned`) and retry-storm signals + // key on, so dropping it would falsely orphan a long-lived subscription + // (e.g. account.connectionStatus). Ring-buffer from index 1 instead. + trace.frames.splice(1, trace.frames.length - maxFramesPerTrace); + trace.truncated = true; + } + // Byte cap: only bites when bytes are retained (level-2 decode). Evict oldest + // non-opener frames until the retained payload is under budget, so one id's + // large payloads can't grow memory without bound even under the count cap. + if (frame.bytes !== undefined && maxBytesPerTrace !== Infinity) { + let retained = 0; + for (const f of trace.frames) retained += f.bytes?.length ?? 0; + while (retained > maxBytesPerTrace && trace.frames.length > 1) { + const [removed] = trace.frames.splice(1, 1); + retained -= removed?.bytes?.length ?? 0; + trace.truncated = true; + } } trace.lastAt = frame.timestamp; traces.set(key, trace); + current.set(baseKey, key); while (traces.size > maxTraces) { const oldest = traces.keys().next().value; if (oldest === undefined) break; + const evicted = traces.get(oldest); traces.delete(oldest); + evictedCount += 1; + // If the evicted op was an id's current, forget it so reuse starts clean. + if (evicted !== undefined) { + const bk = keyOf(evicted.channelId, evicted.requestId); + if (current.get(bk) === oldest) current.delete(bk); + } } try { @@ -240,16 +330,40 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug return { observe, traces: () => [...traces.values()], - trace: (requestId, channelId) => { - if (channelId !== undefined) return traces.get(keyOf(channelId, requestId)); - // No channel given: first trace matching this requestId in activity order. + trace: (requestId, channelId, generation) => { + // A specific generation (drill-down into one op of a recycled id). + if (generation !== undefined) { + for (const t of traces.values()) { + if ( + t.requestId === requestId && + t.generation === generation && + (channelId === undefined || t.channelId === channelId) + ) { + return t; + } + } + return undefined; + } + // The current (latest) generation for this id. + if (channelId !== undefined) { + const key = current.get(keyOf(channelId, requestId)); + return key !== undefined ? traces.get(key) : undefined; + } + // No channel given: the most recent op matching this requestId. Iterate in + // LRU order and keep the last match, so a reused id resolves to its latest op. + let match: WireTrace | undefined; for (const t of traces.values()) { - if (t.requestId === requestId) return t; + if (t.requestId === requestId) match = t; } - return undefined; + return match; }, tracesForChannel: (channelId) => [...traces.values()].filter((t) => t.channelId === channelId), - clear: () => traces.clear(), + evictedTraces: () => evictedCount, + clear: () => { + traces.clear(); + current.clear(); + evictedCount = 0; + }, }; } diff --git a/js/packages/truapi-host/src/worker-runtime.test.ts b/js/packages/truapi-host/src/worker-runtime.test.ts new file mode 100644 index 000000000..362643896 --- /dev/null +++ b/js/packages/truapi-host/src/worker-runtime.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { isLoopbackWsUrl } from "./worker-runtime.js"; + +describe("isLoopbackWsUrl", () => { + test("accepts ws:// on every genuine loopback form", () => { + expect(isLoopbackWsUrl("ws://localhost:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://127.0.0.1:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://127.5.6.7:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://[::1]:9231")).toBe(true); + }); + + test("rejects wss:// — the tap is ws-only, matching the native sink", () => { + expect(isLoopbackWsUrl("wss://localhost:9231")).toBe(false); + expect(isLoopbackWsUrl("wss://127.0.0.1:9231")).toBe(false); + }); + + test("rejects non-ws schemes and non-loopback hosts", () => { + expect(isLoopbackWsUrl("http://127.0.0.1:9231")).toBe(false); + expect(isLoopbackWsUrl("ws://192.0.2.1:9231")).toBe(false); + expect(isLoopbackWsUrl("ws://example.com:9231")).toBe(false); + expect(isLoopbackWsUrl("not a url")).toBe(false); + }); +}); diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 7f51f654e..6ed38be7e 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -10,6 +10,7 @@ import type { WorkerToMain, } from "./worker-protocol.js"; import type { GenericError } from "@parity/truapi"; +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import { createWorkerRawCallbacks, type CallbackName, @@ -201,14 +202,22 @@ function toBase64(bytes: Uint8Array): string { * debugger only loses the trace, it can never throw into the frame path. */ /** - * Is `url` a WebSocket URL on a loopback host? The debug tap forwards raw frames + * Envelope version stamped on each frame, mirroring the debugger's + * `WIRE_ENVELOPE_VERSION`. Kept in sync by hand (a value constant, not a shared + * dep, to avoid truapi-host depending on the debugger package). + */ +const WIRE_ENVELOPE_VERSION = 1; + +/** + * Is `url` a `ws://` URL on a loopback host? The debug tap forwards raw frames * (including sensitive payloads, before the debugger's denylist runs), so it is - * loopback-only: refuse to stream them off the local machine. + * loopback-only: refuse to stream them off the local machine. `ws://` only, + * matching the native sink (`native_debug.rs`), which is also ws-only. */ -function isLoopbackWsUrl(url: string): boolean { +export function isLoopbackWsUrl(url: string): boolean { try { const u = new URL(url); - if (u.protocol !== "ws:" && u.protocol !== "wss:") return false; + if (u.protocol !== "ws:") return false; const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); return ( host === "localhost" || @@ -225,13 +234,26 @@ function isLoopbackWsUrl(url: string): boolean { function createDebuggerLink(url: string): { emit(channelId: string, dir: string, frame: Uint8Array): void; } { - // Loopback-only, dev-only: a non-loopback debugger URL yields an inert link - // rather than streaming frames across the network. - if (!isLoopbackWsUrl(url)) return { emit() {} }; + // Loopback-only, dev-only: a non-loopback (or non-ws://) debugger URL yields an + // inert link rather than streaming frames across the network. Warn so a + // mistyped value reads as "misconfigured", not "the debugger doesn't work". + if (!isLoopbackWsUrl(url)) { + console.warn( + `[truapi] wire debugger URL rejected (must be ws:// on a loopback host): ${url}`, + ); + return { emit() {} }; + } let socket: WebSocket | null = null; let open = false; const queue: string[] = []; + // Count *and* byte caps: each queued item is a base64 ProtocolMessage (storage + // writes, RPC responses - up to MBs each), so a count-only cap would let a slow + // or absent debugger buffer unbounded RSS on the observed session. Whichever + // ceiling hits first drops the frame (counted), never blocking the frame path. const MAX_QUEUE = 1000; + const MAX_QUEUE_BYTES = 8 * 1024 * 1024; + let queuedBytes = 0; + let droppedSinceSend = 0; function connect(): void { try { @@ -242,7 +264,24 @@ function createDebuggerLink(url: string): { } socket.addEventListener("open", () => { open = true; - for (const message of queue.splice(0)) send(message); + const pending = queue.splice(0); + queuedBytes = 0; + // Deliver drops accumulated while disconnected by stamping the count on the + // first drained frame - a bare marker without channelId/dir/frame wouldn't + // parse server-side. Drops only happen once the queue is full, so when the + // count is nonzero there is always a pending frame to carry it; if not, it + // rides the next live emit. + if (pending.length > 0 && droppedSinceSend > 0) { + try { + const first = JSON.parse(pending[0]) as Record; + first.dropped = droppedSinceSend; + pending[0] = JSON.stringify(first); + droppedSinceSend = 0; + } catch { + // Leave the frame as-is; the count rides the next live emit. + } + } + for (const message of pending) send(message); }); socket.addEventListener("close", () => { open = false; @@ -274,15 +313,57 @@ function createDebuggerLink(url: string): { connect(); + let warnedDrop = false; return { emit(channelId, dir, frame) { - const message = JSON.stringify({ channelId, dir, frame: toBase64(frame) }); - if (open && socket) { - send(message); - return; + // A debug tap must never throw into the observed frame path: toBase64 / + // JSON.stringify can raise on a pathological frame (btoa or V8 string-length + // limits), and only send() swallows its own errors. Losing a trace is fine; + // breaking dispatch is not. + try { + const base = { + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId, + dir, + frame: toBase64(frame), + }; + if (open && socket) { + // Piggyback any frames dropped while the link was down onto the next + // live frame, so the debugger attributes the gap to the link, not the + // host. + send( + droppedSinceSend > 0 + ? JSON.stringify({ ...base, dropped: droppedSinceSend }) + : JSON.stringify(base), + ); + droppedSinceSend = 0; + return; + } + const message = JSON.stringify(base); + if ( + queue.length < MAX_QUEUE && + queuedBytes + message.length <= MAX_QUEUE_BYTES + ) { + queue.push(message); + queuedBytes += message.length; + } else { + droppedSinceSend += 1; + if (!warnedDrop) { + // The link buffers a bounded backlog while the debugger is + // absent/slow; once full (by count or bytes), frames are dropped. + // Warn once so the gap is attributable to the link, not the host. + warnedDrop = true; + console.warn( + "[truapi] wire debugger link queue full — dropping frames until it drains", + ); + } + } + if (!socket) connect(); + } catch { + // Swallow: never let the tap disturb the frame path. } - if (queue.length < MAX_QUEUE) queue.push(message); - if (!socket) connect(); }, }; } diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 3842cb459..7b44c5cec 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -113,9 +113,12 @@ bytes }`, opaque bytes - to a separate debugger app, which decodes and groups th - The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) -stays here, since it is generated from this package's contract. The debugger app is payload-blind -today - it decodes only the wire envelope (`requestId`, frame id) via `decodeWireMessage`, not -payloads - so this table is unused for now; it is the decode source for a future typed-value view. +stays here, since it is generated from this package's contract. It is the decode source the +[`@parity/truapi-debugger`](../truapi-debugger/) app uses for its opt-in, level-2 typed-value view: +payload decode is available in the debugger behind `TRUAPI_DEBUGGER_DECODE_VALUES` (off by default), +with sensitive frames excluded by the generated `SENSITIVE_FRAME_IDS` denylist. `@parity/truapi` +itself never decodes payloads — the envelope decode it does expose (`decodeWireMessage`: `requestId`, +frame id) carries no payload value. ## Wire format diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 9fd3e0adb..59d877162 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -150,7 +150,16 @@ fn main() -> Result<()> { println!("Generated client examples in {path}"); } if let Some(path) = &cli.rust_output { - rust::generate(&api, path) + // The Rust routing table (wire_table.rs) is version-*unfiltered* - the + // native host can route any method the crate defines - so its stamp hashes + // the full/latest table, not the client-pinned subset. Otherwise a + // `--client-version`-pinned build would route a newer #[wire(sensitive)] + // frame under an older hash that a same-pinned debugger would accept and + // decode. At the default (latest) client version this equals the TS hash. + let schema_hash = + ts::wire_schema_hash(&api, ts::latest_wire_version(&api), cli.codec_version) + .context("computing wire schema hash")?; + rust::generate(&api, path, &schema_hash) .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 0e3b23cf4..252342d65 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -23,11 +23,11 @@ pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. -pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { +pub fn generate(api: &ApiDefinition, output_dir: &Path, schema_hash: &str) -> Result<()> { fs::create_dir_all(output_dir)?; let dispatcher = generate_dispatcher(api)?; fs::write(output_dir.join("dispatcher.rs"), dispatcher)?; - let wire_table = generate_wire_table(api)?; + let wire_table = generate_wire_table(api, schema_hash)?; fs::write(output_dir.join("wire_table.rs"), wire_table)?; Ok(()) } @@ -277,7 +277,7 @@ mod tests { types: vec![], }; - let src = generate_wire_table(&api).expect("generate_wire_table"); + let src = generate_wire_table(&api, "testhash").expect("generate_wire_table"); let entries = parse_entries(&src); assert_eq!( entries, @@ -325,7 +325,7 @@ mod tests { "dispatcher missing prefixed Preimage const:\n{dispatcher}" ); - let table = generate_wire_table(&api).expect("wire_table"); + let table = generate_wire_table(&api, "testhash").expect("wire_table"); let entries = parse_entries(&table); assert!( entries @@ -366,7 +366,8 @@ mod tests { public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("duplicate wire method name must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("duplicate wire method name must error"); let msg = format!("{err}"); assert!( msg.contains("wire method name `foo_bar_baz` reused"), @@ -400,8 +401,8 @@ mod tests { let dispatcher_b = generate_dispatcher(&api).expect("dispatcher b"); assert_eq!(dispatcher_a, dispatcher_b); - let table_a = generate_wire_table(&api).expect("wire_table a"); - let table_b = generate_wire_table(&api).expect("wire_table b"); + let table_a = generate_wire_table(&api, "testhash").expect("wire_table a"); + let table_b = generate_wire_table(&api, "testhash").expect("wire_table b"); assert_eq!(table_a, table_b); } @@ -424,7 +425,7 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("duplicate ids must error"); + let err = generate_wire_table(&api, "testhash").expect_err("duplicate ids must error"); let msg = format!("{err}"); assert!( msg.contains("wire id 10 reused"), @@ -479,7 +480,8 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("request kind + start_id must error"); + let err = + generate_wire_table(&api, "testhash").expect_err("request kind + start_id must error"); let msg = format!("{err}"); assert!( msg.contains("must not use subscription wire ids"), @@ -502,7 +504,8 @@ mod tests { public_trait_order: vec!["Account".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("subscription kind + request_id must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("subscription kind + request_id must error"); let msg = format!("{err}"); assert!( msg.contains("must not use request wire ids"), @@ -526,7 +529,8 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("missing request_id annotation must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("missing request_id annotation must error"); let msg = format!("{err}"); assert!( msg.contains("missing #[wire(request_id"), @@ -549,7 +553,8 @@ mod tests { public_trait_order: vec!["Account".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("missing start_id annotation must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("missing start_id annotation must error"); let msg = format!("{err}"); assert!( msg.contains("missing #[wire(start_id"), diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index 8696b5756..540322482 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -38,8 +38,9 @@ enum MethodEntry { Subscription(SubEntry), } -/// Emit the contents of `wire_table.rs`. -pub fn generate_wire_table(api: &ApiDefinition) -> Result { +/// Emit the contents of `wire_table.rs`. `schema_hash` is the wire-contract +/// fingerprint emitted as `TRUAPI_WIRE_SCHEMA_HASH`, identical to the TS client's. +pub fn generate_wire_table(api: &ApiDefinition, schema_hash: &str) -> Result { let mut method_entries: Vec<(String, MethodEntry)> = Vec::new(); let mut seen: BTreeMap = BTreeMap::new(); let mut seen_methods: BTreeMap = BTreeMap::new(); @@ -68,7 +69,7 @@ pub fn generate_wire_table(api: &ApiDefinition) -> Result { MethodEntry::Subscription(SubEntry { start_id, .. }) => *start_id, }); - render(&method_entries) + render(&method_entries, schema_hash) } fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result { @@ -169,7 +170,7 @@ fn insert_entry( Ok(()) } -fn render(methods: &[(String, MethodEntry)]) -> Result { +fn render(methods: &[(String, MethodEntry)], schema_hash: &str) -> Result { let mut out = String::new(); writedoc!( out, @@ -225,6 +226,19 @@ fn render(methods: &[(String, MethodEntry)]) -> Result { ) .unwrap(); + writedoc!( + out, + r#" + /// Fingerprint of this build's wire contract: frame ids, method legs, + /// sensitivity, and codec version, identical to the TS client's + /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so + /// the debugger refuses to decode a frame whose contract differs from + /// its own, even when the coarse handshake codec version is unchanged. + pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "{schema_hash}"; + "# + ) + .unwrap(); + // Per-method consts: the single source of truth for each method's ids. for (name, entry) in methods { let konst = const_name(name); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index df0ee2648..9153d3635 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -677,6 +677,60 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result> { + let wrappers = collect_versioned_wrappers(api); + let mut seen: BTreeMap = BTreeMap::new(); + for trait_def in &api.traits { + for method in &trait_def.methods { + if !method_is_included(trait_def, method, &wrappers, target_version)? { + continue; + } + let wire_ids = wire_ids_for_method(trait_def, method)?; + for (id, tag) in wire_ids.entries(&method.name) { + if let Some((existing, _)) = seen.insert(id, (tag.clone(), method.wire.sensitive)) { + bail!("wire id {id} reused: `{existing}` and `{tag}` collide"); + } + } + } + } + Ok(seen + .into_iter() + .map(|(id, (tag, sensitive))| (id, tag, sensitive)) + .collect()) +} + +/// A stable fingerprint of the wire contract: every frame id, the method leg it +/// resolves to, and its sensitivity, folded together with the codec version. +/// Two builds whose frame tables differ - a reassigned id, a renamed or +/// added/removed method, or a flipped `#[wire(sensitive)]` - produce different +/// hashes even when the handshake `codec_version` is unchanged, which is the +/// case the coarse codec number cannot see. Emitted as `TRUAPI_WIRE_SCHEMA_HASH` +/// on both the TS and Rust sides so a host stamps it on every debug envelope and +/// the debugger refuses to decode a frame whose contract differs from its own. +pub(crate) fn wire_schema_hash( + api: &ApiDefinition, + target_version: u32, + codec_version: u8, +) -> Result { + let mut canonical = format!("codec={codec_version}\n"); + for (id, tag, sensitive) in wire_id_rows(api, target_version)? { + let flag = u8::from(sensitive); + canonical.push_str(&format!("{id}:{tag}:{flag}\n")); + } + // FNV-1a 64-bit: deterministic across platforms and Rust versions (unlike + // `DefaultHasher`), dependency-free, and ample for a contract fingerprint. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in canonical.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + Ok(format!("{hash:016x}")) +} + fn method_is_included( trait_def: &TraitDef, method: &MethodDef, @@ -951,6 +1005,7 @@ fn generate_types(api: &ApiDefinition, target_version: u32) -> Result { fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) -> Result { validate_versioned_wrapper_shapes(api)?; + let schema_hash = wire_schema_hash(api, target_version, codec_version)?; let mut out = String::new(); writedoc!( out, @@ -969,6 +1024,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) export type {{ ObservableLike, Observer, Result, Subscription, TrUApiTransport }}; export const TRUAPI_VERSION = {target_version} as const; export const TRUAPI_CODEC_VERSION = {codec_version} as const; + export const TRUAPI_WIRE_SCHEMA_HASH = "{schema_hash}" as const; function toSubscriptionError(error: unknown): SubscriptionError {{ if (error instanceof SubscriptionError) return error as SubscriptionError; diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d0427..92415e9a0 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -45,6 +45,12 @@ pub enum WireKind { /// Subscription method. Subscription(SubscriptionFrameIds), } +/// Fingerprint of this build's wire contract: frame ids, method legs, +/// sensitivity, and codec version, identical to the TS client's +/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so +/// the debugger refuses to decode a frame whose contract differs from +/// its own, even when the coarse handshake codec version is unchanged. +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c18def0e997626eb"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d0427..92415e9a0 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -45,6 +45,12 @@ pub enum WireKind { /// Subscription method. Subscription(SubscriptionFrameIds), } +/// Fingerprint of this build's wire contract: frame ids, method legs, +/// sensitivity, and codec version, identical to the TS client's +/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so +/// the debugger refuses to decode a frame whose contract differs from +/// its own, even when the coarse handshake codec version is unchanged. +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c18def0e997626eb"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 269a65f0b..ad6aa5394 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -86,6 +86,22 @@ impl FrameDirection { } } +/// Hand one event to a [`DebugSink`] without letting a misbehaving out-of-repo +/// implementation take down a live dispatch. +/// +/// The trait contract forbids `emit` from panicking, but the trait is `pub`, so +/// this guards the two in-path call sites: a panic is caught, logged, and +/// swallowed. `DebugEvent` is `UnwindSafe` (a `ChannelId`/`Vec`), so the +/// caught closure carries no broken invariant across the boundary. +fn emit_debug(sink: &dyn DebugSink, event: DebugEvent) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + sink.emit(event); + })); + if result.is_err() { + tracing::error!("truapi debug sink panicked in emit; frame dropped, session unaffected"); + } +} + /// One observable host debug event. Frame bytes are the untouched /// `ProtocolMessage`; the debugger decodes them, so the core never does. The /// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, @@ -559,11 +575,14 @@ impl ProductRuntime { // Tap inbound before decode, so a corrupt frame is still observed. if let Some((channel_id, debug)) = self.transport.debug() { - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::In, - bytes: frame.clone(), - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }, + ); } let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { @@ -701,11 +720,14 @@ impl Transport for SinkTransport { match self.debug() { Some((channel_id, debug)) => { self.sink.emit_frame(encoded.clone()); - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::Out, - bytes: encoded, - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }, + ); } None => self.sink.emit_frame(encoded), } @@ -869,6 +891,48 @@ mod tests { ); } + struct PanickingDebugSink; + + impl DebugSink for PanickingDebugSink { + fn emit(&self, _event: DebugEvent) { + panic!("misbehaving out-of-repo debug sink"); + } + } + + #[test] + fn a_panicking_debug_sink_does_not_take_down_the_dispatch() { + // The trait forbids panicking, but it is `pub`, so a bad out-of-repo sink + // could. `emit_debug` catches it: `receive_frame` must still succeed. + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + runtime.set_debug_sink( + ChannelId("myapp.dot".to_string()), + Arc::new(PanickingDebugSink), + ); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let raw = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + } + .encode(); + // The inbound tap panics inside receive_frame; the guard swallows it. + let result = futures::executor::block_on(runtime.receive_frame(raw)); + assert!( + result.is_ok(), + "a panicking sink must not fail the dispatch" + ); + } + #[test] fn frame_direction_wire_str_is_product_vantage() { // The wire string is product-vantage (what the debugger and design doc diff --git a/rust/crates/truapi-server/src/native_debug.rs b/rust/crates/truapi-server/src/native_debug.rs index 89b1b1b4b..ec04ffd3e 100644 --- a/rust/crates/truapi-server/src/native_debug.rs +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -21,7 +21,7 @@ //! the tap inert. use core::net::SocketAddr; -use core::sync::atomic::{AtomicU64, Ordering}; +use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use core::time::Duration; use std::sync::Arc; @@ -37,12 +37,28 @@ use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; use tracing::debug; +use crate::generated::wire_table::TRUAPI_WIRE_SCHEMA_HASH; use crate::host_core::{DebugEvent, DebugSink}; /// Bounded so a stalled or absent debugger applies backpressure as counted /// drops, never unbounded memory growth on the observed session. const QUEUE_CAPACITY: usize = 4096; +/// Byte budget alongside [`QUEUE_CAPACITY`]: one `ProtocolMessage` can be MBs, so +/// a count-only cap could still buffer unbounded RSS while the debugger is +/// absent. Whichever ceiling hits first drops the frame (counted), never blocks. +const MAX_QUEUE_BYTES: usize = 8 * 1024 * 1024; + +/// Envelope version, mirroring the debugger's `WIRE_ENVELOPE_VERSION` and the web +/// host's constant. Kept in sync by hand. +const WIRE_ENVELOPE_VERSION: u32 = 1; + +/// The host's wire codec version, mirroring `@parity/truapi`'s +/// `TRUAPI_CODEC_VERSION` (the handshake `codec_version`). Stamped on the +/// envelope so the debugger refuses to decode a frame whose codec differs from +/// its own, rather than resolving `u8` frame ids against the wrong contract. +const WIRE_CODEC_VERSION: u32 = 1; + /// Initial reconnect delay; doubles on each failed dial up to [`MAX_BACKOFF`]. const INITIAL_BACKOFF: Duration = Duration::from_millis(200); @@ -76,12 +92,17 @@ pub enum DebugSinkError { pub struct WsDebugSink { outbound: mpsc::Sender, dropped: Arc, + queued_bytes: Arc, } /// The wire envelope, matching the debugger's `parseWireMessage` / ingest /// `DebugFrameEnvelope`: `dir` is product-vantage, `frame` is base64 SCALE bytes. +/// `v`/`codec` are the identity the debugger checks before decoding. #[derive(Serialize)] struct WireMessage<'a> { + v: u32, + codec: u32, + schema: &'static str, #[serde(rename = "channelId")] channel_id: &'a str, dir: &'a str, @@ -130,13 +151,19 @@ impl WsDebugSink { let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); let dropped = Arc::new(AtomicU64::new(0)); + let queued_bytes = Arc::new(AtomicUsize::new(0)); tokio::spawn(writer_loop( url.to_string(), addr, inbox, Arc::clone(&dropped), + Arc::clone(&queued_bytes), )); - Ok(Arc::new(Self { outbound, dropped })) + Ok(Arc::new(Self { + outbound, + dropped, + queued_bytes, + })) } /// Number of frames dropped because the outbound queue was full (debugger @@ -154,6 +181,9 @@ impl DebugSink for WsDebugSink { bytes, } = event; let message = WireMessage { + v: WIRE_ENVELOPE_VERSION, + codec: WIRE_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, channel_id: &channel_id.0, // Product-vantage string; never hand-mapped, so it cannot invert. dir: dir.wire_str(), @@ -163,8 +193,28 @@ impl DebugSink for WsDebugSink { self.dropped.fetch_add(1, Ordering::Relaxed); return; }; + // Byte budget on top of the channel's count cap: one frame can be MBs, so + // a count-only bound could still grow RSS without limit while the debugger + // is absent. Reserve the frame's bytes BEFORE handing the line to the + // channel: the writer task can recv and release (fetch_sub) the instant + // try_send succeeds, so adding *after* would let that sub run first and + // wrap the counter - an overflow panic in debug builds, on the frame path. + // Reserve atomically, then release on any failure. + let len = line.len(); + if self.queued_bytes.fetch_add(len, Ordering::Relaxed) + len > MAX_QUEUE_BYTES { + // This reservation pushed us past the budget: back it out and drop. + self.queued_bytes.fetch_sub(len, Ordering::Relaxed); + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + debug!("truapi debug sink: byte budget full, frame dropped (total {dropped})"); + return; + } if self.outbound.try_send(line).is_err() { - self.dropped.fetch_add(1, Ordering::Relaxed); + // Not enqueued after all: release the reservation. The frame is lost + // (never the session); count it and log so the gap is attributable to + // the link, not to the host. + self.queued_bytes.fetch_sub(len, Ordering::Relaxed); + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + debug!("truapi debug sink: outbound queue full, frame dropped (total {dropped})"); } } } @@ -176,6 +226,7 @@ async fn writer_loop( addr: SocketAddr, mut inbox: mpsc::Receiver, dropped: Arc, + queued_bytes: Arc, ) { let mut backoff = INITIAL_BACKOFF; loop { @@ -217,15 +268,20 @@ async fn writer_loop( loop { tokio::select! { queued = inbox.recv() => match queued { - Some(line) => match write.send(Message::Text(line)).await { - Ok(()) => backoff = INITIAL_BACKOFF, - Err(_) => { - debug!("truapi debug sink: socket closed, reconnecting"); - // The in-flight line is lost across this reconnect. - dropped.fetch_add(1, Ordering::Relaxed); - break; + Some(line) => { + // Off the queue now: release its bytes from the budget + // before the (moving) send so the counter can't drift. + queued_bytes.fetch_sub(line.len(), Ordering::Relaxed); + match write.send(Message::Text(line)).await { + Ok(()) => backoff = INITIAL_BACKOFF, + Err(_) => { + debug!("truapi debug sink: socket closed, reconnecting"); + // The in-flight line is lost across this reconnect. + dropped.fetch_add(1, Ordering::Relaxed); + break; + } } - }, + } // All senders dropped: the sink is gone, so is the host. Done. None => return, }, @@ -286,6 +342,10 @@ mod tests { let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(value["channelId"], "myapp.dot"); + // Identity the debugger checks before decoding. + assert_eq!(value["v"], WIRE_ENVELOPE_VERSION); + assert_eq!(value["codec"], WIRE_CODEC_VERSION); + assert_eq!(value["schema"], TRUAPI_WIRE_SCHEMA_HASH); // Guard against re-inversion: In must serialize as product-vantage "out". assert_eq!(value["dir"], FrameDirection::In.wire_str()); assert_eq!(value["dir"], "out"); @@ -333,4 +393,30 @@ mod tests { "a full queue must count drops, not block" ); } + + #[tokio::test] + async fn byte_budget_drops_large_frames_before_the_count_cap() { + // Nothing listening: the writer never drains, so queued bytes accumulate. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + // ~2 MiB per frame; a handful blows past the 8 MiB byte budget long before + // the 4096-frame count cap, so the BYTE cap is what drops here. Also + // exercises reserve-before-send: emit must never panic on the counter even + // as the writer task races it. + let big = vec![0u8; 2 * 1024 * 1024]; + for _ in 0..8 { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: big.clone(), + }); + } + assert!( + sink.dropped() > 0, + "the byte budget must drop large frames well under the count cap" + ); + } } From 6470d8872409f1ecef2bc2703dc9fd6dc487468d Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 4 Aug 2026 18:55:01 +0530 Subject: [PATCH 8/9] feat(truapi-debugger): in-app embed engine for host-mounted panels --- .../truapi-debugger/src/in-app.test.ts | 81 +++++++++++++++ js/packages/truapi-debugger/src/in-app.ts | 98 +++++++++++++++++++ js/packages/truapi-debugger/src/index.ts | 2 + js/packages/truapi-debugger/tsconfig.json | 1 + 4 files changed, 182 insertions(+) create mode 100644 js/packages/truapi-debugger/src/in-app.test.ts create mode 100644 js/packages/truapi-debugger/src/in-app.ts diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts new file mode 100644 index 000000000..4db9222ac --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createInAppDebugger } from "./in-app.js"; + +// A minimal element stand-in — the mount only needs createElement, append, +// textContent/className/innerHTML, and remove(). No real DOM needed. +interface FakeEl { + textContent: string; + className: string; + innerHTML: string; + children: FakeEl[]; + append(...nodes: FakeEl[]): void; + remove(): void; +} +function fakeEl(): FakeEl { + return { + textContent: "", + className: "", + innerHTML: "", + children: [], + append(...nodes) { + this.children.push(...nodes); + }, + remove() {}, + }; +} + +function frameBytes(id: number, value: number[] = [0]): Uint8Array { + const r = encodeWireMessage({ + requestId: "p:1", + payload: { id, value: new Uint8Array(value) }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +describe("createInAppDebugger", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shim a DOM + const g = globalThis as any; + const original = g.document; + beforeAll(() => { + g.document = { createElement: (): FakeEl => fakeEl() }; + }); + afterAll(() => { + g.document = original; + }); + + test("feeds frames in-process and mounts a payload-blind panel", () => { + const dbg = createInAppDebugger(); // decode OFF by default + + // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). + dbg.handleFrame("shop.dot", "out", frameBytes(W.ACCOUNT_GET_ACCOUNT.request)); + dbg.handleFrame("shop.dot", "in", frameBytes(W.ACCOUNT_GET_ACCOUNT.response)); + + expect(dbg.session.traceEngine.traces()).toHaveLength(1); + expect(dbg.session.decodeValues).toBe(false); // payload-blind by default + expect(dbg.session.revealSensitive).toBe(false); + + const el = fakeEl(); + const dispose = dbg.mount(el as unknown as HTMLElement); + const list = el.children[1]; // [style, list] + // Rendered by the shared renderer — the method resolved via the wire table. + expect(list.innerHTML).toContain("account.getAccount"); + dispose(); + expect(list.children).toHaveLength(0); + }); + + test("a sensitive op stays redacted with decode off", () => { + const dbg = createInAppDebugger(); + dbg.handleFrame("shop.dot", "out", frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2])); + dbg.handleFrame("shop.dot", "in", frameBytes(W.SIGNING_SIGN_RAW.response)); + const view = dbg.session.traceEngine.traces()[0]; + expect(view).toBeDefined(); + // The signing op is on the type-driven denylist, so the session flags it. + expect( + dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind, + ).not.toBe("decoded"); + }); +}); diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts new file mode 100644 index 000000000..bc9489900 --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -0,0 +1,98 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * In-app mount: render the inspector from a {@link DebugSession} that lives in + * the SAME app as the host — no server, no dial-out, no relay. A host running in + * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame}; + * {@link InAppDebugger.mount} renders them with the same engine, renderer, and + * type-driven denylist the standalone app uses, payload-blind by default. + * + * This is the "host and debugger in the same bits" transport: the frames never + * leave the app, so each browser tab is its own tenant — nothing to host or + * scope. Browser-only (uses `document`). + * + * @module + */ + +import { createDebugSession } from "./session.js"; +import type { DebugSession, DebugSessionOptions } from "./session.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderTraceDetail } from "./trace-render.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** A same-app debugger: feed it frames, mount its panel. */ +export interface InAppDebugger { + /** The underlying session — grouped traces, per-frame decode gate. */ + readonly session: DebugSession; + /** + * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir` + * is product-vantage (`out` = left the product), matching the standalone tap. + */ + handleFrame(channelId: string, dir: "in" | "out", frame: Uint8Array): void; + /** + * Render a live, self-contained panel into `el` and keep it refreshed; returns + * a disposer that tears the panel down. Payload-blind unless the session was + * created with `decodeValues`. + */ + mount(el: HTMLElement, options?: { refreshMs?: number }): () => void; +} + +/** + * Create an in-app debugger. Decode stays OFF unless `decodeValues` is set (the + * reveal gate folds under it exactly as {@link createDebugSession} does), so a + * bundled mount is payload-blind by default. + */ +export function createInAppDebugger( + options: DebugSessionOptions = {}, +): InAppDebugger { + const session = createDebugSession(options); + return { + session, + handleFrame(channelId, dir, frame) { + session.handleEnvelope({ channelId, dir, frame }); + }, + mount(el, mountOptions = {}) { + const style = document.createElement("style"); + style.textContent = TRACE_DETAIL_CSS; + const list = document.createElement("div"); + list.className = "td-inapp"; + el.append(style, list); + + let disposed = false; + const render = (): void => { + if (disposed) return; + const traces = session.traceEngine.traces(); + const storms = detectRetryStorms(traces); + list.innerHTML = + traces.length === 0 + ? `
no frames yet
` + : traces + .map( + (trace) => + `
${renderTraceDetail( + wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + session.sensitiveIds, + ), + { + offerDecode: session.decodeValues, + offerReveal: session.revealSensitive, + }, + )}
`, + ) + .join(""); + }; + render(); + const timer = setInterval(render, mountOptions.refreshMs ?? 1000); + return () => { + disposed = true; + clearInterval(timer); + style.remove(); + list.remove(); + }; + }, + }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 643651ed3..6f755529a 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -41,3 +41,5 @@ export type { RenderTraceDetailOptions } from "./trace-render.js"; export { detectRetryStorms } from "./retry-storm.js"; export type { RetryStormOptions } from "./retry-storm.js"; export { TRACE_DETAIL_CSS } from "./trace-styles.js"; +export { createInAppDebugger } from "./in-app.js"; +export type { InAppDebugger } from "./in-app.js"; diff --git a/js/packages/truapi-debugger/tsconfig.json b/js/packages/truapi-debugger/tsconfig.json index d9330dd38..caa17a6be 100644 --- a/js/packages/truapi-debugger/tsconfig.json +++ b/js/packages/truapi-debugger/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ES2022", + "lib": ["ES2022", "DOM"], "moduleResolution": "bundler", "composite": true, "declaration": true, From 03f75739021ca889c0ba7d0ffbbfbf63928d5726 Mon Sep 17 00:00:00 2001 From: Nidish Date: Wed, 5 Aug 2026 13:01:46 +0530 Subject: [PATCH 9/9] feat(truapi): decode every wire frame by default in the dev-only debugger --- js/packages/truapi-debugger/README.md | 49 +-- js/packages/truapi-debugger/package.json | 1 - js/packages/truapi-debugger/src/cli-client.ts | 125 ------ js/packages/truapi-debugger/src/cli.ts | 182 -------- .../truapi-debugger/src/decode.test.ts | 344 ++------------- js/packages/truapi-debugger/src/decode.ts | 184 +------- .../truapi-debugger/src/in-app.test.ts | 53 ++- js/packages/truapi-debugger/src/in-app.ts | 43 +- js/packages/truapi-debugger/src/index.ts | 2 +- js/packages/truapi-debugger/src/ingest.ts | 4 +- js/packages/truapi-debugger/src/repl.ts | 309 -------------- .../truapi-debugger/src/server.test.ts | 266 +++++++----- js/packages/truapi-debugger/src/server.ts | 401 ++++++------------ js/packages/truapi-debugger/src/session.ts | 92 ++-- .../truapi-debugger/src/trace-render.test.ts | 16 +- .../truapi-debugger/src/trace-render.ts | 109 +---- .../truapi-debugger/src/trace-styles.ts | 23 - .../truapi-debugger/src/trace-text.test.ts | 103 ----- js/packages/truapi-debugger/src/trace-text.ts | 175 -------- js/packages/truapi-debugger/src/trace-view.ts | 15 - .../truapi-debugger/src/wire-debugger.test.ts | 27 ++ .../truapi-debugger/src/wire-debugger.ts | 34 +- .../src/web/create-worker-host-runtime.ts | 14 +- js/packages/truapi/README.md | 10 +- rust/crates/truapi-codegen/src/rustdoc.rs | 5 +- rust/crates/truapi-codegen/src/ts.rs | 54 --- rust/crates/truapi-macros/src/lib.rs | 6 +- rust/crates/truapi-server/src/native_debug.rs | 3 +- 28 files changed, 580 insertions(+), 2069 deletions(-) delete mode 100644 js/packages/truapi-debugger/src/cli-client.ts delete mode 100644 js/packages/truapi-debugger/src/cli.ts delete mode 100644 js/packages/truapi-debugger/src/repl.ts delete mode 100644 js/packages/truapi-debugger/src/trace-text.test.ts delete mode 100644 js/packages/truapi-debugger/src/trace-text.ts diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md index ade73a1c4..6c1dc0bb7 100644 --- a/js/packages/truapi-debugger/README.md +++ b/js/packages/truapi-debugger/README.md @@ -36,43 +36,34 @@ of in the product transport. 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. + 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, off by default) +## Value decode (level 2 — dev-only, on 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: +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: -- **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. +- **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. -- **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. +- **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 @@ -83,10 +74,10 @@ path. Its contract: ```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 +npm run serve # bun run src/server.ts — listens on :9231, decodes by default -# opt into level-2 value decode (dev machines only) -TRUAPI_DEBUGGER_DECODE_VALUES=1 npm run serve +# turn value decode off for a demo +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve ``` Point a host's debugger URL at `ws://:9231` (the host dials out), diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json index f6e800693..3f1a2826b 100644 --- a/js/packages/truapi-debugger/package.json +++ b/js/packages/truapi-debugger/package.json @@ -13,7 +13,6 @@ "build": "tsc -b", "typecheck": "tsc -b", "serve": "bun run src/server.ts", - "view": "bun run src/cli.ts", "test": "bun test" }, "devDependencies": { diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts deleted file mode 100644 index aa08ee543..000000000 --- a/js/packages/truapi-debugger/src/cli-client.ts +++ /dev/null @@ -1,125 +0,0 @@ -// 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; - /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ - generation?: number; - 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, - generation: entry.generation, - 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; - stats(channel: string | null): Promise; - channels(): Promise; - /** - * 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; -} - -/** Build a {@link DebuggerClient} for `host` (e.g. `http://localhost:9231`). */ -export function createDebuggerClient(host: string): DebuggerClient { - const getJson = async (path: string): Promise => { - const res = await fetch(host + path); - if (!res.ok) throw new Error(`${host}${path} → HTTP ${String(res.status)}`); - return res.json() as Promise; - }; - const channelQuery = (channel: string | null): string => - channel ? `?channel=${encodeURIComponent(channel)}` : ""; - return { - host, - traces: () => getJson("/traces"), - stats: (channel) => getJson(`/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(`/frame?${p.toString()}`); - }, - }; -} diff --git a/js/packages/truapi-debugger/src/cli.ts b/js/packages/truapi-debugger/src/cli.ts deleted file mode 100644 index 766f553d0..000000000 --- a/js/packages/truapi-debugger/src/cli.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: MIT -/** - * `truapi-debugger` terminal frontend: look at wire traces from a shell, for - * headless / SSH / CI workflows where the web inspector isn't reachable. - * - * Two frontends over one running debugger (`:9231` by default), sharing the same - * {@link module:cli-client} engine and the same sensitive denylist as the web - * inspector - no forked engine, no forked denylist: - * - * - `ui` / `repl` (default in a terminal): the interactive query {@link module:repl} - * - a prompt you keep querying: ls, filter, sort, use , show, reveal. - * - `ls` / `stats` / `show` / `tail`: one-shot commands for scripting + piping. - * - * Usage (from js/packages/truapi-debugger): - * bun run src/cli.ts # interactive query REPL - * bun run src/cli.ts ls # ops + aggregate summary - * bun run src/cli.ts stats # just the aggregate line - * bun run src/cli.ts show p:4 --reveal # one op's frames + decoded values - * bun run src/cli.ts tail # live view, refreshes each second - * Flags: --host http://localhost:9231 · --channel · --reveal · --interval - * - * @module - */ - -import { - createDebuggerClient, - toView, - type FrameValueDetail, - type TracesEntry, -} from "./cli-client.js"; -import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; -import { runRepl } from "./repl.js"; - -interface ParsedArgs { - cmd: string; - positional: string[]; - flags: Record; -} - -/** Flags that take a following value; everything else is a boolean flag. */ -const VALUE_FLAGS = new Set(["host", "channel", "interval"]); - -function parseArgs(argv: string[]): ParsedArgs { - const flags: Record = {}; - const positional: string[] = []; - let cmd = ""; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a.startsWith("--")) { - const key = a.slice(2); - const next = argv[i + 1]; - // Only value-flags consume the next token; a boolean flag (e.g. --reveal) - // leaves it as a positional, so `show --reveal p:4` parses correctly. - if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) { - flags[key] = next; - i++; - } else { - flags[key] = true; - } - } else if (cmd === "") { - cmd = a; - } else { - positional.push(a); - } - } - // No command in an interactive terminal → the query REPL; otherwise the list. - if (cmd === "") cmd = process.stdout.isTTY ? "ui" : "ls"; - return { cmd, positional, flags }; -} - -const args = parseArgs(process.argv.slice(2)); -// A bare `--host`/`--channel` (no value) parses as boolean `true`; take only a -// real string value as provided, otherwise fall back rather than coerce garbage. -const flagValue = (v: string | boolean | undefined): string | undefined => - typeof v === "string" ? v : undefined; -const host = - flagValue(args.flags.host) ?? - process.env.TRUAPI_DEBUGGER_HTTP ?? - "http://localhost:9231"; -const channel = flagValue(args.flags.channel) ?? null; -const reveal = args.flags.reveal === true || args.flags.reveal === "1"; -const client = createDebuggerClient(host); - -async function traces(): Promise { - const all = await client.traces(); - return channel === null ? all : all.filter((t) => t.channelId === channel); -} - -async function cmdStats(): Promise { - console.log(formatStats(await client.stats(channel))); -} - -async function cmdLs(): Promise { - const [stats, entries] = await Promise.all([client.stats(channel), traces()]); - console.log(formatStats(stats)); - console.log(""); - if (entries.length === 0) console.log(" (no operations yet)"); - // Unscoped view: show the channel so same-id ops from two hosts are distinct. - for (const t of entries) console.log(formatOpRow(toView(t), channel === null)); -} - -async function cmdShow(): Promise { - const id = args.positional[0]; - if (id === undefined) { - console.error("usage: show [--reveal] [--channel ]"); - process.exit(1); - } - const entry = (await traces()).find((t) => t.requestId === id); - if (entry === undefined) { - console.error(`no operation with requestId ${id}`); - process.exit(1); - } - const view = toView(entry); - if (reveal) { - // The one-shot reveal is a deliberate, non-interactive scripting path (the - // interactive REPL uses a typed `reveal ` + `yes` confirm instead). Warn - // up front as the REPL does; the server still only honors reveal when armed. - console.error( - "\x1b[31m⚠ revealing SENSITIVE payloads\x1b[0m\x1b[2m — output may contain a private key, signature, or credential; do NOT run this while screen-sharing or recording. Honored only on a server armed with TRUAPI_DEBUGGER_REVEAL_SENSITIVE.\x1b[0m", - ); - } - const decoded = new Map(); - for (const f of view.frames) { - try { - decoded.set( - f.seq, - await client.frame(entry.requestId, f.seq, entry.channelId, reveal), - ); - } catch { - // Leave the frame value-less; the row still renders. - } - } - console.log(formatOpDetail(view, decoded)); -} - -async function cmdTail(): Promise { - const interval = Number(args.flags.interval ?? 1000); - const render = async (): Promise => { - const [stats, entries] = await Promise.all([client.stats(channel), traces()]); - process.stdout.write("\x1b[2J\x1b[H"); - console.log(formatStats(stats)); - console.log(""); - for (const t of entries.slice(-40)) console.log(formatOpRow(toView(t))); - console.log( - `\n\x1b[2mwatching ${host}${channel ? ` · ${channel}` : ""} — Ctrl-C to stop\x1b[0m`, - ); - }; - await render(); - setInterval(() => { - render().catch((e: unknown) => { - console.error(e instanceof Error ? e.message : String(e)); - }); - }, interval); -} - -async function cmdUi(): Promise { - await runRepl(client, channel); -} - -const commands: Record Promise> = { - ui: cmdUi, - repl: cmdUi, - stats: cmdStats, - ls: cmdLs, - ops: cmdLs, - show: cmdShow, - tail: cmdTail, - watch: cmdTail, -}; - -const run = commands[args.cmd]; -if (run === undefined) { - console.error( - `unknown command: ${args.cmd}\ncommands: ui · stats · ls · show · tail`, - ); - process.exit(1); -} -run().catch((e: unknown) => { - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); -}); diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts index 3c7d78c38..857481910 100644 --- a/js/packages/truapi-debugger/src/decode.test.ts +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -3,11 +3,7 @@ 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, - SENSITIVE_FRAME_IDS, - type FrameValueDetail, -} from "./decode.js"; +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. */ @@ -24,111 +20,10 @@ function frame(frameId: number, bytes?: Uint8Array): ObservedFrame { }; } -describe("sensitive denylist from the generated wire-table", () => { - // Authoritative denylist: the generated SENSITIVE_FRAME_IDS set, emitted by - // truapi-codegen from every `#[wire(..., sensitive)]` method on the Rust trait. - const sensitive = SENSITIVE_FRAME_IDS; - - test("re-exports the generated SENSITIVE_FRAME_IDS set verbatim", () => { - expect(sensitive).toBe(W.SENSITIVE_FRAME_IDS); - }); - - // Every id of each sensitive family must be present (both request/response, - // both start/receive), so neither leg of a sensitive op can be decoded. - const mustExclude: Record> = { - "signing/create-transaction": Object.values(W.SIGNING_CREATE_TRANSACTION), - "signing/sign-raw": Object.values(W.SIGNING_SIGN_RAW), - "signing/sign-payload": Object.values(W.SIGNING_SIGN_PAYLOAD), - "signing/sign-raw-legacy": Object.values( - W.SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, - ), - "account/create-proof": Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), - "statement-store/create-proof": Object.values(W.STATEMENT_STORE_CREATE_PROOF), - "statement-store/create-proof-authorized": Object.values( - W.STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, - ), - "entropy/derive": Object.values(W.ENTROPY_DERIVE), - "account/request-login": Object.values(W.ACCOUNT_REQUEST_LOGIN), - "account/get-user-id": Object.values(W.ACCOUNT_GET_USER_ID), - "account/sign-vrf": Object.values(W.ACCOUNT_SIGN_VRF), - "local-storage/read": Object.values(W.LOCAL_STORAGE_READ), - "local-storage/write": Object.values(W.LOCAL_STORAGE_WRITE), - // Payment payloads carrying key material / bearer secrets (C1/M2). - "payment/top-up": Object.values(W.PAYMENT_TOP_UP), - "coin-payment/create-cheque": Object.values(W.COIN_PAYMENT_CREATE_CHEQUE), - "coin-payment/deposit": Object.values(W.COIN_PAYMENT_DEPOSIT), - "coin-payment/listen-for-payment": Object.values( - W.COIN_PAYMENT_LISTEN_FOR_PAYMENT, - ), - // Statement-store subscribe/submit carry SignedStatement.decryptionKey. - "statement-store/subscribe": Object.values(W.STATEMENT_STORE_SUBSCRIBE), - "statement-store/submit": Object.values(W.STATEMENT_STORE_SUBMIT), - }; - for (const [name, ids] of Object.entries(mustExclude)) { - test(`excludes ${name}`, () => { - for (const id of ids) expect(sensitive.has(id)).toBe(true); - }); - } - - // Non-sensitive families stay decodable: chain reads, account reads, payments. - // local-storage/clear is deliberately decodable — its request is just a key - // name and its response is empty, so unlike read/write it carries no secret. - const mustAllow: Record> = { - "local-storage/clear": Object.values(W.LOCAL_STORAGE_CLEAR), - "account/get-account": Object.values(W.ACCOUNT_GET_ACCOUNT), - "account/connection-status": Object.values( - W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, - ), - "chain/call-head": Object.values(W.CHAIN_CALL_HEAD), - "chain/broadcast-transaction": Object.values(W.CHAIN_BROADCAST_TRANSACTION), - "payment/request": Object.values(W.PAYMENT_REQUEST), - }; - for (const [name, ids] of Object.entries(mustAllow)) { - test(`allows ${name}`, () => { - for (const id of ids) expect(sensitive.has(id)).toBe(false); - }); - } -}); - -describe("gated frame decoder (real table + denylist)", () => { - test("a signing frame does NOT decode even with the toggle on", () => { - const decoder = createFrameDecoder({ enabled: true }); - const detail = decoder.detail( - frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([1, 2, 3, 4])), - ); - expect(detail.kind).toBe("redacted"); - if (detail.kind === "redacted") { - expect(detail.reason).toBe("sensitive method"); - expect(detail.byteLength).toBe(4); - } - }); - - test("every signing family id redacts, never decodes", () => { - const decoder = createFrameDecoder({ enabled: true }); - for (const id of [ - ...Object.values(W.SIGNING_CREATE_TRANSACTION), - ...Object.values(W.SIGNING_SIGN_PAYLOAD), - ...Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), - ...Object.values(W.ENTROPY_DERIVE), - ...Object.values(W.ACCOUNT_REQUEST_LOGIN), - ]) { - const detail = decoder.detail(frame(id, new Uint8Array([0, 0]))); - expect(detail.kind).toBe("redacted"); - } - }); - - test("payment.topUp redacts (never decodes a raw private key) with toggle on (C1)", () => { - const decoder = createFrameDecoder({ enabled: true }); - for (const id of Object.values(W.PAYMENT_TOP_UP)) { - expect(decoder.detail(frame(id, new Uint8Array([0, 0]))).kind).toBe( - "redacted", - ); - } - }); - +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, non-sensitive frame the generated table can decode. + // index byte: a real frame the generated table can decode. const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; const bytes = new Uint8Array([0]); @@ -144,6 +39,19 @@ describe("gated frame decoder (real table + denylist)", () => { 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 [ @@ -156,16 +64,11 @@ describe("gated frame decoder (real table + denylist)", () => { }); }); -describe("gated frame decoder (injected table for gating isolation)", () => { +describe("frame decoder (injected table)", () => { const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; - const sensitiveIds = new Set([7]); - test("decodes a non-sensitive id when enabled and bytes present", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: table, - sensitiveIds, - }); + 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", @@ -173,24 +76,20 @@ describe("gated frame decoder (injected table for gating isolation)", () => { } satisfies FrameValueDetail); }); - test("redacts a sensitive id before ever touching the table", () => { - let called = false; + test("decodes a secret-named field too — no content guard withholds it", () => { const decoder = createFrameDecoder({ enabled: true, - decodeTable: { 7: () => ((called = true), "leaked") }, - sensitiveIds, + decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) }, }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2, 3]))); - expect(detail.kind).toBe("redacted"); - expect(called).toBe(false); + 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, - sensitiveIds, - }); + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); expect(decoder.detail(frame(999)).kind).toBe("bytes"); }); @@ -202,195 +101,12 @@ describe("gated frame decoder (injected table for gating isolation)", () => { throw new Error("bad payload"); }, }, - sensitiveIds, }); expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); }); - test("content guard redacts a decoded value carrying a secret-named field", () => { - // A non-denylisted id whose decoded payload nonetheless carries key material - // (the C1/H1 class): the fail-closed content check must redact it. - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => ({ source: { PrivateKey: { sr25519SecretKey: "0xdead" } } }), - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts encryptedSecrets (cheque bearer material)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ cheque: { encryptedSecrets: "0xbeef" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts a decryptionKey (statement key material)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => ({ statements: [{ decryptionKey: "0xc0ffee" }] }), - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts a generically-named credential field", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard still decodes a public identifier (publicKey)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ account: { publicKey: "0x01" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard allows a benign value with no secret-named field", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ account: { address: "0x01" }, amount: 5 }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard terminates on a cyclic / shared-DAG value (no blowup)", () => { - // The pre-visited-set guard hung on exactly this shape (a cycle with two - // back-edges + shared substructure). If it regresses to exponential, this - // test hangs instead of passing - which is the signal we want. - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => { - const a: Record = {}; - const b: Record = { a }; - a.b = b; - a.self = a; - return { a, b, both: [a, b, a, b] }; - }, - }, - sensitiveIds: new Set(), - }); - // Benign field names ⇒ decodes (and, crucially, returns promptly). - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard still redacts a secret nested inside a cyclic value", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => { - const a: Record = { secretKey: "0xdead" }; - const b: Record = { a }; - a.b = b; - return { a, b }; - }, - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); -}); - -describe("sensitive reveal escape hatch (dev-only, safe by default)", () => { - const table = { 7: (b: Uint8Array) => ({ secretKey: Array.from(b) }) }; - const sensitiveIds = new Set([7]); - - test("with reveal capability OFF, an explicit reveal request is ignored", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: table, - sensitiveIds, - // revealSensitive omitted → off - }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { - reveal: true, - }); - expect(detail.kind).toBe("redacted"); - }); - - test("with reveal capability ON but no explicit request, sensitive still redacts", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - // Default call (no reveal) — the safe default must still hold. - expect(decoder.detail(frame(7, new Uint8Array([1, 2]))).kind).toBe( - "redacted", - ); - }); - - test("with reveal capability ON and an explicit request, a sensitive frame decodes and is marked", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { - reveal: true, - }); - expect(detail).toEqual({ - kind: "decoded", - value: { secretKey: [1, 2] }, - sensitive: true, - } satisfies FrameValueDetail); - }); - - test("an explicit reveal also bypasses the content guard for a non-denylisted frame", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, - sensitiveIds: new Set(), - }); - const detail = decoder.detail(frame(999, new Uint8Array([1])), { - reveal: true, - }); - expect(detail.kind).toBe("decoded"); - if (detail.kind === "decoded") expect(detail.sensitive).toBe(true); - }); - - test("the master gate still wins: reveal armed but decode disabled ⇒ bytes only", () => { - const decoder = createFrameDecoder({ - enabled: false, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - expect(decoder.detail(frame(7, new Uint8Array([1, 2])), { reveal: true }).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"); }); }); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts index 0110493f9..5a9c2c51b 100644 --- a/js/packages/truapi-debugger/src/decode.ts +++ b/js/packages/truapi-debugger/src/decode.ts @@ -2,25 +2,19 @@ // SPDX-License-Identifier: MIT /** * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the - * drill-down detail path only, behind a dev-only opt-in. + * drill-down detail path. * * This is the one place the debugger looks *inside* a frame. Everything else - * the trace engine, `/traces`, the host tap - is payload-blind and stays that - * way. The rules that make that safe live here: + * way. The rules that make that work live here: * - * - **Off by default.** With the decoder disabled every frame reports its byte - * length and nothing else; no payload is ever inspected. + * - **Dev-only tool: decode everything.** This debugger decodes every frame it + * can, with no "sensitive" special-casing. A developer inspecting their own + * session's traffic sees the real values. When decoding is disabled every + * frame reports its byte length only. * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the * client uses. The debugger writes no codecs 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: a sensitive frame is never decoded, even with the toggle on - it - * reports its byte length labelled `"sensitive method"`. The denylist is - * itself generated: `SENSITIVE_FRAME_IDS` in `@parity/truapi/wire-table` - * carries every frame id of a method marked `#[wire(..., sensitive)]` on the - * Rust trait, so sensitivity is a property of the payload type, not a name - * the debugger pattern-matches. * * Nothing here is ever serialized into `/traces`; the detail it produces is * returned only from the explicit per-frame drill-down. @@ -29,106 +23,25 @@ */ import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; -import * as W from "@parity/truapi/wire-table"; import type { ObservedFrame } from "./observed-frame.js"; /** * Per-frame decode result for the drill-down detail path. * - * `"bytes"` is the safe default returned whenever the decoder is off, the frame - * carries no retained bytes, the id has no codec, or decoding throws. - * `"redacted"` is returned for a sensitive frame even when the decoder is on. - * `"decoded"` carries the plain JS value and is reachable only with the decoder - * on, for a non-sensitive frame whose id is in the table. + * `"decoded"` carries the plain JS value, returned whenever the decoder is on + * and the frame's id has a codec that decodes its retained bytes. `"bytes"` is + * the fallback: the decoder is off, the frame carries no retained bytes, its id + * has no codec, or decoding threw. */ export type FrameValueDetail = - | { kind: "decoded"; value: unknown; sensitive?: boolean } - | { kind: "redacted"; reason: "sensitive method"; byteLength: number } + | { kind: "decoded"; value: unknown } | { kind: "bytes"; byteLength: number }; -/** - * The set of wire `frameId`s that must never be decoded, sourced directly from - * the generated {@link W.SENSITIVE_FRAME_IDS}. That set is emitted by - * `truapi-codegen` from every method marked `#[wire(..., sensitive)]` on the - * Rust trait and carries all of the method's frame ids (request/response and - * start/stop/interrupt/receive), so both legs of a sensitive op are redacted. - * - * Sensitivity therefore lives on the Rust payload type, not on a name the - * debugger pattern-matches: a codegen rename cannot silently drop a family, and - * a newly annotated method is denylisted the moment the client is regenerated. - * The families it covers today: - * - * - signing — every method (create-transaction(+legacy), sign-raw(+legacy), - * sign-payload(+legacy)): payloads to be signed and the resulting signatures. - * - account/statement-store proof creation: cryptographic proofs bound to a - * key/identity. - * - entropy/derive: key-derivation material. - * - account request-login / get-user-id: SSO/login and the user id it resolves. - * - local-storage read/write: a read response or a write request can carry - * tokens, session state, or PII. (`clear` carries only a key name and an - * empty response, so it is intentionally *not* sensitive.) - * - payment top-up: can carry a raw sr25519 secret key (PaymentTopUpSource). - * - coin-payment create-cheque/deposit/listen-for-payment: redeemable - * `encryptedSecrets` on a CoinPaymentCheque. - * - statement-store subscribe/submit: a SignedStatement's `decryptionKey`. - * - * Deliberately decodable, because they hold no key material: chain calls - * (`CHAIN_*`) carry public on-chain data — headers, bodies, storage, runtime - * calls, and the broadcast of already-public signed transactions — and are the - * primary useful decode surface; chat, notifications, permissions, theme, - * resource-allocation, and preimage likewise carry no credentials. - * - * Because sensitivity is a property of the payload *type*, the decoder also - * applies a fail-closed content check (see {@link createFrameDecoder}) that - * redacts any decoded value carrying a secret-named field — so a secret-bearing - * method that was never annotated is still caught. - */ -export const SENSITIVE_FRAME_IDS: ReadonlySet = W.SENSITIVE_FRAME_IDS; - -/** - * Field-name pattern for the fail-closed content check: keys whose name implies - * key material or a bearer secret (`sr25519SecretKey`, `encryptedSecrets`, - * `decryptionKey`, a mnemonic, a token/credential/passphrase, …). Deliberately - * omits a bare `key` so public identifiers like `publicKey` still decode. This - * is only a backstop — the authoritative guarantee is the generated - * {@link SENSITIVE_FRAME_IDS} denylist (type-driven via `#[wire(sensitive)]`); - * the content check catches any secret-bearing method that was never annotated. - */ -const SECRET_FIELD_RE = - /secret|mnemonic|entropy|private|decrypt|token|credential|passphrase|password|apikey|bearer|seed/i; - -/** - * Does a decoded value carry a secret-named field anywhere in its structure? - * - * Sensitivity ultimately lives in the payload type, so this backs up - * {@link SENSITIVE_FRAME_IDS}: a decoded value with a secret-named key is - * redacted even if its method was not on the denylist. The `seen` set makes it - * O(nodes) - each object is visited once - so it terminates in linear time on - * cycles and shared-substructure DAGs, not just trees. Safe on arrays, tagged - * unions, and nested structs. - */ -function containsSecretField( - value: unknown, - seen: WeakSet = new WeakSet(), - depth = 0, -): boolean { - // Depth cap is generous headroom; the `seen` set is what bounds work, by - // never revisiting an object even when the graph re-references it. - if (depth > 64 || value === null || typeof value !== "object") return false; - if (seen.has(value)) return false; - seen.add(value); - for (const [key, nested] of Object.entries(value as Record)) { - if (SECRET_FIELD_RE.test(key)) return true; - if (containsSecretField(nested, seen, depth + 1)) return true; - } - return false; -} - /** Options for {@link createFrameDecoder}. */ export interface FrameDecoderOptions { /** * Master gate. `false` (the default) means the decoder never inspects a - * payload: every frame reports bytes only. This is the dev-only opt-in. + * payload: every frame reports bytes only. */ enabled?: boolean; /** @@ -136,97 +49,34 @@ export interface FrameDecoderOptions { * {@link WIRE_DECODE_TABLE}; overridable for tests. */ decodeTable?: Record unknown>; - /** - * Frame ids that must never be decoded. Defaults to the generated - * {@link SENSITIVE_FRAME_IDS} denylist. - */ - sensitiveIds?: ReadonlySet; - /** - * Second, independent gate that *allows* a sensitive frame to be decoded - but - * only on an explicit per-frame `reveal` request (see {@link FrameDecoder.detail}), - * never by default. Off by default and only meaningful when {@link enabled} is - * also on. This is the dev-only "reveal sensitive" escape hatch: it is wired - * from its own env gate (`TRUAPI_DEBUGGER_REVEAL_SENSITIVE`) so it is - * structurally impossible to turn on in a shipped build, and even with it on - * the safe default (redact) still holds until the operator confirms a reveal. - */ - revealSensitive?: boolean; -} - -/** Options for a single {@link FrameDecoder.detail} call. */ -export interface FrameDetailOptions { - /** - * Explicit operator request to reveal a sensitive frame's value. Honored only - * when the decoder was built with {@link FrameDecoderOptions.revealSensitive} - * (and {@link FrameDecoderOptions.enabled}); otherwise ignored and the frame - * redacts as usual. A reveal bypasses both the denylist and the content guard - * for that one frame - it is the "show me everything" dev path. - */ - reveal?: boolean; } /** A gated per-frame value decoder for the drill-down detail path. */ export interface FrameDecoder { /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ readonly enabled: boolean; - /** Whether the sensitive-reveal escape hatch is armed (still off by default per call). */ - readonly revealSensitive: boolean; - /** The sensitive-frame denylist in effect (redacted unless explicitly revealed). */ - readonly sensitiveIds: ReadonlySet; /** Resolve one frame to its {@link FrameValueDetail}. */ - detail(frame: ObservedFrame, options?: FrameDetailOptions): FrameValueDetail; + detail(frame: ObservedFrame): FrameValueDetail; } /** * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. - * Even then, sensitive frames (see {@link SENSITIVE_FRAME_IDS}) are reported - * as `"redacted"`, never decoded. + * When on, every frame with a codec and retained bytes decodes to its value. */ export function createFrameDecoder( options: FrameDecoderOptions = {}, ): FrameDecoder { const enabled = options.enabled ?? false; - const revealSensitive = options.revealSensitive ?? false; const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; - const sensitiveIds = options.sensitiveIds ?? SENSITIVE_FRAME_IDS; - const detail = ( - frame: ObservedFrame, - detailOptions: FrameDetailOptions = {}, - ): FrameValueDetail => { + const detail = (frame: ObservedFrame): FrameValueDetail => { if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; - // The reveal escape hatch fires only when the capability is armed AND the - // operator explicitly asked for this frame. Absent either, the safe default - // (redact sensitive / content-guard) stands - so the guarantee "sensitive - // never decodes" holds by default even in a reveal-armed session. - const reveal = revealSensitive && detailOptions.reveal === true; - if (sensitiveIds.has(frame.frameId) && !reveal) { - return { - kind: "redacted", - reason: "sensitive method", - byteLength: frame.byteLength, - }; - } const decode = decodeTable[frame.frameId]; if (!decode || !frame.bytes) { return { kind: "bytes", byteLength: frame.byteLength }; } try { - const value = decode(frame.bytes); - // Fail-closed net: redact if the decoded payload carries a secret-named - // field, even though the method itself was not on the denylist - unless - // this is an explicit reveal, which is the "show me everything" path. - if (!reveal && containsSecretField(value)) { - return { - kind: "redacted", - reason: "sensitive method", - byteLength: frame.byteLength, - }; - } - // Mark a revealed value so the UI can style it as the danger it is. - return reveal - ? { kind: "decoded", value, sensitive: true } - : { kind: "decoded", value }; + return { kind: "decoded", value: decode(frame.bytes) }; } catch { // A malformed or version-skewed payload must not break the drill-down; // fall back to the byte-length view. @@ -234,5 +84,5 @@ export function createFrameDecoder( } }; - return { enabled, revealSensitive, sensitiveIds, detail }; + return { enabled, detail }; } diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts index 4db9222ac..0e5900083 100644 --- a/js/packages/truapi-debugger/src/in-app.test.ts +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { encodeWireMessage } from "@parity/truapi"; +import { encodeWireMessage, VersionedHostAccountGetRequest } from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; import { createInAppDebugger } from "./in-app.js"; @@ -36,6 +36,25 @@ function frameBytes(id: number, value: number[] = [0]): Uint8Array { return r.value; } +/** A real, decodable account-get request wire message (non-sensitive). */ +function accountGetRequestBytes(): Uint8Array { + const value = VersionedHostAccountGetRequest.enc({ + tag: "V1", + value: { + productAccountId: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Left", value: 0 }, + }, + }, + }); + const r = encodeWireMessage({ + requestId: "p:1", + payload: { id: W.ACCOUNT_GET_ACCOUNT.request, value }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + describe("createInAppDebugger", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shim a DOM const g = globalThis as any; @@ -47,16 +66,20 @@ describe("createInAppDebugger", () => { g.document = original; }); - test("feeds frames in-process and mounts a payload-blind panel", () => { - const dbg = createInAppDebugger(); // decode OFF by default + test("feeds frames in-process and decodes by default", () => { + const dbg = createInAppDebugger(); // decode ON by default (dev-only tool) // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). - dbg.handleFrame("shop.dot", "out", frameBytes(W.ACCOUNT_GET_ACCOUNT.request)); + // The request leg carries a real, decodable account-get payload. + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes()); dbg.handleFrame("shop.dot", "in", frameBytes(W.ACCOUNT_GET_ACCOUNT.response)); expect(dbg.session.traceEngine.traces()).toHaveLength(1); - expect(dbg.session.decodeValues).toBe(false); // payload-blind by default - expect(dbg.session.revealSensitive).toBe(false); + expect(dbg.session.decodeValues).toBe(true); // decodes by default + + // The drill-down surfaces the decoded value. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(detail?.kind).toBe("decoded"); const el = fakeEl(); const dispose = dbg.mount(el as unknown as HTMLElement); @@ -67,15 +90,23 @@ describe("createInAppDebugger", () => { expect(list.children).toHaveLength(0); }); - test("a sensitive op stays redacted with decode off", () => { + test("a formerly-sensitive op is no longer special-cased (never redacted)", () => { const dbg = createInAppDebugger(); dbg.handleFrame("shop.dot", "out", frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2])); dbg.handleFrame("shop.dot", "in", frameBytes(W.SIGNING_SIGN_RAW.response)); const view = dbg.session.traceEngine.traces()[0]; expect(view).toBeDefined(); - // The signing op is on the type-driven denylist, so the session flags it. - expect( - dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind, - ).not.toBe("decoded"); + // No denylist: the drill-down either decodes or falls back to bytes, but + // never returns the old "redacted" state. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(["decoded", "bytes"]).toContain(detail?.kind); + expect(detail?.kind).not.toBe("redacted"); + }); + + test("decodeValues:false keeps the mount payload-blind (bytes only)", () => { + const dbg = createInAppDebugger({ decodeValues: false }); + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes()); + expect(dbg.session.decodeValues).toBe(false); + expect(dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("bytes"); }); }); diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts index bc9489900..aeadf37c7 100644 --- a/js/packages/truapi-debugger/src/in-app.ts +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -4,8 +4,8 @@ * In-app mount: render the inspector from a {@link DebugSession} that lives in * the SAME app as the host — no server, no dial-out, no relay. A host running in * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame}; - * {@link InAppDebugger.mount} renders them with the same engine, renderer, and - * type-driven denylist the standalone app uses, payload-blind by default. + * {@link InAppDebugger.mount} renders them with the same engine and renderer the + * standalone app uses, decoding every frame by default (dev-only tool). * * This is the "host and debugger in the same bits" transport: the frames never * leave the app, so each browser tab is its own tenant — nothing to host or @@ -14,7 +14,7 @@ * @module */ -import { createDebugSession } from "./session.js"; +import { createDebugSession, decodeTraceFrames } from "./session.js"; import type { DebugSession, DebugSessionOptions } from "./session.js"; import { wireTraceToView } from "./trace-view.js"; import { renderTraceDetail } from "./trace-render.js"; @@ -23,7 +23,7 @@ import { TRACE_DETAIL_CSS } from "./trace-styles.js"; /** A same-app debugger: feed it frames, mount its panel. */ export interface InAppDebugger { - /** The underlying session — grouped traces, per-frame decode gate. */ + /** The underlying session — grouped traces, inline value decode. */ readonly session: DebugSession; /** * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir` @@ -32,16 +32,15 @@ export interface InAppDebugger { handleFrame(channelId: string, dir: "in" | "out", frame: Uint8Array): void; /** * Render a live, self-contained panel into `el` and keep it refreshed; returns - * a disposer that tears the panel down. Payload-blind unless the session was - * created with `decodeValues`. + * a disposer that tears the panel down. Decodes every frame unless the session + * was created with `decodeValues: false`. */ mount(el: HTMLElement, options?: { refreshMs?: number }): () => void; } /** - * Create an in-app debugger. Decode stays OFF unless `decodeValues` is set (the - * reveal gate folds under it exactly as {@link createDebugSession} does), so a - * bundled mount is payload-blind by default. + * Create an in-app debugger. Decode is ON by default (dev-only tool); pass + * `decodeValues: false` to keep a bundled mount payload-blind. */ export function createInAppDebugger( options: DebugSessionOptions = {}, @@ -68,21 +67,17 @@ export function createInAppDebugger( traces.length === 0 ? `
no frames yet
` : traces - .map( - (trace) => - `
${renderTraceDetail( - wireTraceToView( - trace, - session.methodNames, - storms.get(trace) ?? [], - session.sensitiveIds, - ), - { - offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, - }, - )}
`, - ) + .map((trace) => { + const view = wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + ); + return `
${renderTraceDetail(view, { + offerDecode: session.decodeValues, + decoded: decodeTraceFrames(session, view), + })}
`; + }) .join(""); }; render(); diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 6f755529a..1ac12ae01 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -8,7 +8,7 @@ export { createDebugIngest } from "./ingest.js"; export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; export { createDebugSession } from "./session.js"; export type { DebugSession, DebugSessionOptions } from "./session.js"; -export { createFrameDecoder, SENSITIVE_FRAME_IDS } from "./decode.js"; +export { createFrameDecoder } from "./decode.js"; export type { FrameDecoder, FrameDecoderOptions, diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts index 482dd0e45..ce9f64a85 100644 --- a/js/packages/truapi-debugger/src/ingest.ts +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -21,8 +21,8 @@ import type { WireMethodInfo } from "./wire-debugger.js"; * web host's debugger link) stamp it alongside a codec identity so the debugger * can refuse to decode a frame against a wire contract that isn't its own - * frame ids are `u8` discriminants that get reassigned as the API evolves, so an - * unversioned envelope from an older host would resolve to the wrong method, the - * wrong value, and worst case decode a frame the host's build marks sensitive. + * unversioned envelope from an older host would resolve to the wrong method and + * the wrong value. */ export const WIRE_ENVELOPE_VERSION = 1; diff --git a/js/packages/truapi-debugger/src/repl.ts b/js/packages/truapi-debugger/src/repl.ts deleted file mode 100644 index 2c5319053..000000000 --- a/js/packages/truapi-debugger/src/repl.ts +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: MIT -/** - * Interactive query REPL for the wire debugger - a prompt you keep talking to, - * rather than a full-screen app. Line-based (via `node:readline`, so history and - * line editing come for free), over a running debugger, reusing the same - * {@link buildTraceView} engine and denylist as the web inspector. - * - * Session scope (channel / filter / sort / sensitive-only) persists across - * queries, so `ls` reflects the state you set. The sensitive-reveal escape hatch - * is a two-step, in-loop confirm (`reveal ` then `yes`) - no nested prompt, - * and the reveal is honored only when the server is armed. - * - * @module - */ - -import readline from "node:readline"; - -import { - toView, - viewMethod, - type DebuggerClient, - type FrameValueDetail, -} from "./cli-client.js"; -import type { TraceView } from "./trace-view.js"; -import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; - -const COLOR = - process.env.NO_COLOR === undefined && process.stdout.isTTY === true; -function c(code: string, s: string): string { - return COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; -} -const bold = (s: string): string => c("1", s); -const dim = (s: string): string => c("2", s); -const red = (s: string): string => c("31", s); -const green = (s: string): string => c("32", s); -const cyan = (s: string): string => c("36", s); - -const SORTS = ["arrival", "recent", "method", "duration", "frames"]; - -interface ReplState { - channel: string | null; - filter: string; - sort: string; - sensOnly: boolean; - /** A reveal awaiting the next line's `yes` confirmation. */ - pendingReveal: { requestId: string; seq?: number } | null; -} - -const HELP = [ - bold("commands"), - ` ${cyan("ls")} [text] list ops (aggregate + rows); optional inline method filter`, - ` ${cyan("stats")} just the aggregate summary line`, - ` ${cyan("show")} an op's frames, decoding non-sensitive values`, - ` ${cyan("decode")} alias for show`, - ` ${cyan("reveal")} [seq] reveal sensitive frame(s) — asks to confirm (dev, armed server only)`, - ` ${cyan("channels")} hosts that have dialed in`, - ` ${cyan("use")} scope every query to one channel`, - ` ${cyan("filter")} [text] persistent method filter (empty clears)`, - ` ${cyan("sort")} ${SORTS.join(" | ")}`, - ` ${cyan("sensitive")} [on|off] show only ops with a sensitive method`, - ` ${cyan("clear")} clear the screen`, - ` ${cyan("help")} · ${cyan("quit")}`, -].join("\n"); - -/** Run the query REPL against `client`. Resolves when the user quits. */ -export async function runRepl( - client: DebuggerClient, - channel: string | null, -): Promise { - const state: ReplState = { - channel, - filter: "", - sort: "arrival", - sensOnly: false, - pendingReveal: null, - }; - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - historySize: 200, - terminal: process.stdin.isTTY === true, - }); - - console.log(`${bold("TrUAPI wire debugger")}${dim(` — ${client.host}`)}`); - console.log(dim("type `help` for commands, `quit` to exit")); - - const promptStr = (): string => { - const bits = [state.channel ?? "all"]; - if (state.filter) bits.push(cyan(`/${state.filter}`)); - if (state.sort !== "arrival") bits.push(`sort:${state.sort}`); - if (state.sensOnly) bits.push(red("\u{1f512}")); - return `${green("truapi")} ${dim(bits.join(" "))} ${bold("▸")} `; - }; - - function sortViews(views: TraceView[]): TraceView[] { - if (state.sort === "arrival") return views; - return [...views].sort((a, b) => { - switch (state.sort) { - case "recent": - return b.lastAt - a.lastAt; - case "duration": - return b.durationMs - a.durationMs; - case "frames": - return b.frames.length - a.frames.length; - case "method": - return viewMethod(a).localeCompare(viewMethod(b)); - default: - return 0; - } - }); - } - - async function views(inlineFilter?: string): Promise { - const all = await client.traces(); - let vs = all - .filter((t) => state.channel === null || t.channelId === state.channel) - .map(toView); - const f = (inlineFilter ?? state.filter).toLowerCase(); - if (f) vs = vs.filter((v) => viewMethod(v).toLowerCase().includes(f)); - if (state.sensOnly) vs = vs.filter((v) => v.sensitive === true); - return sortViews(vs); - } - - async function doList(inlineFilter?: string): Promise { - const [stats, vs] = await Promise.all([ - client.stats(state.channel), - views(inlineFilter), - ]); - console.log(formatStats(stats)); - console.log(""); - if (vs.length === 0) console.log(dim(" (no operations match)")); - // Unscoped view: show the channel so same-id ops from two hosts are distinct. - for (const v of vs) console.log(formatOpRow(v, state.channel === null)); - } - - async function doChannels(): Promise { - const chs = await client.channels(); - if (chs.length === 0) { - console.log(dim(" (no hosts have dialed in yet)")); - return; - } - for (const ch of chs) { - console.log( - `${ch.connected ? green("●") : dim("○")} ${ch.channelId} ${dim(`(${String(ch.frameCount)} frames)`)}${ch.channelId === state.channel ? cyan(" ← scoped") : ""}`, - ); - } - } - - async function findOp(id: string) { - return (await client.traces()).find( - (t) => - t.requestId === id && - (state.channel === null || t.channelId === state.channel), - ); - } - - async function doShow(id: string, revealSeqs?: Set): Promise { - const entry = await findOp(id); - if (entry === undefined) { - console.log(red(`no operation with requestId ${id}`)); - return; - } - const view = toView(entry); - const decoded = new Map(); - for (const f of view.frames) { - const reveal = revealSeqs?.has(f.seq) ?? false; - try { - decoded.set( - f.seq, - await client.frame(entry.requestId, f.seq, entry.channelId, reveal), - ); - } catch { - // Leave the frame value-less; the row still renders. - } - } - console.log(formatOpDetail(view, decoded)); - } - - async function startReveal(id: string, seqArg?: string): Promise { - const entry = await findOp(id); - if (entry === undefined) { - console.log(red(`no operation with requestId ${id}`)); - return; - } - const view = toView(entry); - const seq = seqArg === undefined ? undefined : Number(seqArg); - const targets = - seq === undefined - ? view.frames.filter((f) => f.sensitive === true) - : view.frames.filter((f) => f.seq === seq); - if (targets.length === 0) { - console.log(dim(" (no sensitive frame to reveal here)")); - return; - } - state.pendingReveal = { requestId: id, seq }; - console.log( - red("⚠ reveal SENSITIVE payload") + - dim(" — may contain a private key/credential; not while screen-sharing.\n") + - ` type ${bold("yes")} to confirm (anything else cancels)`, - ); - } - - async function handle(line: string): Promise { - // A pending reveal consumes this line as its confirmation. - if (state.pendingReveal) { - const { requestId, seq } = state.pendingReveal; - state.pendingReveal = null; - if (line.toLowerCase() !== "yes" && line.toLowerCase() !== "y") { - console.log(dim(" (reveal cancelled)")); - return; - } - const entry = await findOp(requestId); - if (entry === undefined) { - console.log(red(`no operation with requestId ${requestId}`)); - return; - } - const view = toView(entry); - // A specific seq reveals just that frame; otherwise every sensitive frame. - const revealSeqs = - seq === undefined - ? new Set(view.frames.filter((f) => f.sensitive === true).map((f) => f.seq)) - : new Set([seq]); - await doShow(requestId, revealSeqs); - return; - } - - const [cmd, ...rest] = line.split(/\s+/).filter(Boolean); - if (cmd === undefined) return; - const pos = rest.filter((a) => !a.startsWith("--")); - const arg = pos[0]; - switch (cmd) { - case "help": - case "?": - console.log(HELP); - return; - case "ls": - case "ops": - return doList(arg); - case "stats": - console.log(formatStats(await client.stats(state.channel))); - return; - case "channels": - return doChannels(); - case "show": - case "decode": - if (arg === undefined) { - console.log(dim("usage: show ")); - return; - } - return doShow(arg); - case "reveal": - if (arg === undefined) { - console.log(dim("usage: reveal [seq]")); - return; - } - return startReveal(arg, pos[1]); - case "use": - case "channel": - state.channel = arg === undefined || arg === "all" ? null : arg; - return; - case "filter": - state.filter = rest.filter((a) => !a.startsWith("--")).join(" "); - return; - case "sort": - if (arg !== undefined && SORTS.includes(arg)) state.sort = arg; - else console.log(dim(`sort: ${SORTS.join(" | ")}`)); - return; - case "sensitive": - case "sens": - state.sensOnly = arg === undefined ? !state.sensOnly : arg === "on"; - return; - case "clear": - console.clear(); - return; - case "quit": - case "exit": - case "q": - rl.close(); - return; - default: - console.log(dim(`unknown command: ${cmd} — try \`help\``)); - } - } - - const prompt = (): void => { - rl.setPrompt(promptStr()); - rl.prompt(); - }; - - // Serialize line handling so piped input and in-flight fetches never interleave. - let chain: Promise = Promise.resolve(); - prompt(); - rl.on("line", (line) => { - chain = chain - .then(() => handle(line.trim())) - .catch((e: unknown) => { - console.error(red(e instanceof Error ? e.message : String(e))); - }) - .then(() => prompt()); - }); - - await new Promise((resolve) => { - rl.on("close", () => { - console.log(dim("bye")); - resolve(); - }); - }); -} diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index 0b6f84d91..c55934d0c 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -1,9 +1,13 @@ import { expect, test } from "bun:test"; -import { encodeWireMessage, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; +import { + encodeWireMessage, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostSignRawRequest, +} from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; -import { startDebugServer } from "./server.js"; +import { isLoopbackDebugHost, startDebugServer } from "./server.js"; interface TraceFrameView { direction: string; @@ -23,6 +27,30 @@ function encodeFrame(requestId: string, frameId: number, value: Uint8Array): str 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: "Left", 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, @@ -105,7 +133,7 @@ test("the inspector page is served at /", async () => { expect(html).toContain("TrUAPI Wire Inspector"); // The shell fetches the shared fragments, not a bespoke renderer. expect(html).toContain("/op-list"); - expect(html).toContain("/frame-html"); + expect(html).toContain("/op?id="); } finally { server.stop(); } @@ -232,16 +260,17 @@ test("/stats is byte- and value-free even with value decode on", async () => { } }); -test("/frame decodes a non-sensitive frame only when decode is on", async () => { +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]), ); - // Decode ON: the drill-down surfaces the decoded value. - const on = startDebugServer({ port: 0, decodeValues: true }); + // 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(); @@ -251,8 +280,8 @@ test("/frame decodes a non-sensitive frame only when decode is on", async () => on.stop(); } - // Decode OFF (the default): the same drill-down reports byte length only. - const off = startDebugServer({ port: 0 }); + // `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}`; @@ -265,84 +294,129 @@ test("/frame decodes a non-sensitive frame only when decode is on", async () => } }); -test("/frame redacts a signing frame even with decode on, and /traces never carries its bytes", async () => { +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 { - const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01]); - const frame = encodeFrame("p:sign", W.SIGNING_SIGN_RAW.request, secret); - await streamFrame(base, server.port, frame); + 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("redacted"); - expect(detail.reason).toBe("sensitive method"); - expect(detail.byteLength).toBe(secret.length); + 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 signing payload bytes must not appear anywhere in the trace list. + // 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("222,173"); // 0xde,0xad as a decimal byte array + expect(raw).not.toContain("alice.dot"); } finally { server.stop(); } }); -test("/view renders the shared drill-down and stays payload-blind by default", async () => { - // Decode OFF (default): the level-1 view shows the frame sequence but offers - // no decode control and no value. - const off = startDebugServer({ port: 0 }); +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:${off.port}`; + const base = `http://localhost:${server.port}`; const frame = encodeFrame( "p:1", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, new Uint8Array([0]), ); - await streamFrame(base, off.port, frame); + 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"'); - // Payload-blind: no decode affordance and no decoded value. + // 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"); - expect(html).not.toContain("V1"); } finally { - off.stop(); + server.stop(); } }); -test("/view offers a decode control per frame when level-2 is on", async () => { - const on = startDebugServer({ port: 0, decodeValues: true }); +test("/view is payload-blind when decode is off", async () => { + const off = startDebugServer({ port: 0, decodeValues: false }); try { - const base = `http://localhost:${on.port}`; + const base = `http://localhost:${off.port}`; const frame = encodeFrame( "p:1", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, new Uint8Array([0]), ); - await streamFrame(base, on.port, frame); + await streamFrame(base, off.port, frame); const html = await (await fetch(`${base}/view`)).text(); - expect(html).toContain("td-frame-decode-btn"); - // The control is still an opt-in click; the value is not inlined into /view. - expect(html).not.toContain("V1"); + 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 { - on.stop(); + off.stop(); } }); -test("/frame-html renders a redacted fragment for a signing frame", async () => { - const server = startDebugServer({ port: 0, decodeValues: true }); +test("/op decodes every frame inline via the real decodeTraceFrames path", async () => { + const server = startDebugServer({ port: 0 }); const base = `http://localhost:${server.port}`; try { - const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01]); - const frame = encodeFrame("p:sign", W.SIGNING_SIGN_RAW.request, secret); - await streamFrame(base, server.port, frame); - const res = await fetch(`${base}/frame-html?id=p:sign&i=0`); - expect(res.headers.get("content-type")).toContain("text/html"); - const html = await res.text(); - expect(html).toContain("redacted"); - expect(html).not.toContain("deadbeef"); + // 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(); } @@ -457,6 +531,24 @@ test("a wrong-schema or unstamped host refuses to decode, but still groups", asy } }); +test("isLoopbackDebugHost is an exact allowlist (drives the Host-header guard)", () => { + expect(isLoopbackDebugHost("127.0.0.1")).toBe(true); + expect(isLoopbackDebugHost("localhost")).toBe(true); + expect(isLoopbackDebugHost("::1")).toBe(true); + // Everything else is non-loopback. A fuzzy match that read any of these as + // loopback would let a rebound page past the DNS-rebinding Host guard. + for (const host of [ + "0.0.0.0", + "127.0.0.1.evil.com", + "127.0.0.2", + "[::1]", + "LOCALHOST", + "example.com", + ]) { + expect(isLoopbackDebugHost(host)).toBe(false); + } +}); + 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}`; @@ -476,76 +568,42 @@ test("/frame rejects out-of-range indices (negative and huge) with 404", async ( } }); -test("the reveal gate folds in decode: armed without decode ⇒ not armed", async () => { - // A stray TRUAPI_DEBUGGER_REVEAL_SENSITIVE with decode OFF must not arm reveal. - const server = startDebugServer({ - port: 0, - decodeValues: false, - revealSensitive: true, - }); - const base = `http://localhost:${server.port}`; - try { - expect(server.revealSensitive).toBe(false); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0xde, 0xad]), - ); - await streamFrame(base, server.port, frame); - // Decode off ⇒ bytes-only regardless of a reveal request. - const detail = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) - ).json(); - expect(detail.kind).toBe("bytes"); - } finally { - server.stop(); - } -}); - -test("an unarmed server ignores reveal=1 and still redacts a sensitive frame", async () => { - // Decode ON but reveal NOT armed: reveal=1 must be ignored server-side. - const server = startDebugServer({ port: 0, decodeValues: true }); +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.revealSensitive).toBe(false); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0xde, 0xad]), - ); - await streamFrame(base, server.port, frame); - const detail = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) + 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(detail.kind).toBe("redacted"); + expect(still.kind).toBe("decoded"); } finally { server.stop(); } }); -test("an armed server honors reveal only on the explicit per-call flag", async () => { - const server = startDebugServer({ - port: 0, - decodeValues: true, - revealSensitive: true, - }); +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 { - expect(server.revealSensitive).toBe(true); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0]), - ); - await streamFrame(base, server.port, frame); - // No reveal flag ⇒ still redacts, even on an armed server. - const guarded = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); - expect(guarded.kind).toBe("redacted"); - // With the explicit flag ⇒ the denylist is bypassed (decoded or bytes, never redacted). - const revealed = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) - ).json(); - expect(revealed.kind).not.toBe("redacted"); + // 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(); } diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index 4186e08d3..190f6577f 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -7,10 +7,10 @@ * `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 /frame?id=&i=` is the drill-down - * detail path, the only place a decoded value can surface, and only when level-2 - * decode is opted in (`TRUAPI_DEBUGGER_DECODE_VALUES`, off by default) and the - * frame is not sensitive; `GET /` serves a page that polls `/traces`. + * 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 @@ -20,19 +20,14 @@ */ import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; -import { createDebugSession } from "./session.js"; +import { createDebugSession, decodeTraceFrames } from "./session.js"; import { DEFAULT_MAX_ID_CHARS, WIRE_ENVELOPE_VERSION, type DebugFrameEnvelope, } from "./ingest.js"; import { wireTraceToView, type TraceView } from "./trace-view.js"; -import type { CliStats } from "./trace-text.js"; -import { - renderFrameValueDetail, - renderOperationRow, - renderTraceDetail, -} from "./trace-render.js"; +import { renderOperationRow, renderTraceDetail } from "./trace-render.js"; import { detectRetryStorms } from "./retry-storm.js"; import { TRACE_DETAIL_CSS } from "./trace-styles.js"; @@ -61,10 +56,10 @@ interface WireMessage { codec?: number; /** * The host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a hash of - * every frame id, its method leg, and its sensitivity. Unlike `codec` (the - * coarse handshake number, bumped ~never), this changes whenever a frame id is - * reassigned or a `#[wire(sensitive)]` flag flips - the case where a - * host-sensitive frame could otherwise decode off this debugger's denylist. + * 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. */ @@ -126,6 +121,40 @@ function optionalInt(raw: string | null): number | null | undefined { return Number.isInteger(n) ? n : null; } +/** + * Whether `host` is a loopback name. The `Host`-header DNS-rebinding guard keys + * on this, so an exact allowlist - never a fuzzy match that could read + * `127.0.0.1.evil.com` as loopback - is the security-relevant classification, + * unit-tested separately. + */ +export function isLoopbackDebugHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +/** + * Whether a request's `Host` header targets an address this server is willing to + * answer for: a loopback name. + * + * 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. + */ +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 isLoopbackDebugHost(normalized); +} + /** Parse and validate one inbound WS text message, or `null`. */ function parseWireMessage(raw: string): ParsedWireMessage | null { let parsed: unknown; @@ -162,8 +191,6 @@ export interface DebugServer { readonly port: number; /** Whether level-2 value decode is enabled on the drill-down path. */ readonly decodeValues: boolean; - /** Whether the dev-only sensitive-reveal escape hatch is armed. */ - readonly revealSensitive: boolean; /** Stop listening and drop active connections. */ stop(): void; } @@ -197,26 +224,19 @@ export function startDebugServer( options: { port?: number; decodeValues?: boolean; - revealSensitive?: boolean; } = {}, ): DebugServer { - const decodeValues = options.decodeValues ?? false; - // The reveal escape hatch is meaningless without decode on; fold the master - // gate in here so a stray env var alone can never arm it. - const revealSensitive = decodeValues && (options.revealSensitive ?? false); - const session = createDebugSession({ decodeValues, revealSensitive }); + // 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 + denylist. */ + /** 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) ?? [], - session.sensitiveIds, - ); + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []); /** * Compute the cross-op retry-storm signal once over a trace set, then adapt @@ -233,7 +253,8 @@ export function startDebugServer( function tracesJson(): string { // Payload-blind view: raw `bytes` and decoded values are deliberately never - // serialized here - decode lives only on the `/frame` drill-down. `method` + // 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 @@ -266,7 +287,6 @@ export function startDebugServer( const id = url.searchParams.get("id"); const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; - const reveal = url.searchParams.get("reveal") === "1"; // `Number("")`/`Number(" ")` are both 0 and pass Number.isInteger, so an // empty or whitespace `?i=` or `?gen=` would otherwise resolve frame 0 / // generation 0 (the oldest recycled op) with a 200; optionalInt rejects them. @@ -285,7 +305,7 @@ export function startDebugServer( }); } if (!decodeTrusted(channel)) return codecRefusal("application/json"); - const detail = session.frameDetail(id, index, channel, reveal, generation); + const detail = session.frameDetail(id, index, channel, generation); if (!detail) { return new Response('{"error":"no such frame"}', { status: 404, @@ -298,9 +318,10 @@ export function startDebugServer( } /** - * The `/view` payload-blind level-1 fragment: every trace rendered by the - * shared {@link renderTraceDetail}, the same renderer dotli's panel mounts. - * No payloads here; decode controls appear per frame only when level-2 is on. + * The `/view` fragment: every trace 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. */ function viewHtml(): string { const entries = viewsFor(session.traceEngine.traces()); @@ -315,56 +336,17 @@ export function startDebugServer( `
` + renderTraceDetail(view, { offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, + // 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(""); } - /** - * The `/frame-html?id=&i=` server-rendered level-2 fragment for one frame. - * Reuses the denylist-gated {@link DebugSession.frameDetail} and the shared - * value renderer, so a sensitive frame renders redacted here too. - */ - function frameHtmlResponse(url: URL): Response { - const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; - const id = url.searchParams.get("id"); - const rawIndex = url.searchParams.get("i"); - const channel = url.searchParams.get("channel") ?? undefined; - const reveal = url.searchParams.get("reveal") === "1"; - const generation = optionalInt(url.searchParams.get("gen")); - const index = Number(rawIndex); - if ( - id === null || - rawIndex === null || - rawIndex.trim() === "" || - !Number.isInteger(index) || - generation === null - ) { - return new Response(`
bad request
`, { - status: 400, - headers: htmlHeaders, - }); - } - if (!decodeTrusted(channel)) { - return new Response( - `
decode refused — host wire codec mismatch
`, - { status: 409, headers: htmlHeaders }, - ); - } - const detail = session.frameDetail(id, index, channel, reveal, generation); - if (!detail) { - return new Response(`
no such frame
`, { - status: 404, - headers: htmlHeaders, - }); - } - return new Response(renderFrameValueDetail(detail), { - headers: htmlHeaders, - }); - } - // 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 @@ -456,8 +438,8 @@ export function startDebugServer( * `schema` and never mismatched. * * This is a COMPATIBILITY guard against honest version drift - a host built - * against a different frame table, where a host-sensitive id could resolve off - * this debugger's `SENSITIVE_FRAME_IDS` - not authentication: + * 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. @@ -506,6 +488,26 @@ export function startDebugServer( * 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; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; + } const traces = channel === null ? session.traceEngine.traces() @@ -518,7 +520,6 @@ export function startDebugServer( let orphaned = 0; let retryStorms = 0; let truncated = 0; - let sensitive = 0; let out = 0; let inbound = 0; let durationTotal = 0; @@ -532,7 +533,6 @@ export function startDebugServer( if (view.badges.includes("orphaned")) orphaned += 1; if (view.badges.includes("retry-storm")) retryStorms += 1; if (view.badges.includes("truncated")) truncated += 1; - if (view.sensitive) sensitive += 1; if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { subscriptions += 1; if (!view.frames.some((f) => f.role === "stop")) { @@ -569,8 +569,8 @@ export function startDebugServer( 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 the CLI parses back as CliStats. - const payload: CliStats = { + // the payload a client parses back. + const payload: StatsPayload = { ops, frames, bytes, @@ -583,7 +583,6 @@ export function startDebugServer( evictedTraces, droppedByHost, codecMismatch, - sensitive, out, in: inbound, avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), @@ -692,24 +691,37 @@ export function startDebugServer( const storms = detectRetryStorms( session.traceEngine.tracesForChannel(trace.channelId), ); - return renderTraceDetail(toView(trace, storms), { + const view = toView(trace, storms); + return renderTraceDetail(view, { offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, + // 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 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 them or inject frames. The CLI and same-origin inspector both target - // localhost, so nothing else changes. + // 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) { - // Reject cross-origin WebSocket upgrades (CSWSH): binding to 127.0.0.1 - // 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 + const url = new URL(req.url); + const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; + // DNS-rebinding guard: the request's Host must be loopback. 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. + // Applies before any route dispatch. + if (!hostHeaderAllowed(req.headers.get("host"))) { + return new Response("forbidden host", { status: 403 }); + } + // 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"))) { @@ -717,8 +729,6 @@ export function startDebugServer( } if (srv.upgrade(req)) return undefined; } - const url = new URL(req.url); - const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; if (url.pathname === "/traces") { return new Response(tracesJson(), { headers: { "content-type": "application/json" }, @@ -765,15 +775,13 @@ export function startDebugServer( if (url.pathname === "/frame") { return frameResponse(url); } - if (url.pathname === "/frame-html") { - return frameHtmlResponse(url); - } - return new Response( - VIEW_HTML.replace("__DECODE_STATE__", decodeValues ? "on" : "off"), - { headers: htmlHeaders }, - ); + return new Response(VIEW_HTML, { headers: htmlHeaders }); }, websocket: { + // Cap one inbound frame at 1 MiB rather than Bun's 16 MiB default: a host + // dial is one small SCALE frame per message, so a larger payload is either + // a bug or an attempt to exhaust memory. Bun drops an over-cap message. + maxPayloadLength: 1024 * 1024, open() { openSockets += 1; }, @@ -804,7 +812,6 @@ export function startDebugServer( // Always a TCP port here; the `?? 0` only satisfies Bun's unix-socket union. port: server.port ?? 0, decodeValues, - revealSensitive, stop: () => server.stop(true), }; } @@ -818,11 +825,10 @@ export function startDebugServer( * * The client is a thin shell over server-rendered fragments: it polls * `/op-list` (the shared {@link renderOperationRow}) and `/channels`, and fetches - * `/op` and `/frame-html` on interaction. Every injected fragment is produced - * and escaped server-side, so `innerHTML` is safe. Payload-blind by default: - * `/op-list` and `/op` carry only shape/timing; a value appears only after an - * explicit per-frame decode, and a sensitive frame renders redacted, never its - * value. `td-*` classes are owned by the shared renderer. + * `/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 = ` @@ -846,8 +852,6 @@ const VIEW_HTML = ` .ins-chan .dot { width: 6px; height: 6px; border-radius: 50%; background: #4b5563; } .ins-chan .dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; } .ins-chan.active .dot.live { background: #0a0a0a; box-shadow: none; } - .ins-gate { color: #6b7280; white-space: nowrap; } - .ins-gate.on { color: #fbbf24; } .ins-body { display: grid; grid-template-columns: var(--list-w, 340px) 6px 1fr; min-height: 0; } .ins-list { overflow: auto; outline: none; } @@ -888,49 +892,16 @@ ${TRACE_DETAIL_CSS} .td-frame-decoded > * { margin: 0; } .td-frame-decoded .td-detail-pre { max-height: 240px; overflow: auto; margin: 0; white-space: pre; } - /* Blur-to-reveal placeholder: decorative blocks (no real bytes), revealed on - decode. Full width of the payload column so all placeholders line up. */ - .td-frame-decode-btn { display: flex; align-items: center; gap: 8px; width: 100%; - padding: 3px 8px; border: 1px solid rgba(255,255,255,.10); border-radius: 5px; - background: rgba(255,255,255,.03); color: #94a3b8; cursor: pointer; - font: inherit; text-align: left; transition: background .12s, border-color .12s; } - .td-frame-decode-btn:hover { background: rgba(74,222,128,.10); border-color: rgba(74,222,128,.4); color: #d1fae5; } - .td-frame-decode-btn:disabled { opacity: .5; cursor: progress; } - .td-enc-blur { flex: 1; min-width: 0; overflow: hidden; color: #64748b; - filter: blur(3px); user-select: none; letter-spacing: -1px; } - .td-enc-hint { white-space: nowrap; font-size: 10.5px; color: #6b7280; } - .td-frame-decode-btn:hover .td-enc-hint { color: #86efac; } - /* Bulk decode/encode controls in the top bar (shown only when decode is on). */ - .ins-bulk { display: none; gap: 6px; } - .ins-bulk.on { display: inline-flex; } - .ins-btn { padding: 1px 9px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; - background: transparent; color: #cbd5e1; cursor: pointer; font: inherit; white-space: nowrap; } - .ins-btn:hover { border-color: rgba(74,222,128,.5); color: #86efac; } - .ins-btn.primary { border-color: rgba(251,191,36,.45); color: #fbbf24; } - .ins-btn.primary:hover { background: rgba(251,191,36,.12); } - /* Top-bar filter / sort / sensitive-only controls. */ + /* Top-bar filter / sort controls. */ .ins-filter { width: 148px; padding: 2px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; background: rgba(255,255,255,.03); color: #e0e0e0; font: inherit; } .ins-filter:focus { outline: none; border-color: rgba(74,222,128,.5); } .ins-sort { padding: 2px 6px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; background: #0a0a0a; color: #cbd5e1; font: inherit; cursor: pointer; } - .ins-sens-toggle.active { border-color: #f87171; color: #f87171; background: rgba(248,113,113,.10); } .td-op.filtered-out { display: none; } - /* Privacy markers on the op row and the frame. */ - .td-op-lock, .td-frame-lock { font-size: 10px; opacity: .9; } - .td-op-lock { margin-left: 3px; } - .td-frame-lock { margin-left: -3px; } - .ins-stat.lock .n { color: #fca5a5; } /* Clickable top-method pills. */ .ins-method { cursor: pointer; } .ins-method:hover { border-color: rgba(74,222,128,.5); color: #d1fae5; } - /* Sensitive-reveal escape hatch (dev-only, env-armed): danger styling. */ - .td-frame-reveal-btn { display: flex; align-items: center; gap: 6px; width: 100%; - padding: 3px 8px; border: 1px dashed rgba(248,113,113,.55); border-radius: 5px; - background: rgba(248,113,113,.06); color: #f87171; cursor: pointer; font: inherit; text-align: left; } - .td-frame-reveal-btn:hover { background: rgba(248,113,113,.15); border-style: solid; } - .td-detail-danger { border-color: rgba(248,113,113,.6) !important; - box-shadow: inset 3px 0 0 #f87171; } /* Aggregate summary strip: the "at a glance" row of metric tiles. */ .ins-summary { display: flex; gap: 6px; align-items: flex-start; flex-wrap: nowrap; padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.08); @@ -971,22 +942,13 @@ ${TRACE_DETAIL_CSS} - - - - - - decode: __DECODE_STATE__
waiting for frames…
waiting for frames…
-
Select an operation to inspect its frames. ↑/↓ to move, Enter to open, d to decode a frame.
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open.
connecting…