myc

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

commit d28bfe0e3f5b1e3906c11891915c9df574e3660f
parent d8c73c5fa9e48fb1b927536756f98b4557ac146e
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 15:00:18 +0000

discovery: harden relay availability handling

- separate relay fetch availability from semantic live discovery state
- preserve healthy relay results while surfacing unavailable relay errors explicitly
- require force to refresh when configured discovery relays are unavailable and cover outage cases end to end
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked

Diffstat:
Msrc/app/runtime.rs | 1+
Msrc/audit.rs | 2++
Msrc/cli.rs | 10++++++++++
Msrc/discovery.rs | 209++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/error.rs | 4++++
Msrc/lib.rs | 12++++++------
Mtests/discovery_cli.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/nip46_e2e.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
8 files changed, 461 insertions(+), 39 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -310,6 +310,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { ), crate::audit::MycOperationAuditOutcome::Rejected | crate::audit::MycOperationAuditOutcome::Restored + | crate::audit::MycOperationAuditOutcome::Unavailable | crate::audit::MycOperationAuditOutcome::Drifted | crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!( operation = ?record.operation, diff --git a/src/audit.rs b/src/audit.rs @@ -20,6 +20,7 @@ pub enum MycOperationAuditKind { ConnectAcceptPublish, AuthReplayPublish, AuthReplayRestore, + DiscoveryHandlerFetch, DiscoveryHandlerPublish, DiscoveryHandlerCompare, DiscoveryHandlerRefresh, @@ -31,6 +32,7 @@ pub enum MycOperationAuditOutcome { Succeeded, Rejected, Restored, + Unavailable, Missing, Matched, Drifted, diff --git a/src/cli.rs b/src/cli.rs @@ -172,6 +172,7 @@ pub struct MycOperationOutcomeCounts { pub succeeded: usize, pub rejected: usize, pub restored: usize, + pub unavailable: usize, pub missing: usize, pub matched: usize, pub drifted: usize, @@ -188,6 +189,7 @@ pub struct MycAuditSummaryOutput { pub runtime_operation_outcomes: MycOperationOutcomeCounts, pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, pub runtime_publish_rejection_count: usize, + pub runtime_unavailable_count: usize, pub runtime_replay_restore_count: usize, } @@ -454,6 +456,7 @@ 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_unavailable_count = 0; let mut runtime_replay_restore_count = 0; for record in &audit.runtime_operation_audit { increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome); @@ -465,6 +468,9 @@ fn summarize_audit_output( if record.outcome == MycOperationAuditOutcome::Rejected { runtime_publish_rejection_count += 1; } + if record.outcome == MycOperationAuditOutcome::Unavailable { + runtime_unavailable_count += 1; + } if record.operation == MycOperationAuditKind::AuthReplayRestore && record.outcome == MycOperationAuditOutcome::Restored { @@ -480,6 +486,7 @@ fn summarize_audit_output( runtime_operation_outcomes, runtime_operation_by_kind, runtime_publish_rejection_count, + runtime_unavailable_count, runtime_replay_restore_count, }) } @@ -496,6 +503,7 @@ fn increment_outcome_counts( MycOperationAuditOutcome::Succeeded => counts.succeeded += 1, MycOperationAuditOutcome::Rejected => counts.rejected += 1, MycOperationAuditOutcome::Restored => counts.restored += 1, + MycOperationAuditOutcome::Unavailable => counts.unavailable += 1, MycOperationAuditOutcome::Missing => counts.missing += 1, MycOperationAuditOutcome::Matched => counts.matched += 1, MycOperationAuditOutcome::Drifted => counts.drifted += 1, @@ -510,6 +518,7 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String { MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish".to_owned(), MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(), MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(), + MycOperationAuditKind::DiscoveryHandlerFetch => "discovery_handler_fetch".to_owned(), MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(), MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(), MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(), @@ -698,6 +707,7 @@ mod tests { 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_unavailable_count, 0); assert_eq!(summary.runtime_replay_restore_count, 1); assert_eq!( summary diff --git a/src/discovery.rs b/src/discovery.rs @@ -5,10 +5,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ - RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrEvent, - RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, - radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag, - radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value, + RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrError, + RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, + RadrootsNostrRelayUrl, radroots_nostr_build_application_handler_event, + radroots_nostr_filter_tag, radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value, }; use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri}; use serde::{Deserialize, Serialize}; @@ -118,13 +118,27 @@ pub struct MycLiveNip89Group { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycLiveNip89RelayState { pub relay_url: String, + pub fetch_status: MycDiscoveryRelayFetchStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub fetch_error: Option<String>, pub live_groups: Vec<MycLiveNip89Group>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycDiscoveryRelayFetchStatus { + Available, + Unavailable, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycDiscoveryRelayState { pub relay_url: String, - pub status: MycDiscoveryLiveStatus, + pub fetch_status: MycDiscoveryRelayFetchStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub fetch_error: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub live_status: Option<MycDiscoveryLiveStatus>, pub differing_fields: Vec<String>, pub live_groups: Vec<MycLiveNip89Group>, } @@ -132,6 +146,7 @@ pub struct MycDiscoveryRelayState { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct MycDiscoveryRelaySummary { pub total_relays: usize, + pub unavailable_relays: Vec<String>, pub missing_relays: Vec<String>, pub matched_relays: Vec<String>, pub drifted_relays: Vec<String>, @@ -504,7 +519,7 @@ pub async fn publish_nip89_event( pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; - let fetched = fetch_live_nip89_state(&context).await?; + let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?; Ok(MycFetchedLiveNip89Output { author_public_key_hex: context.app_identity().public_key_hex(), publish_relays: context @@ -521,7 +536,7 @@ pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip8 pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; let local_handler = context.render_normalized_nip89_handler(); - let fetched = fetch_live_nip89_state(&context).await?; + let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?; let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states); let relay_summary = summarize_relay_diffs(&relay_states); let live_groups = fetched.live_groups; @@ -542,7 +557,7 @@ pub async fn refresh_nip89( ) -> Result<MycRefreshedNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; let local_handler = context.render_normalized_nip89_handler(); - let fetched = fetch_live_nip89_state(&context).await?; + let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?; let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states); let relay_summary = summarize_relay_diffs(&relay_states); let live_groups = fetched.live_groups; @@ -558,10 +573,29 @@ pub async fn refresh_nip89( None, compare_request_id, relay_count, - relay_count, + relay_count.saturating_sub(relay_summary.unavailable_relays.len()), compare_summary, )); + if !relay_summary.unavailable_relays.is_empty() && !force { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerRefresh, + MycOperationAuditOutcome::Unavailable, + None, + compare_request_id, + relay_count, + relay_count.saturating_sub(relay_summary.unavailable_relays.len()), + format!( + "discovery relays were unavailable; rerun refresh with --force to override: {}", + relay_summary.unavailable_relays.join(", ") + ), + )); + return Err(MycError::InvalidOperation(format!( + "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}", + relay_summary.unavailable_relays.join(", ") + ))); + } + if status == MycDiscoveryLiveStatus::Conflicted && !force { runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, @@ -569,7 +603,7 @@ pub async fn refresh_nip89( None, compare_request_id, relay_count, - relay_count, + relay_count.saturating_sub(relay_summary.unavailable_relays.len()), "live discovery handler state is conflicted; rerun refresh with --force to override" .to_owned(), )); @@ -586,7 +620,7 @@ pub async fn refresh_nip89( None, compare_request_id, relay_count, - relay_count, + relay_count.saturating_sub(relay_summary.unavailable_relays.len()), "local discovery handler already matches live state".to_owned(), )); return Ok(MycRefreshedNip89Output { @@ -612,6 +646,54 @@ pub async fn refresh_nip89( }) } +async fn fetch_live_nip89_state_for_runtime( + runtime: &MycRuntime, + context: &MycDiscoveryContext, +) -> Result<MycFetchedLiveNip89State, MycError> { + match fetch_live_nip89_state(context).await { + Ok(fetched) => { + let unavailable_relays = fetched + .relay_states + .iter() + .filter(|relay_state| { + relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable + }) + .collect::<Vec<_>>(); + if !unavailable_relays.is_empty() { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerFetch, + MycOperationAuditOutcome::Unavailable, + None, + latest_live_event_id(&fetched.live_groups), + fetched.relay_states.len(), + fetched.relay_states.len() - unavailable_relays.len(), + summarize_unavailable_relays(&fetched.relay_states), + )); + } + Ok(fetched) + } + Err(MycError::DiscoveryFetchUnavailable { + relay_count, + details, + }) => { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerFetch, + MycOperationAuditOutcome::Unavailable, + None, + None, + relay_count, + 0, + details.clone(), + )); + Err(MycError::DiscoveryFetchUnavailable { + relay_count, + details, + }) + } + Err(error) => Err(error), + } +} + pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> { let output_dir = output_dir.as_ref().to_path_buf(); let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); @@ -642,11 +724,35 @@ async fn fetch_live_nip89_state( for relay in context.publish_relays() { let relay_url = relay.to_string(); - let relay_events = fetch_live_nip89_events_for_relay(context, relay).await?; - all_events.extend(relay_events.iter().cloned()); - relay_states.push(MycLiveNip89RelayState { - relay_url, - live_groups: group_live_nip89_events(relay_events)?, + match fetch_live_nip89_events_for_relay(context, relay).await { + Ok(relay_events) => { + all_events.extend(relay_events.iter().cloned()); + relay_states.push(MycLiveNip89RelayState { + relay_url, + fetch_status: MycDiscoveryRelayFetchStatus::Available, + fetch_error: None, + live_groups: group_live_nip89_events(relay_events)?, + }); + } + Err(error) => { + relay_states.push(MycLiveNip89RelayState { + relay_url, + fetch_status: MycDiscoveryRelayFetchStatus::Unavailable, + fetch_error: Some(error.to_string()), + live_groups: Vec::new(), + }); + } + } + } + + let available_relay_count = relay_states + .iter() + .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available) + .count(); + if available_relay_count == 0 { + return Err(MycError::DiscoveryFetchUnavailable { + relay_count: relay_states.len(), + details: summarize_unavailable_relays(&relay_states), }); } @@ -662,10 +768,13 @@ async fn fetch_live_nip89_events_for_relay( ) -> Result<Vec<MycSourcedLiveNip89Event>, MycError> { let client = RadrootsNostrClient::from_identity(context.app_identity()); let _ = client.add_relay(relay.as_str()).await?; - client.connect().await; client - .wait_for_connection(Duration::from_secs(context.connect_timeout_secs())) - .await; + .try_connect_relay( + relay.as_str(), + Duration::from_secs(context.connect_timeout_secs()), + ) + .await + .map_err(RadrootsNostrError::from)?; let mut filter = RadrootsNostrFilter::new() .author(context.app_identity().public_key()) @@ -752,7 +861,7 @@ fn describe_compare_status( live_groups: &[MycLiveNip89Group], relay_summary: &MycDiscoveryRelaySummary, ) -> String { - match status { + let base = match status { MycDiscoveryLiveStatus::Missing => { "no live NIP-89 handler was found for the configured discovery identity".to_owned() } @@ -775,6 +884,15 @@ fn describe_compare_status( relay_summary.missing_relays.len(), relay_summary.conflicted_relays.len(), ), + }; + + if relay_summary.unavailable_relays.is_empty() { + base + } else { + format!( + "{base}; unavailable relays: {}", + relay_summary.unavailable_relays.join(", ") + ) } } @@ -935,11 +1053,19 @@ fn build_relay_diffs( relay_states .iter() .map(|relay_state| { - let (status, differing_fields) = - compare_live_handler(local_handler, &relay_state.live_groups); + let (live_status, differing_fields) = + if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { + (None, Vec::new()) + } else { + let (status, differing_fields) = + compare_live_handler(local_handler, &relay_state.live_groups); + (Some(status), differing_fields) + }; MycDiscoveryRelayState { relay_url: relay_state.relay_url.clone(), - status, + fetch_status: relay_state.fetch_status, + fetch_error: relay_state.fetch_error.clone(), + live_status, differing_fields, live_groups: relay_state.live_groups.clone(), } @@ -954,25 +1080,52 @@ fn summarize_relay_diffs(relay_states: &[MycDiscoveryRelayState]) -> MycDiscover }; for relay_state in relay_states { - match relay_state.status { - MycDiscoveryLiveStatus::Missing => { + if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { + summary + .unavailable_relays + .push(relay_state.relay_url.clone()); + continue; + } + match relay_state.live_status { + Some(MycDiscoveryLiveStatus::Missing) => { summary.missing_relays.push(relay_state.relay_url.clone()) } - MycDiscoveryLiveStatus::Matched => { + Some(MycDiscoveryLiveStatus::Matched) => { summary.matched_relays.push(relay_state.relay_url.clone()) } - MycDiscoveryLiveStatus::Drifted => { + Some(MycDiscoveryLiveStatus::Drifted) => { summary.drifted_relays.push(relay_state.relay_url.clone()) } - MycDiscoveryLiveStatus::Conflicted => summary + Some(MycDiscoveryLiveStatus::Conflicted) => summary .conflicted_relays .push(relay_state.relay_url.clone()), + None => {} } } summary } +fn summarize_unavailable_relays(relay_states: &[MycLiveNip89RelayState]) -> String { + let unavailable = relay_states + .iter() + .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable) + .map(|relay_state| { + let details = relay_state + .fetch_error + .as_deref() + .unwrap_or("unknown relay fetch failure"); + format!("{}: {details}", relay_state.relay_url) + }) + .collect::<Vec<_>>(); + + if unavailable.is_empty() { + "all configured discovery relays were available".to_owned() + } else { + format!("unavailable discovery relays: {}", unavailable.join("; ")) + } +} + fn latest_group_sort_key(group: &MycLiveNip89Group) -> (u64, &str) { group .events diff --git a/src/error.rs b/src/error.rs @@ -73,6 +73,10 @@ pub enum MycError { InvalidDiscoveryBundle(String), #[error("invalid discovery event: {0}")] InvalidDiscoveryEvent(String), + #[error( + "failed to fetch discovery state from all configured relays ({relay_count}): {details}" + )] + DiscoveryFetchUnavailable { relay_count: usize, details: String }, #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] diff --git a/src/lib.rs b/src/lib.rs @@ -23,12 +23,12 @@ pub use config::{ pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, - MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayState, - MycDiscoveryRelaySummary, 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, + MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, + MycDiscoveryRelayState, MycDiscoveryRelaySummary, 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, MycTransportSnapshot}; diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fs; +use std::net::TcpListener as StdTcpListener; use std::path::Path; use std::process::{Command, Output}; use std::sync::Arc; @@ -357,6 +358,13 @@ fn run_myc(config_path: &Path, args: &[&str]) -> TestResult<Output> { .output()?) } +fn unavailable_relay_url() -> TestResult<String> { + let listener = StdTcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + drop(listener); + Ok(format!("ws://{addr}")) +} + async fn publish_handler_event( relay_url: &str, identity: &RadrootsIdentity, @@ -562,7 +570,11 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { 1 ); assert_eq!( - diff_output["relay_states"][0]["status"], + diff_output["relay_states"][0]["fetch_status"], + Value::String("available".to_owned()) + ); + assert_eq!( + diff_output["relay_states"][0]["live_status"], Value::String("matched".to_owned()) ); @@ -633,6 +645,12 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { .len(), 1 ); + assert!( + diff_output["relay_summary"]["unavailable_relays"] + .as_array() + .unwrap() + .is_empty() + ); let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; assert!( @@ -789,6 +807,97 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul Value::Array(vec![]) ); assert_eq!(diff_output["relay_states"].as_array().unwrap().len(), 2); + for relay_state in diff_output["relay_states"].as_array().unwrap() { + assert_eq!( + relay_state["fetch_status"], + Value::String("available".to_owned()) + ); + assert!(relay_state["live_status"].is_string()); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_the_cli() +-> TestResult<()> { + let relay = TestRelay::spawn().await?; + 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"); + 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.url(), unavailable_relay.as_str()], + ); + + let inspect = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?; + assert!( + inspect.status.success(), + "inspect-live-nip89 failed: {}", + String::from_utf8_lossy(&inspect.stderr) + ); + let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?; + assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 0); + assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2); + assert!( + inspect_output["relay_states"] + .as_array() + .unwrap() + .iter() + .any(|relay_state| { + relay_state["relay_url"] == Value::String(unavailable_relay.clone()) + && relay_state["fetch_status"] == Value::String("unavailable".to_owned()) + && relay_state["live_status"].is_null() + && relay_state["fetch_error"].is_string() + }) + ); + + let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + assert!( + !refresh.status.success(), + "refresh-nip89 unexpectedly succeeded: {}", + String::from_utf8_lossy(&refresh.stdout) + ); + assert!( + String::from_utf8_lossy(&refresh.stderr).contains("unavailable"), + "unexpected refresh stderr: {}", + String::from_utf8_lossy(&refresh.stderr) + ); + + let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; + assert!( + forced_refresh.status.success(), + "refresh-nip89 --force failed: {}", + String::from_utf8_lossy(&forced_refresh.stderr) + ); + let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?; + assert_eq!(forced_refresh_output["status"], "missing"); + assert_eq!( + forced_refresh_output["relay_summary"]["unavailable_relays"], + Value::Array(vec![Value::String(unavailable_relay.clone())]) + ); + assert!(forced_refresh_output["published"].is_object()); Ok(()) } diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, VecDeque}; +use std::net::TcpListener as StdTcpListener; use std::sync::Arc; use std::time::Duration; @@ -6,8 +7,9 @@ use futures_util::{SinkExt, StreamExt}; use myc::control; use myc::{ MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus, - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, - diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89, + MycDiscoveryRelayFetchStatus, MycOperationAuditKind, MycOperationAuditOutcome, + MycOperationAuditRecord, MycRuntime, diff_live_nip89, fetch_live_nip89, publish_nip89_event, + refresh_nip89, }; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; @@ -416,6 +418,13 @@ fn identity(secret_key: &str) -> RadrootsIdentity { RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") } +fn unavailable_relay_url() -> TestResult<String> { + let listener = StdTcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + drop(listener); + Ok(format!("ws://{addr}")) +} + async fn publish_handler_event( relay_url: &str, identity: &RadrootsIdentity, @@ -1129,6 +1138,43 @@ async fn fetch_live_nip89_reports_missing_when_handler_is_unpublished() -> TestR assert_eq!(output.handler_identifier, "myc"); assert_eq!(output.publish_relays, vec![relay.url().to_owned()]); assert!(output.live_groups.is_empty()); + assert_eq!(output.relay_states.len(), 1); + assert_eq!( + output.relay_states[0].fetch_status, + MycDiscoveryRelayFetchStatus::Available + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fetch_live_nip89_fails_when_all_discovery_relays_are_unavailable() -> TestResult<()> { + let unavailable_a = unavailable_relay_url()?; + let unavailable_b = unavailable_relay_url()?; + let test_runtime = MycTestRuntime::new_with_discovery_relays( + &[unavailable_a.as_str(), unavailable_b.as_str()], + MycConnectionApproval::ExplicitUser, + ); + + let error = fetch_live_nip89(&test_runtime.runtime) + .await + .expect_err("all-unavailable discovery fetch should fail"); + assert!( + error + .to_string() + .contains("failed to fetch discovery state from all configured relays") + ); + + let audit = wait_for_operation_audit_count(&test_runtime.runtime, 1).await?; + assert_eq!( + audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerFetch + ); + assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable); + assert_eq!(audit[0].relay_count, 2); + assert_eq!(audit[0].acknowledged_relay_count, 0); + assert!(audit[0].relay_outcome_summary.contains(&unavailable_a)); + assert!(audit[0].relay_outcome_summary.contains(&unavailable_b)); Ok(()) } @@ -1410,6 +1456,7 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu diff.relay_summary.drifted_relays, vec![relay_b.url().to_owned()] ); + assert!(diff.relay_summary.unavailable_relays.is_empty()); assert!(diff.relay_summary.missing_relays.is_empty()); assert!(diff.relay_summary.conflicted_relays.is_empty()); @@ -1418,7 +1465,14 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu .iter() .find(|relay_state| relay_state.relay_url == relay_a.url()) .expect("matched relay"); - assert_eq!(matched_relay.status, MycDiscoveryLiveStatus::Matched); + assert_eq!( + matched_relay.fetch_status, + MycDiscoveryRelayFetchStatus::Available + ); + assert_eq!( + matched_relay.live_status, + Some(MycDiscoveryLiveStatus::Matched) + ); assert_eq!(matched_relay.live_groups.len(), 1); assert_eq!( matched_relay.live_groups[0].source_relays, @@ -1430,7 +1484,14 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu .iter() .find(|relay_state| relay_state.relay_url == relay_b.url()) .expect("drifted relay"); - assert_eq!(drifted_relay.status, MycDiscoveryLiveStatus::Drifted); + assert_eq!( + drifted_relay.fetch_status, + MycDiscoveryRelayFetchStatus::Available + ); + assert_eq!( + drifted_relay.live_status, + Some(MycDiscoveryLiveStatus::Drifted) + ); assert_eq!(drifted_relay.live_groups.len(), 1); assert_eq!( drifted_relay.live_groups[0].source_relays, @@ -1449,6 +1510,88 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_requires_force_when_any_discovery_relay_is_unavailable() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let unavailable_relay = unavailable_relay_url()?; + let test_runtime = MycTestRuntime::new_with_discovery_relays( + &[relay.url(), unavailable_relay.as_str()], + MycConnectionApproval::ExplicitUser, + ); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + let diff = diff_live_nip89(&runtime).await?; + assert_eq!(diff.status, MycDiscoveryLiveStatus::Missing); + assert_eq!( + diff.relay_summary.unavailable_relays, + vec![unavailable_relay.clone()] + ); + assert_eq!( + diff.relay_summary.missing_relays, + vec![relay.url().to_owned()] + ); + + let unavailable_state = diff + .relay_states + .iter() + .find(|relay_state| relay_state.relay_url == unavailable_relay) + .expect("unavailable relay"); + assert_eq!( + unavailable_state.fetch_status, + MycDiscoveryRelayFetchStatus::Unavailable + ); + assert_eq!(unavailable_state.live_status, None); + assert!(unavailable_state.fetch_error.is_some()); + + let error = refresh_nip89(&runtime, false) + .await + .expect_err("refresh without force should fail when a relay is unavailable"); + assert!(error.to_string().contains("unavailable")); + + let audit = wait_for_operation_audit_count(&runtime, 4).await?; + assert_eq!( + audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerFetch + ); + assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable); + assert_eq!( + audit[1].operation, + MycOperationAuditKind::DiscoveryHandlerFetch + ); + assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Unavailable); + assert_eq!( + audit[2].operation, + MycOperationAuditKind::DiscoveryHandlerCompare + ); + assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Missing); + assert_eq!( + audit[3].operation, + MycOperationAuditKind::DiscoveryHandlerRefresh + ); + assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Unavailable); + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + let refreshed = refresh_nip89(&runtime, true).await?; + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); + assert_eq!( + refreshed.relay_summary.unavailable_relays, + vec![unavailable_relay.clone()] + ); + assert!(refreshed.published.is_some()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime =