commit d99aeaf635f6ccbe3ae7481a682fbcd7a83b122e
parent 1e46710080517e5625477bc1c7163daf58bfa5b6
Author: triesap <tyson@radroots.org>
Date: Sat, 25 Apr 2026 09:14:41 +0000
status: add signer contract view
Diffstat:
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);