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:
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"]);