cli

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

commit e6a4e95b04b2fcfa016557b96f6cf5c20c7e6799
parent d9f1278eae3ab6e5fd1b6a8f7b8cde24b609a6bb
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 20:31:36 +0000

signer: align actor-authored writes with composed bindings

Diffstat:
Msrc/runtime/listing.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/order.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/signer.rs | 179++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtests/listing.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/order.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 881 insertions(+), 68 deletions(-)

diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -33,6 +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::sync::freshness_from_executor; const DRAFT_KIND: &str = "listing_draft_v1"; @@ -543,6 +544,20 @@ 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_session_id = match daemon::resolve_signer_session_id( config, "seller", @@ -1091,6 +1106,68 @@ fn daemon_error_view( } } +fn binding_error_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_preview: ListingMutationEventView, + error: ActorWriteBindingError, +) -> ListingMutationView { + let (state, reason, actions) = match error { + ActorWriteBindingError::Unconfigured(reason) => ( + "unconfigured".to_owned(), + reason, + vec![ + "radroots --signer myc signer status".to_owned(), + "radroots rpc sessions".to_owned(), + ], + ), + ActorWriteBindingError::Unavailable(reason) => ( + "unavailable".to_owned(), + reason, + vec![ + "radroots myc status".to_owned(), + "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), + ], + ), + }; + + ListingMutationView { + state: state.clone(), + operation: operation.as_str().to_owned(), + source: LISTING_WRITE_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr, + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: false, + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + event_id: None, + event_addr: None, + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + reason: Some(reason), + job: args.print_job.then(|| ListingMutationJobView { + rpc_method: "bridge.listing.publish".to_owned(), + state, + job_id: None, + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + }), + event: args.print_event.then_some(event_preview), + actions, + } +} + fn issue_from_trade_validation( error: RadrootsTradeListingValidationError, contents: &str, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -22,6 +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}; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts ยท local first"; @@ -418,6 +419,12 @@ 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_session_id = match daemon::resolve_signer_session_id( config, "buyer", @@ -1186,6 +1193,60 @@ fn order_submit_error_view( } } +fn order_binding_error_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + error: ActorWriteBindingError, +) -> OrderSubmitView { + let (state, reason, actions) = match error { + ActorWriteBindingError::Unconfigured(reason) => ( + "unconfigured".to_owned(), + reason, + vec![ + "radroots --signer myc signer status".to_owned(), + "radroots rpc sessions".to_owned(), + ], + ), + ActorWriteBindingError::Unavailable(reason) => ( + "unavailable".to_owned(), + reason, + vec![ + "radroots myc status".to_owned(), + "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), + ], + ), + }; + + let mut actions = actions; + actions.push(format!( + "radroots order get {}", + loaded.document.order.order_id + )); + + OrderSubmitView { + state: state.clone(), + source: daemon::bridge_source().to_owned(), + order_id: loaded.document.order.order_id.clone(), + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + buyer_account_id: loaded.document.buyer_account_id.clone(), + buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), + seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + dry_run: false, + deduplicated: false, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + reason: Some(reason), + job: None, + issues: Vec::new(), + actions, + } +} + fn order_watch_error_view( loaded: &LoadedOrderDraft, args: &OrderWatchArgs, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -16,6 +16,19 @@ use radroots_nostr_signer::prelude::{ const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; +#[derive(Debug, Clone)] +struct MycBindingResolution { + view: SignerBindingStatusView, + resolved_account_id: Option<String>, + resolved_signer_public_key_hex: Option<String>, +} + +#[derive(Debug, Clone)] +pub enum ActorWriteBindingError { + Unconfigured(String), + Unavailable(String), +} + pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { match config.signer.backend { SignerBackend::Local => resolve_local_signer_status(config), @@ -23,6 +36,50 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { } } +pub fn validate_actor_write_binding( + config: &RuntimeConfig, + actor_role: &str, + actor_pubkey: &str, +) -> Result<(), ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Myc) { + return Ok(()); + } + + let myc = crate::runtime::myc::resolve_status(&config.myc); + let resolution = resolve_myc_binding(config, &myc); + match resolution.view.state.as_str() { + "ready" => {} + "unavailable" => { + return Err(ActorWriteBindingError::Unavailable( + resolution.view.reason.unwrap_or_else(|| { + "myc signer binding is unavailable for actor-authored writes".to_owned() + }), + )); + } + _ => { + return Err(ActorWriteBindingError::Unconfigured( + resolution.view.reason.unwrap_or_else(|| { + "myc signer binding is not ready for actor-authored writes".to_owned() + }), + )); + } + } + + let Some(resolved_signer_public_key_hex) = resolution.resolved_signer_public_key_hex else { + return Err(ActorWriteBindingError::Unconfigured( + "myc signer binding reported ready without a resolved signer identity".to_owned(), + )); + }; + + if !resolved_signer_public_key_hex.eq_ignore_ascii_case(actor_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "configured myc signer binding resolves signer pubkey `{resolved_signer_public_key_hex}` instead of {actor_role} pubkey `{actor_pubkey}`" + ))); + } + + Ok(()) +} + fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { let secret_backend = crate::runtime::accounts::secret_backend_status(config); if secret_backend.state == "unavailable" { @@ -138,7 +195,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { let myc = crate::runtime::myc::resolve_status(&config.myc); - let binding = resolve_myc_binding_status(config, &myc); + let resolution = resolve_myc_binding(config, &myc); + let binding = resolution.view; let state = myc_signer_state(&myc, &binding).to_owned(); SignerStatusView { mode: config.signer.backend.as_str().to_owned(), @@ -148,7 +206,7 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { } else { myc.source.clone() }, - account_id: resolve_myc_account_id(&binding, &myc), + account_id: resolution.resolved_account_id, reason: if myc.state == "ready" { binding.reason.clone() } else { @@ -179,27 +237,28 @@ fn disabled_binding_status() -> SignerBindingStatusView { } } -fn resolve_myc_binding_status( - config: &RuntimeConfig, - myc: &MycStatusView, -) -> SignerBindingStatusView { +fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindingResolution { let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else { - return SignerBindingStatusView { - capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), - provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), - binding_model: SIGNER_BINDING_MODEL.to_owned(), - state: "unconfigured".to_owned(), - source: "no explicit capability binding".to_owned(), - target_kind: None, - target: None, - managed_account_ref: None, - signer_session_ref: None, - resolved_signer_session_id: None, - matched_session_count: None, - reason: Some( - "configure [[capability_binding]] for `signer.remote_nip46` before using `--signer myc`" - .to_owned(), - ), + return MycBindingResolution { + view: SignerBindingStatusView { + capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), + provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), + binding_model: SIGNER_BINDING_MODEL.to_owned(), + state: "unconfigured".to_owned(), + source: "no explicit capability binding".to_owned(), + target_kind: None, + target: None, + managed_account_ref: None, + signer_session_ref: None, + resolved_signer_session_id: None, + matched_session_count: None, + reason: Some( + "configure [[capability_binding]] for `signer.remote_nip46` before using `--signer myc`" + .to_owned(), + ), + }, + resolved_account_id: None, + resolved_signer_public_key_hex: None, }; }; @@ -212,6 +271,7 @@ fn resolve_myc_binding_status( "unsupported", None, None, + None, format!( "signer.remote_nip46 only supports target_kind `managed_instance`; got `{}`", binding.target_kind.as_str() @@ -225,6 +285,7 @@ fn resolve_myc_binding_status( "unsupported", None, None, + None, format!( "managed myc target `{}` is not supported yet; use target `default`", binding.target @@ -240,6 +301,7 @@ fn resolve_myc_binding_status( "unconfigured", None, None, + None, myc.reason.clone().unwrap_or_else(|| { "myc is not configured for composed signer bindings".to_owned() }), @@ -251,6 +313,7 @@ fn resolve_myc_binding_status( "unavailable", None, None, + None, myc.reason .clone() .unwrap_or_else(|| "myc is not ready for remote signer bindings".to_owned()), @@ -275,6 +338,7 @@ fn resolve_myc_binding_status( "unavailable", None, Some(0), + None, format!("configured signer session `{session_ref}` is not currently available"), ); }; @@ -285,6 +349,7 @@ fn resolve_myc_binding_status( "unauthorized", None, Some(1), + None, format!( "configured signer session `{session_ref}` is not approved for `sign_event`" ), @@ -298,6 +363,7 @@ fn resolve_myc_binding_status( "unauthorized", None, Some(1), + None, format!( "configured signer session `{session_ref}` resolves signer `{}` instead of managed account `{account_ref}`", session.signer_identity.id @@ -311,6 +377,7 @@ fn resolve_myc_binding_status( "ready", Some(session.connection_id.clone()), Some(1), + Some(session), String::new(), ); } @@ -329,6 +396,7 @@ fn resolve_myc_binding_status( "unavailable", None, Some(0), + None, "no authorized remote signer session currently exposes `sign_event`".to_owned(), ); } @@ -339,6 +407,7 @@ fn resolve_myc_binding_status( "ambiguous", None, Some(signing_sessions.len()), + None, "multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(), ); } @@ -352,6 +421,7 @@ fn resolve_myc_binding_status( "ready", Some(session.connection_id.clone()), Some(1), + Some(session), String::new(), ) } @@ -360,13 +430,14 @@ fn resolve_matching_sessions( binding: &CapabilityBindingConfig, account_ref: &str, matching_sessions: Vec<&MycRemoteSessionView>, -) -> SignerBindingStatusView { +) -> MycBindingResolution { if matching_sessions.is_empty() { return binding_status( binding, "unavailable", None, Some(0), + None, format!( "no authorized remote signer session currently matches managed account `{account_ref}`" ), @@ -379,6 +450,7 @@ fn resolve_matching_sessions( "ambiguous", None, Some(matching_sessions.len()), + None, format!( "multiple authorized remote signer sessions currently match managed account `{account_ref}`; set signer_session_ref" ), @@ -394,6 +466,7 @@ fn resolve_matching_sessions( "ready", Some(session.connection_id.clone()), Some(1), + Some(session), String::new(), ) } @@ -403,25 +476,31 @@ fn binding_status( state: &str, resolved_signer_session_id: Option<String>, matched_session_count: Option<usize>, + resolved_session: Option<&MycRemoteSessionView>, reason: String, -) -> SignerBindingStatusView { - SignerBindingStatusView { - capability_id: binding.capability_id.clone(), - provider_runtime_id: binding.provider_runtime_id.clone(), - binding_model: binding.binding_model.clone(), - state: state.to_owned(), - source: binding.source.as_str().to_owned(), - target_kind: Some(binding.target_kind.as_str().to_owned()), - target: Some(binding.target.clone()), - managed_account_ref: binding.managed_account_ref.clone(), - signer_session_ref: binding.signer_session_ref.clone(), - resolved_signer_session_id, - matched_session_count, - reason: if reason.is_empty() { - None - } else { - Some(reason) +) -> MycBindingResolution { + MycBindingResolution { + view: SignerBindingStatusView { + capability_id: binding.capability_id.clone(), + provider_runtime_id: binding.provider_runtime_id.clone(), + binding_model: binding.binding_model.clone(), + state: state.to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + managed_account_ref: binding.managed_account_ref.clone(), + signer_session_ref: binding.signer_session_ref.clone(), + resolved_signer_session_id, + matched_session_count, + reason: if reason.is_empty() { + None + } else { + Some(reason) + }, }, + resolved_account_id: resolved_session.map(|session| session.signer_identity.id.clone()), + resolved_signer_public_key_hex: resolved_session + .map(|session| session.signer_identity.public_key_hex.clone()), } } @@ -438,26 +517,6 @@ fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> & } } -fn resolve_myc_account_id( - binding: &SignerBindingStatusView, - myc: &MycStatusView, -) -> Option<String> { - if let Some(account_ref) = &binding.managed_account_ref { - return Some(account_ref.clone()); - } - - binding - .resolved_signer_session_id - .as_deref() - .or(binding.signer_session_ref.as_deref()) - .and_then(|session_id| { - myc.remote_sessions - .iter() - .find(|session| session.connection_id == session_id) - .map(|session| session.signer_identity.id.clone()) - }) -} - fn session_supports_signing(session: &MycRemoteSessionView) -> bool { session .permissions diff --git a/tests/listing.rs b/tests/listing.rs @@ -1,6 +1,7 @@ use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; @@ -55,6 +56,56 @@ fn cli_command_in(workdir: &Path) -> Command { command } +fn write_workspace_config(workdir: &Path, contents: &str) { + let config_dir = workdir.join(".radroots"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); +} + +fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { + let path = dir.join("fake-myc"); + fs::write(&path, script).expect("write fake myc"); + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake myc"); + path +} + +fn successful_status_script(payload_json: String) -> String { + format!( + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" + ) +} + +fn sample_myc_status_payload( + account_id: &str, + public_identity: &Value, + connection_id: &str, +) -> Value { + json!({ + "status": "healthy", + "ready": true, + "reasons": [], + "signer_backend": { + "local_signer": { + "account_id": account_id, + "public_identity": public_identity, + "availability": "SecretBacked" + }, + "remote_session_count": 1, + "remote_sessions": [ + { + "connection_id": connection_id, + "signer_identity": public_identity, + "user_identity": public_identity, + "relays": ["wss://relay.one"], + "permissions": "sign_event" + } + ] + } + }) +} + fn listing_test_guard() -> MutexGuard<'static, ()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -561,6 +612,271 @@ fn listing_archive_and_dry_run_are_truthful() { } #[test] +fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { + 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"); + 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("myc-listing.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "AAAAAAAAAAAAAAAAAAAAAw", + seller_pubkey.as_str(), + "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"); + + write_workspace_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{account_id}" +"# + ) + .as_str(), + ); + let myc = write_fake_myc( + dir.path(), + successful_status_script( + sample_myc_status_payload(account_id, &public_identity, "conn_listing_binding_01") + .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" => { + assert_eq!(auth_header, None); + MockRpcResponse::success(json!([sample_session( + "sess_publish_01", + seller_pubkey.as_str(), + &["sign_event"], + true + )])) + } + "bridge.listing.publish" => { + assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); + MockRpcResponse::success(json!({ + "deduplicated": false, + "job": sample_listing_job( + "job_listing_02", + "published", + "event_listing_02", + "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "sess_publish_01" + ) + })) + } + other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), + } + }); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .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!(output.status.success()); + 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"); + assert_eq!(publish_json["signer_session_id"], "sess_publish_01"); + assert_eq!(publish_json["requested_signer_session_id"], Value::Null); + + let recorded = requests.lock().expect("requests"); + assert_eq!(recorded.len(), 2); + assert_eq!(recorded[0]["method"], "nip46.session.list"); + assert_eq!(recorded[1]["method"], "bridge.listing.publish"); + assert_eq!( + recorded[1]["params"]["signer_session_id"], + "sess_publish_01" + ); +} + +#[test] +fn listing_publish_rejects_myc_binding_that_resolves_the_wrong_actor() { + 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 seller_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey") + .to_owned(); + seed_farm( + dir.path(), + seller_pubkey.as_str(), + "AAAAAAAAAAAAAAAAAAAAAw", + "La Huerta", + ); + + let mismatch_account_output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run mismatch account new"); + assert!(mismatch_account_output.status.success()); + let mismatch_account_json: Value = + serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json"); + let mismatch_account_id = mismatch_account_json["account"]["id"] + .as_str() + .expect("mismatch account id"); + let mismatch_public_identity = mismatch_account_json["public_identity"].clone(); + + let draft_path = dir.path().join("wrong-myc-listing.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "AAAAAAAAAAAAAAAAAAAAAw", + seller_pubkey.as_str(), + "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"); + + write_workspace_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{mismatch_account_id}" +"# + ) + .as_str(), + ); + let myc = write_fake_myc( + dir.path(), + successful_status_script( + sample_myc_status_payload( + mismatch_account_id, + &mismatch_public_identity, + "conn_listing_binding_02", + ) + .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()); + MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached") + }); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .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_eq!(publish_json["signer_mode"], "myc"); + assert!(publish_json["reason"].as_str().is_some_and(|value| { + value.contains("configured myc signer binding resolves signer pubkey") + })); + assert!(requests.lock().expect("requests").is_empty()); +} + +#[test] fn listing_publish_without_matching_signer_session_exits_unconfigured() { let _guard = listing_test_guard(); let dir = tempdir().expect("tempdir"); diff --git a/tests/order.rs b/tests/order.rs @@ -1,6 +1,7 @@ use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; @@ -54,6 +55,56 @@ fn order_command_in(workdir: &Path) -> Command { command } +fn write_workspace_config(workdir: &Path, contents: &str) { + let config_dir = workdir.join(".radroots"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); +} + +fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { + let path = dir.join("fake-myc"); + fs::write(&path, script).expect("write fake myc"); + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake myc"); + path +} + +fn successful_status_script(payload_json: String) -> String { + format!( + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" + ) +} + +fn sample_myc_status_payload( + account_id: &str, + public_identity: &Value, + connection_id: &str, +) -> Value { + json!({ + "status": "healthy", + "ready": true, + "reasons": [], + "signer_backend": { + "local_signer": { + "account_id": account_id, + "public_identity": public_identity, + "availability": "SecretBacked" + }, + "remote_session_count": 1, + "remote_sessions": [ + { + "connection_id": connection_id, + "signer_identity": public_identity, + "user_identity": public_identity, + "relays": ["wss://relay.one"], + "permissions": "sign_event" + } + ] + } + }) +} + fn order_test_guard() -> MutexGuard<'static, ()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -248,7 +299,7 @@ fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result< .map_err(|error| format!("flush mock rpc response: {error}")) } -fn sample_bridge_job(job_id: &str, state: &str, terminal: bool) -> Value { +fn sample_bridge_job(job_id: &str, state: &str, terminal: bool, signer_session_id: &str) -> Value { serde_json::json!({ "job_id": job_id, "command": "bridge.order.request", @@ -259,7 +310,7 @@ fn sample_bridge_job(job_id: &str, state: &str, terminal: bool) -> Value { "requested_at_unix": 1_712_720_000, "completed_at_unix": terminal.then_some(1_712_720_030), "signer_mode": "nip46_session", - "signer_session_id": "sess_order_01", + "signer_session_id": signer_session_id, "event_kind": 30420, "event_id": "evt_order_01", "event_addr": "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", @@ -504,11 +555,14 @@ fn order_submit_persists_submission_metadata_and_reports_job() { )])), "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ "deduplicated": false, - "job": sample_bridge_job("job_order_01", "accepted", false), + "job": sample_bridge_job("job_order_01", "accepted", false, "sess_order_01"), })), - "bridge.job.status" => { - MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false)) - } + "bridge.job.status" => MockRpcResponse::success(sample_bridge_job( + "job_order_01", + "accepted", + false, + "sess_order_01", + )), other => panic!("unexpected mock rpc method {other}"), } }); @@ -584,9 +638,19 @@ fn order_watch_reports_job_frames_for_submitted_order() { let mut count = watch_polls.lock().expect("watch polls lock"); *count += 1; if *count == 1 { - MockRpcResponse::success(sample_bridge_job("job_watch_01", "accepted", false)) + MockRpcResponse::success(sample_bridge_job( + "job_watch_01", + "accepted", + false, + "sess_order_01", + )) } else { - MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true)) + MockRpcResponse::success(sample_bridge_job( + "job_watch_01", + "completed", + true, + "sess_order_01", + )) } } other => panic!("unexpected mock rpc method {other}"), @@ -646,6 +710,242 @@ job_id = "job_watch_01" } #[test] +fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let account_output = order_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"); + let public_identity = account_json["public_identity"].clone(); + let buyer_pubkey = public_identity["public_key_hex"] + .as_str() + .expect("buyer pubkey") + .to_owned(); + + write_workspace_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{account_id}" +"# + ) + .as_str(), + ); + let myc = write_fake_myc( + dir.path(), + successful_status_script( + sample_myc_status_payload(account_id, &public_identity, "conn_order_binding_01") + .to_string(), + ) + .as_str(), + ); + + let new_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + "--qty", + "2", + ]) + .output() + .expect("run order new"); + assert!(new_output.status.success()); + let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); + let order_id = new_json["order_id"].as_str().expect("order id"); + + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, auth_header| { + recorded + .lock() + .expect("recorded requests lock") + .push(MockRpcRequest { + body: body.clone(), + method: body["method"].as_str().unwrap_or_default().to_owned(), + auth_header, + }); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_order_02", + buyer_pubkey.as_str(), + &["sign_event"], + true + )])), + "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ + "deduplicated": false, + "job": sample_bridge_job("job_order_02", "accepted", false, "sess_order_02"), + })), + other => panic!("unexpected mock rpc method {other}"), + } + }); + + let submit_output = order_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") + .args([ + "--json", + "--signer", + "myc", + "--myc-executable", + myc.to_str().expect("myc path"), + "order", + "submit", + order_id, + ]) + .output() + .expect("run order submit"); + + assert!(submit_output.status.success()); + let submit_json: Value = + serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); + assert_eq!(submit_json["state"], "accepted"); + assert_eq!(submit_json["signer_mode"], "nip46_session"); + assert_eq!(submit_json["signer_session_id"], "sess_order_02"); + assert_eq!(submit_json["requested_signer_session_id"], Value::Null); + + let recorded_requests = requests.lock().expect("requests lock"); + assert!( + recorded_requests + .iter() + .any(|request| request.method == "nip46.session.list") + ); + let request = recorded_requests + .iter() + .find(|request| request.method == "bridge.order.request") + .expect("bridge order request"); + assert_eq!(request.body["params"]["signer_session_id"], "sess_order_02"); +} + +#[test] +fn order_submit_rejects_myc_binding_that_resolves_the_wrong_actor() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + + let new_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + "--qty", + "2", + ]) + .output() + .expect("run order new"); + assert!(new_output.status.success()); + let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); + let order_id = new_json["order_id"].as_str().expect("order id"); + + let mismatch_account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run mismatch account new"); + assert!(mismatch_account_output.status.success()); + let mismatch_account_json: Value = + serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json"); + let mismatch_account_id = mismatch_account_json["account"]["id"] + .as_str() + .expect("mismatch account id"); + let mismatch_public_identity = mismatch_account_json["public_identity"].clone(); + + write_workspace_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{mismatch_account_id}" +"# + ) + .as_str(), + ); + let myc = write_fake_myc( + dir.path(), + successful_status_script( + sample_myc_status_payload( + mismatch_account_id, + &mismatch_public_identity, + "conn_order_binding_02", + ) + .to_string(), + ) + .as_str(), + ); + + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, auth_header| { + recorded + .lock() + .expect("recorded requests lock") + .push(MockRpcRequest { + body, + method: "unexpected".to_owned(), + auth_header, + }); + panic!("daemon write path should not be reached"); + }); + + let submit_output = order_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") + .args([ + "--json", + "--signer", + "myc", + "--myc-executable", + myc.to_str().expect("myc path"), + "order", + "submit", + order_id, + ]) + .output() + .expect("run order submit"); + + assert_eq!(submit_output.status.code(), Some(3)); + let submit_json: Value = + serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); + assert_eq!(submit_json["state"], "unconfigured"); + assert_eq!(submit_json["signer_mode"], "myc"); + assert!(submit_json["reason"].as_str().is_some_and(|value| { + value.contains("configured myc signer binding resolves signer pubkey") + })); + assert!(requests.lock().expect("requests lock").is_empty()); +} + +#[test] fn order_submit_without_unique_matching_signer_session_exits_unconfigured() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir");