cli

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

commit edc766dcb8f4607d7fd51c467e6099c6251ef88b
parent 4210455995ea516c84cd6788851b1ad8830a7be1
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 09:55:36 +0000

signer: report kind specific readiness

Diffstat:
Msrc/domain/runtime.rs | 12++++++++++++
Msrc/render/mod.rs | 42+++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/signer.rs | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/myc_status.rs | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 336 insertions(+), 21 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2148,6 +2148,8 @@ pub struct SignerStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, pub binding: SignerBindingStatusView, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub write_kinds: Vec<SignerWriteKindReadinessView>, #[serde(skip_serializing_if = "Option::is_none")] pub local: Option<LocalSignerStatusView>, #[serde(skip_serializing_if = "Option::is_none")] @@ -2155,6 +2157,16 @@ pub struct SignerStatusView { } #[derive(Debug, Clone, Serialize)] +pub struct SignerWriteKindReadinessView { + pub command: String, + pub event_kind: u32, + pub permission: String, + pub ready: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct SignerSessionActionView { pub action: String, pub state: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -11,7 +11,7 @@ use crate::domain::runtime::{ OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, - StatusView, SyncActionView, SyncStatusView, SyncWatchView, + SignerWriteKindReadinessView, StatusView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat, Verbosity}; @@ -237,6 +237,10 @@ fn render_human_view_to( signer_rows.push(("signer account id", account_id.as_str())); } render_pairs(stdout, "signer", signer_rows.as_slice())?; + if !view.write_kinds.is_empty() { + writeln!(stdout)?; + render_signer_write_kinds(stdout, &view.write_kinds)?; + } writeln!(stdout)?; render_account_resolution(stdout, &view.account_resolution)?; if let Some(reason) = &view.reason { @@ -4193,6 +4197,42 @@ fn render_myc_remote_session( Ok(()) } +fn render_signer_write_kinds( + stdout: &mut dyn Write, + write_kinds: &[SignerWriteKindReadinessView], +) -> Result<(), RuntimeError> { + let table = Table { + headers: &["command", "kind", "ready", "permission"], + rows: write_kinds + .iter() + .map(|kind| { + vec![ + kind.command.clone(), + kind.event_kind.to_string(), + yes_no(kind.ready).to_owned(), + kind.permission.clone(), + ] + }) + .collect(), + }; + render_table(stdout, &table)?; + let reasons = write_kinds + .iter() + .filter_map(|kind| { + kind.reason + .as_ref() + .map(|reason| (kind.command.as_str(), reason)) + }) + .collect::<Vec<_>>(); + if !reasons.is_empty() { + writeln!(stdout)?; + for (command, reason) in reasons { + writeln!(stdout, "{command}: {reason}")?; + } + } + Ok(()) +} + fn render_myc_custody_identity( stdout: &mut dyn Write, heading: &str, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -1,12 +1,14 @@ use crate::domain::runtime::{ IdentityPublicView, LocalSignerStatusView, MycRemoteSessionView, MycStatusView, - SignerBindingStatusView, SignerStatusView, + SignerBindingStatusView, SignerStatusView, SignerWriteKindReadinessView, }; use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; use crate::runtime::config::{ CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, }; +use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_PROFILE}; +use radroots_events::trade::RadrootsTradeMessageType; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, @@ -17,6 +19,12 @@ use serde::{Deserialize, Serialize}; const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; +#[derive(Debug, Clone, Copy)] +struct CliWriteKind { + command: &'static str, + event_kind: u32, +} + #[derive(Debug, Clone)] struct MycBindingResolution { view: SignerBindingStatusView, @@ -131,14 +139,16 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { .map(|account| account.record.account_id.to_string()), ), Err(error) => { + let reason = error.to_string(); return SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "error".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), signer_account_id: None, account_resolution: empty_account_resolution_view(), - reason: Some(error.to_string()), + reason: Some(reason.clone()), binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(false, Some(reason)), local: None, myc: None, }; @@ -146,28 +156,32 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { }; let secret_backend = crate::runtime::accounts::secret_backend_status(config); if secret_backend.state == "unavailable" { + let reason = secret_backend.reason.clone(); return SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unavailable".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), signer_account_id: resolved_account_id.clone(), account_resolution: account_resolution.clone(), - reason: secret_backend.reason, + reason: reason.clone(), binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(false, reason), local: None, myc: None, }; } if secret_backend.state == "error" { + let reason = secret_backend.reason.clone(); return SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "error".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), signer_account_id: resolved_account_id.clone(), account_resolution: account_resolution.clone(), - reason: secret_backend.reason, + reason: reason.clone(), binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(false, reason), local: None, myc: None, }; @@ -199,6 +213,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { account_resolution: account_resolution.clone(), reason: None, binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(true, None), local: Some(LocalSignerStatusView { account_id: local.account_id.to_string(), public_identity: IdentityPublicView::from_public_identity( @@ -223,6 +238,13 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { account.account_id )), binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness( + false, + Some(format!( + "local account {} is present but not secret-backed", + account.account_id + )), + ), local: Some(LocalSignerStatusView { account_id: account.account_id.to_string(), public_identity: IdentityPublicView::from_public_identity(&account.public_identity), @@ -242,20 +264,28 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { account_resolution: account_resolution.clone(), reason: crate::runtime::accounts::unresolved_account_reason(config).ok(), binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness( + false, + crate::runtime::accounts::unresolved_account_reason(config).ok(), + ), local: None, myc: None, }, - Err(error) => SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "error".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: resolved_account_id, - account_resolution, - reason: Some(error.to_string()), - binding: disabled_binding_status(), - local: None, - myc: None, - }, + Err(error) => { + let reason = error.to_string(); + SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "error".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + signer_account_id: resolved_account_id, + account_resolution, + reason: Some(reason.clone()), + binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(false, Some(reason)), + local: None, + myc: None, + } + } } } @@ -268,6 +298,19 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { let resolution = resolve_myc_binding(config, &myc); let binding = resolution.view; let state = myc_signer_state(&myc, &binding).to_owned(); + let resolved_session = binding + .resolved_signer_session_id + .as_deref() + .and_then(|session_id| { + myc.remote_sessions + .iter() + .find(|session| session.connection_id == session_id) + }); + let write_kinds = myc_write_kind_readiness( + resolved_session, + binding.state.as_str(), + binding.reason.as_deref(), + ); SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state, @@ -284,6 +327,7 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { myc.reason.clone().or_else(|| binding.reason.clone()) }, binding, + write_kinds, local: None, myc: Some(myc), } @@ -422,7 +466,7 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin Some(1), None, format!( - "configured signer session `{session_ref}` is not approved for `sign_event`" + "configured signer session `{session_ref}` is not approved for any cli write event kind" ), ); } @@ -468,7 +512,8 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin None, Some(0), None, - "no authorized remote signer session currently exposes `sign_event`".to_owned(), + "no authorized remote signer session currently approves a cli write event kind" + .to_owned(), ); } @@ -479,7 +524,7 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin None, Some(signing_sessions.len()), None, - "multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(), + "multiple authorized remote signer sessions approve cli write event kinds; set managed_account_ref or signer_session_ref".to_owned(), ); } @@ -589,10 +634,94 @@ fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> & } fn session_supports_signing(session: &MycRemoteSessionView) -> bool { + cli_write_kinds() + .iter() + .any(|kind| session_allows_event_kind(session, kind.event_kind)) +} + +fn cli_write_kinds() -> [CliWriteKind; 4] { + [ + CliWriteKind { + command: "farm profile publish", + event_kind: KIND_PROFILE, + }, + CliWriteKind { + command: "farm publish", + event_kind: KIND_FARM, + }, + CliWriteKind { + command: "listing publish", + event_kind: KIND_LISTING, + }, + CliWriteKind { + command: "order submit", + event_kind: u32::from(RadrootsTradeMessageType::OrderRequest.kind()), + }, + ] +} + +fn local_write_kind_readiness( + ready: bool, + reason: Option<String>, +) -> Vec<SignerWriteKindReadinessView> { + cli_write_kinds() + .iter() + .map(|kind| SignerWriteKindReadinessView { + command: kind.command.to_owned(), + event_kind: kind.event_kind, + permission: "local_account_secret".to_owned(), + ready, + reason: if ready { None } else { reason.clone() }, + }) + .collect() +} + +fn myc_write_kind_readiness( + session: Option<&MycRemoteSessionView>, + binding_state: &str, + binding_reason: Option<&str>, +) -> Vec<SignerWriteKindReadinessView> { + cli_write_kinds() + .iter() + .map(|kind| { + let permission = format!("sign_event:{}", kind.event_kind); + match session { + Some(session) => { + let ready = session_allows_event_kind(session, kind.event_kind); + SignerWriteKindReadinessView { + command: kind.command.to_owned(), + event_kind: kind.event_kind, + permission, + ready, + reason: if ready { + None + } else { + Some(format!( + "resolved signer session `{}` is not approved for sign_event:{}", + session.connection_id, kind.event_kind + )) + }, + } + } + None => SignerWriteKindReadinessView { + command: kind.command.to_owned(), + event_kind: kind.event_kind, + permission, + ready: false, + reason: binding_reason + .map(str::to_owned) + .or_else(|| Some(format!("signer binding is {binding_state}"))), + }, + } + }) + .collect() +} + +fn session_allows_event_kind(session: &MycRemoteSessionView, kind: u32) -> bool { session .permissions .iter() - .any(|permission| permission == "sign_event" || permission.starts_with("sign_event:")) + .any(|permission| permission == "sign_event" || permission == &format!("sign_event:{kind}")) } fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str { diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -261,6 +261,82 @@ signer_session_ref = "{signer_session_ref}" json["myc"]["local_signer"]["account_id"], json["myc"]["remote_sessions"][0]["user_identity"]["id"] ); + assert!( + json["write_kinds"] + .as_array() + .expect("write kinds") + .iter() + .all(|kind| kind["ready"] == true) + ); +} + +#[test] +fn signer_status_reports_kind_specific_myc_write_readiness() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_status_payload_with_permissions(true, &["sign_event:30402"]); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + let managed_account_ref = + payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] + .as_str() + .expect("managed account ref"); + let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] + .as_str() + .expect("signer session ref"); + write_user_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{managed_account_ref}" +signer_session_ref = "{signer_session_ref}" +"# + ) + .as_str(), + ); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "--signer", + "myc", + "--myc-executable", + executable.to_str().expect("executable path"), + "signer", + "status", + ]) + .output() + .expect("run signer status"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["binding"]["state"], "ready"); + let write_kinds = json["write_kinds"].as_array().expect("write kinds"); + let listing = write_kinds + .iter() + .find(|kind| kind["event_kind"] == 30402) + .expect("listing kind"); + assert_eq!(listing["command"], "listing publish"); + assert_eq!(listing["ready"], true); + let order = write_kinds + .iter() + .find(|kind| kind["event_kind"] == 3422) + .expect("order kind"); + assert_eq!(order["command"], "order submit"); + assert_eq!(order["ready"], false); + assert!( + order["reason"] + .as_str() + .is_some_and(|value| value.contains("sign_event:3422")) + ); } #[test] @@ -431,7 +507,65 @@ signer_session_ref = "{signer_session_ref}" assert!( json["binding"]["reason"] .as_str() - .is_some_and(|value| value.contains("not approved for `sign_event`")) + .is_some_and(|value| value.contains("not approved for any cli write event kind")) + ); +} + +#[test] +fn signer_status_does_not_treat_sign_event_star_as_wildcard() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_status_payload_with_permissions(true, &["sign_event:*"]); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + let managed_account_ref = + payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] + .as_str() + .expect("managed account ref"); + let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] + .as_str() + .expect("signer session ref"); + write_user_config( + dir.path(), + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{managed_account_ref}" +signer_session_ref = "{signer_session_ref}" +"# + ) + .as_str(), + ); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "--signer", + "myc", + "--myc-executable", + executable.to_str().expect("executable path"), + "signer", + "status", + ]) + .output() + .expect("run signer status"); + + assert_eq!(output.status.code(), Some(3)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["binding"]["state"], "unauthorized"); + assert!( + json["write_kinds"] + .as_array() + .expect("write kinds") + .iter() + .all(|kind| kind["ready"] == false) ); }