myc

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

commit 82849b0b980ef26adfd3a42a35d22de8430a71b7
parent f69cf665dc584644e0bbf9b1506e2828b89f1193
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 16:10:33 +0000

cli: separate repair and publish summary signals

- add a compact repair_summary to refresh output while keeping remaining_repair_relays explicit for follow-up repair
- split audit summary counts between aggregate publish rejections and per-relay discovery repair outcomes
- add unit and cli smoke coverage for mixed-success targeted repair accounting
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked

Diffstat:
Msrc/cli.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/discovery.rs | 28++++++++++++++++++++++++++++
Msrc/lib.rs | 11++++++-----
Mtests/discovery_cli.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtests/nip46_e2e.rs | 24++++++++++++++++++++++++
5 files changed, 324 insertions(+), 25 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -188,7 +188,9 @@ pub struct MycAuditSummaryOutput { pub runtime_operation_total: usize, pub runtime_operation_outcomes: MycOperationOutcomeCounts, pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, - pub runtime_publish_rejection_count: usize, + pub runtime_aggregate_publish_rejection_count: usize, + pub runtime_repair_success_count: usize, + pub runtime_repair_rejection_count: usize, pub runtime_unavailable_count: usize, pub runtime_replay_restore_count: usize, } @@ -455,7 +457,9 @@ fn summarize_audit_output( let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default(); let mut runtime_operation_by_kind = BTreeMap::new(); - let mut runtime_publish_rejection_count = 0; + let mut runtime_aggregate_publish_rejection_count = 0; + let mut runtime_repair_success_count = 0; + let mut runtime_repair_rejection_count = 0; let mut runtime_unavailable_count = 0; let mut runtime_replay_restore_count = 0; for record in &audit.runtime_operation_audit { @@ -465,8 +469,17 @@ fn summarize_audit_output( runtime_operation_by_kind.entry(key).or_default(), record.outcome, ); - if record.outcome == MycOperationAuditOutcome::Rejected { - runtime_publish_rejection_count += 1; + if is_aggregate_publish_operation(record.operation) + && record.outcome == MycOperationAuditOutcome::Rejected + { + runtime_aggregate_publish_rejection_count += 1; + } + if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair { + match record.outcome { + MycOperationAuditOutcome::Succeeded => runtime_repair_success_count += 1, + MycOperationAuditOutcome::Rejected => runtime_repair_rejection_count += 1, + _ => {} + } } if record.outcome == MycOperationAuditOutcome::Unavailable { runtime_unavailable_count += 1; @@ -485,7 +498,9 @@ fn summarize_audit_output( runtime_operation_total: audit.runtime_operation_audit.len(), runtime_operation_outcomes, runtime_operation_by_kind, - runtime_publish_rejection_count, + runtime_aggregate_publish_rejection_count, + runtime_repair_success_count, + runtime_repair_rejection_count, runtime_unavailable_count, runtime_replay_restore_count, }) @@ -526,6 +541,16 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String { } } +fn is_aggregate_publish_operation(kind: MycOperationAuditKind) -> bool { + matches!( + kind, + MycOperationAuditKind::ListenerResponsePublish + | MycOperationAuditKind::ConnectAcceptPublish + | MycOperationAuditKind::AuthReplayPublish + | MycOperationAuditKind::DiscoveryHandlerPublish + ) +} + fn print_json<T>(value: &T) -> Result<(), MycError> where T: Serialize, @@ -707,7 +732,9 @@ mod tests { assert_eq!(summary.runtime_operation_total, 2); assert_eq!(summary.runtime_operation_outcomes.succeeded, 1); assert_eq!(summary.runtime_operation_outcomes.restored, 1); - assert_eq!(summary.runtime_publish_rejection_count, 0); + assert_eq!(summary.runtime_aggregate_publish_rejection_count, 0); + assert_eq!(summary.runtime_repair_success_count, 0); + assert_eq!(summary.runtime_repair_rejection_count, 0); assert_eq!(summary.runtime_unavailable_count, 0); assert_eq!(summary.runtime_replay_restore_count, 1); assert_eq!( @@ -729,4 +756,98 @@ mod tests { assert_eq!(denied.audit.request_id.as_str(), "request-1"); assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2"); } + + #[test] + fn audit_summary_separates_repair_rejections_from_aggregate_publish_rejections() { + let runtime = runtime(); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerPublish, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some("request-1"), + 2, + 1, + "1/2 relays acknowledged publish; failures: relay-b: blocked", + )); + runtime.record_operation_audit( + &MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerRepair, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some("request-1"), + 1, + 1, + "relay repaired", + ) + .with_relay_url("wss://relay-a.example.com"), + ); + runtime.record_operation_audit( + &MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerRepair, + MycOperationAuditOutcome::Rejected, + Some(&connection.connection_id), + Some("request-1"), + 1, + 0, + "blocked by relay", + ) + .with_relay_url("wss://relay-b.example.com"), + ); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Rejected, + Some(&connection.connection_id), + Some("request-2"), + 1, + 0, + "listener publish rejected", + )); + + let summary = summarize_audit_output( + &runtime, + &manager, + Some(connection.connection_id.as_str()), + MycAuditScope::Operation, + Some(10), + ) + .expect("summary"); + + assert_eq!(summary.runtime_operation_total, 4); + assert_eq!(summary.runtime_aggregate_publish_rejection_count, 1); + assert_eq!(summary.runtime_repair_success_count, 1); + assert_eq!(summary.runtime_repair_rejection_count, 1); + assert_eq!(summary.runtime_replay_restore_count, 0); + assert_eq!( + summary + .runtime_operation_by_kind + .get("discovery_handler_publish") + .expect("publish kind") + .succeeded, + 1 + ); + assert_eq!( + summary + .runtime_operation_by_kind + .get("discovery_handler_repair") + .expect("repair kind") + .succeeded, + 1 + ); + assert_eq!( + summary + .runtime_operation_by_kind + .get("discovery_handler_repair") + .expect("repair kind") + .rejected, + 1 + ); + } } diff --git a/src/discovery.rs b/src/discovery.rs @@ -91,6 +91,14 @@ pub enum MycDiscoveryRepairOutcome { Skipped, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct MycDiscoveryRepairSummary { + pub repaired: usize, + pub failed: usize, + pub unchanged: usize, + pub skipped: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycDiscoveryRelayRepairResult { pub relay_url: String, @@ -200,6 +208,7 @@ pub struct MycRefreshedNip89Output { pub live_groups: Vec<MycLiveNip89Group>, pub relay_states: Vec<MycDiscoveryRelayState>, pub relay_summary: MycDiscoveryRelaySummary, + pub repair_summary: MycDiscoveryRepairSummary, pub repair_results: Vec<MycDiscoveryRelayRepairResult>, pub remaining_repair_relays: Vec<String>, pub published: Option<MycPublishedNip89Output>, @@ -651,6 +660,7 @@ pub async fn refresh_nip89( if refresh_relays.is_empty() { let repair_results = build_repair_results(&context, &relay_states, &[], None, None); + let repair_summary = summarize_repair_results(&repair_results); runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, MycOperationAuditOutcome::Skipped, @@ -667,6 +677,7 @@ pub async fn refresh_nip89( live_groups, relay_states, relay_summary, + repair_summary, repair_results, remaining_repair_relays: Vec::new(), published: None, @@ -687,6 +698,7 @@ pub async fn refresh_nip89( Some(published.event.id.to_hex()), &repair_results, ); + let repair_summary = summarize_repair_results(&repair_results); let remaining_repair_relays = remaining_repair_relays(&repair_results); return Ok(MycRefreshedNip89Output { status, @@ -695,6 +707,7 @@ pub async fn refresh_nip89( live_groups, relay_states, relay_summary, + repair_summary, repair_results, remaining_repair_relays, published: Some(published), @@ -845,6 +858,21 @@ fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) -> .collect() } +fn summarize_repair_results( + repair_results: &[MycDiscoveryRelayRepairResult], +) -> MycDiscoveryRepairSummary { + let mut summary = MycDiscoveryRepairSummary::default(); + for result in repair_results { + match result.outcome { + MycDiscoveryRepairOutcome::Repaired => summary.repaired += 1, + MycDiscoveryRepairOutcome::Failed => summary.failed += 1, + MycDiscoveryRepairOutcome::Unchanged => summary.unchanged += 1, + MycDiscoveryRepairOutcome::Skipped => summary.skipped += 1, + } + } + summary +} + fn record_refresh_repair_audit( runtime: &MycRuntime, request_id: Option<String>, diff --git a/src/lib.rs b/src/lib.rs @@ -25,11 +25,12 @@ pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, MycDiscoveryRelayRepairResult, MycDiscoveryRelayState, MycDiscoveryRelaySummary, - MycDiscoveryRepairOutcome, MycFetchedLiveNip89Output, MycLiveNip89Event, MycLiveNip89Group, - MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument, - MycNormalizedNip89Handler, MycPublishedNip89Output, MycRefreshedNip89Output, - MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, - publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle, + MycDiscoveryRepairOutcome, MycDiscoveryRepairSummary, MycFetchedLiveNip89Output, + MycLiveNip89Event, MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document, + MycNip05DocumentSection, MycNip89HandlerDocument, MycNormalizedNip89Handler, + MycPublishedNip89Output, MycRefreshedNip89Output, MycRenderedNip05Output, + MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89, + render_nip05_output, verify_bundle, }; pub use error::MycError; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::fs; use std::net::TcpListener as StdTcpListener; use std::path::Path; @@ -36,6 +36,7 @@ struct RelayState { senders: HashMap<usize, mpsc::UnboundedSender<Message>>, subscriptions: Vec<RelaySubscription>, published_events: Vec<Event>, + publish_outcomes_by_pubkey: HashMap<String, VecDeque<bool>>, } struct TestRelay { @@ -86,6 +87,13 @@ impl TestRelay { self.url.as_str() } + async fn queue_publish_outcomes(&self, public_key: PublicKey, outcomes: &[bool]) { + let mut state = self.state.lock().await; + state + .publish_outcomes_by_pubkey + .insert(public_key.to_hex(), outcomes.iter().copied().collect()); + } + async fn wait_for_published_events_by_author( &self, public_key: PublicKey, @@ -247,28 +255,43 @@ async fn accept_published_event( Vec<(mpsc::UnboundedSender<Message>, Message)>, )> { let event_id = event.id; + let event_pubkey_hex = event.pubkey.to_hex(); let mut subscriber_messages = Vec::new(); let mut ok_message = None; { let mut state = state.lock().await; + let publish_status = state + .publish_outcomes_by_pubkey + .get_mut(&event_pubkey_hex) + .and_then(|outcomes| outcomes.pop_front()) + .unwrap_or(true); + if let Some(sender) = state.senders.get(&connection_id).cloned() { - let message = RelayMessage::ok(event_id, true, "").as_json(); + let message = if publish_status { + RelayMessage::ok(event_id, true, "").as_json() + } else { + RelayMessage::ok(event_id, false, "blocked by test relay").as_json() + }; ok_message = Some((sender, Message::Text(message.into()))); } - state.published_events.push(event.clone()); - for subscription in &state.subscriptions { - if subscription - .filters - .iter() - .any(|filter| filter.match_event(&event, MatchEventOptions::new())) - { - if let Some(sender) = state.senders.get(&subscription.connection_id).cloned() { - let message = - RelayMessage::event(subscription.subscription_id.clone(), event.clone()) - .as_json(); - subscriber_messages.push((sender, Message::Text(message.into()))); + if publish_status { + state.published_events.push(event.clone()); + for subscription in &state.subscriptions { + if subscription + .filters + .iter() + .any(|filter| filter.match_event(&event, MatchEventOptions::new())) + { + if let Some(sender) = state.senders.get(&subscription.connection_id).cloned() { + let message = RelayMessage::event( + subscription.subscription_id.clone(), + event.clone(), + ) + .as_json(); + subscriber_messages.push((sender, Message::Text(message.into()))); + } } } } @@ -678,6 +701,108 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> TestResult<()> { + let relay_a = TestRelay::spawn().await?; + let relay_b = TestRelay::spawn().await?; + let temp = tempfile::tempdir()?; + let config_path = temp.path().join("config.toml"); + let state_dir = temp.path().join("state"); + let signer_identity_path = temp.path().join("signer.json"); + let user_identity_path = temp.path().join("user.json"); + let app_identity_path = temp.path().join("app.json"); + let app_identity = RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + )?; + + write_identity( + &signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + app_identity.save_json(&app_identity_path)?; + write_config( + &config_path, + &state_dir, + &signer_identity_path, + &user_identity_path, + &app_identity_path, + &[relay_a.url(), relay_b.url()], + ); + + relay_a + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + relay_b + .queue_publish_outcomes(app_identity.public_key(), &[false]) + .await; + + let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + assert!( + refresh.status.success(), + "refresh-nip89 failed: {}", + String::from_utf8_lossy(&refresh.stderr) + ); + let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?; + assert_eq!(refresh_output["status"], "missing"); + assert_eq!(refresh_output["repair_summary"]["repaired"], 1); + assert_eq!(refresh_output["repair_summary"]["failed"], 1); + assert_eq!(refresh_output["repair_summary"]["unchanged"], 0); + assert_eq!(refresh_output["repair_summary"]["skipped"], 0); + assert_eq!( + refresh_output["remaining_repair_relays"], + Value::Array(vec![Value::String(relay_b.url().to_owned())]) + ); + assert_eq!( + refresh_output["published"]["acknowledged_relay_count"], + Value::from(1_u64) + ); + + relay_a + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + assert_eq!( + relay_b + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 0 + ); + + let audit_summary = run_myc(&config_path, &["audit", "summary", "--scope", "operation"])?; + assert!( + audit_summary.status.success(), + "audit summary failed: {}", + String::from_utf8_lossy(&audit_summary.stderr) + ); + let audit_summary_output: Value = serde_json::from_slice(&audit_summary.stdout)?; + assert_eq!( + audit_summary_output["runtime_aggregate_publish_rejection_count"], + Value::from(0_u64) + ); + assert_eq!( + audit_summary_output["runtime_repair_success_count"], + Value::from(1_u64) + ); + assert_eq!( + audit_summary_output["runtime_repair_rejection_count"], + Value::from(1_u64) + ); + assert_eq!( + audit_summary_output["runtime_operation_by_kind"]["discovery_handler_publish"]["succeeded"], + Value::from(1_u64) + ); + assert_eq!( + audit_summary_output["runtime_operation_by_kind"]["discovery_handler_repair"]["rejected"], + Value::from(1_u64) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResult<()> { let relay_a = TestRelay::spawn().await?; let relay_b = TestRelay::spawn().await?; diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -1391,6 +1391,10 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<() assert_eq!(refreshed.differing_fields, vec!["live_groups".to_owned()]); assert!(refreshed.live_groups.is_empty()); assert!(refreshed.published.is_some()); + assert_eq!(refreshed.repair_summary.repaired, 1); + assert_eq!(refreshed.repair_summary.failed, 0); + assert_eq!(refreshed.repair_summary.unchanged, 0); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); assert_eq!(refreshed.repair_results.len(), 1); assert_eq!( @@ -1455,6 +1459,10 @@ async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relay assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); assert_eq!(published.relay_count, 1); assert_eq!(published.acknowledged_relay_count, 1); + assert_eq!(refreshed.repair_summary.repaired, 1); + assert_eq!(refreshed.repair_summary.failed, 0); + assert_eq!(refreshed.repair_summary.unchanged, 1); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); assert_eq!(refreshed.repair_results.len(), 2); assert_eq!( @@ -1531,6 +1539,10 @@ async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { assert!(refreshed.differing_fields.is_empty()); assert_eq!(refreshed.live_groups.len(), 1); assert!(refreshed.published.is_none()); + assert_eq!(refreshed.repair_summary.repaired, 0); + assert_eq!(refreshed.repair_summary.failed, 0); + assert_eq!(refreshed.repair_summary.unchanged, 1); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); assert_eq!(refreshed.repair_results.len(), 1); assert_eq!( @@ -1589,6 +1601,10 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted); assert_eq!(refreshed.live_groups.len(), 1); assert!(refreshed.published.is_some()); + assert_eq!(refreshed.repair_summary.repaired, 1); + assert_eq!(refreshed.repair_summary.failed, 0); + assert_eq!(refreshed.repair_summary.unchanged, 0); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); assert_eq!(refreshed.repair_results.len(), 1); assert_eq!( @@ -1668,6 +1684,10 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); assert_eq!(published.relay_count, 1); assert_eq!(published.acknowledged_relay_count, 1); + assert_eq!(refreshed.repair_summary.repaired, 1); + assert_eq!(refreshed.repair_summary.failed, 0); + assert_eq!(refreshed.repair_summary.unchanged, 1); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); assert_eq!(refreshed.repair_results.len(), 2); assert_eq!( @@ -1746,6 +1766,10 @@ async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() -> assert_eq!(published.relay_count, 2); assert_eq!(published.acknowledged_relay_count, 1); assert_eq!(published.relay_results.len(), 2); + assert_eq!(refreshed.repair_summary.repaired, 1); + assert_eq!(refreshed.repair_summary.failed, 1); + assert_eq!(refreshed.repair_summary.unchanged, 0); + assert_eq!(refreshed.repair_summary.skipped, 0); assert_eq!(refreshed.repair_results.len(), 2); assert_eq!( refreshed.remaining_repair_relays,