myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit d99aeaf635f6ccbe3ae7481a682fbcd7a83b122e
parent 1e46710080517e5625477bc1c7163daf58bfa5b6
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 09:14:41 +0000

status: add signer contract view

Diffstat:
Msrc/cli.rs | 25+++++++++++++++++++++----
Msrc/lib.rs | 12++++++------
Msrc/operability/mod.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/operability_cli.rs | 44++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 180 insertions(+), 15 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -22,9 +22,10 @@ use crate::discovery::{ use crate::error::MycError; use crate::logging; use crate::operability::{ - MycAuditDecisionCounts, MycOperationOutcomeCounts, MycStatusFullOutput, MycStatusSummaryOutput, - collect_metrics, collect_status_full, collect_status_summary, increment_outcome_counts, - is_aggregate_publish_operation, operation_kind_label, render_metrics_text, + MycAuditDecisionCounts, MycOperationOutcomeCounts, MycStatusFullOutput, MycStatusSignerOutput, + MycStatusSummaryOutput, collect_metrics, collect_status_full, collect_status_signer, + collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation, + operation_kind_label, render_metrics_text, }; use crate::persistence::{ MycPersistenceImportSelection, backup_persistence, import_json_to_sqlite, restore_backup, @@ -222,6 +223,7 @@ pub enum MycDiscoveryRepairAttemptView { #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum MycStatusView { + Signer, Summary, Full, } @@ -373,6 +375,7 @@ pub enum MycDiscoveryRepairAttemptOutput { #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(untagged)] pub enum MycStatusOutput { + Signer(MycStatusSignerOutput), Summary(MycStatusSummaryOutput), Full(MycStatusFullOutput), } @@ -389,6 +392,7 @@ pub async fn run_from_env() -> Result<(), MycError> { MycCommand::Status { view } => { let runtime = MycRuntime::bootstrap(config)?; let output = match view { + MycStatusView::Signer => MycStatusOutput::Signer(collect_status_signer(&runtime)?), MycStatusView::Summary => { MycStatusOutput::Summary(collect_status_summary(&runtime).await?) } @@ -1052,7 +1056,7 @@ mod tests { use crate::config::MycConfig; use super::{ - MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, + MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, MycStatusView, granted_permissions_for_approval, load_audit_output, summarize_audit_output, }; use crate::app::MycRuntime; @@ -1376,6 +1380,19 @@ mod tests { } #[test] + fn parses_signer_status_view() { + let cli = MycCli::try_parse_from(["myc", "status", "--view", "signer"]) + .expect("parse signer status"); + + assert!(matches!( + cli.command, + Some(MycCommand::Status { + view: MycStatusView::Signer + }) + )); + } + + #[test] fn parses_custody_list_command() { let status = MycCli::try_parse_from(["myc", "custody", "status", "--role", "signer"]) .expect("parse custody status"); diff --git a/src/lib.rs b/src/lib.rs @@ -53,14 +53,14 @@ pub use discovery::{ }; pub use error::MycError; pub use operability::{ - MycAuditDecisionCounts, MycCustodyStatusOutput, MycDeliveryOutboxStatusOutput, - MycDeliveryRecoveryStatusOutput, MycDiscoveryStatusOutput, MycMetricsSnapshot, - MycOperationOutcomeCounts, MycPersistenceStatusOutput, MycRelayProbe, + MYC_SIGNER_STATUS_CONTRACT_VERSION, MycAuditDecisionCounts, MycCustodyStatusOutput, + MycDeliveryOutboxStatusOutput, MycDeliveryRecoveryStatusOutput, MycDiscoveryStatusOutput, + MycMetricsSnapshot, MycOperationOutcomeCounts, MycPersistenceStatusOutput, MycRelayProbe, MycRelayProbeAvailability, MycRuntimeAuditPersistenceStatusOutput, MycRuntimeStatus, MycSignerBackendStatusOutput, MycSignerStatePersistenceStatusOutput, - MycSqliteSchemaStatusOutput, MycStatusFullOutput, MycStatusSummaryOutput, - MycTransportStatusOutput, collect_metrics, collect_status_full, collect_status_summary, - render_metrics_text, + MycSqliteSchemaStatusOutput, MycStatusFullOutput, MycStatusSignerOutput, + MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, + collect_status_signer, collect_status_summary, render_metrics_text, }; pub use outbox::{ MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -29,6 +29,7 @@ use crate::outbox::{MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, now_unix_s use crate::transport::MycTransportSnapshot; const MYC_RELAY_PROBE_CONCURRENCY_LIMIT: usize = 4; +pub const MYC_SIGNER_STATUS_CONTRACT_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -197,6 +198,17 @@ pub struct MycStatusFullOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycStatusSignerOutput { + pub status_contract_version: u32, + pub status: MycRuntimeStatus, + pub ready: bool, + pub reasons: Vec<String>, + pub runtime_contract: MycRuntimeContractOutput, + pub signer_backend: MycSignerBackendStatusOutput, + pub custody: MycCustodyStatusOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycStatusSummaryOutput { pub status: MycRuntimeStatus, pub ready: bool, @@ -368,6 +380,39 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu }) } +pub fn collect_status_signer(runtime: &MycRuntime) -> Result<MycStatusSignerOutput, MycError> { + let signer_backend = collect_signer_backend_status(runtime)?; + let custody = collect_custody_status(runtime)?; + let mut reasons = Vec::new(); + + if !custody.output.signer.resolved { + reasons.push("signer identity could not be resolved".to_owned()); + } + if !custody.output.user.resolved { + reasons.push("user identity could not be resolved".to_owned()); + } + match signer_backend.local_signer.as_ref() { + Some(local_signer) if local_signer.is_secret_backed() => {} + Some(_) => reasons.push("local signer capability is not secret-backed".to_owned()), + None => reasons.push("local signer capability is unavailable".to_owned()), + } + + let ready = reasons.is_empty(); + Ok(MycStatusSignerOutput { + status_contract_version: MYC_SIGNER_STATUS_CONTRACT_VERSION, + status: if ready { + MycRuntimeStatus::Healthy + } else { + MycRuntimeStatus::Unready + }, + ready, + reasons, + runtime_contract: runtime.config().runtime_contract_output(), + signer_backend, + custody: custody.output, + }) +} + pub async fn collect_status_summary( runtime: &MycRuntime, ) -> Result<MycStatusSummaryOutput, MycError> { @@ -1603,9 +1648,9 @@ mod tests { }; use super::{ - MycMetricsSnapshot, MycOperationOutcomeCounts, MycRuntimeStatus, collect_metrics, - collect_status_full, inspect_runtime_audit_sqlite_schema, render_metrics_text, - worse_runtime_status, + MYC_SIGNER_STATUS_CONTRACT_VERSION, MycMetricsSnapshot, MycOperationOutcomeCounts, + MycRuntimeStatus, collect_metrics, collect_status_full, collect_status_signer, + inspect_runtime_audit_sqlite_schema, render_metrics_text, worse_runtime_status, }; use crate::app::{MycRuntime, MycRuntimePaths}; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; @@ -1808,4 +1853,67 @@ mod tests { connection.connection_id ); } + + #[test] + fn status_signer_reports_remote_sessions_without_transport_diagnostics() { + use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend; + + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.transport.enabled = true; + config.transport.relays = vec!["ws://127.0.0.1:9".to_owned()]; + config.transport.connect_timeout_secs = 99; + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config).expect("runtime"); + let backend = runtime.signer_backend(); + let connection = backend + .register_connection(RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let status = collect_status_signer(&runtime).expect("status"); + + assert_eq!( + status.status_contract_version, + MYC_SIGNER_STATUS_CONTRACT_VERSION + ); + assert_eq!(status.status, MycRuntimeStatus::Healthy); + assert!(status.ready); + assert!(status.reasons.is_empty()); + assert_eq!( + status.custody.signer.public_key_hex.as_deref(), + Some("4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa") + ); + assert_eq!( + status.custody.user.public_key_hex.as_deref(), + Some("466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27") + ); + assert!( + status + .signer_backend + .local_signer + .as_ref() + .expect("local signer") + .is_secret_backed() + ); + assert_eq!(status.signer_backend.remote_session_count, 1); + assert_eq!(status.signer_backend.remote_sessions.len(), 1); + assert_eq!( + status.signer_backend.remote_sessions[0].connection_id, + connection.connection_id + ); + } } diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -3,8 +3,9 @@ use std::path::Path; use std::process::Command; use myc::{ - MycActiveIdentity, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycOperationAuditKind, - MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, + MYC_SIGNER_STATUS_CONTRACT_VERSION, MycActiveIdentity, MycDeliveryOutboxKind, + MycDeliveryOutboxRecord, MycOperationAuditKind, MycOperationAuditOutcome, + MycOperationAuditRecord, MycRuntime, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind}; @@ -62,6 +63,45 @@ fn signed_event(identity: &MycActiveIdentity) -> nostr::Event { } #[test] +fn status_signer_command_emits_local_contract_json() { + let temp = tempfile::tempdir().expect("tempdir"); + let env_path = write_env_file(&temp); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&env_path) + .arg("status") + .arg("--view") + .arg("signer") + .output() + .expect("run myc signer status"); + + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json"); + assert_eq!( + value["status_contract_version"], + MYC_SIGNER_STATUS_CONTRACT_VERSION + ); + assert_eq!(value["status"], "healthy"); + assert_eq!(value["ready"], true); + assert_eq!( + value["runtime_contract"]["active_profile"], + "interactive_user" + ); + assert_eq!(value["custody"]["signer"]["resolved"], true); + assert_eq!(value["custody"]["user"]["resolved"], true); + assert_eq!( + value["signer_backend"]["local_signer"]["availability"], + "SecretBacked" + ); + assert_eq!(value["signer_backend"]["remote_session_count"], 0); + assert!(value.get("transport").is_none()); + assert!(value.get("discovery").is_none()); + assert!(value.get("persistence").is_none()); + assert!(value.get("delivery_outbox").is_none()); +} + +#[test] fn status_summary_command_emits_machine_readable_json() { let temp = tempfile::tempdir().expect("tempdir"); let env_path = write_env_file(&temp);