myc

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

commit 499e51644dd4408a7edddd894e799a5a6e456021
parent 687db8274eafba7aadd2b3f8f9863546d4db6791
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 17:00:37 +0000

audit: persist blocked discovery repair state

Diffstat:
Msrc/audit.rs | 43++++++++++++++++++++++++++++++++++++++++++-
Msrc/cli.rs | 34+++++++++++++++++++++++++++++++++-
Msrc/discovery.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/main.rs | 10++++++++++
Mtests/discovery_cli.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 295 insertions(+), 9 deletions(-)

diff --git a/src/audit.rs b/src/audit.rs @@ -54,6 +54,12 @@ pub struct MycOperationAuditRecord { pub request_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub attempt_id: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub planned_repair_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option<String>, pub relay_count: usize, pub acknowledged_relay_count: usize, pub relay_outcome_summary: String, @@ -83,6 +89,9 @@ impl MycOperationAuditRecord { connection_id: connection_id.map(ToString::to_string), request_id: request_id.map(ToOwned::to_owned), attempt_id: None, + planned_repair_relays: Vec::new(), + blocked_relays: Vec::new(), + blocked_reason: None, relay_count, acknowledged_relay_count, relay_outcome_summary: relay_outcome_summary.into(), @@ -98,6 +107,21 @@ impl MycOperationAuditRecord { self.attempt_id = Some(attempt_id.into()); self } + + pub fn with_planned_repair_relays(mut self, planned_repair_relays: Vec<String>) -> Self { + self.planned_repair_relays = planned_repair_relays; + self + } + + pub fn with_blocked_relays( + mut self, + blocked_reason: impl Into<String>, + blocked_relays: Vec<String>, + ) -> Self { + self.blocked_reason = Some(blocked_reason.into()); + self.blocked_relays = blocked_relays; + self + } } impl MycOperationAuditStore { @@ -542,7 +566,12 @@ mod tests { 0, "first attempt rejected", ) - .with_attempt_id("attempt-1"), + .with_attempt_id("attempt-1") + .with_planned_repair_relays(vec!["wss://relay-a.example.com".to_owned()]) + .with_blocked_relays( + "unavailable_relays", + vec!["wss://relay-b.example.com".to_owned()], + ), ) .expect("append first attempt"); store @@ -585,6 +614,18 @@ mod tests { .all(|record| record.attempt_id.as_deref() == Some("attempt-1")) ); assert_eq!( + attempt_records[0].planned_repair_relays, + vec!["wss://relay-a.example.com".to_owned()] + ); + assert_eq!( + attempt_records[0].blocked_relays, + vec!["wss://relay-b.example.com".to_owned()] + ); + assert_eq!( + attempt_records[0].blocked_reason.as_deref(), + Some("unavailable_relays") + ); + assert_eq!( store .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh) .expect("latest attempt"), diff --git a/src/cli.rs b/src/cli.rs @@ -240,6 +240,10 @@ pub struct MycDiscoveryRepairAttemptSummaryOutput { #[serde(skip_serializing_if = "Option::is_none")] pub aggregate_publish_relay_outcome_summary: Option<String>, pub repair_summary: MycDiscoveryRepairSummary, + pub planned_repair_relays: Vec<String>, + pub blocked_relays: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option<String>, pub failed_relays: Vec<String>, pub remaining_repair_relays: Vec<String>, } @@ -705,6 +709,10 @@ impl MycDiscoveryRepairAttemptSummaryOutput { (record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh) .then_some(record.outcome) }); + let refresh_record = records + .iter() + .rev() + .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh); let publish_record = records .iter() .rev() @@ -731,6 +739,27 @@ impl MycDiscoveryRepairAttemptSummaryOutput { } failed_relays.sort(); failed_relays.dedup(); + let planned_repair_relays = refresh_record + .map(|record| record.planned_repair_relays.clone()) + .unwrap_or_default(); + let blocked_relays = refresh_record + .map(|record| record.blocked_relays.clone()) + .unwrap_or_default(); + let blocked_reason = refresh_record.and_then(|record| record.blocked_reason.clone()); + let remaining_repair_relays = if !failed_relays.is_empty() { + failed_relays.clone() + } else if matches!( + refresh_outcome, + Some( + MycOperationAuditOutcome::Unavailable + | MycOperationAuditOutcome::Conflicted + | MycOperationAuditOutcome::Rejected + ) + ) { + planned_repair_relays.clone() + } else { + Vec::new() + }; Ok(Self { attempt_id: attempt_id.to_owned(), @@ -746,8 +775,11 @@ impl MycDiscoveryRepairAttemptSummaryOutput { aggregate_publish_relay_outcome_summary: publish_record .map(|record| record.relay_outcome_summary.clone()), repair_summary, + planned_repair_relays, + blocked_relays, + blocked_reason, failed_relays: failed_relays.clone(), - remaining_repair_relays: failed_relays, + remaining_repair_relays, }) } } diff --git a/src/discovery.rs b/src/discovery.rs @@ -216,6 +216,12 @@ pub struct MycRefreshedNip89Output { pub published: Option<MycPublishedNip89Output>, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MycDiscoveryRefreshPlan { + selected_relays: Vec<RadrootsNostrRelayUrl>, + planned_repair_relays: Vec<String>, +} + #[derive(Debug, Clone)] struct MycSourcedLiveNip89Event { source_relay: String, @@ -611,6 +617,7 @@ pub async fn refresh_nip89( ) -> Result<MycRefreshedNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; let attempt_id = RadrootsNostrSignerRequestId::new_v7().into_string(); + let configured_publish_relays = relay_urls_to_strings(context.publish_relays()); let local_handler = context.render_normalized_nip89_handler(); let fetched = match fetch_live_nip89_state_for_runtime( runtime, @@ -634,7 +641,8 @@ pub async fn refresh_nip89( 0, details.clone(), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_blocked_relays("all_relays_unavailable", configured_publish_relays.clone()), ); return Err(MycError::DiscoveryFetchUnavailable { relay_count, @@ -654,6 +662,8 @@ pub async fn refresh_nip89( let compare_request_id = latest_live_event_id(&live_groups); let compare_summary = describe_compare_status(status, &differing_fields, &live_groups, &relay_summary); + let blocked_refresh_plan = build_refresh_plan(&context, &relay_states, true) + .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?; runtime.record_operation_audit( &MycOperationAuditRecord::new( @@ -682,7 +692,12 @@ pub async fn refresh_nip89( relay_summary.unavailable_relays.join(", ") ), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone()) + .with_blocked_relays( + "unavailable_relays", + relay_summary.unavailable_relays.clone(), + ), ); return Err( MycError::InvalidOperation(format!( @@ -705,7 +720,12 @@ pub async fn refresh_nip89( "live discovery handler state is conflicted; rerun refresh with --force to override" .to_owned(), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone()) + .with_blocked_relays( + "conflicted_relays", + relay_summary.conflicted_relays.clone(), + ), ); return Err( MycError::InvalidOperation( @@ -716,8 +736,10 @@ pub async fn refresh_nip89( ); } - let refresh_relays = select_refresh_relays(&context, &relay_states, force) + let refresh_plan = build_refresh_plan(&context, &relay_states, force) .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?; + let refresh_relays = refresh_plan.selected_relays; + let refresh_relay_urls = relay_urls_to_strings(&refresh_relays); if refresh_relays.is_empty() { let repair_results = build_repair_results(&context, &relay_states, &[], None, None); @@ -738,7 +760,8 @@ pub async fn refresh_nip89( relay_count.saturating_sub(relay_summary.unavailable_relays.len()), "local discovery handler already matches live state".to_owned(), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_planned_repair_relays(refresh_relay_urls.clone()), ); return Ok(MycRefreshedNip89Output { attempt_id, @@ -796,7 +819,8 @@ pub async fn refresh_nip89( repair_summary.skipped ), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_planned_repair_relays(refresh_relay_urls.clone()), ); return Ok(MycRefreshedNip89Output { attempt_id, @@ -838,13 +862,30 @@ pub async fn refresh_nip89( repair_summary.skipped ), ) - .with_attempt_id(attempt_id.clone()), + .with_attempt_id(attempt_id.clone()) + .with_planned_repair_relays(refresh_relay_urls.clone()), ); return Err(error.with_discovery_refresh_attempt_id(attempt_id)); } } } +fn build_refresh_plan( + context: &MycDiscoveryContext, + relay_states: &[MycDiscoveryRelayState], + force: bool, +) -> Result<MycDiscoveryRefreshPlan, MycError> { + let selected_relays = select_refresh_relays(context, relay_states, force)?; + Ok(MycDiscoveryRefreshPlan { + selected_relays: selected_relays.clone(), + planned_repair_relays: relay_urls_to_strings(&selected_relays), + }) +} + +fn relay_urls_to_strings(relays: &[RadrootsNostrRelayUrl]) -> Vec<String> { + relays.iter().map(ToString::to_string).collect() +} + fn select_refresh_relays( context: &MycDiscoveryContext, relay_states: &[MycDiscoveryRelayState], diff --git a/src/main.rs b/src/main.rs @@ -1,5 +1,7 @@ #![forbid(unsafe_code)] +use serde_json::json; + #[tokio::main] async fn main() { if let Err(err) = myc::run_cli().await { @@ -9,6 +11,14 @@ async fn main() { eprintln!( "myc: inspect with `myc audit discovery-repair-attempt --attempt-id {attempt_id}`" ); + let hint = json!({ + "attempt_id": attempt_id, + "inspect_args": ["audit", "discovery-repair-attempt", "--attempt-id", attempt_id], + }); + eprintln!( + "myc: discovery repair attempt json: {}", + serde_json::to_string(&hint).expect("discovery repair attempt hint json") + ); } std::process::exit(1); } diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -387,6 +387,13 @@ fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> { .find_map(|line| line.strip_prefix("myc: discovery repair attempt id: ")) } +fn extract_discovery_attempt_hint(stderr: &str) -> Option<Value> { + stderr.lines().find_map(|line| { + line.strip_prefix("myc: discovery repair attempt json: ") + .and_then(|json| serde_json::from_str(json).ok()) + }) +} + fn unavailable_relay_url() -> TestResult<String> { let listener = StdTcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?; @@ -694,6 +701,20 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { ); let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); + assert_eq!( + attempt_hint["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + assert_eq!( + attempt_hint["inspect_args"], + Value::Array(vec![ + Value::String("audit".to_owned()), + Value::String("discovery-repair-attempt".to_owned()), + Value::String("--attempt-id".to_owned()), + Value::String(attempt_id.to_owned()), + ]) + ); let attempt = run_myc( &config_path, &[ @@ -717,6 +738,22 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { attempt_output["refresh_outcome"], Value::String("conflicted".to_owned()) ); + assert_eq!( + attempt_output["planned_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); + assert_eq!( + attempt_output["blocked_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); + assert_eq!( + attempt_output["blocked_reason"], + Value::String("conflicted_relays".to_owned()) + ); + assert_eq!( + attempt_output["remaining_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; assert!( @@ -880,6 +917,11 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> "unexpected refresh stderr: {refresh_stderr}" ); let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); + assert_eq!( + attempt_hint["attempt_id"], + Value::String(attempt_id.to_owned()) + ); let attempt = run_myc( &config_path, @@ -916,6 +958,12 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> attempt_output["remaining_repair_relays"], Value::Array(vec![Value::String(relay.url().to_owned())]) ); + assert_eq!( + attempt_output["planned_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); + assert_eq!(attempt_output["blocked_relays"], Value::Array(vec![])); + assert!(attempt_output["blocked_reason"].is_null()); Ok(()) } @@ -1309,6 +1357,11 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th ); let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); + assert_eq!( + attempt_hint["attempt_id"], + Value::String(attempt_id.to_owned()) + ); let attempt = run_myc( &config_path, &[ @@ -1332,6 +1385,22 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th attempt_output["refresh_outcome"], Value::String("unavailable".to_owned()) ); + assert_eq!( + attempt_output["planned_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); + assert_eq!( + attempt_output["blocked_relays"], + Value::Array(vec![Value::String(unavailable_relay.clone())]) + ); + assert_eq!( + attempt_output["blocked_reason"], + Value::String("unavailable_relays".to_owned()) + ); + assert_eq!( + attempt_output["remaining_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; assert!( @@ -1349,3 +1418,96 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavailable() +-> TestResult<()> { + let unavailable_relay = unavailable_relay_url()?; + 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"); + + write_identity( + &signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + write_identity( + &app_identity_path, + "3333333333333333333333333333333333333333333333333333333333333333", + ); + write_config( + &config_path, + &state_dir, + &signer_identity_path, + &user_identity_path, + &app_identity_path, + &[unavailable_relay.as_str()], + ); + + let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + assert!( + !refresh.status.success(), + "refresh-nip89 unexpectedly succeeded: {}", + String::from_utf8_lossy(&refresh.stdout) + ); + let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); + assert!( + refresh_stderr.contains("failed to fetch discovery state from all configured relays"), + "unexpected refresh stderr: {refresh_stderr}" + ); + let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); + assert_eq!( + attempt_hint["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + + let attempt = run_myc( + &config_path, + &[ + "audit", + "discovery-repair-attempt", + "--attempt-id", + attempt_id, + ], + )?; + assert!( + attempt.status.success(), + "discovery-repair-attempt failed: {}", + String::from_utf8_lossy(&attempt.stderr) + ); + let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; + assert_eq!( + attempt_output["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + assert_eq!( + attempt_output["refresh_outcome"], + Value::String("unavailable".to_owned()) + ); + assert_eq!( + attempt_output["planned_repair_relays"], + Value::Array(vec![]) + ); + assert_eq!( + attempt_output["blocked_relays"], + Value::Array(vec![Value::String(unavailable_relay)]) + ); + assert_eq!( + attempt_output["blocked_reason"], + Value::String("all_relays_unavailable".to_owned()) + ); + assert_eq!( + attempt_output["remaining_repair_relays"], + Value::Array(vec![]) + ); + + Ok(()) +}