cli

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

commit d9f1278eae3ab6e5fd1b6a8f7b8cde24b609a6bb
parent c9ace3d4d43b8084bd42e48ab6e037298de0e242
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 20:16:42 +0000

signer: resolve composed myc bindings

Diffstat:
Msrc/domain/runtime.rs | 37+++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/config.rs | 8+++++++-
Msrc/runtime/myc.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/runtime/signer.rs | 346++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/myc_status.rs | 386++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/signer_status.rs | 3+++
7 files changed, 883 insertions(+), 37 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1340,6 +1340,7 @@ pub struct SignerStatusView { pub account_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, + pub binding: SignerBindingStatusView, #[serde(skip_serializing_if = "Option::is_none")] pub local: Option<LocalSignerStatusView>, #[serde(skip_serializing_if = "Option::is_none")] @@ -1369,6 +1370,29 @@ pub struct LocalSignerStatusView { } #[derive(Debug, Clone, Serialize)] +pub struct SignerBindingStatusView { + pub capability_id: String, + pub provider_runtime_id: String, + pub binding_model: String, + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_kind: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_account_ref: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_session_ref: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_signer_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_session_count: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct MycStatusView { pub executable: String, pub state: String, @@ -1379,8 +1403,11 @@ pub struct MycStatusView { pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reasons: Vec<String>, + pub remote_session_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub local_signer: Option<LocalSignerStatusView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remote_sessions: Vec<MycRemoteSessionView>, #[serde(skip_serializing_if = "Option::is_none")] pub custody: Option<MycCustodyView>, } @@ -1397,6 +1424,16 @@ impl MycStatusView { } #[derive(Debug, Clone, Serialize)] +pub struct MycRemoteSessionView { + pub connection_id: String, + pub signer_identity: IdentityPublicView, + pub user_identity: IdentityPublicView, + pub relay_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct MycCustodyView { pub signer: MycCustodyIdentityView, pub user: MycCustodyIdentityView, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -174,6 +174,8 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), writeln!(stdout, "reason: {reason}")?; } writeln!(stdout, "source: {}", view.source)?; + writeln!(stdout)?; + render_signer_binding(stdout, &view.binding)?; if let Some(local) = &view.local { writeln!(stdout)?; render_local_signer(stdout, "local account", local)?; @@ -2017,6 +2019,8 @@ fn render_myc_status( if let Some(service_status) = &view.service_status { rows.push(("service status", service_status.as_str())); } + let remote_session_count = view.remote_session_count.to_string(); + rows.push(("remote session count", remote_session_count.as_str())); render_pairs(stdout, "myc", rows.as_slice())?; if let Some(reason) = &view.reason { writeln!(stdout, "reason: {reason}")?; @@ -2029,6 +2033,10 @@ fn render_myc_status( writeln!(stdout)?; render_local_signer(stdout, "myc local signer", local_signer)?; } + for session in &view.remote_sessions { + writeln!(stdout)?; + render_myc_remote_session(stdout, session)?; + } if let Some(custody) = &view.custody { writeln!(stdout)?; render_myc_custody_identity(stdout, "myc custody signer", &custody.signer)?; @@ -2040,6 +2048,64 @@ fn render_myc_status( Ok(()) } +fn render_signer_binding( + stdout: &mut dyn Write, + binding: &crate::domain::runtime::SignerBindingStatusView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "signer binding")?; + writeln!(stdout, " capability: {}", binding.capability_id)?; + writeln!(stdout, " provider: {}", binding.provider_runtime_id)?; + writeln!(stdout, " model: {}", binding.binding_model)?; + writeln!(stdout, " status: {}", binding.state)?; + writeln!(stdout, " source: {}", binding.source)?; + if let Some(target_kind) = &binding.target_kind { + writeln!(stdout, " target kind: {target_kind}")?; + } + if let Some(target) = &binding.target { + writeln!(stdout, " target: {target}")?; + } + if let Some(account_ref) = &binding.managed_account_ref { + writeln!(stdout, " managed account ref: {account_ref}")?; + } + if let Some(session_ref) = &binding.signer_session_ref { + writeln!(stdout, " signer session ref: {session_ref}")?; + } + if let Some(session_id) = &binding.resolved_signer_session_id { + writeln!(stdout, " resolved signer session id: {session_id}")?; + } + if let Some(count) = binding.matched_session_count { + writeln!(stdout, " matched session count: {count}")?; + } + if let Some(reason) = &binding.reason { + writeln!(stdout, " reason: {reason}")?; + } + Ok(()) +} + +fn render_myc_remote_session( + stdout: &mut dyn Write, + session: &crate::domain::runtime::MycRemoteSessionView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "myc remote session {}", session.connection_id)?; + writeln!(stdout, " signer id: {}", session.signer_identity.id)?; + writeln!( + stdout, + " signer npub: {}", + session.signer_identity.public_key_npub + )?; + writeln!(stdout, " user id: {}", session.user_identity.id)?; + writeln!( + stdout, + " user npub: {}", + session.user_identity.public_key_npub + )?; + writeln!(stdout, " relay count: {}", session.relay_count)?; + if !session.permissions.is_empty() { + writeln!(stdout, " permissions: {}", session.permissions.join(", "))?; + } + Ok(()) +} + fn render_myc_custody_identity( stdout: &mut dyn Write, heading: &str, @@ -2327,7 +2393,9 @@ mod tests { ready: false, reason: None, reasons: Vec::new(), + remote_session_count: 0, local_signer: None, + remote_sessions: Vec::new(), custody: None, })); let mut buffer = Vec::new(); diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -379,7 +379,7 @@ struct CapabilityBindingSpec { binding_model: &'static str, } -const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; +pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; const WRITE_PLANE_TRADE_JSONRPC_CAPABILITY: &str = "write_plane.trade_jsonrpc"; const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade"; const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; @@ -639,6 +639,12 @@ impl RuntimeConfig { }) .collect() } + + pub fn capability_binding(&self, capability_id: &str) -> Option<&CapabilityBindingConfig> { + self.capability_bindings + .iter() + .find(|binding| binding.capability_id == capability_id) + } } fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig { diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs @@ -3,12 +3,14 @@ use std::process::{Child, Command, ExitStatus, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use radroots_nostr_signer::prelude::RadrootsNostrLocalSignerCapability; +use radroots_nostr_signer::prelude::{ + RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability, +}; use serde::Deserialize; use crate::domain::runtime::{ IdentityPublicView, LocalSignerStatusView, MycCustodyIdentityView, MycCustodyView, - MycStatusView, + MycRemoteSessionView, MycStatusView, }; use crate::runtime::config::MycConfig; @@ -104,34 +106,47 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { } }; - let local_signer = payload - .signer_backend - .local_signer - .map(local_signer_status_view); - let custody = payload.custody.into_view(); - let state = if payload.ready { + let MycStatusPayload { + status, + ready, + reasons, + signer_backend, + custody, + } = payload; + let MycSignerBackendPayload { + local_signer, + remote_session_count, + remote_sessions, + } = signer_backend; + + let remote_sessions = remote_sessions + .iter() + .map(remote_session_status_view) + .collect::<Vec<_>>(); + let local_signer = local_signer.map(local_signer_status_view); + let remote_session_count = remote_session_count.max(remote_sessions.len()); + let custody = custody.into_view(); + let state = if ready { "ready" } else { - match payload.status.as_str() { + match status.as_str() { "degraded" => "degraded", _ => "unavailable", } }; - let reason = primary_reason( - payload.ready, - payload.status.as_str(), - payload.reasons.as_slice(), - ); + let reason = primary_reason(ready, status.as_str(), reasons.as_slice()); MycStatusView { executable, state: state.to_owned(), source: "myc status command · local first".to_owned(), - service_status: Some(payload.status), - ready: payload.ready, + service_status: Some(status), + ready, reason, - reasons: payload.reasons, + reasons, + remote_session_count, local_signer, + remote_sessions, custody, } } @@ -208,6 +223,23 @@ fn local_signer_status_view( } } +fn remote_session_status_view( + capability: &RadrootsNostrRemoteSessionSignerCapability, +) -> MycRemoteSessionView { + MycRemoteSessionView { + connection_id: capability.connection_id.to_string(), + signer_identity: IdentityPublicView::from_public_identity(&capability.signer_identity), + user_identity: IdentityPublicView::from_public_identity(&capability.user_identity), + relay_count: capability.relays.len(), + permissions: capability + .permissions + .as_slice() + .iter() + .map(ToString::to_string) + .collect(), + } +} + fn primary_reason(ready: bool, service_status: &str, reasons: &[String]) -> Option<String> { if ready { return None; @@ -228,7 +260,9 @@ fn unavailable_status(executable: String, state: &str, reason: String) -> MycSta ready: false, reason: Some(reason), reasons: Vec::new(), + remote_session_count: 0, local_signer: None, + remote_sessions: Vec::new(), custody: None, } } @@ -257,6 +291,10 @@ struct MycStatusPayload { struct MycSignerBackendPayload { #[serde(default)] local_signer: Option<RadrootsNostrLocalSignerCapability>, + #[serde(default)] + remote_session_count: usize, + #[serde(default)] + remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, } #[derive(Debug, Default, Deserialize)] diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -1,12 +1,21 @@ -use crate::domain::runtime::{IdentityPublicView, LocalSignerStatusView, SignerStatusView}; +use crate::domain::runtime::{ + IdentityPublicView, LocalSignerStatusView, MycRemoteSessionView, MycStatusView, + SignerBindingStatusView, SignerStatusView, +}; use crate::runtime::accounts::SHARED_ACCOUNT_STORE_SOURCE; -use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::config::{ + CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig, + SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, +}; use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, }; +const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; +const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; + pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { match config.signer.backend { SignerBackend::Local => resolve_local_signer_status(config), @@ -23,6 +32,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), account_id: None, reason: secret_backend.reason, + binding: disabled_binding_status(), local: None, myc: None, }; @@ -35,6 +45,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), account_id: None, reason: secret_backend.reason, + binding: disabled_binding_status(), local: None, myc: None, }; @@ -64,6 +75,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), account_id: Some(local.account_id.to_string()), reason: None, + binding: disabled_binding_status(), local: Some(LocalSignerStatusView { account_id: local.account_id.to_string(), public_identity: IdentityPublicView::from_public_identity( @@ -86,6 +98,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { "local account {} is present but not secret-backed", account.account_id )), + binding: disabled_binding_status(), local: Some(LocalSignerStatusView { account_id: account.account_id.to_string(), public_identity: IdentityPublicView::from_public_identity(&account.public_identity), @@ -106,6 +119,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { "no local account is selected in {}", config.account.store_path.display() )), + binding: disabled_binding_status(), local: None, myc: None, }, @@ -115,6 +129,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), account_id: None, reason: Some(error.to_string()), + binding: disabled_binding_status(), local: None, myc: None, }, @@ -123,20 +138,333 @@ 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 state = myc_signer_state(&myc, &binding).to_owned(); SignerStatusView { mode: config.signer.backend.as_str().to_owned(), - state: myc.state.clone(), - source: "myc status command · local first".to_owned(), - account_id: myc - .local_signer - .as_ref() - .map(|local| local.account_id.clone()), - reason: myc.reason.clone(), + state, + source: if myc.state == "ready" { + binding.source.clone() + } else { + myc.source.clone() + }, + account_id: resolve_myc_account_id(&binding, &myc), + reason: if myc.state == "ready" { + binding.reason.clone() + } else { + myc.reason.clone().or_else(|| binding.reason.clone()) + }, + binding, local: None, myc: Some(myc), } } +fn disabled_binding_status() -> SignerBindingStatusView { + 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: "disabled".to_owned(), + source: "independent local signer mode".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( + "remote myc signer binding is disabled while cli signer mode is `local`".to_owned(), + ), + } +} + +fn resolve_myc_binding_status( + config: &RuntimeConfig, + myc: &MycStatusView, +) -> SignerBindingStatusView { + 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(), + ), + }; + }; + + if !matches!( + binding.target_kind, + CapabilityBindingTargetKind::ManagedInstance + ) { + return binding_status( + binding, + "unsupported", + None, + None, + format!( + "signer.remote_nip46 only supports target_kind `managed_instance`; got `{}`", + binding.target_kind.as_str() + ), + ); + } + + if binding.target != "default" { + return binding_status( + binding, + "unsupported", + None, + None, + format!( + "managed myc target `{}` is not supported yet; use target `default`", + binding.target + ), + ); + } + + match myc.state.as_str() { + "ready" => {} + "unconfigured" => { + return binding_status( + binding, + "unconfigured", + None, + None, + myc.reason.clone().unwrap_or_else(|| { + "myc is not configured for composed signer bindings".to_owned() + }), + ); + } + _ => { + return binding_status( + binding, + "unavailable", + None, + None, + myc.reason + .clone() + .unwrap_or_else(|| "myc is not ready for remote signer bindings".to_owned()), + ); + } + } + + let signing_sessions = myc + .remote_sessions + .iter() + .filter(|session| session_supports_signing(session)) + .collect::<Vec<_>>(); + + if let Some(session_ref) = binding.signer_session_ref.as_deref() { + let Some(session) = myc + .remote_sessions + .iter() + .find(|session| session.connection_id == session_ref) + else { + return binding_status( + binding, + "unavailable", + None, + Some(0), + format!("configured signer session `{session_ref}` is not currently available"), + ); + }; + + if !session_supports_signing(session) { + return binding_status( + binding, + "unauthorized", + None, + Some(1), + format!( + "configured signer session `{session_ref}` is not approved for `sign_event`" + ), + ); + } + + if let Some(account_ref) = binding.managed_account_ref.as_deref() { + if session.signer_identity.id != account_ref { + return binding_status( + binding, + "unauthorized", + None, + Some(1), + format!( + "configured signer session `{session_ref}` resolves signer `{}` instead of managed account `{account_ref}`", + session.signer_identity.id + ), + ); + } + } + + return binding_status( + binding, + "ready", + Some(session.connection_id.clone()), + Some(1), + String::new(), + ); + } + + if let Some(account_ref) = binding.managed_account_ref.as_deref() { + let matching_sessions = signing_sessions + .into_iter() + .filter(|session| session.signer_identity.id == account_ref) + .collect::<Vec<_>>(); + return resolve_matching_sessions(binding, account_ref, matching_sessions); + } + + if signing_sessions.is_empty() { + return binding_status( + binding, + "unavailable", + None, + Some(0), + "no authorized remote signer session currently exposes `sign_event`".to_owned(), + ); + } + + if signing_sessions.len() > 1 { + return binding_status( + binding, + "ambiguous", + None, + Some(signing_sessions.len()), + "multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(), + ); + } + + let session = signing_sessions + .into_iter() + .next() + .expect("single matching signer session"); + binding_status( + binding, + "ready", + Some(session.connection_id.clone()), + Some(1), + String::new(), + ) +} + +fn resolve_matching_sessions( + binding: &CapabilityBindingConfig, + account_ref: &str, + matching_sessions: Vec<&MycRemoteSessionView>, +) -> SignerBindingStatusView { + if matching_sessions.is_empty() { + return binding_status( + binding, + "unavailable", + None, + Some(0), + format!( + "no authorized remote signer session currently matches managed account `{account_ref}`" + ), + ); + } + + if matching_sessions.len() > 1 { + return binding_status( + binding, + "ambiguous", + None, + Some(matching_sessions.len()), + format!( + "multiple authorized remote signer sessions currently match managed account `{account_ref}`; set signer_session_ref" + ), + ); + } + + let session = matching_sessions + .into_iter() + .next() + .expect("single matching signer session"); + binding_status( + binding, + "ready", + Some(session.connection_id.clone()), + Some(1), + String::new(), + ) +} + +fn binding_status( + binding: &CapabilityBindingConfig, + state: &str, + resolved_signer_session_id: Option<String>, + matched_session_count: Option<usize>, + 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) + }, + } +} + +fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> &'static str { + match myc.state.as_str() { + "degraded" => "degraded", + "unavailable" => "unavailable", + "unconfigured" => "unconfigured", + _ => match binding.state.as_str() { + "ready" => "ready", + "unavailable" => "unavailable", + _ => "unconfigured", + }, + } +} + +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 + .iter() + .any(|permission| permission == "sign_event" || permission.starts_with("sign_event:")) +} + fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str { match value { RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only", diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -6,6 +6,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; use assert_cmd::prelude::*; use radroots_identity::RadrootsIdentity; +use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; use serde_json::{Value, json}; use tempfile::tempdir; @@ -41,6 +42,12 @@ 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"); +} + #[test] fn myc_status_reports_ready_for_valid_full_status_payload() { let _guard = myc_test_guard(); @@ -68,6 +75,8 @@ fn myc_status_reports_ready_for_valid_full_status_payload() { assert_eq!(json["source"], "myc status command · local first"); assert_eq!(json["ready"], true); assert_eq!(json["service_status"], "healthy"); + assert_eq!(json["remote_session_count"], 1); + assert_eq!(json["remote_sessions"][0]["permissions"][0], "sign_event"); assert_eq!(json["local_signer"]["availability"], "secret_backed"); assert_eq!(json["custody"]["signer"]["resolved"], true); assert_eq!( @@ -166,12 +175,10 @@ fn signer_status_reports_degraded_myc_backend_as_external_unavailable() { assert_eq!(json["mode"], "myc"); assert_eq!(json["state"], "degraded"); assert_eq!(json["source"], "myc status command · local first"); - assert_eq!( - json["account_id"], - json["myc"]["local_signer"]["account_id"] - ); + assert_eq!(json["account_id"], Value::Null); assert_eq!(json["myc"]["state"], "degraded"); assert_eq!(json["myc"]["service_status"], "degraded"); + assert_eq!(json["binding"]["state"], "unconfigured"); assert!( json["reason"] .as_str() @@ -180,6 +187,289 @@ fn signer_status_reports_degraded_myc_backend_as_external_unavailable() { } #[test] +fn signer_status_reports_ready_for_configured_myc_managed_account_binding() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_status_payload(true); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + let managed_account_ref = payload["signer_backend"]["local_signer"]["account_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_workspace_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["mode"], "myc"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["source"], "workspace config [[capability_binding]]"); + assert_eq!(json["account_id"], managed_account_ref); + assert_eq!(json["binding"]["state"], "ready"); + assert_eq!( + json["binding"]["resolved_signer_session_id"], + signer_session_ref + ); + assert_eq!(json["binding"]["managed_account_ref"], managed_account_ref); + assert_eq!(json["binding"]["signer_session_ref"], signer_session_ref); + assert_eq!(json["myc"]["remote_session_count"], 1); +} + +#[test] +fn signer_status_reports_unconfigured_when_myc_binding_is_missing() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let executable = write_fake_myc( + dir.path(), + successful_status_script(sample_status_payload(true).to_string()).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"], "unconfigured"); + assert!( + json["binding"]["reason"] + .as_str() + .is_some_and(|value| value.contains("configure [[capability_binding]]")) + ); +} + +#[test] +fn signer_status_reports_unsupported_for_explicit_endpoint_binding() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let executable = write_fake_myc( + dir.path(), + successful_status_script(sample_status_payload(true).to_string()).as_str(), + ); + write_workspace_config( + dir.path(), + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "https://myc.example.invalid" +"#, + ); + + 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"], "unsupported"); + assert!( + json["binding"]["reason"] + .as_str() + .is_some_and(|value| value.contains("only supports target_kind `managed_instance`")) + ); +} + +#[test] +fn signer_status_reports_ambiguous_for_accountless_myc_binding() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_multi_session_status_payload(); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + write_workspace_config( + dir.path(), + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +"#, + ); + + 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"], "ambiguous"); + assert_eq!(json["binding"]["matched_session_count"], 2); +} + +#[test] +fn signer_status_reports_unauthorized_for_session_without_sign_event_permission() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_status_payload_with_permissions(true, &["nip44_encrypt"]); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + let managed_account_ref = payload["signer_backend"]["local_signer"]["account_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_workspace_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["binding"]["reason"] + .as_str() + .is_some_and(|value| value.contains("not approved for `sign_event`")) + ); +} + +#[test] +fn signer_status_reports_unavailable_for_missing_bound_session() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let payload = sample_status_payload(true); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + let managed_account_ref = payload["signer_backend"]["local_signer"]["account_id"] + .as_str() + .expect("managed account ref"); + write_workspace_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 = "missing-session" +"# + ) + .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(4)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "unavailable"); + assert_eq!(json["binding"]["state"], "unavailable"); + assert!( + json["binding"]["reason"] + .as_str() + .is_some_and(|value| value.contains("missing-session")) + ); +} + +#[test] fn myc_status_reports_unavailable_when_executable_is_missing() { let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); @@ -289,8 +579,17 @@ fn successful_status_script(payload_json: String) -> String { } fn sample_status_payload(ready: bool) -> Value { + sample_status_payload_with_permissions(ready, &["sign_event"]) +} + +fn sample_status_payload_with_permissions(ready: bool, permissions: &[&str]) -> Value { let signer_identity = RadrootsIdentity::generate().to_public(); let user_identity = RadrootsIdentity::generate().to_public(); + let session_id = RadrootsNostrSignerConnectionId::new_v7().to_string(); + let signer_id = signer_identity.id.clone(); + let signer_public_key_hex = signer_identity.public_key_hex.clone(); + let user_id = user_identity.id.clone(); + let user_public_key_hex = user_identity.public_key_hex.clone(); let service_status = if ready { "healthy" } else { "degraded" }; let reasons = if ready { Vec::<String>::new() @@ -304,25 +603,92 @@ fn sample_status_payload(ready: bool) -> Value { "reasons": reasons, "signer_backend": { "local_signer": { - "account_id": signer_identity.id, - "public_identity": signer_identity, + "account_id": signer_id, + "public_identity": signer_identity.clone(), "availability": "SecretBacked" + }, + "remote_session_count": 1, + "remote_sessions": [ + { + "connection_id": session_id, + "signer_identity": signer_identity, + "user_identity": user_identity.clone(), + "relays": ["wss://relay.example.test"], + "permissions": permissions.join(",") + } + ] + }, + "custody": { + "signer": { + "resolved": true, + "selected_account_id": "signer-account", + "selected_account_state": "ready", + "identity_id": signer_id, + "public_key_hex": signer_public_key_hex + }, + "user": { + "resolved": true, + "selected_account_id": "user-account", + "selected_account_state": "public_only", + "identity_id": user_id, + "public_key_hex": user_public_key_hex } + } + }) +} + +fn sample_multi_session_status_payload() -> Value { + let first_signer = RadrootsIdentity::generate().to_public(); + let first_user = RadrootsIdentity::generate().to_public(); + let second_signer = RadrootsIdentity::generate().to_public(); + let second_user = RadrootsIdentity::generate().to_public(); + let first_signer_id = first_signer.id.clone(); + let first_signer_public_key_hex = first_signer.public_key_hex.clone(); + let first_user_id = first_user.id.clone(); + let first_user_public_key_hex = first_user.public_key_hex.clone(); + + json!({ + "status": "healthy", + "ready": true, + "reasons": [], + "signer_backend": { + "local_signer": { + "account_id": first_signer_id, + "public_identity": first_signer.clone(), + "availability": "SecretBacked" + }, + "remote_session_count": 2, + "remote_sessions": [ + { + "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(), + "signer_identity": first_signer, + "user_identity": first_user.clone(), + "relays": ["wss://relay.example.test"], + "permissions": "sign_event" + }, + { + "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(), + "signer_identity": second_signer, + "user_identity": second_user, + "relays": ["wss://relay-secondary.example.test"], + "permissions": "sign_event" + } + ] }, "custody": { "signer": { "resolved": true, "selected_account_id": "signer-account", "selected_account_state": "ready", - "identity_id": signer_identity.id, - "public_key_hex": signer_identity.public_key_hex + "identity_id": first_signer_id, + "public_key_hex": first_signer_public_key_hex }, "user": { "resolved": true, "selected_account_id": "user-account", "selected_account_state": "public_only", - "identity_id": user_identity.id, - "public_key_hex": user_identity.public_key_hex + "identity_id": first_user_id, + "public_key_hex": first_user_public_key_hex } } }) diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -71,6 +71,8 @@ fn signer_status_reports_local_ready_when_account_exists() { assert_eq!(json["source"], "shared account store · local first"); assert_eq!(json["account_id"], json["local"]["account_id"]); assert_eq!(json["reason"], Value::Null); + assert_eq!(json["binding"]["state"], "disabled"); + assert_eq!(json["binding"]["source"], "independent local signer mode"); assert_eq!(json["local"]["availability"], "secret_backed"); assert_eq!(json["local"]["secret_backed"], true); assert_eq!(json["local"]["backend"], "encrypted_file"); @@ -91,6 +93,7 @@ fn signer_status_reports_local_unconfigured_when_no_account_is_selected() { let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["mode"], "local"); assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["binding"]["state"], "disabled"); assert!( json["reason"] .as_str()