cli

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

commit 7d09767e52a1000067783ae27e0cf929b74841ca
parent 39b618c2b97454c5a66afd9e53ba68b120a8b1dd
Author: triesap <tyson@radroots.org>
Date:   Wed, 24 Jun 2026 06:25:53 +0000

cli: harden Myc signer readiness

- use typed NIP-46 permissions for write readiness
- keep unsupported sync profile signing fail-closed
- fail managed account status when no actor resolves
- cover typed permission and unresolved binding regressions

Diffstat:
Msrc/runtime/signer.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtests/signer_runtime_modes.rs | 43+++++++++++++++++++++++++++++++++++++++++++
2 files changed, 127 insertions(+), 18 deletions(-)

diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -14,11 +14,13 @@ use radroots_events::kinds::{ KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; +use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, }; use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings; +use std::str::FromStr; use url::Url; const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; @@ -408,13 +410,25 @@ fn myc_binding_readiness( if let Err(reason) = validate_myc_target(binding.target.as_str()) { reasons.push(reason); } - if let (Some(managed_account_ref), Some(actor_pubkey)) = - (binding.managed_account_ref.as_deref(), actor_pubkey) - { - if !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) { - reasons.push(format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" - )); + if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() { + let managed_account_matches = actor_pubkey + .map(|actor_pubkey| { + myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) + }) + .unwrap_or_else(|| { + actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) + }); + if !managed_account_matches { + let reason = if actor_account_id.is_none() && actor_pubkey.is_none() { + format!( + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` cannot be evaluated because no actor account or pubkey resolved" + ) + } else { + format!( + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" + ) + }; + reasons.push(reason); } } let signer_session_ref = binding.signer_session_ref.clone(); @@ -519,20 +533,43 @@ fn myc_write_kind_readiness( ready: bool, reason: Option<String>, ) -> Vec<SignerWriteKindReadinessView> { - let permissions = radroots_sdk_myc_nip46_product_permission_strings(); + myc_write_kind_readiness_for_permissions(ready, reason, sdk_myc_nip46_product_permissions()) +} + +fn sdk_myc_nip46_product_permissions() -> Result<RadrootsNostrConnectPermissions, String> { + RadrootsNostrConnectPermissions::from_str( + radroots_sdk_myc_nip46_product_permission_strings() + .join(",") + .as_str(), + ) + .map_err(|error| format!("SDK Myc signer permissions are invalid: {error}")) +} + +fn myc_write_kind_readiness_for_permissions( + ready: bool, + reason: Option<String>, + permissions: Result<RadrootsNostrConnectPermissions, String>, +) -> Vec<SignerWriteKindReadinessView> { + let permissions = match permissions { + Ok(permissions) => permissions, + Err(error) => { + return cli_write_kinds() + .iter() + .map(|kind| SignerWriteKindReadinessView { + command: kind.command.to_owned(), + event_kind: kind.event_kind, + permission: sign_event_permission_for_kind(kind.event_kind), + ready: false, + reason: Some(error.clone()), + }) + .collect(); + } + }; cli_write_kinds() .iter() .map(|kind| { let permission = sign_event_permission_for_kind(kind.event_kind); - let permission = permissions - .iter() - .find(|configured| configured.as_str() == permission.as_str()) - .cloned() - .unwrap_or(permission); - let permission_ready = ready - && permissions - .iter() - .any(|configured| configured == &permission); + let permission_ready = ready && permissions.allows_sign_event_kind(kind.event_kind); SignerWriteKindReadinessView { command: kind.command.to_owned(), event_kind: kind.event_kind, @@ -562,7 +599,11 @@ mod tests { use super::{ KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, cli_write_kinds, - myc_managed_account_ref_matches, myc_write_kind_readiness, sign_event_permission_for_kind, + myc_managed_account_ref_matches, myc_write_kind_readiness, + myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind, + }; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, }; const RESERVED_ORDER_KIND_3431: u32 = 3431; @@ -682,6 +723,31 @@ mod tests { } #[test] + fn myc_write_readiness_uses_typed_kind_permissions() { + let readiness = myc_write_kind_readiness_for_permissions( + true, + None, + Ok(RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + format!("kind:{KIND_LISTING}"), + ), + ])), + ); + let listing = readiness + .iter() + .find(|kind| kind.command == "listing.publish") + .expect("listing readiness"); + let farm = readiness + .iter() + .find(|kind| kind.command == "farm.publish") + .expect("farm readiness"); + + assert!(listing.ready); + assert!(!farm.ready); + } + + #[test] fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() { let actor_account_id = Some("acct_farmer_market"); let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e"; diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -662,6 +662,49 @@ fn myc_signer_status_reports_missing_binding() { assert_no_removed_command_reference(&value, &["signer", "status", "get"]); } +#[test] +fn myc_signer_status_fails_closed_when_managed_account_is_unresolved() { + let sandbox = RadrootsCliSandbox::new(); + let missing_myc = sandbox.root().join("bin/missing-myc"); + let remote_signer = identity_public(91); + sandbox.write_app_config(&format!( + r#"[signer] +backend = "myc" + +[myc] +executable = "{}" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "bunker://{}?relay=wss%3A%2F%2Frelay.example" +managed_account_ref = "acct_missing" +signer_session_ref = "session_missing" +"#, + toml_string(missing_myc.display().to_string().as_str()), + remote_signer.public_key_hex, + )); + + let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "unconfigured"); + assert_eq!(value["result"]["binding"]["state"], "unconfigured"); + assert_eq!(value["result"]["myc"]["ready"], false); + assert_contains( + &value["result"]["reason"], + "managed_account_ref `acct_missing` cannot be evaluated", + ); + assert!( + value["result"]["write_kinds"] + .as_array() + .expect("write kinds") + .iter() + .all(|kind| kind["ready"] == false) + ); +} + #[cfg(unix)] #[test] fn myc_signer_status_does_not_invoke_configured_executable() {