From ce7e3154081c5385e3a4146ef4e015d2847fa350 Mon Sep 17 00:00:00 2001 From: liberuum Date: Fri, 25 Sep 2026 12:06:53 +0100 Subject: [PATCH 1/4] fix(sign): sign with the reactor's v2 scheme so writes land on dev.24 documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 6.2.3-dev.24 reactor creates every document v2-required and refuses the legacy tuple the CLI signed: [SCHEME_BELOW_POLICY]: action … carries a legacy tuple but … requires v2 signatures The v2 hash is "v2:" + base64url(sha256(canonicalJson([...]))) over the document, branch, scope, type, action id, timestamp, input and signer, as @powerhousedao/shared's hashActionV2 builds it. canonicalJson sorts keys by UTF-16 code unit and writes numbers as JavaScript does (2.0 is 2, 1e21 is 1e+21, -0 is 0); serde_json does neither. sign_action now takes the job's document and branch: a relationship is signed against its sourceId, other document-scope actions against input.documentId. The message layout is unchanged, so a Switchboard older than dev.24, which checks only the ECDSA over the tuple, accepts a v2 tuple too. Checked with dev.20's own verifyOperationSignature, so the CLI always signs v2. The hash is pinned by 13 vectors generated from the reactor's hashActionV2 (tests/fixtures/, with the script that regenerates them). `docs apply` now refuses to sign when the document id does not resolve, rather than sign the raw fallback the reactor would reject. Removes the legacy action_hash, compact_json and sign, which this replaces. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- src/cli/docs.rs | 21 +- src/cli/guide.rs | 2 +- src/cli/mutate.rs | 2 +- src/identity.rs | 421 ++++++++++++++++++++++++++---- tests/fixtures/gen_v2_vectors.mjs | 75 ++++++ tests/fixtures/v2_vectors.json | 185 +++++++++++++ 7 files changed, 643 insertions(+), 65 deletions(-) create mode 100644 tests/fixtures/gen_v2_vectors.mjs create mode 100644 tests/fixtures/v2_vectors.json diff --git a/README.md b/README.md index 9e2b623..099d8a7 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ switchboard auth login --renown --app-name my-agent # what the vault shows bes switchboard auth status # Signing: on as my-agent (did:key:z…) acting for 0x… ``` -From then on `docs apply`, `docs mutate`, `docs link`, `docs annotate` and `docs unlink` send actions through the typed `execute` mutation with a `context.signer`: ECDSA P-256 over `"\x19Signed Operation:\n" + len + timestamp + did + sha256(scope+type+input) + prevStateHash`, exactly what `@renown/sdk` produces, so the reactor stores the action untouched and rejects a tampered one (`InvalidSignatureError`). `docs create` and the model-namespaced paths that build actions server-side remain server-signed. +From then on `docs apply`, `docs mutate`, `docs link`, `docs annotate` and `docs unlink` send actions through the typed `execute` mutation with a `context.signer`: ECDSA P-256 over `"\x19Signed Operation:\n" + len + timestamp + did + actionHash + prevStateHash`, where `actionHash` is the reactor's v2 hash — `"v2:" + base64url(sha256(canonicalJson([…])))` over the document, branch, scope, type, action id, timestamp, input and signer — exactly what `@powerhousedao/shared`'s `signActionV2` produces. The reactor stores the action untouched and refuses a tampered one. A v2 signature is bound to one action in one document: a document created by a 6.2.3-dev.24 or later reactor refuses anything else (`SCHEME_BELOW_POLICY`), and an older Switchboard, which checks only the ECDSA, accepts it too. `docs create` and the model-namespaced paths that build actions server-side remain server-signed. Per-invocation overrides: `SWITCHBOARD_APP_NAME` (label this run differently), `SWITCHBOARD_IDENTITY_DIR` (another `.ph` directory), `SWITCHBOARD_UNSIGNED=1` (write unsigned on purpose). A configured identity that cannot be loaded is an error, never a silent downgrade to unsigned. The signature proves the key; the key↔address binding is the Renown credential (`.renown.json`, 7-day validity — `auth status` warns when it has expired; renew with `ph login`). diff --git a/src/cli/docs.rs b/src/cli/docs.rs index 58dac41..d70194a 100644 --- a/src/cli/docs.rs +++ b/src/cli/docs.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use clap::Subcommand; use colored::Colorize; use dialoguer::{Input, Select}; @@ -1819,9 +1819,8 @@ async fn apply( // mutateDocument resolver doesn't inject it (unlike model-specific resolvers). let actions = stamp_actions(actions); - let resolved_id = helpers::resolve_doc(&client, id) - .await - .unwrap_or_else(|_| id.to_string()); + let resolved = helpers::resolve_doc(&client, id).await; + let resolved_id = resolved.as_deref().unwrap_or(id).to_string(); let job_id = match identity { // Signed: every action carries our signature and goes through the @@ -1829,7 +1828,13 @@ async fn apply( // The reactor stores a pre-signed action untouched, so the operation // is attributed to this key — not to the Switchboard's identity. Some((identity, app_name)) => { - let actions = sign_actions(actions, &identity, &app_name)?; + // A v2 signature covers the document id, and the reactor resolves + // a slug before it verifies — signing the raw fallback would sign + // a preimage the reactor never builds. + let document_id = resolved.with_context(|| { + format!("cannot sign for '{id}': it did not resolve to a document") + })?; + let actions = sign_actions(actions, &identity, &app_name, &document_id, "main")?; let vars = serde_json::json!({ "documentIdentifier": resolved_id, "actions": actions, @@ -2062,7 +2067,7 @@ async fn relationship( let mut action = relationship_action(op, &source_id, &target_id, rel_type, metadata.as_ref()); let signed_as = match identity { Some((identity, app_name)) => { - identity.sign_action(&mut action, &app_name)?; + identity.sign_action(&mut action, &app_name, &source_id, "main")?; Some(app_name) } None => None, @@ -2128,6 +2133,8 @@ pub fn sign_actions( actions: Value, identity: &crate::identity::Identity, app_name: &str, + document_id: &str, + branch: &str, ) -> Result { let Value::Array(mut arr) = actions else { bail!("Actions must be a JSON array"); @@ -2139,7 +2146,7 @@ pub fn sign_actions( map.entry("scope") .or_insert_with(|| Value::String("global".to_string())); } - identity.sign_action(action, app_name)?; + identity.sign_action(action, app_name, document_id, branch)?; } Ok(Value::Array(arr)) } diff --git a/src/cli/guide.rs b/src/cli/guide.rs index b54a44f..8a62344 100644 --- a/src/cli/guide.rs +++ b/src/cli/guide.rs @@ -452,7 +452,7 @@ SIGNED WRITES Without an identity the Switchboard signs your actions with ITS identity and attributes them to whoever ran `ph login` on the server. With `auth login --renown`, docs apply / mutate / link / unlink are signed - client-side (ECDSA P-256, the @renown/sdk scheme) and stored untouched, so + client-side (ECDSA P-256, the reactor's v2 scheme) and stored untouched, so the vault sees your key, your address and your --app-name. Overrides per run: SWITCHBOARD_APP_NAME, SWITCHBOARD_IDENTITY_DIR, SWITCHBOARD_UNSIGNED=1. diff --git a/src/cli/mutate.rs b/src/cli/mutate.rs index eb862a1..9b8319e 100644 --- a/src/cli/mutate.rs +++ b/src/cli/mutate.rs @@ -199,7 +199,7 @@ pub async fn run(args: MutateArgs, format: OutputFormat, profile_name: Option<&s "timestampUtcMs": crate::cli::docs::iso_now(), "input": input_value, }); - identity.sign_action(&mut action, app_name)?; + identity.sign_action(&mut action, app_name, &resolved_doc_id, "main")?; eprintln!( "Running: {}", format!( diff --git a/src/identity.rs b/src/identity.rs index ada2db1..9bf488b 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -11,21 +11,38 @@ //! `app.name` lets an agent name itself (`powerhouse-knowledge`) so a vault //! can tell agent writes from a human's in Connect. //! -//! # What is signed, byte for byte (mirrors `@renown/sdk` `RenownCryptoSigner`) +//! # What is signed, byte for byte (mirrors `@powerhousedao/shared` `signActionV2`) //! //! ```text -//! actionHash = base64( sha256( scope + type + JSON.stringify(input) ) ) +//! preimage = canonicalJson([ "v2", documentId, branch, scope, type, id, +//! timestampUtcMs, input, user.address, +//! user.networkId, user.chainId, app.key ]) +//! actionHash = "v2:" + base64url( sha256( preimage ) ) — unpadded //! params = [ unixSeconds, did:key, actionHash, prevStateHash ] //! message = "\x19Signed Operation:\n" + len(params.join("")) + params.join("") //! signature = ECDSA P-256 / SHA-256 over message, raw r||s, "0x" + hex //! tuple = params + [signature] — transported joined by ", " //! ``` //! +//! This is the reactor's v2 scheme (6.2.3-dev.24, #3088). Unlike the legacy +//! hash over `scope + type + input`, it binds the signature to one action in +//! one document: a v2-required document — every one a dev.24 reactor creates +//! — refuses a legacy tuple outright (`SCHEME_BELOW_POLICY`). The message +//! layout is unchanged, so a Switchboard older than dev.24, which checks only +//! the ECDSA over the tuple, accepts a v2 tuple too; the CLI therefore always +//! signs v2. +//! //! The leading `0x19` byte is the EIP-191 domain prefix and is invisible in a -//! terminal; leave it out and every signature fails verification. `input` is -//! serialized with the key order the caller wrote (`serde_json` with -//! `preserve_order`), which is also the order sent on the wire, so anyone -//! recomputing the hash from the stored input gets the same bytes. +//! terminal; leave it out and every signature fails verification. +//! +//! `documentId` is the document the action is stored in, which is not always +//! the one the job was submitted for — see [`signing_document_id`]. +//! +//! `canonicalJson` is `safe-stable-stringify`: object keys sorted by UTF-16 +//! code unit, and numbers written as JavaScript writes them. The reactor +//! hashes the value JavaScript parsed off the wire, so `serde_json`'s own +//! output would not do: it keeps insertion order and writes `2.0`, `-0.0` +//! and `1e21` where JavaScript writes `2`, `0` and `1e+21`. //! //! `prevStateHash` is left empty, as the Switchboard's own signer does when an //! action carries no `prevOpHash`; the wire records it rather than enforcing it. @@ -201,10 +218,22 @@ impl Identity { } /// Sign one action in place: sets `context.signer` with the user, the - /// app (`app_name`, this did) and a single signature tuple. An action + /// app (`app_name`, this did) and a single v2 signature tuple. An action /// that already carries signatures is left alone — the reactor treats /// those as authoritative and so must we. - pub fn sign_action(&self, action: &mut Value, app_name: &str) -> Result<()> { + /// + /// `document_id` and `branch` are those of the job the action is + /// submitted with: the v2 hash covers the document the action is stored + /// in, which [`signing_document_id`] derives from them. `document_id` must + /// be the resolved id, never a slug — the reactor resolves slugs before + /// it verifies, so a slug here signs a different preimage. + pub fn sign_action( + &self, + action: &mut Value, + app_name: &str, + document_id: &str, + branch: &str, + ) -> Result<()> { let obj = action .as_object_mut() .ok_or_else(|| anyhow!("action is not a JSON object"))?; @@ -217,22 +246,38 @@ impl Identity { if already_signed { return Ok(()); } - let scope = obj - .get("scope") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("action has no `scope`"))? - .to_string(); - let action_type = obj - .get("type") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("action has no `type`"))? - .to_string(); + // The v2 preimage refuses an action with no input, and whatever is + // hashed has to be what goes on the wire — so make `{}` explicit. let input = obj - .get("input") - .cloned() - .unwrap_or(Value::Object(Default::default())); - - let tuple = self.sign(&scope, &action_type, &input, "", unix_seconds_now()); + .entry("input") + .or_insert_with(|| Value::Object(Default::default())) + .clone(); + let field = |name: &str| -> Result { + obj.get(name) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow!("action has no `{name}`, which the v2 signature covers")) + }; + let scope = field("scope")?; + let action_type = field("type")?; + let id = field("id")?; + let timestamp = field("timestampUtcMs")?; + + let target = signing_document_id(&action_type, &input, document_id); + let hash = action_hash_v2(&ActionPreimage { + document_id: &target, + branch, + scope: &scope, + action_type: &action_type, + id: &id, + timestamp_utc_ms: ×tamp, + input: &input, + address: &self.user.address, + network_id: &self.user.network_id, + chain_id: self.user.chain_id, + app_key: &self.did, + }); + let tuple = self.sign_tuple(hash, unix_seconds_now()); let signer = serde_json::json!({ "user": { "address": self.user.address, @@ -256,21 +301,14 @@ impl Identity { Ok(()) } - /// Produce the 5-element signature tuple for an action's parts. - pub fn sign( - &self, - scope: &str, - action_type: &str, - input: &Value, - prev_state_hash: &str, - unix_seconds: u64, - ) -> [String; 5] { - let hash = action_hash(scope, action_type, input); + /// The 5-element signature tuple for an action hash: the signing time, + /// this did, the hash, an empty `prevStateHash`, then the signature. + pub fn sign_tuple(&self, hash: String, unix_seconds: u64) -> [String; 5] { let params = [ unix_seconds.to_string(), self.did.clone(), hash, - prev_state_hash.to_string(), + String::new(), ]; let message = signature_message(¶ms); let signature: Signature = self.signing_key.sign(&message); @@ -280,10 +318,78 @@ impl Identity { } } -/// `base64(sha256(scope + type + JSON.stringify(input)))`. -pub fn action_hash(scope: &str, action_type: &str, input: &Value) -> String { - let payload = format!("{scope}{action_type}{}", compact_json(input)); - base64::engine::general_purpose::STANDARD.encode(Sha256::digest(payload.as_bytes())) +/// Everything the v2 hash covers, in the order of the reactor's preimage. +pub struct ActionPreimage<'a> { + pub document_id: &'a str, + pub branch: &'a str, + pub scope: &'a str, + pub action_type: &'a str, + pub id: &'a str, + pub timestamp_utc_ms: &'a str, + pub input: &'a Value, + pub address: &'a str, + pub network_id: &'a str, + pub chain_id: i64, + pub app_key: &'a str, +} + +/// Actions the reactor reduces itself, onto the document scope. +const DOCUMENT_SCOPE_ACTION_TYPES: [&str; 6] = [ + "CREATE_DOCUMENT", + "DELETE_DOCUMENT", + "UPGRADE_DOCUMENT", + "ADD_RELATIONSHIP", + "REMOVE_RELATIONSHIP", + "UPDATE_RELATIONSHIP", +]; + +/// The document an action is stored in, and so signed against +/// (`actionSigningTarget`). A relationship action lands on its `sourceId`, +/// the reactor's other document-scope actions on their `input.documentId`, +/// and everything else on the document the job was submitted for — which is +/// also the fallback when the input names none. +pub fn signing_document_id(action_type: &str, input: &Value, job_document_id: &str) -> String { + if !DOCUMENT_SCOPE_ACTION_TYPES.contains(&action_type) { + return job_document_id.to_string(); + } + let key = if action_type.ends_with("_RELATIONSHIP") { + "sourceId" + } else { + "documentId" + }; + input + .get(key) + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(job_document_id) + .to_string() +} + +/// The canonical JSON the v2 hash is taken over (`actionPreimageV2`). +pub fn action_preimage_v2(p: &ActionPreimage) -> String { + canonical_json(&serde_json::json!([ + "v2", + p.document_id, + p.branch, + p.scope, + p.action_type, + p.id, + p.timestamp_utc_ms, + p.input, + p.address, + p.network_id, + p.chain_id, + p.app_key, + ])) +} + +/// `"v2:" + base64url(sha256(preimage))`, unpadded (`hashActionV2`). +pub fn action_hash_v2(p: &ActionPreimage) -> String { + let digest = Sha256::digest(action_preimage_v2(p).as_bytes()); + format!( + "v2:{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) + ) } /// The bytes that are signed: prefix + length + concatenated params. @@ -298,9 +404,102 @@ pub fn signature_message(params: &[String; 4]) -> Vec { .into_bytes() } -/// `JSON.stringify` parity: compact, insertion-ordered (preserve_order). -fn compact_json(v: &Value) -> String { - serde_json::to_string(v).unwrap_or_default() +/// `safe-stable-stringify`'s output: compact, object keys sorted, numbers as +/// JavaScript writes them. +pub fn canonical_json(value: &Value) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + out +} + +fn write_canonical(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => out.push_str(&js_number(n)), + Value::String(s) => out.push_str(&json_string(s)), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + Value::Object(map) => { + // JavaScript's default sort compares UTF-16 code units, which is + // not UTF-8 byte order once a key holds an astral character. + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + out.push('{'); + for (i, key) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&json_string(key)); + out.push(':'); + write_canonical(&map[key], out); + } + out.push('}'); + } + } +} + +/// A JSON string literal. `serde_json` escapes exactly what `JSON.stringify` +/// does — `"`, `\`, the short forms, other C0 controls as `\u00xx` — and +/// writes everything else raw. +fn json_string(s: &str) -> String { + serde_json::to_string(s).expect("a str always serializes") +} + +/// A JSON number as `JSON.stringify` writes it (`Number::toString`). An +/// integer beyond 2^53 is formatted from its `f64`, because that is the value +/// JavaScript holds once it has parsed the wire. +fn js_number(n: &serde_json::Number) -> String { + const MAX_SAFE: u64 = 1 << 53; + if let Some(i) = n.as_i64() + && i.unsigned_abs() <= MAX_SAFE + { + return i.to_string(); + } + if let Some(u) = n.as_u64() + && u <= MAX_SAFE + { + return u.to_string(); + } + // A serde_json number is always finite, so there is no NaN/Infinity case. + js_f64(n.as_f64().unwrap_or_default()) +} + +/// ECMAScript `Number::toString(x)` for a finite `x`. +fn js_f64(x: f64) -> String { + if x == 0.0 { + return "0".to_string(); // -0 too + } + // `{:e}` is the shortest representation that round-trips — the same + // digits JavaScript picks — as `d[.ddd]e`. + let sci = format!("{:e}", x.abs()); + let (mantissa, exp) = sci.split_once('e').expect("LowerExp has an exponent"); + let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); + let k = digits.len() as i32; + // x = 0.d1d2…dk × 10^n + let n = exp.parse::().expect("LowerExp exponent is an integer") + 1; + let body = if k <= n && n <= 21 { + format!("{digits}{}", "0".repeat((n - k) as usize)) + } else if 0 < n && n <= 21 { + format!("{}.{}", &digits[..n as usize], &digits[n as usize..]) + } else if -6 < n && n <= 0 { + format!("0.{}{digits}", "0".repeat((-n) as usize)) + } else { + let e = n - 1; + let sign = if e >= 0 { "+" } else { "-" }; + let (first, rest) = digits.split_at(1); + let point = if rest.is_empty() { "" } else { "." }; + format!("{first}{point}{rest}e{sign}{}", e.abs()) + }; + if x < 0.0 { format!("-{body}") } else { body } } fn did_key_from_public(key: &p256::ecdsa::VerifyingKey) -> Result { @@ -375,20 +574,64 @@ mod tests { } } + /// Vectors produced by the reactor's own `hashActionV2` (see + /// `tests/fixtures/gen_v2_vectors.mjs`). Each input is JSON *text*, parsed + /// here by serde_json exactly as the CLI parses what it is given. #[test] - fn action_hash_matches_js_sha256_of_scope_type_input() { - // node: crypto.createHash("sha256").update("globalSET_TITLE{}").digest("base64") - assert_eq!( - action_hash("global", "SET_TITLE", &serde_json::json!({})), - "WzeFpsIT3tXHGq4vMdGkPGH3+lVzmdc1aeScx6N/AQI=" + fn v2_hash_matches_the_reactor_for_every_vector() { + let fixture: Value = + serde_json::from_str(include_str!("../tests/fixtures/v2_vectors.json")).unwrap(); + let signer = &fixture["signer"]; + let vectors = fixture["vectors"].as_array().unwrap(); + assert!( + vectors.len() >= 13, + "fixture lost vectors: {}", + vectors.len() ); + for v in vectors { + let name = v["name"].as_str().unwrap(); + let input: Value = serde_json::from_str(v["inputText"].as_str().unwrap()).unwrap(); + let action_type = v["type"].as_str().unwrap(); + let target = + signing_document_id(action_type, &input, v["jobDocumentId"].as_str().unwrap()); + assert_eq!( + target, v["expectedTargetDocumentId"], + "{name}: signed against the wrong document" + ); + let preimage = ActionPreimage { + document_id: &target, + branch: v["branch"].as_str().unwrap(), + scope: v["scope"].as_str().unwrap(), + action_type, + id: v["id"].as_str().unwrap(), + timestamp_utc_ms: v["timestampUtcMs"].as_str().unwrap(), + input: &input, + address: signer["user"]["address"].as_str().unwrap(), + network_id: signer["user"]["networkId"].as_str().unwrap(), + chain_id: signer["user"]["chainId"].as_i64().unwrap(), + app_key: signer["app"]["key"].as_str().unwrap(), + }; + assert_eq!( + action_preimage_v2(&preimage), + v["expectedPreimage"].as_str().unwrap(), + "{name}: preimage differs from the reactor's" + ); + assert_eq!( + action_hash_v2(&preimage), + v["expectedHash"].as_str().unwrap(), + "{name}: hash differs from the reactor's" + ); + } } #[test] - fn action_hash_preserves_the_callers_key_order() { - // {"b":1,"a":2} must NOT be re-sorted — JSON.stringify keeps insertion order. - let input: Value = serde_json::from_str(r#"{"b":1,"a":2}"#).unwrap(); - assert_eq!(compact_json(&input), r#"{"b":1,"a":2}"#); + fn canonical_json_sorts_keys_at_every_depth_and_keeps_array_order() { + let input: Value = + serde_json::from_str(r#"{"b":1,"a":{"d":[3,{"z":1,"y":2}],"c":2}}"#).unwrap(); + assert_eq!( + canonical_json(&input), + r#"{"a":{"c":2,"d":[3,{"y":2,"z":1}]},"b":1}"# + ); } #[test] @@ -418,13 +661,19 @@ mod tests { #[test] fn signature_verifies_against_the_did_key() { let id = test_identity(); - let input = serde_json::json!({ "title": "t", "updatedAt": "2026-09-02T00:00:00.000Z" }); - let tuple = id.sign("global", "SET_TITLE", &input, "", 1788349213); + let hash = "v2:8CmPfRq3fy5X27Wz8xGHmwTM1-MSp680B4YDjOlxM9A".to_string(); + let tuple = id.sign_tuple(hash.clone(), 1788349213); assert_eq!(tuple[0], "1788349213"); assert_eq!(tuple[1], id.did); - assert_eq!(tuple[2], action_hash("global", "SET_TITLE", &input)); + assert_eq!(tuple[2], hash); assert_eq!(tuple[3], ""); + // v2TupleProblem: "0x" and 128 LOWERCASE hex digits. assert!(tuple[4].starts_with("0x") && tuple[4].len() == 2 + 128); + assert!( + tuple[4][2..] + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f')) + ); let message = signature_message(&[ tuple[0].clone(), @@ -458,7 +707,8 @@ mod tests { "id": "a1", "type": "SET_TITLE", "scope": "global", "timestampUtcMs": "2026-09-02T00:00:00.000Z", "input": { "title": "t", "updatedAt": "2026-09-02T00:00:00.000Z" } }); - id.sign_action(&mut action, "switchboard-cli").unwrap(); + id.sign_action(&mut action, "switchboard-cli", "DOC", "main") + .unwrap(); let signer = &action["context"]["signer"]; assert_eq!(signer["app"]["name"], "switchboard-cli"); assert_eq!(signer["app"]["key"], id.did); @@ -472,13 +722,74 @@ mod tests { .collect(); assert_eq!(parts.len(), 5); assert_eq!(parts[1], id.did); + assert_eq!(parts[2], expected_hash(&id, &action, "DOC")); // Already signed → untouched. let before = action.clone(); - id.sign_action(&mut action, "other-app").unwrap(); + id.sign_action(&mut action, "other-app", "DOC", "main") + .unwrap(); assert_eq!(action, before); } + #[test] + fn sign_action_signs_a_relationship_against_its_source_document() { + // Submitted with the source as the job document in practice, but the + // hash must follow `input.sourceId` whatever the job says. + let id = test_identity(); + let mut action = serde_json::json!({ + "id": "a2", "type": "ADD_RELATIONSHIP", "scope": "document", + "timestampUtcMs": "2026-09-02T00:00:00.000Z", + "input": { "sourceId": "SOURCE", "targetId": "TARGET", "relationshipType": "BUILDS_ON" } + }); + id.sign_action(&mut action, "switchboard-cli", "SOMEWHERE-ELSE", "main") + .unwrap(); + let tuple = action["context"]["signer"]["signatures"][0] + .as_str() + .unwrap(); + let hash = tuple.split(SIGNATURE_SEPARATOR).nth(2).unwrap(); + assert_eq!(hash, expected_hash(&id, &action, "SOURCE")); + assert_ne!(hash, expected_hash(&id, &action, "SOMEWHERE-ELSE")); + } + + #[test] + fn sign_action_makes_a_missing_input_explicit_and_refuses_a_missing_id() { + let id = test_identity(); + // What is hashed has to be what goes on the wire: `{}` is written in. + let mut no_input = serde_json::json!({ + "id": "a3", "type": "NOOP_LIKE", "scope": "global", "timestampUtcMs": "2026-09-02T00:00:00.000Z" + }); + id.sign_action(&mut no_input, "switchboard-cli", "DOC", "main") + .unwrap(); + assert_eq!(no_input["input"], serde_json::json!({})); + + // The v2 preimage covers `id`; signing without one would sign an + // action the reactor then stamps with an id of its own. + let mut no_id = serde_json::json!({ + "type": "SET_TITLE", "scope": "global", "timestampUtcMs": "2026-09-02T00:00:00.000Z", "input": {} + }); + let err = id + .sign_action(&mut no_id, "switchboard-cli", "DOC", "main") + .unwrap_err(); + assert!(err.to_string().contains("`id`"), "{err}"); + } + + /// The v2 hash `sign_action` should have produced for `action` in `doc`. + fn expected_hash(id: &Identity, action: &Value, doc: &str) -> String { + action_hash_v2(&ActionPreimage { + document_id: doc, + branch: "main", + scope: action["scope"].as_str().unwrap(), + action_type: action["type"].as_str().unwrap(), + id: action["id"].as_str().unwrap(), + timestamp_utc_ms: action["timestampUtcMs"].as_str().unwrap(), + input: &action["input"], + address: &id.user.address, + network_id: &id.user.network_id, + chain_id: id.user.chain_id, + app_key: &id.did, + }) + } + #[test] fn loading_refuses_a_key_the_credential_did_not_authorise() { let signing_key = SigningKey::from_slice(&[9u8; 32]).unwrap(); diff --git a/tests/fixtures/gen_v2_vectors.mjs b/tests/fixtures/gen_v2_vectors.mjs new file mode 100644 index 0000000..56ccd90 --- /dev/null +++ b/tests/fixtures/gen_v2_vectors.mjs @@ -0,0 +1,75 @@ +// Regenerates tests/fixtures/v2_vectors.json from the reactor's own v2 hash. +// +// node tests/fixtures/gen_v2_vectors.mjs +// +// e.g. .../bai-knowledge-note/node_modules/@powerhousedao/shared +// +// Every `input` is JSON *text*, parsed by JSON.parse here and by +// serde_json in the Rust test: the same bytes the CLI reads from the user, +// and the server reads off the wire, so both sides start from the same value. +import { readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const sharedDir = process.argv[2]; +if (!sharedDir) throw new Error("usage: gen_v2_vectors.mjs "); +const { version } = JSON.parse(readFileSync(join(sharedDir, "package.json"), "utf8")); +const dm = await import(pathToFileURL(join(sharedDir, "dist/document-model/index.js")).href); + +const signer = { + user: { address: "0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4", networkId: "eip155", chainId: 1 }, + app: { name: "switchboard-cli", key: "did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU" }, +}; +const JOB_DOC = "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI"; + +const cases = [ + ["simple", "SET_TITLE", "global", `{"title":"Hello","updatedAt":"2026-09-25T10:00:00.000Z"}`], + ["unsorted_nested", "SET_METADATA", "global", `{"zeta":1,"alpha":{"b":2,"a":1},"mid":[{"y":1,"x":2}]}`], + ["numbers", "SET_STATS", "global", `{"int":42,"neg":-7,"frac":0.1,"integralFloat":2.0,"big":1e21,"small":1e-7,"zero":0,"negZero":-0.0,"huge":1.5e300,"half":0.5,"third":0.3333333333333333}`], + // Every branch of Number::toString: the 1e21 / 1e-6 thresholds on both + // sides, a point inside the digits, negatives, and an integer past 2^53 + // (JavaScript holds 9007199254740993 as 9007199254740992). + ["number_boundaries", "SET_STATS", "global", `{"e20":1e20,"e21":1e21,"m6":1e-6,"m7":1e-7,"mid":123.456,"negFrac":-2.5,"negSmall":-1e-7,"pastSafe":9007199254740993,"hundred":100,"tiny":1.23e-5,"negBig":-4.5e22}`], + ["unicode", "SET_TITLE", "global", `{"title":"em—dash ☃ 😀 中文 é"}`], + ["escapes", "SET_CONTENT", "global", `{"content":"quote \\" backslash \\\\ newline \\n tab \\t ctrl \\u0001 slash / del \\u007f"}`], + ["literals", "SET_FLAGS", "global", `{"n":null,"t":true,"f":false,"emptyArr":[],"emptyObj":{}}`], + ["empty_input", "NOOP_LIKE", "global", `{}`], + // U+FF01 sorts AFTER an astral char in UTF-16 (FF01 > D83D) but BEFORE it in + // UTF-8 bytes (EF.. < F0..): the case where JavaScript's key order and a + // naive Rust byte sort disagree. + ["unicode_keys", "SET_METADATA", "global", `{"\uff01":1,"\ud83d\ude00":2,"z":3,"\u00e9":4,"a":5,"\u4e2d":6}`], + ["relationship", "ADD_RELATIONSHIP", "document", `{"sourceId":"SRC-DOC-ID","targetId":"TGT-DOC-ID","relationshipType":"BUILDS_ON","metadata":{"reason":"why","confidence":"grounded"}}`], + ["relationship_no_source", "REMOVE_RELATIONSHIP", "document", `{"targetId":"TGT-DOC-ID","relationshipType":"BUILDS_ON"}`], + ["create_document", "CREATE_DOCUMENT", "document", `{"documentId":"NEW-DOC-ID","model":"bai/knowledge-note"}`], + ["delete_document_no_id", "DELETE_DOCUMENT", "document", `{}`], +]; + +const vectors = []; +for (const [name, type, scope, inputText] of cases) { + const action = { + id: `action-${name}`, + type, + scope, + timestampUtcMs: "2026-09-25T10:00:00.000Z", + input: JSON.parse(inputText), + }; + const target = dm.actionSigningTarget(action, JOB_DOC, "main"); + vectors.push({ + name, + type, + scope, + id: action.id, + timestampUtcMs: action.timestampUtcMs, + inputText, + jobDocumentId: JOB_DOC, + branch: "main", + expectedTargetDocumentId: target.documentId, + expectedPreimage: dm.actionPreimageV2(action, target, signer), + expectedHash: await dm.hashActionV2(action, target, signer), + }); +} + +const out = { generatedFrom: `@powerhousedao/shared@${version}`, signer, vectors }; +const path = join(dirname(fileURLToPath(import.meta.url)), "v2_vectors.json"); +writeFileSync(path, JSON.stringify(out, null, 2) + "\n"); +console.log(`wrote ${vectors.length} vectors from ${out.generatedFrom} -> ${path}`); diff --git a/tests/fixtures/v2_vectors.json b/tests/fixtures/v2_vectors.json new file mode 100644 index 0000000..c835451 --- /dev/null +++ b/tests/fixtures/v2_vectors.json @@ -0,0 +1,185 @@ +{ + "generatedFrom": "@powerhousedao/shared@6.2.3-dev.24", + "signer": { + "user": { + "address": "0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4", + "networkId": "eip155", + "chainId": 1 + }, + "app": { + "name": "switchboard-cli", + "key": "did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU" + } + }, + "vectors": [ + { + "name": "simple", + "type": "SET_TITLE", + "scope": "global", + "id": "action-simple", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"title\":\"Hello\",\"updatedAt\":\"2026-09-25T10:00:00.000Z\"}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_TITLE\",\"action-simple\",\"2026-09-25T10:00:00.000Z\",{\"title\":\"Hello\",\"updatedAt\":\"2026-09-25T10:00:00.000Z\"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:8CmPfRq3fy5X27Wz8xGHmwTM1-MSp680B4YDjOlxM9A" + }, + { + "name": "unsorted_nested", + "type": "SET_METADATA", + "scope": "global", + "id": "action-unsorted_nested", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"zeta\":1,\"alpha\":{\"b\":2,\"a\":1},\"mid\":[{\"y\":1,\"x\":2}]}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_METADATA\",\"action-unsorted_nested\",\"2026-09-25T10:00:00.000Z\",{\"alpha\":{\"a\":1,\"b\":2},\"mid\":[{\"x\":2,\"y\":1}],\"zeta\":1},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:-FbaxHAEwMj47TgwPrcyuZK5PLqIsGlotL1xwAeVgK8" + }, + { + "name": "numbers", + "type": "SET_STATS", + "scope": "global", + "id": "action-numbers", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"int\":42,\"neg\":-7,\"frac\":0.1,\"integralFloat\":2.0,\"big\":1e21,\"small\":1e-7,\"zero\":0,\"negZero\":-0.0,\"huge\":1.5e300,\"half\":0.5,\"third\":0.3333333333333333}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_STATS\",\"action-numbers\",\"2026-09-25T10:00:00.000Z\",{\"big\":1e+21,\"frac\":0.1,\"half\":0.5,\"huge\":1.5e+300,\"int\":42,\"integralFloat\":2,\"neg\":-7,\"negZero\":0,\"small\":1e-7,\"third\":0.3333333333333333,\"zero\":0},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:h71DCd87XslgbuYh2phRbEW-sbj9sjkIJLeSxX7IVhw" + }, + { + "name": "number_boundaries", + "type": "SET_STATS", + "scope": "global", + "id": "action-number_boundaries", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"e20\":1e20,\"e21\":1e21,\"m6\":1e-6,\"m7\":1e-7,\"mid\":123.456,\"negFrac\":-2.5,\"negSmall\":-1e-7,\"pastSafe\":9007199254740993,\"hundred\":100,\"tiny\":1.23e-5,\"negBig\":-4.5e22}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_STATS\",\"action-number_boundaries\",\"2026-09-25T10:00:00.000Z\",{\"e20\":100000000000000000000,\"e21\":1e+21,\"hundred\":100,\"m6\":0.000001,\"m7\":1e-7,\"mid\":123.456,\"negBig\":-4.5e+22,\"negFrac\":-2.5,\"negSmall\":-1e-7,\"pastSafe\":9007199254740992,\"tiny\":0.0000123},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:XyK6rRKbTv9J4GVMYMmTNU6DzdxDMrXP_bfjaknk2vk" + }, + { + "name": "unicode", + "type": "SET_TITLE", + "scope": "global", + "id": "action-unicode", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"title\":\"em—dash ☃ 😀 中文 é\"}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_TITLE\",\"action-unicode\",\"2026-09-25T10:00:00.000Z\",{\"title\":\"em—dash ☃ 😀 中文 é\"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:4oxEIN_VxoLwDt2mb0VmMmFs582XcxL7YwPNFOkaIj0" + }, + { + "name": "escapes", + "type": "SET_CONTENT", + "scope": "global", + "id": "action-escapes", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"content\":\"quote \\\" backslash \\\\ newline \\n tab \\t ctrl \\u0001 slash / del \\u007f\"}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_CONTENT\",\"action-escapes\",\"2026-09-25T10:00:00.000Z\",{\"content\":\"quote \\\" backslash \\\\ newline \\n tab \\t ctrl \\u0001 slash / del \"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:mPUlHRFe6FQtIbSyBUehiQ7pAt_xMSXMTpnHwl0kTZY" + }, + { + "name": "literals", + "type": "SET_FLAGS", + "scope": "global", + "id": "action-literals", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"n\":null,\"t\":true,\"f\":false,\"emptyArr\":[],\"emptyObj\":{}}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_FLAGS\",\"action-literals\",\"2026-09-25T10:00:00.000Z\",{\"emptyArr\":[],\"emptyObj\":{},\"f\":false,\"n\":null,\"t\":true},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:a8IiYi0pVM_NTEPz22hAs9pRq15oHtfZIMf4lOjZiQo" + }, + { + "name": "empty_input", + "type": "NOOP_LIKE", + "scope": "global", + "id": "action-empty_input", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"NOOP_LIKE\",\"action-empty_input\",\"2026-09-25T10:00:00.000Z\",{},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:ZcnWGd-jq6-QKM3uK4w-5HSUhHsEXt_Penu7NxPTXL4" + }, + { + "name": "unicode_keys", + "type": "SET_METADATA", + "scope": "global", + "id": "action-unicode_keys", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"!\":1,\"😀\":2,\"z\":3,\"é\":4,\"a\":5,\"中\":6}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"global\",\"SET_METADATA\",\"action-unicode_keys\",\"2026-09-25T10:00:00.000Z\",{\"a\":5,\"z\":3,\"é\":4,\"中\":6,\"😀\":2,\"!\":1},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:IRb2l_O3iIkn9Xnr3SGqJsv5GM1NU9mp67Zp2pzlIm4" + }, + { + "name": "relationship", + "type": "ADD_RELATIONSHIP", + "scope": "document", + "id": "action-relationship", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"sourceId\":\"SRC-DOC-ID\",\"targetId\":\"TGT-DOC-ID\",\"relationshipType\":\"BUILDS_ON\",\"metadata\":{\"reason\":\"why\",\"confidence\":\"grounded\"}}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "SRC-DOC-ID", + "expectedPreimage": "[\"v2\",\"SRC-DOC-ID\",\"main\",\"document\",\"ADD_RELATIONSHIP\",\"action-relationship\",\"2026-09-25T10:00:00.000Z\",{\"metadata\":{\"confidence\":\"grounded\",\"reason\":\"why\"},\"relationshipType\":\"BUILDS_ON\",\"sourceId\":\"SRC-DOC-ID\",\"targetId\":\"TGT-DOC-ID\"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:XElp0-NS8OfHdHlaot_qFao9k9NFtm6tt_ahl34lKKM" + }, + { + "name": "relationship_no_source", + "type": "REMOVE_RELATIONSHIP", + "scope": "document", + "id": "action-relationship_no_source", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"targetId\":\"TGT-DOC-ID\",\"relationshipType\":\"BUILDS_ON\"}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"document\",\"REMOVE_RELATIONSHIP\",\"action-relationship_no_source\",\"2026-09-25T10:00:00.000Z\",{\"relationshipType\":\"BUILDS_ON\",\"targetId\":\"TGT-DOC-ID\"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:qSdx808fUt0b0kLP3TG9ZprjL_z0TRZHKM9U_Kz1K-o" + }, + { + "name": "create_document", + "type": "CREATE_DOCUMENT", + "scope": "document", + "id": "action-create_document", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{\"documentId\":\"NEW-DOC-ID\",\"model\":\"bai/knowledge-note\"}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "NEW-DOC-ID", + "expectedPreimage": "[\"v2\",\"NEW-DOC-ID\",\"main\",\"document\",\"CREATE_DOCUMENT\",\"action-create_document\",\"2026-09-25T10:00:00.000Z\",{\"documentId\":\"NEW-DOC-ID\",\"model\":\"bai/knowledge-note\"},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:xxg8JqWD6VVtDjCm6RmXnjR2oCSgvti7UXhdckfsg7M" + }, + { + "name": "delete_document_no_id", + "type": "DELETE_DOCUMENT", + "scope": "document", + "id": "action-delete_document_no_id", + "timestampUtcMs": "2026-09-25T10:00:00.000Z", + "inputText": "{}", + "jobDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "branch": "main", + "expectedTargetDocumentId": "1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI", + "expectedPreimage": "[\"v2\",\"1LGfE6gA0RDQ2urs7l9BxMXpCcpVN8irrcZlLddqCkI\",\"main\",\"document\",\"DELETE_DOCUMENT\",\"action-delete_document_no_id\",\"2026-09-25T10:00:00.000Z\",{},\"0xadbA7C2F82139031D7564D18aC22D09B12A0BcA4\",\"eip155\",1,\"did:key:zDnaevybtcoaSAYQtPqgiiJFxdJUx1m9ErTeD8a7qwGxxBSxU\"]", + "expectedHash": "v2:V_yjTnUQJMjmzOYKgbmOcb0sSlZ7CqcXAH5BO0jQAwA" + } + ] +} From b2ec924e5967f0089a03b90025c4ed582205285d Mon Sep 17 00:00:00 2001 From: liberuum Date: Fri, 25 Sep 2026 12:07:00 +0100 Subject: [PATCH 2/4] feat(drives): show `drives get` as a tree with one line per document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default view nested a table under every folder, two lines per document plus borders, so a large drive ran to hundreds of lines and came apart when a row wrapped. It is now a plain folder/document tree, `📄 name type id`, with the type and id dimmed and the id kept whole and last. My Personal Vault goes from 701 lines to 349. Columns are not padded: document names run to ~100 characters, and aligning on them would make a folder wider than the table this replaces. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cli/drives.rs | 128 +++++++++++++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 46 deletions(-) diff --git a/src/cli/drives.rs b/src/cli/drives.rs index 2348ecf..02bab17 100644 --- a/src/cli/drives.rs +++ b/src/cli/drives.rs @@ -4,8 +4,6 @@ use colored::Colorize; use dialoguer::{Confirm, Input}; use serde_json::Value; -use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL}; - use crate::cli::helpers; use crate::output::{self, OutputFormat, print_json, print_table}; @@ -242,7 +240,7 @@ async fn get( if files > 0 || folders > 0 { println!(); - print_drive_tree(nodes, None, ""); + print_drive_tree(nodes); } } } @@ -467,10 +465,20 @@ async fn find_drive_uuid_by_name( .map(String::from) } -/// Print drive contents as a hybrid tree (folders) + table (documents) view. -/// Folders are rendered with tree connectors; documents inside each folder are -/// displayed as a formatted table indented under the folder. -fn print_drive_tree(nodes: &[Value], parent: Option<&str>, indent: &str) { +/// Print drive contents as a folder/document tree, one line per node. +fn print_drive_tree(nodes: &[Value]) { + for line in drive_tree_lines(nodes, None, "") { + println!("{line}"); + } +} + +/// The tree under `parent`: folders as `📁 name/`, documents as +/// `📄 name type id`, one line each. Type and id are dimmed so the name +/// reads first, and are not padded into columns — document names run to +/// ~100 characters, so aligning them would make a folder wider than the table +/// this replaces. The id is kept whole and last, where it is easy to copy. +/// Within a folder, documents come before sub-folders. +fn drive_tree_lines(nodes: &[Value], parent: Option<&str>, indent: &str) -> Vec { let children: Vec<&Value> = nodes .iter() .filter(|n| { @@ -481,54 +489,49 @@ fn print_drive_tree(nodes: &[Value], parent: Option<&str>, indent: &str) { } }) .collect(); - - let folders: Vec<&Value> = children + let files = children .iter() - .filter(|n| n["kind"].as_str() == Some("folder")) - .copied() - .collect(); - - let files: Vec<&Value> = children + .filter(|n| n["kind"].as_str() == Some("file")); + let folders = children .iter() - .filter(|n| n["kind"].as_str() == Some("file")) - .copied() - .collect(); - - // Render documents as an indented table - if !files.is_empty() { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Disabled); - table.set_header(["ID", "Name", "Type"]); - for f in &files { - table.add_row(vec![ - f["id"].as_str().unwrap_or("-"), - f["name"].as_str().unwrap_or("-"), - f["documentType"].as_str().unwrap_or("-"), - ]); - } - for line in table.to_string().lines() { - println!("{indent}{line}"); - } - } + .filter(|n| n["kind"].as_str() == Some("folder")); + let ordered: Vec<&Value> = files.chain(folders).copied().collect(); - // Render sub-folders as tree entries - for (i, folder) in folders.iter().enumerate() { - let is_last = i == folders.len() - 1; + let mut lines = Vec::new(); + for (i, node) in ordered.iter().enumerate() { + let is_last = i + 1 == ordered.len(); let connector = if is_last { "\u{2514}\u{2500}\u{2500} " } else { "\u{251c}\u{2500}\u{2500} " }; - let child_indent = if is_last { " " } else { "\u{2502} " }; - - let name = folder["name"].as_str().unwrap_or("-"); - let id = folder["id"].as_str().unwrap_or(""); - - println!("{indent}{connector}\u{1f4c1} {name}/"); - print_drive_tree(nodes, Some(id), &format!("{indent}{child_indent}")); + let name = node["name"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or("-"); + if node["kind"].as_str() == Some("folder") { + lines.push(format!("{indent}{connector}\u{1f4c1} {name}/")); + // A folder with no id would match the root's children and recurse + // forever; list the folder, not its (unaddressable) contents. + if let Some(id) = node["id"].as_str().filter(|s| !s.is_empty()) { + let child_indent = if is_last { " " } else { "\u{2502} " }; + lines.extend(drive_tree_lines( + nodes, + Some(id), + &format!("{indent}{child_indent}"), + )); + } + } else { + let doc_type = node["documentType"].as_str().unwrap_or("-"); + let id = node["id"].as_str().unwrap_or("-"); + lines.push(format!( + "{indent}{connector}\u{1f4c4} {name} {} {}", + doc_type.dimmed(), + id.dimmed() + )); + } } + lines } async fn check(id: &str, format: OutputFormat, profile_name: Option<&str>) -> Result<()> { @@ -672,3 +675,36 @@ async fn fix( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drive_tree_is_one_line_per_node_with_name_type_and_id() { + colored::control::set_override(false); + let nodes = serde_json::json!([ + { "id": "f1", "kind": "file", "name": "readme", "documentType": "bai/note", "parentFolder": null }, + { "id": "k", "kind": "folder", "name": "knowledge", "parentFolder": "" }, + { "id": "f2", "kind": "file", "name": "m6", "documentType": "bai/moc", "parentFolder": "k" }, + { "id": "n", "kind": "folder", "name": "notes", "parentFolder": "k" }, + { "id": "f3", "kind": "file", "name": "", "documentType": "bai/moc", "parentFolder": "k" }, + { "id": "f4", "kind": "file", "name": "a long note name", "documentType": "bai/knowledge-note", "parentFolder": "n" }, + // No id: listed, never recursed into (it would match the root). + { "id": "", "kind": "folder", "name": "ghost", "parentFolder": null } + ]); + let lines = drive_tree_lines(nodes.as_array().unwrap(), None, ""); + assert_eq!( + lines, + [ + "├── 📄 readme bai/note f1", + "├── 📁 knowledge/", + "│ ├── 📄 m6 bai/moc f2", + "│ ├── 📄 - bai/moc f3", + "│ └── 📁 notes/", + "│ └── 📄 a long note name bai/knowledge-note f4", + "└── 📁 ghost/", + ] + ); + } +} From b65770ee9e97648ae498b240fb39c2e0a0798b61 Mon Sep 17 00:00:00 2001 From: liberuum Date: Fri, 25 Sep 2026 12:07:00 +0100 Subject: [PATCH 3/4] chore: sync Cargo.lock to 1.0.37 The release job bumps Cargo.toml but not Cargo.lock, which still named 1.0.36. Nothing builds with --locked, so this never failed a build. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 2fdfd6e..89ac7ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "switchboard-cli" -version = "1.0.36" +version = "1.0.37" dependencies = [ "anyhow", "base64", From 186798d0133fd13f8699c01665ccfbd590128a88 Mon Sep 17 00:00:00 2001 From: liberuum Date: Fri, 25 Sep 2026 12:19:30 +0100 Subject: [PATCH 4/4] fix(release): bump Cargo.lock along with Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release job rewrote the version in Cargo.toml and committed only that, so the committed lock trailed by a release (1.0.36 against 1.0.37). It now bumps the crate's own entry in Cargo.lock too — only the line after `name = "switchboard-cli"`, since the lock holds a `version =` line for every one of its 358 packages — and commits both files. A plain sed, so the release job still needs no Rust toolchain or network. Checked with `cargo metadata --locked`: it accepts the lock this produces, and refuses the one the old step left behind. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f25641..9757917 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,10 +223,15 @@ jobs: files: | artifacts/* - - name: Sync Cargo.toml version + - name: Sync Cargo.toml and Cargo.lock version run: | - sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' Cargo.toml + VERSION="${{ needs.version.outputs.version }}" + sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + # Cargo.lock records the crate's own version as well, and every + # dependency's: bump only the line after `name = "switchboard-cli"`. + # Without this the committed lock trails Cargo.toml by one release. + sed -i '/^name = "switchboard-cli"$/{n;s/^version = ".*"/version = "'"${VERSION}"'"/}' Cargo.lock git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add Cargo.toml + git add Cargo.toml Cargo.lock git diff --cached --quiet || (git commit -m "chore: release v${{ needs.version.outputs.version }}" && git push)