cli

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

commit e42b6344a3c97ad363bb49718e807e6d01736317
parent 2ccd369dd5073e1e11f46faa78378e8c08eee067
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 21:00:59 +0000

signer: carry myc authority into daemon writes

Diffstat:
Msrc/runtime/daemon.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/listing.rs | 32++++++++++++++++++--------------
Msrc/runtime/order.rs | 14++++++++------
Msrc/runtime/signer.rs | 27+++++++++++++++++++++++----
Mtests/listing.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/order.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++-------
6 files changed, 355 insertions(+), 41 deletions(-)

diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -13,6 +13,7 @@ use crate::domain::runtime::{ }; use crate::runtime::config::RuntimeConfig; use crate::runtime::provider; +use crate::runtime::signer::ActorWriteSignerAuthority; const RPC_SOURCE: &str = "daemon rpc · durable write plane"; const BRIDGE_SOURCE: &str = "daemon bridge · durable write plane"; @@ -126,6 +127,8 @@ struct Nip46SessionRemote { auth_required: bool, authorized: bool, expires_in_secs: Option<u64>, + #[serde(default)] + signer_authority: Option<ActorWriteSignerAuthority>, } #[derive(Debug, Clone, Deserialize)] @@ -386,6 +389,7 @@ pub fn bridge_listing_publish( kind: u32, idempotency_key: Option<&str>, signer_session_id: Option<&str>, + signer_authority: Option<&ActorWriteSignerAuthority>, ) -> Result<BridgeListingPublishResult, DaemonRpcError> { let target = actor_write_target(config)?; let response: BridgePublishResponseRemote = call( @@ -396,6 +400,7 @@ pub fn bridge_listing_publish( "kind": kind, "idempotency_key": idempotency_key, "signer_session_id": signer_session_id, + "signer_authority": signer_authority, })), RpcAuthMode::BridgeBearer, )?; @@ -417,6 +422,7 @@ pub fn bridge_order_request( order: &RadrootsTradeOrder, idempotency_key: Option<&str>, signer_session_id: Option<&str>, + signer_authority: Option<&ActorWriteSignerAuthority>, ) -> Result<BridgeOrderRequestResult, DaemonRpcError> { let target = actor_write_target(config)?; let response: BridgePublishResponseRemote = call( @@ -426,6 +432,7 @@ pub fn bridge_order_request( "order": order, "idempotency_key": idempotency_key, "signer_session_id": signer_session_id, + "signer_authority": signer_authority, })), RpcAuthMode::BridgeBearer, )?; @@ -503,6 +510,7 @@ pub fn resolve_signer_session_id( actor_pubkey: &str, event_kind: u32, requested_session_id: Option<&str>, + signer_authority: Option<&ActorWriteSignerAuthority>, ) -> Result<String, DaemonRpcError> { let target = actor_write_target(config)?; let sessions = nip46_sessions_with_target(&target)?; @@ -516,13 +524,21 @@ pub fn resolve_signer_session_id( "requested signer session `{session_id}` was not found" ))); }; - validate_signer_session(&session, actor_role, actor_pubkey, event_kind)?; + validate_signer_session( + &session, + actor_role, + actor_pubkey, + event_kind, + signer_authority, + )?; return Ok(session.session_id); } let mut matches = sessions .into_iter() - .filter(|session| session_matches_actor(session, actor_pubkey, event_kind)) + .filter(|session| { + session_matches_actor(session, actor_pubkey, event_kind, signer_authority) + }) .map(|session| session.session_id) .collect::<Vec<_>>(); @@ -542,6 +558,7 @@ fn validate_signer_session( actor_role: &str, actor_pubkey: &str, event_kind: u32, + signer_authority: Option<&ActorWriteSignerAuthority>, ) -> Result<(), DaemonRpcError> { if !session.authorized { return Err(DaemonRpcError::Unconfigured(format!( @@ -561,6 +578,7 @@ fn validate_signer_session( session.session_id ))); } + validate_signer_authority(session, signer_authority)?; Ok(()) } @@ -568,10 +586,61 @@ fn session_matches_actor( session: &Nip46SessionRemote, actor_pubkey: &str, event_kind: u32, + signer_authority: Option<&ActorWriteSignerAuthority>, ) -> bool { session.authorized && session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) && sign_event_allowed(&session.permissions, event_kind) + && signer_authority_matches(session, signer_authority) +} + +fn validate_signer_authority( + session: &Nip46SessionRemote, + signer_authority: Option<&ActorWriteSignerAuthority>, +) -> Result<(), DaemonRpcError> { + let Some(expected) = signer_authority else { + return Ok(()); + }; + let Some(actual) = session.signer_authority.as_ref() else { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` is missing signer authority continuity metadata", + session.session_id + ))); + }; + if actual.provider_runtime_id != expected.provider_runtime_id { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` provider `{}` does not match required provider `{}`", + session.session_id, actual.provider_runtime_id, expected.provider_runtime_id + ))); + } + if actual.account_identity_id != expected.account_identity_id { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` account identity `{}` does not match required account `{}`", + session.session_id, actual.account_identity_id, expected.account_identity_id + ))); + } + if actual.provider_signer_session_id != expected.provider_signer_session_id { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` provider signer session `{}` does not match required provider session `{}`", + session.session_id, + actual + .provider_signer_session_id + .as_deref() + .unwrap_or("<none>"), + expected + .provider_signer_session_id + .as_deref() + .unwrap_or("<none>") + ))); + } + Ok(()) +} + +fn signer_authority_matches( + session: &Nip46SessionRemote, + signer_authority: Option<&ActorWriteSignerAuthority>, +) -> bool { + validate_signer_authority(session, signer_authority).is_ok() } fn sign_event_allowed(perms: &[String], kind: u32) -> bool { diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -33,7 +33,7 @@ use crate::runtime::accounts; use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon; use crate::runtime::daemon::DaemonRpcError; -use crate::runtime::signer::{ActorWriteBindingError, validate_actor_write_binding}; +use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; const DRAFT_KIND: &str = "listing_draft_v1"; @@ -544,19 +544,21 @@ fn mutate( }); } - if let Err(error) = - validate_actor_write_binding(config, "seller", canonical.seller_pubkey.as_str()) - { - return Ok(binding_error_view( - config, - args, - operation, - &canonical, - listing_addr, - event_preview, - error, - )); - } + let signer_authority = + match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { + Ok(authority) => authority, + Err(error) => { + return Ok(binding_error_view( + config, + args, + operation, + &canonical, + listing_addr, + event_preview, + error, + )); + } + }; let signer_session_id = match daemon::resolve_signer_session_id( config, @@ -564,6 +566,7 @@ fn mutate( canonical.seller_pubkey.as_str(), KIND_LISTING, args.signer_session_id.as_deref(), + signer_authority.as_ref(), ) { Ok(session_id) => session_id, Err(error) => { @@ -585,6 +588,7 @@ fn mutate( KIND_LISTING, args.idempotency_key.as_deref(), Some(signer_session_id.as_str()), + signer_authority.as_ref(), ) { Ok(result) => { let failed = result.status == "failed"; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -22,7 +22,7 @@ use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon::{self, DaemonRpcError}; -use crate::runtime::signer::{ActorWriteBindingError, validate_actor_write_binding}; +use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; @@ -419,11 +419,11 @@ pub fn submit( }); } - if let Err(error) = - validate_actor_write_binding(config, "buyer", loaded.document.order.buyer_pubkey.as_str()) - { - return Ok(order_binding_error_view(config, &loaded, args, error)); - } + let signer_authority = + match resolve_actor_write_authority(config, "buyer", loaded.document.order.buyer_pubkey.as_str()) { + Ok(authority) => authority, + Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)), + }; let signer_session_id = match daemon::resolve_signer_session_id( config, @@ -431,6 +431,7 @@ pub fn submit( loaded.document.order.buyer_pubkey.as_str(), u32::from(RadrootsTradeMessageType::OrderRequest.kind()), args.signer_session_id.as_deref(), + signer_authority.as_ref(), ) { Ok(session_id) => session_id, Err(error) => return Ok(order_submit_error_view(&loaded, args, error)), @@ -442,6 +443,7 @@ pub fn submit( &order, args.idempotency_key.as_deref(), Some(signer_session_id.as_str()), + signer_authority.as_ref(), ) { Ok(result) => { let mut updated = loaded.document.clone(); diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -12,6 +12,7 @@ use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, }; +use serde::{Deserialize, Serialize}; const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; @@ -29,6 +30,14 @@ pub enum ActorWriteBindingError { Unavailable(String), } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActorWriteSignerAuthority { + pub provider_runtime_id: String, + pub account_identity_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_signer_session_id: Option<String>, +} + pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { match config.signer.backend { SignerBackend::Local => resolve_local_signer_status(config), @@ -36,13 +45,13 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { } } -pub fn validate_actor_write_binding( +pub fn resolve_actor_write_authority( config: &RuntimeConfig, actor_role: &str, actor_pubkey: &str, -) -> Result<(), ActorWriteBindingError> { +) -> Result<Option<ActorWriteSignerAuthority>, ActorWriteBindingError> { if !matches!(config.signer.backend, SignerBackend::Myc) { - return Ok(()); + return Ok(None); } let myc = crate::runtime::myc::resolve_status(&config.myc); @@ -77,7 +86,17 @@ pub fn validate_actor_write_binding( ))); } - Ok(()) + let Some(resolved_account_id) = resolution.resolved_account_id else { + return Err(ActorWriteBindingError::Unconfigured( + "myc signer binding reported ready without a resolved account identity".to_owned(), + )); + }; + + Ok(Some(ActorWriteSignerAuthority { + provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), + account_identity_id: resolved_account_id, + provider_signer_session_id: resolution.view.resolved_signer_session_id.clone(), + })) } fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { diff --git a/tests/listing.rs b/tests/listing.rs @@ -355,6 +355,10 @@ fn listing_publish_and_update_use_durable_bridge_publish() { assert!(account_output.status.success()); let account_json: Value = serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); let seller_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() .expect("seller pubkey") @@ -393,11 +397,13 @@ fn listing_publish_and_update_use_durable_bridge_publish() { match body["method"].as_str().unwrap_or_default() { "nip46.session.list" => { assert_eq!(auth_header, None); - MockRpcResponse::success(json!([sample_session( + MockRpcResponse::success(json!([sample_session_with_authority( "sess_publish_01", seller_pubkey.as_str(), &["sign_event"], - true + true, + Some(account_id.as_str()), + Some("conn_listing_binding_01") )])) } "bridge.listing.publish" => { @@ -653,7 +659,10 @@ fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { assert!(account_output.status.success()); let account_json: Value = serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"].as_str().expect("account id"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); let public_identity = account_json["public_identity"].clone(); let seller_pubkey = public_identity["public_key_hex"] .as_str() @@ -693,7 +702,11 @@ fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { let myc = write_fake_myc( dir.path(), successful_status_script( - sample_myc_status_payload(account_id, &public_identity, "conn_listing_binding_01") + sample_myc_status_payload( + account_id.as_str(), + &public_identity, + "conn_listing_binding_01", + ) .to_string(), ) .as_str(), @@ -701,16 +714,19 @@ fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { let requests = Arc::new(Mutex::new(Vec::<Value>::new())); let recorded = Arc::clone(&requests); + let session_account_id = account_id.clone(); let server = MockRpcServer::start(move |body, auth_header| { recorded.lock().expect("recorded").push(body.clone()); match body["method"].as_str().unwrap_or_default() { "nip46.session.list" => { assert_eq!(auth_header, None); - MockRpcResponse::success(json!([sample_session( + MockRpcResponse::success(json!([sample_session_with_authority( "sess_publish_01", seller_pubkey.as_str(), &["sign_event"], - true + true, + Some(session_account_id.as_str()), + Some("conn_listing_binding_01"), )])) } "bridge.listing.publish" => { @@ -763,7 +779,12 @@ managed_account_ref = "{account_id}" .output() .expect("run listing publish"); - assert!(output.status.success()); + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(output.stdout.as_slice()), + String::from_utf8_lossy(output.stderr.as_slice()) + ); let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); assert_eq!(publish_json["state"], "published"); assert_eq!(publish_json["signer_mode"], "nip46_session"); @@ -778,6 +799,18 @@ managed_account_ref = "{account_id}" recorded[1]["params"]["signer_session_id"], "sess_publish_01" ); + assert_eq!( + recorded[1]["params"]["signer_authority"]["provider_runtime_id"], + "myc" + ); + assert_eq!( + recorded[1]["params"]["signer_authority"]["account_identity_id"], + account_id + ); + assert_eq!( + recorded[1]["params"]["signer_authority"]["provider_signer_session_id"], + "conn_listing_binding_01" + ); } #[test] @@ -908,6 +941,136 @@ managed_account_ref = "{mismatch_account_id}" } #[test] +fn listing_publish_rejects_daemon_session_with_mismatched_myc_authority() { + let _guard = listing_test_guard(); + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let account_output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); + let public_identity = account_json["public_identity"].clone(); + let seller_pubkey = public_identity["public_key_hex"] + .as_str() + .expect("seller pubkey") + .to_owned(); + seed_farm( + dir.path(), + seller_pubkey.as_str(), + "AAAAAAAAAAAAAAAAAAAAAw", + "La Huerta", + ); + + let draft_path = dir.path().join("mismatched-authority.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "", + "", + "eggs", + "Pasture eggs", + "Protein", + "Fresh pasture-raised eggs collected daily.", + "12", + "each", + "4.50", + "USD", + "1", + "each", + "18", + "pickup", + "La Huerta del Sur", + ), + ) + .expect("write listing draft"); + + let myc = write_fake_myc( + dir.path(), + successful_status_script( + sample_myc_status_payload( + account_id.as_str(), + &public_identity, + "conn_listing_binding_03", + ) + .to_string(), + ) + .as_str(), + ); + + let requests = Arc::new(Mutex::new(Vec::<Value>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded.lock().expect("recorded").push(body.clone()); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session_with_authority( + "sess_mismatch_01", + seller_pubkey.as_str(), + &["sign_event:30402"], + true, + Some("acct_wrong"), + Some("conn_listing_binding_03"), + )])), + _ => MockRpcResponse::rpc_error(-32601, "unexpected rpc method"), + } + }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane( + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{account_id}" +"# + ) + .as_str(), + server.url().as_str(), + ) + .as_str(), + ); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") + .args([ + "--json", + "--signer", + "myc", + "--myc-executable", + myc.to_str().expect("myc path"), + "listing", + "publish", + draft_path.to_str().expect("draft path"), + ]) + .output() + .expect("run listing publish"); + + assert_eq!(output.status.code(), Some(3)); + let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(publish_json["state"], "unconfigured"); + assert!(publish_json["reason"].as_str().is_some()); + + let recorded = requests.lock().expect("requests"); + assert_eq!(recorded.len(), 1); + assert_eq!(recorded[0]["method"], "nip46.session.list"); +} + +#[test] fn listing_publish_without_matching_signer_session_exits_unconfigured() { let _guard = listing_test_guard(); let dir = tempdir().expect("tempdir"); @@ -1442,6 +1605,17 @@ fn sample_session( permissions: &[&str], authorized: bool, ) -> Value { + sample_session_with_authority(session_id, signer_pubkey, permissions, authorized, None, None) +} + +fn sample_session_with_authority( + session_id: &str, + signer_pubkey: &str, + permissions: &[&str], + authorized: bool, + account_identity_id: Option<&str>, + provider_signer_session_id: Option<&str>, +) -> Value { json!({ "session_id": session_id, "role": "remote_signer", @@ -1452,7 +1626,12 @@ fn sample_session( "permissions": permissions, "auth_required": false, "authorized": authorized, - "expires_in_secs": Value::Null + "expires_in_secs": Value::Null, + "signer_authority": account_identity_id.map(|account_identity_id| json!({ + "provider_runtime_id": "myc", + "account_identity_id": account_identity_id, + "provider_signer_session_id": provider_signer_session_id + })) }) } diff --git a/tests/order.rs b/tests/order.rs @@ -361,7 +361,10 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { assert!(account_output.status.success()); let account_json: Value = serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"].as_str().expect("account id"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); let buyer_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() .expect("buyer pubkey"); @@ -744,7 +747,10 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { assert!(account_output.status.success()); let account_json: Value = serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"].as_str().expect("account id"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); let public_identity = account_json["public_identity"].clone(); let buyer_pubkey = public_identity["public_key_hex"] .as_str() @@ -754,8 +760,12 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { let myc = write_fake_myc( dir.path(), successful_status_script( - sample_myc_status_payload(account_id, &public_identity, "conn_order_binding_01") - .to_string(), + sample_myc_status_payload( + account_id.as_str(), + &public_identity, + "conn_order_binding_01", + ) + .to_string(), ) .as_str(), ); @@ -782,6 +792,7 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); let recorded = Arc::clone(&requests); + let session_account_id = account_id.clone(); let server = MockRpcServer::start(move |body, auth_header| { recorded .lock() @@ -792,11 +803,13 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { auth_header, }); match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "nip46.session.list" => MockRpcResponse::success(json!([sample_session_with_authority( "sess_order_02", buyer_pubkey.as_str(), &["sign_event"], - true + true, + Some(session_account_id.as_str()), + Some("conn_order_binding_01") )])), "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ "deduplicated": false, @@ -858,6 +871,18 @@ managed_account_ref = "{account_id}" .find(|request| request.method == "bridge.order.request") .expect("bridge order request"); assert_eq!(request.body["params"]["signer_session_id"], "sess_order_02"); + assert_eq!( + request.body["params"]["signer_authority"]["provider_runtime_id"], + "myc" + ); + assert_eq!( + request.body["params"]["signer_authority"]["account_identity_id"], + account_id + ); + assert_eq!( + request.body["params"]["signer_authority"]["provider_signer_session_id"], + "conn_order_binding_01" + ); } #[test] @@ -1153,6 +1178,17 @@ fn sample_session( permissions: &[&str], authorized: bool, ) -> Value { + sample_session_with_authority(session_id, signer_pubkey, permissions, authorized, None, None) +} + +fn sample_session_with_authority( + session_id: &str, + signer_pubkey: &str, + permissions: &[&str], + authorized: bool, + account_identity_id: Option<&str>, + provider_signer_session_id: Option<&str>, +) -> Value { json!({ "session_id": session_id, "role": "remote_signer", @@ -1163,7 +1199,12 @@ fn sample_session( "permissions": permissions, "auth_required": false, "authorized": authorized, - "expires_in_secs": Value::Null + "expires_in_secs": Value::Null, + "signer_authority": account_identity_id.map(|account_identity_id| json!({ + "provider_runtime_id": "myc", + "account_identity_id": account_identity_id, + "provider_signer_session_id": provider_signer_session_id + })) }) }