cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 5e41f8a06adcf359a88b21b9c5c8bf4a7e7c2745
parent 1c640fead823200aa26d57707eddbe504d1c961d
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 05:39:34 +0000

listing: trust radrootsd publish receipts

- derive daemon listing identity from returned event addrs
- classify bridge auth and signer failures distinctly
- map provider and operation bridge errors precisely
- cover daemon receipt and bridge error output

Diffstat:
Msrc/operation_adapter.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/listing.rs | 41++++++++++++++++++++++++++++++++++++++---
Mtests/target_cli.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 279 insertions(+), 26 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -541,6 +541,27 @@ impl OperationAdapterError { message, } } + RuntimeError::Network(_) if looks_like_auth_failure(&lowered) => { + auth_runtime_failure(operation_id, message, &lowered) + } + RuntimeError::Network(_) if looks_like_signer_failure(&lowered) => { + Self::SignerUnavailable { + operation_id: operation_id.to_owned(), + message, + } + } + RuntimeError::Network(_) if looks_like_provider_failure(&lowered) => { + Self::ProviderUnavailable { + operation_id: operation_id.to_owned(), + message, + } + } + RuntimeError::Network(_) if looks_like_operation_failure(&lowered) => { + Self::OperationUnavailable { + operation_id: operation_id.to_owned(), + message, + } + } RuntimeError::Network(_) => Self::NetworkUnavailable { operation_id: operation_id.to_owned(), message, @@ -768,6 +789,39 @@ fn account_runtime_failure( } } +fn auth_runtime_failure( + operation_id: &str, + message: String, + lowered: &str, +) -> OperationAdapterError { + let unauthorized = contains_any( + lowered, + &[ + "unauthorized", + "forbidden", + "permission denied", + "invalid token", + "bearer token rejected", + "http 401", + "http 403", + "status 401", + "status 403", + ], + ); + OperationAdapterError::DetailedFailure { + operation_id: operation_id.to_owned(), + code: if unauthorized { + "auth_unauthorized".to_owned() + } else { + "auth_unavailable".to_owned() + }, + class: "auth".to_owned(), + message, + exit_code: CliExitCode::AuthorizationFailed, + detail_json: Value::Null.to_string(), + } +} + fn classify_runtime_failure( operation_id: &str, message: String, @@ -860,6 +914,75 @@ fn contains_any(value: &str, needles: &[&str]) -> bool { needles.iter().any(|needle| value.contains(needle)) } +fn looks_like_auth_failure(value: &str) -> bool { + contains_any( + value, + &[ + "authentication", + "bridge auth", + "authorization", + "authorize", + "unauthorized", + "forbidden", + "bearer token", + "invalid token", + "permission denied", + "status 401", + "status 403", + "http 401", + "http 403", + ], + ) +} + +fn looks_like_signer_failure(value: &str) -> bool { + contains_any( + value, + &[ + "signer", + "sign_event", + "sign event", + "signer_session_id", + "signer session", + "nip46", + "nip-46", + "remote_nip46", + ], + ) +} + +fn looks_like_provider_failure(value: &str) -> bool { + contains_any( + value, + &[ + "provider unavailable", + "provider unconfigured", + "provider runtime", + "provider failed", + "radrootsd unavailable", + "daemon unavailable", + "bridge provider", + ], + ) +} + +fn looks_like_operation_failure(value: &str) -> bool { + contains_any( + value, + &[ + "method not found", + "unknown method", + "unsupported method", + "unsupported operation", + "operation unavailable", + "operation disabled", + "bridge disabled", + "bridge is disabled", + "bridge.listing.publish is disabled", + ], + ) +} + fn looks_like_not_found(value: &str) -> bool { contains_any( value, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1194,10 +1194,24 @@ fn radrootsd_mutation_view( if let Some(event_id) = event_id.as_ref() { event.event_id = Some(event_id.clone()); } + let daemon_identity = radrootsd + .event_addr + .as_deref() + .and_then(daemon_listing_identity); let event_addr = radrootsd .event_addr .clone() .unwrap_or_else(|| listing_addr.clone()); + event.event_addr = event_addr.clone(); + let listing_id = daemon_identity + .as_ref() + .map(|identity| identity.listing_id.clone()) + .unwrap_or_else(|| canonical.listing_id.clone()); + let seller_pubkey = daemon_identity + .as_ref() + .map(|identity| identity.seller_pubkey.clone()) + .unwrap_or_else(|| canonical.seller_pubkey.clone()); + event.author = seller_pubkey.clone(); let job_status = radrootsd.status.clone(); let state = match operation { ListingMutationOperation::Archive => "archived", @@ -1211,9 +1225,9 @@ fn radrootsd_mutation_view( operation: operation.as_str().to_owned(), source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), + listing_id, + listing_addr: event_addr.clone(), + seller_pubkey, event_kind: event_kind.unwrap_or(KIND_LISTING), dry_run: false, deduplicated: radrootsd.deduplicated, @@ -1236,6 +1250,27 @@ fn radrootsd_mutation_view( }) } +#[derive(Debug, Clone)] +struct DaemonListingIdentity { + seller_pubkey: String, + listing_id: String, +} + +fn daemon_listing_identity(event_addr: &str) -> Option<DaemonListingIdentity> { + let (kind, rest) = event_addr.split_once(':')?; + if kind.parse::<u32>().ok()? != KIND_LISTING { + return None; + } + let (seller_pubkey, listing_id) = rest.split_once(':')?; + if seller_pubkey.trim().is_empty() || listing_id.trim().is_empty() || listing_id.contains(':') { + return None; + } + Some(DaemonListingIdentity { + seller_pubkey: seller_pubkey.to_owned(), + listing_id: listing_id.to_owned(), + }) +} + fn radrootsd_job_view( args: &ListingMutationArgs, requested_session_id: &str, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -35,6 +35,41 @@ struct OneShotJsonRpcServer { impl OneShotJsonRpcServer { fn listing_publish() -> Self { + Self::listing_publish_response(json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job_listing_publish_test", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46", + "signer_session_id": "session_test", + "event_kind": 30402, + "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "event_addr": "30402:daemon_test:radrootsd-router", + "relay_count": 2, + "acknowledged_relay_count": 1 + } + } + })) + } + + fn listing_publish_error(message: &str) -> Self { + Self::listing_publish_response(json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "error": { + "code": -32000, + "message": message + } + })) + } + + fn listing_publish_response(response: Value) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake radrootsd"); let endpoint = format!( "http://{}/jsonrpc", @@ -45,28 +80,7 @@ impl OneShotJsonRpcServer { let (mut stream, _) = listener.accept().expect("accept fake radrootsd request"); let request = read_jsonrpc_request(&mut stream); tx.send(request).expect("send fake radrootsd request"); - let response = json!({ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job_listing_publish_test", - "command": "bridge.listing.publish", - "status": "published", - "terminal": true, - "recovered_after_restart": false, - "signer_mode": "nip46", - "signer_session_id": "session_test", - "event_kind": 30402, - "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "event_addr": "30402:daemon_test:radrootsd-router", - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }) - .to_string(); + let response = response.to_string(); write!( stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", @@ -531,6 +545,12 @@ signer_session_ref = "session_test" value["result"]["event_addr"], "30402:daemon_test:radrootsd-router" ); + assert_eq!( + value["result"]["listing_addr"], + "30402:daemon_test:radrootsd-router" + ); + assert_eq!(value["result"]["listing_id"], "radrootsd-router"); + assert_eq!(value["result"]["seller_pubkey"], "daemon_test"); assert_eq!(value["result"]["signer_mode"], "nip46"); assert_eq!(value["result"]["signer_session_id"], "session_test"); assert_eq!( @@ -671,7 +691,12 @@ signer_session_ref = "session_test" value["result"]["source"], "radrootsd publish transport ยท signer session" ); - assert_eq!(value["result"]["seller_pubkey"], seller.public_key_hex); + assert_eq!( + value["result"]["listing_addr"], + "30402:daemon_test:radrootsd-router" + ); + assert_eq!(value["result"]["listing_id"], "radrootsd-router"); + assert_eq!(value["result"]["seller_pubkey"], "daemon_test"); assert_eq!(request.body["method"], "bridge.listing.publish"); assert_eq!(request.body["params"]["signer_session_id"], "session_test"); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); @@ -679,6 +704,76 @@ signer_session_ref = "session_test" } #[test] +fn radrootsd_listing_publish_bridge_errors_are_classified() { + for (message, code, class) in [ + ( + "unauthorized bridge bearer token", + "auth_unauthorized", + "auth", + ), + ("signer session unavailable", "signer_unavailable", "signer"), + ( + "provider runtime unavailable", + "provider_unavailable", + "provider", + ), + ( + "bridge.listing.publish is disabled", + "operation_unavailable", + "operation", + ), + ] { + let sandbox = RadrootsCliSandbox::new(); + let seller = identity_public(44); + let listing_file = + create_listing_draft(&sandbox, format!("radrootsd-bridge-error-{class}").as_str()); + make_listing_publishable_with_seller( + &listing_file, + "AAAAAAAAAAAAAAAAAAAAAw", + seller.public_key_hex.as_str(), + ); + sandbox.write_app_config( + r#"[publish] +mode = "radrootsd" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "http://myc.invalid" +signer_session_ref = "session_test" +"#, + ); + let server = OneShotJsonRpcServer::listing_publish_error(message); + + let mut command = sandbox.command(); + command + .env("RADROOTS_RPC_URL", &server.endpoint) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") + .args([ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + let output = command.output().expect("run radrootsd listing publish"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + let request = server.take_request(); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], code); + assert_eq!(value["errors"][0]["detail"]["class"], class); + assert_contains(&value["errors"][0]["message"], message); + assert_eq!(request.body["method"], "bridge.listing.publish"); + } +} + +#[test] fn radrootsd_listing_publish_bypasses_relay_signer_preflight() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);