myc

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

discovery.rs (84335B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 use std::fs;
      3 use std::path::{Path, PathBuf};
      4 use std::time::{Duration, SystemTime, UNIX_EPOCH};
      5 
      6 use radroots_nostr::prelude::{
      7     RadrootsNostrApplicationHandlerSpec, RadrootsNostrError, RadrootsNostrEvent,
      8     RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl,
      9     radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag,
     10     radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value,
     11 };
     12 use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
     13 use radroots_nostr_signer::prelude::RadrootsNostrSignerRequestId;
     14 use serde::{Deserialize, Serialize};
     15 use tokio::task::JoinSet;
     16 
     17 use crate::app::MycRuntime;
     18 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
     19 use crate::config::MycDiscoveryMetadataConfig;
     20 use crate::custody::{MycActiveIdentity, MycIdentityProvider};
     21 use crate::error::MycError;
     22 use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord};
     23 use crate::transport::{MycNostrTransport, MycPublishOutcome, MycRelayPublishResult};
     24 
     25 const NIP46_RPC_KIND: u32 = 24_133;
     26 const DISCOVERY_BUNDLE_VERSION: u32 = 1;
     27 const DISCOVERY_BUNDLE_MANIFEST_FILE_NAME: &str = "bundle.json";
     28 const DISCOVERY_BUNDLE_NIP89_FILE_NAME: &str = "nip89-handler.json";
     29 const DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json";
     30 const DISCOVERY_RELAY_FETCH_CONCURRENCY_LIMIT: usize = 8;
     31 
     32 #[derive(Clone)]
     33 pub struct MycDiscoveryContext {
     34     app_identity: MycActiveIdentity,
     35     signer_identity: MycActiveIdentity,
     36     domain: String,
     37     handler_identifier: String,
     38     public_relays: Vec<RadrootsNostrRelayUrl>,
     39     publish_relays: Vec<RadrootsNostrRelayUrl>,
     40     nostrconnect_url: Option<String>,
     41     metadata: Option<RadrootsNostrMetadata>,
     42     nip05_output_path: Option<PathBuf>,
     43     connect_timeout_secs: u64,
     44 }
     45 
     46 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     47 pub struct MycNip05Document {
     48     pub names: BTreeMap<String, String>,
     49     pub nip46: MycNip05DocumentSection,
     50 }
     51 
     52 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     53 pub struct MycNip05DocumentSection {
     54     pub relays: Vec<String>,
     55     #[serde(skip_serializing_if = "Option::is_none")]
     56     pub nostrconnect_url: Option<String>,
     57 }
     58 
     59 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     60 pub struct MycRenderedNip05Output {
     61     pub domain: String,
     62     #[serde(skip_serializing_if = "Option::is_none")]
     63     pub output_path: Option<PathBuf>,
     64     pub document: MycNip05Document,
     65 }
     66 
     67 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     68 pub struct MycRenderedNip89Output {
     69     pub author_public_key_hex: String,
     70     pub signer_public_key_hex: String,
     71     pub publish_relays: Vec<String>,
     72     pub event: RadrootsNostrEvent,
     73 }
     74 
     75 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     76 pub struct MycPublishedNip89Output {
     77     pub author_public_key_hex: String,
     78     pub signer_public_key_hex: String,
     79     pub publish_relays: Vec<String>,
     80     pub relay_count: usize,
     81     pub acknowledged_relay_count: usize,
     82     pub relay_outcome_summary: String,
     83     pub relay_results: Vec<MycRelayPublishResult>,
     84     pub event: RadrootsNostrEvent,
     85 }
     86 
     87 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
     88 #[serde(rename_all = "snake_case")]
     89 pub enum MycDiscoveryRepairOutcome {
     90     Repaired,
     91     Failed,
     92     Unchanged,
     93     Skipped,
     94 }
     95 
     96 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
     97 pub struct MycDiscoveryRepairSummary {
     98     pub repaired: usize,
     99     pub failed: usize,
    100     pub unchanged: usize,
    101     pub skipped: usize,
    102 }
    103 
    104 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    105 pub struct MycDiscoveryRelayRepairResult {
    106     pub relay_url: String,
    107     pub outcome: MycDiscoveryRepairOutcome,
    108     #[serde(default, skip_serializing_if = "Option::is_none")]
    109     pub detail: Option<String>,
    110 }
    111 
    112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    113 #[serde(rename_all = "snake_case")]
    114 pub enum MycDiscoveryLiveStatus {
    115     Missing,
    116     Matched,
    117     Drifted,
    118     Conflicted,
    119 }
    120 
    121 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    122 pub struct MycNormalizedNip89Handler {
    123     pub author_public_key_hex: String,
    124     pub kinds: Vec<u32>,
    125     pub identifier: String,
    126     pub relays: Vec<String>,
    127     #[serde(skip_serializing_if = "Option::is_none")]
    128     pub nostrconnect_url: Option<String>,
    129     #[serde(skip_serializing_if = "Option::is_none")]
    130     pub metadata: Option<RadrootsNostrMetadata>,
    131 }
    132 
    133 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    134 pub struct MycLiveNip89Event {
    135     pub event_id_hex: String,
    136     pub created_at_unix: u64,
    137     pub source_relays: Vec<String>,
    138     pub handler: MycNormalizedNip89Handler,
    139 }
    140 
    141 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    142 pub struct MycLiveNip89Group {
    143     pub handler: MycNormalizedNip89Handler,
    144     pub source_relays: Vec<String>,
    145     pub events: Vec<MycLiveNip89Event>,
    146 }
    147 
    148 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    149 pub struct MycLiveNip89RelayState {
    150     pub relay_url: String,
    151     pub fetch_status: MycDiscoveryRelayFetchStatus,
    152     #[serde(skip_serializing_if = "Option::is_none")]
    153     pub fetch_error: Option<String>,
    154     pub live_groups: Vec<MycLiveNip89Group>,
    155 }
    156 
    157 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    158 #[serde(rename_all = "snake_case")]
    159 pub enum MycDiscoveryRelayFetchStatus {
    160     Available,
    161     Unavailable,
    162 }
    163 
    164 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    165 pub struct MycDiscoveryRelayState {
    166     pub relay_url: String,
    167     pub fetch_status: MycDiscoveryRelayFetchStatus,
    168     #[serde(skip_serializing_if = "Option::is_none")]
    169     pub fetch_error: Option<String>,
    170     #[serde(skip_serializing_if = "Option::is_none")]
    171     pub live_status: Option<MycDiscoveryLiveStatus>,
    172     pub differing_fields: Vec<String>,
    173     pub live_groups: Vec<MycLiveNip89Group>,
    174 }
    175 
    176 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
    177 pub struct MycDiscoveryRelaySummary {
    178     pub total_relays: usize,
    179     pub unavailable_relays: Vec<String>,
    180     pub missing_relays: Vec<String>,
    181     pub matched_relays: Vec<String>,
    182     pub drifted_relays: Vec<String>,
    183     pub conflicted_relays: Vec<String>,
    184 }
    185 
    186 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    187 pub struct MycFetchedLiveNip89Output {
    188     pub author_public_key_hex: String,
    189     pub publish_relays: Vec<String>,
    190     pub handler_identifier: String,
    191     pub live_groups: Vec<MycLiveNip89Group>,
    192     pub relay_states: Vec<MycLiveNip89RelayState>,
    193 }
    194 
    195 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    196 pub struct MycDiscoveryDiffOutput {
    197     pub status: MycDiscoveryLiveStatus,
    198     pub local_handler: MycNormalizedNip89Handler,
    199     pub live_groups: Vec<MycLiveNip89Group>,
    200     pub relay_states: Vec<MycDiscoveryRelayState>,
    201     pub relay_summary: MycDiscoveryRelaySummary,
    202     pub differing_fields: Vec<String>,
    203 }
    204 
    205 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    206 pub struct MycRefreshedNip89Output {
    207     pub attempt_id: String,
    208     pub status: MycDiscoveryLiveStatus,
    209     pub force: bool,
    210     pub differing_fields: Vec<String>,
    211     pub live_groups: Vec<MycLiveNip89Group>,
    212     pub relay_states: Vec<MycDiscoveryRelayState>,
    213     pub relay_summary: MycDiscoveryRelaySummary,
    214     pub repair_summary: MycDiscoveryRepairSummary,
    215     pub repair_results: Vec<MycDiscoveryRelayRepairResult>,
    216     pub remaining_repair_relays: Vec<String>,
    217     pub published: Option<MycPublishedNip89Output>,
    218 }
    219 
    220 #[derive(Debug, Clone, PartialEq, Eq)]
    221 struct MycDiscoveryRefreshPlan {
    222     selected_relays: Vec<RadrootsNostrRelayUrl>,
    223     planned_repair_relays: Vec<String>,
    224 }
    225 
    226 #[derive(Debug, Clone)]
    227 struct MycSourcedLiveNip89Event {
    228     source_relay: String,
    229     event: RadrootsNostrEvent,
    230 }
    231 
    232 #[derive(Debug, Clone)]
    233 struct MycFetchedLiveNip89State {
    234     live_groups: Vec<MycLiveNip89Group>,
    235     relay_states: Vec<MycLiveNip89RelayState>,
    236 }
    237 
    238 #[derive(Debug)]
    239 struct MycRelayFetchTaskOutput {
    240     relay_index: usize,
    241     relay_events: Vec<MycSourcedLiveNip89Event>,
    242     relay_state: MycLiveNip89RelayState,
    243 }
    244 
    245 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    246 pub struct MycNip89HandlerDocument {
    247     pub kinds: Vec<u32>,
    248     pub identifier: String,
    249     pub relays: Vec<String>,
    250     #[serde(skip_serializing_if = "Option::is_none")]
    251     pub nostrconnect_url: Option<String>,
    252     #[serde(skip_serializing_if = "Option::is_none")]
    253     pub metadata: Option<RadrootsNostrMetadata>,
    254 }
    255 
    256 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    257 pub struct MycDiscoveryBundleManifest {
    258     pub version: u32,
    259     pub domain: String,
    260     pub author_public_key_hex: String,
    261     pub signer_public_key_hex: String,
    262     pub public_relays: Vec<String>,
    263     pub publish_relays: Vec<String>,
    264     #[serde(skip_serializing_if = "Option::is_none")]
    265     pub nostrconnect_url: Option<String>,
    266     pub nip05_relative_path: String,
    267     pub nip89_relative_path: String,
    268 }
    269 
    270 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    271 pub struct MycDiscoveryBundleOutput {
    272     pub output_dir: PathBuf,
    273     pub manifest_path: PathBuf,
    274     pub nip05_path: PathBuf,
    275     pub nip89_handler_path: PathBuf,
    276     pub manifest: MycDiscoveryBundleManifest,
    277     pub nip05_document: MycNip05Document,
    278     pub nip89_handler: MycNip89HandlerDocument,
    279 }
    280 
    281 impl MycDiscoveryContext {
    282     pub fn from_runtime(runtime: &MycRuntime) -> Result<Self, MycError> {
    283         let discovery = &runtime.config().discovery;
    284         if !discovery.enabled {
    285             return Err(MycError::InvalidOperation(
    286                 "discovery.enabled must be true to use discovery commands".to_owned(),
    287             ));
    288         }
    289 
    290         let app_identity = match discovery.app_identity_source() {
    291             Some(source) => MycIdentityProvider::from_source(
    292                 "discovery app",
    293                 source,
    294                 Duration::from_secs(runtime.config().custody.external_command_timeout_secs),
    295             )?
    296             .load_active_identity()?,
    297             None => runtime.signer_identity().clone(),
    298         };
    299         let public_relays = discovery.resolved_public_relays(&runtime.config().transport)?;
    300         let publish_relays = discovery.resolved_publish_relays(&runtime.config().transport)?;
    301         let nostrconnect_url = discovery
    302             .nostrconnect_url_template
    303             .as_deref()
    304             .map(|template| {
    305                 render_nostrconnect_url(template, runtime.signer_identity(), &public_relays)
    306             })
    307             .transpose()?;
    308 
    309         Ok(Self {
    310             app_identity,
    311             signer_identity: runtime.signer_identity().clone(),
    312             domain: discovery.domain.clone().ok_or_else(|| {
    313                 MycError::InvalidConfig(
    314                     "discovery.domain must be set when discovery.enabled is true".to_owned(),
    315                 )
    316             })?,
    317             handler_identifier: discovery.handler_identifier.clone(),
    318             public_relays,
    319             publish_relays,
    320             nostrconnect_url,
    321             metadata: build_metadata(&discovery.metadata),
    322             nip05_output_path: discovery.nip05_output_path.clone(),
    323             connect_timeout_secs: runtime.config().transport.connect_timeout_secs,
    324         })
    325     }
    326 
    327     pub fn app_identity(&self) -> &MycActiveIdentity {
    328         &self.app_identity
    329     }
    330 
    331     pub fn signer_identity(&self) -> &MycActiveIdentity {
    332         &self.signer_identity
    333     }
    334 
    335     pub fn domain(&self) -> &str {
    336         self.domain.as_str()
    337     }
    338 
    339     pub fn handler_identifier(&self) -> &str {
    340         self.handler_identifier.as_str()
    341     }
    342 
    343     pub fn publish_relays(&self) -> &[RadrootsNostrRelayUrl] {
    344         self.publish_relays.as_slice()
    345     }
    346 
    347     pub fn connect_timeout_secs(&self) -> u64 {
    348         self.connect_timeout_secs
    349     }
    350 
    351     pub fn nip05_output_path(&self) -> Option<&Path> {
    352         self.nip05_output_path.as_deref()
    353     }
    354 
    355     pub fn render_nip05_document(&self) -> MycNip05Document {
    356         let mut names = BTreeMap::new();
    357         names.insert("_".to_owned(), self.app_identity.public_key_hex());
    358         MycNip05Document {
    359             names,
    360             nip46: MycNip05DocumentSection {
    361                 relays: self.public_relays.iter().map(ToString::to_string).collect(),
    362                 nostrconnect_url: self.nostrconnect_url.clone(),
    363             },
    364         }
    365     }
    366 
    367     pub fn render_nip05_json_pretty(&self) -> Result<String, MycError> {
    368         Ok(serde_json::to_string_pretty(&self.render_nip05_document())?)
    369     }
    370 
    371     pub fn render_nip05_output(&self, output_path: Option<PathBuf>) -> MycRenderedNip05Output {
    372         MycRenderedNip05Output {
    373             domain: self.domain.clone(),
    374             output_path,
    375             document: self.render_nip05_document(),
    376         }
    377     }
    378 
    379     pub fn write_nip05_document(
    380         &self,
    381         output_path: impl AsRef<Path>,
    382     ) -> Result<MycRenderedNip05Output, MycError> {
    383         let output_path = output_path.as_ref().to_path_buf();
    384         if let Some(parent) = output_path.parent() {
    385             if !parent.as_os_str().is_empty() {
    386                 fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo {
    387                     path: parent.to_path_buf(),
    388                     source,
    389                 })?;
    390             }
    391         }
    392         let json = self.render_nip05_json_pretty()?;
    393         fs::write(&output_path, json).map_err(|source| MycError::DiscoveryIo {
    394             path: output_path.clone(),
    395             source,
    396         })?;
    397         Ok(self.render_nip05_output(Some(output_path)))
    398     }
    399 
    400     pub fn render_nip89_output(&self) -> Result<MycRenderedNip89Output, MycError> {
    401         let event = self.build_signed_handler_event()?;
    402         Ok(MycRenderedNip89Output {
    403             author_public_key_hex: self.app_identity.public_key_hex(),
    404             signer_public_key_hex: self.signer_identity.public_key_hex(),
    405             publish_relays: self
    406                 .publish_relays
    407                 .iter()
    408                 .map(ToString::to_string)
    409                 .collect(),
    410             event,
    411         })
    412     }
    413 
    414     pub fn render_nip89_handler_document(&self) -> MycNip89HandlerDocument {
    415         MycNip89HandlerDocument {
    416             kinds: vec![NIP46_RPC_KIND],
    417             identifier: self.handler_identifier.clone(),
    418             relays: self.public_relays.iter().map(ToString::to_string).collect(),
    419             nostrconnect_url: self.nostrconnect_url.clone(),
    420             metadata: self.metadata.clone(),
    421         }
    422     }
    423 
    424     pub fn render_normalized_nip89_handler(&self) -> MycNormalizedNip89Handler {
    425         MycNormalizedNip89Handler {
    426             author_public_key_hex: self.app_identity.public_key_hex(),
    427             kinds: vec![NIP46_RPC_KIND],
    428             identifier: self.handler_identifier.clone(),
    429             relays: normalize_string_list(
    430                 self.public_relays.iter().map(ToString::to_string).collect(),
    431             ),
    432             nostrconnect_url: normalize_optional_string(self.nostrconnect_url.clone()),
    433             metadata: normalize_metadata(self.metadata.clone()),
    434         }
    435     }
    436 
    437     pub fn render_bundle_manifest(&self) -> MycDiscoveryBundleManifest {
    438         MycDiscoveryBundleManifest {
    439             version: DISCOVERY_BUNDLE_VERSION,
    440             domain: self.domain.clone(),
    441             author_public_key_hex: self.app_identity.public_key_hex(),
    442             signer_public_key_hex: self.signer_identity.public_key_hex(),
    443             public_relays: self.public_relays.iter().map(ToString::to_string).collect(),
    444             publish_relays: self
    445                 .publish_relays
    446                 .iter()
    447                 .map(ToString::to_string)
    448                 .collect(),
    449             nostrconnect_url: self.nostrconnect_url.clone(),
    450             nip05_relative_path: DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH.to_owned(),
    451             nip89_relative_path: DISCOVERY_BUNDLE_NIP89_FILE_NAME.to_owned(),
    452         }
    453     }
    454 
    455     pub fn build_signed_handler_event(&self) -> Result<RadrootsNostrEvent, MycError> {
    456         let builder = radroots_nostr_build_application_handler_event(&self.build_handler_spec())?;
    457         self.app_identity
    458             .sign_event_builder(builder, "NIP-89 application handler")
    459     }
    460 
    461     pub fn write_bundle(
    462         &self,
    463         output_dir: impl AsRef<Path>,
    464     ) -> Result<MycDiscoveryBundleOutput, MycError> {
    465         let output_dir = output_dir.as_ref().to_path_buf();
    466         let staged_output_dir = prepare_staged_output_dir(&output_dir)?;
    467 
    468         let manifest = self.render_bundle_manifest();
    469         let nip05_document = self.render_nip05_document();
    470         let nip89_handler = self.render_nip89_handler_document();
    471         let manifest_path = staged_output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME);
    472         let nip05_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH);
    473         let nip89_handler_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP89_FILE_NAME);
    474 
    475         write_pretty_json(&manifest_path, &manifest)?;
    476         write_pretty_json(&nip05_path, &nip05_document)?;
    477         write_pretty_json(&nip89_handler_path, &nip89_handler)?;
    478         replace_directory_atomically(&staged_output_dir, &output_dir)?;
    479         verify_bundle(&output_dir)
    480     }
    481 
    482     fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec {
    483         let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND]);
    484         spec.identifier = Some(self.handler_identifier.clone());
    485         spec.metadata = self.metadata.clone();
    486         spec.relays = self.public_relays.iter().map(ToString::to_string).collect();
    487         spec.nostrconnect_url = self.nostrconnect_url.clone();
    488         spec
    489     }
    490 }
    491 
    492 pub fn render_nip05_output(
    493     runtime: &MycRuntime,
    494     output_path: Option<&Path>,
    495 ) -> Result<MycRenderedNip05Output, MycError> {
    496     let context = MycDiscoveryContext::from_runtime(runtime)?;
    497     match output_path {
    498         Some(path) => context.write_nip05_document(path),
    499         None => Ok(context.render_nip05_output(None)),
    500     }
    501 }
    502 
    503 pub async fn publish_nip89_event(
    504     runtime: &MycRuntime,
    505 ) -> Result<MycPublishedNip89Output, MycError> {
    506     let context = MycDiscoveryContext::from_runtime(runtime)?;
    507     publish_nip89_event_to_relays(runtime, &context, context.publish_relays(), None).await
    508 }
    509 
    510 async fn publish_nip89_event_to_relays(
    511     runtime: &MycRuntime,
    512     context: &MycDiscoveryContext,
    513     relays: &[RadrootsNostrRelayUrl],
    514     attempt_id: Option<&str>,
    515 ) -> Result<MycPublishedNip89Output, MycError> {
    516     let event = context.build_signed_handler_event()?;
    517     let event_id = event.id.to_hex();
    518     let outbox_record =
    519         build_discovery_outbox_record(event.clone(), relays, event_id.as_str(), attempt_id)?;
    520     if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) {
    521         record_discovery_publish_local_failure(
    522             runtime,
    523             relays.len(),
    524             event_id.as_str(),
    525             attempt_id,
    526             error.to_string(),
    527         );
    528         return Err(error);
    529     }
    530     let publish_outcome = match MycNostrTransport::publish_event_once(
    531         context.app_identity(),
    532         relays,
    533         &runtime.config().transport,
    534         "discovery handler publish",
    535         &event,
    536     )
    537     .await
    538     {
    539         Ok(outcome) => outcome,
    540         Err(error) => {
    541             let error = mark_discovery_outbox_publish_failed(runtime, &outbox_record, error);
    542             record_discovery_publish_failure(
    543                 runtime,
    544                 relays.len(),
    545                 event_id.as_str(),
    546                 attempt_id,
    547                 &error,
    548             );
    549             return Err(error);
    550         }
    551     };
    552     if let Err(error) = runtime
    553         .delivery_outbox_store()
    554         .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count)
    555     {
    556         record_discovery_post_publish_failure(
    557             runtime,
    558             event_id.as_str(),
    559             attempt_id,
    560             &publish_outcome,
    561             format!("failed to persist discovery outbox published state: {error}"),
    562         );
    563         return Err(error);
    564     }
    565     if let Err(error) = runtime
    566         .delivery_outbox_store()
    567         .mark_finalized(&outbox_record.job_id)
    568     {
    569         record_discovery_post_publish_failure(
    570             runtime,
    571             event_id.as_str(),
    572             attempt_id,
    573             &publish_outcome,
    574             format!("failed to finalize discovery outbox job: {error}"),
    575         );
    576         return Err(error);
    577     }
    578 
    579     record_discovery_publish_success(runtime, event_id.as_str(), attempt_id, &publish_outcome);
    580 
    581     Ok(MycPublishedNip89Output {
    582         author_public_key_hex: context.app_identity().public_key_hex(),
    583         signer_public_key_hex: context.signer_identity().public_key_hex(),
    584         publish_relays: relays.iter().map(ToString::to_string).collect(),
    585         relay_count: publish_outcome.relay_count,
    586         acknowledged_relay_count: publish_outcome.acknowledged_relay_count,
    587         relay_outcome_summary: publish_outcome.relay_outcome_summary,
    588         relay_results: publish_outcome.relay_results,
    589         event,
    590     })
    591 }
    592 
    593 pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> {
    594     let context = MycDiscoveryContext::from_runtime(runtime)?;
    595     let fetched = fetch_live_nip89_state_for_runtime(runtime, &context, None).await?;
    596     Ok(MycFetchedLiveNip89Output {
    597         author_public_key_hex: context.app_identity().public_key_hex(),
    598         publish_relays: context
    599             .publish_relays()
    600             .iter()
    601             .map(ToString::to_string)
    602             .collect(),
    603         handler_identifier: context.handler_identifier().to_owned(),
    604         live_groups: fetched.live_groups,
    605         relay_states: fetched.relay_states,
    606     })
    607 }
    608 
    609 pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> {
    610     let context = MycDiscoveryContext::from_runtime(runtime)?;
    611     let local_handler = context.render_normalized_nip89_handler();
    612     let fetched = fetch_live_nip89_state_for_runtime(runtime, &context, None).await?;
    613     let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states);
    614     let relay_summary = summarize_relay_diffs(&relay_states);
    615     let live_groups = fetched.live_groups;
    616     let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups);
    617     Ok(MycDiscoveryDiffOutput {
    618         status,
    619         local_handler,
    620         live_groups,
    621         relay_states,
    622         relay_summary,
    623         differing_fields,
    624     })
    625 }
    626 
    627 pub async fn refresh_nip89(
    628     runtime: &MycRuntime,
    629     force: bool,
    630 ) -> Result<MycRefreshedNip89Output, MycError> {
    631     let context = MycDiscoveryContext::from_runtime(runtime)?;
    632     let attempt_id = RadrootsNostrSignerRequestId::new_v7().into_string();
    633     let configured_publish_relays = relay_urls_to_strings(context.publish_relays());
    634     let local_handler = context.render_normalized_nip89_handler();
    635     let fetched = match fetch_live_nip89_state_for_runtime(
    636         runtime,
    637         &context,
    638         Some(attempt_id.as_str()),
    639     )
    640     .await
    641     {
    642         Ok(fetched) => fetched,
    643         Err(MycError::DiscoveryFetchUnavailable {
    644             relay_count,
    645             details,
    646         }) => {
    647             runtime.record_operation_audit(
    648                 &MycOperationAuditRecord::new(
    649                     MycOperationAuditKind::DiscoveryHandlerRefresh,
    650                     MycOperationAuditOutcome::Unavailable,
    651                     None,
    652                     None,
    653                     relay_count,
    654                     0,
    655                     details.clone(),
    656                 )
    657                 .with_attempt_id(attempt_id.clone())
    658                 .with_blocked_relays("all_relays_unavailable", configured_publish_relays.clone()),
    659             );
    660             return Err(MycError::DiscoveryFetchUnavailable {
    661                 relay_count,
    662                 details,
    663             }
    664             .with_discovery_refresh_attempt_id(attempt_id));
    665         }
    666         Err(error) => {
    667             return Err(error.with_discovery_refresh_attempt_id(attempt_id));
    668         }
    669     };
    670     let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states);
    671     let relay_summary = summarize_relay_diffs(&relay_states);
    672     let live_groups = fetched.live_groups;
    673     let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups);
    674     let relay_count = context.publish_relays().len();
    675     let compare_request_id = latest_live_event_id(&live_groups);
    676     let compare_summary =
    677         describe_compare_status(status, &differing_fields, &live_groups, &relay_summary);
    678     let blocked_refresh_plan = build_refresh_plan(&context, &relay_states, true)
    679         .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?;
    680 
    681     runtime.record_operation_audit(
    682         &MycOperationAuditRecord::new(
    683             MycOperationAuditKind::DiscoveryHandlerCompare,
    684             compare_status_to_audit_outcome(status),
    685             None,
    686             compare_request_id,
    687             relay_count,
    688             relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
    689             compare_summary,
    690         )
    691         .with_attempt_id(attempt_id.clone()),
    692     );
    693 
    694     if !relay_summary.unavailable_relays.is_empty() && !force {
    695         runtime.record_operation_audit(
    696             &MycOperationAuditRecord::new(
    697                 MycOperationAuditKind::DiscoveryHandlerRefresh,
    698                 MycOperationAuditOutcome::Unavailable,
    699                 None,
    700                 compare_request_id,
    701                 relay_count,
    702                 relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
    703                 format!(
    704                     "discovery relays were unavailable; rerun refresh with --force to override: {}",
    705                     relay_summary.unavailable_relays.join(", ")
    706                 ),
    707             )
    708             .with_attempt_id(attempt_id.clone())
    709             .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone())
    710             .with_blocked_relays(
    711                 "unavailable_relays",
    712                 relay_summary.unavailable_relays.clone(),
    713             ),
    714         );
    715         return Err(
    716             MycError::InvalidOperation(format!(
    717                 "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}",
    718                 relay_summary.unavailable_relays.join(", ")
    719             ))
    720             .with_discovery_refresh_attempt_id(attempt_id),
    721         );
    722     }
    723 
    724     if !relay_summary.conflicted_relays.is_empty() && !force {
    725         runtime.record_operation_audit(
    726             &MycOperationAuditRecord::new(
    727                 MycOperationAuditKind::DiscoveryHandlerRefresh,
    728                 MycOperationAuditOutcome::Conflicted,
    729                 None,
    730                 compare_request_id,
    731                 relay_count,
    732                 relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
    733                 "live discovery handler state is conflicted; rerun refresh with --force to override"
    734                     .to_owned(),
    735             )
    736             .with_attempt_id(attempt_id.clone())
    737             .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone())
    738             .with_blocked_relays(
    739                 "conflicted_relays",
    740                 relay_summary.conflicted_relays.clone(),
    741             ),
    742         );
    743         return Err(
    744             MycError::InvalidOperation(
    745                 "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override"
    746                     .to_owned(),
    747             )
    748             .with_discovery_refresh_attempt_id(attempt_id),
    749         );
    750     }
    751 
    752     let refresh_plan = build_refresh_plan(&context, &relay_states, force)
    753         .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?;
    754     let refresh_relays = refresh_plan.selected_relays;
    755     let refresh_relay_urls = relay_urls_to_strings(&refresh_relays);
    756 
    757     if refresh_relays.is_empty() {
    758         let repair_results = build_repair_results(&context, &relay_states, &[], None, None);
    759         let repair_summary = summarize_repair_results(&repair_results);
    760         record_refresh_repair_audit(
    761             runtime,
    762             compare_request_id.map(ToOwned::to_owned),
    763             attempt_id.as_str(),
    764             &repair_results,
    765         );
    766         runtime.record_operation_audit(
    767             &MycOperationAuditRecord::new(
    768                 MycOperationAuditKind::DiscoveryHandlerRefresh,
    769                 MycOperationAuditOutcome::Skipped,
    770                 None,
    771                 compare_request_id,
    772                 relay_count,
    773                 relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
    774                 "local discovery handler already matches live state".to_owned(),
    775             )
    776             .with_attempt_id(attempt_id.clone())
    777             .with_planned_repair_relays(refresh_relay_urls.clone()),
    778         );
    779         return Ok(MycRefreshedNip89Output {
    780             attempt_id,
    781             status,
    782             force,
    783             differing_fields,
    784             live_groups,
    785             relay_states,
    786             relay_summary,
    787             repair_summary,
    788             repair_results,
    789             remaining_repair_relays: Vec::new(),
    790             published: None,
    791         });
    792     }
    793 
    794     match publish_nip89_event_to_relays(
    795         runtime,
    796         &context,
    797         &refresh_relays,
    798         Some(attempt_id.as_str()),
    799     )
    800     .await
    801     {
    802         Ok(published) => {
    803             let published_event_id = published.event.id.to_hex();
    804             let repair_results = build_repair_results(
    805                 &context,
    806                 &relay_states,
    807                 &refresh_relays,
    808                 Some(published.relay_results.as_slice()),
    809                 None,
    810             );
    811             record_refresh_repair_audit(
    812                 runtime,
    813                 Some(published_event_id.clone()),
    814                 attempt_id.as_str(),
    815                 &repair_results,
    816             );
    817             let repair_summary = summarize_repair_results(&repair_results);
    818             let remaining_repair_relays = remaining_repair_relays(&repair_results);
    819             runtime.record_operation_audit(
    820                 &MycOperationAuditRecord::new(
    821                     MycOperationAuditKind::DiscoveryHandlerRefresh,
    822                     MycOperationAuditOutcome::Succeeded,
    823                     None,
    824                     Some(published_event_id.as_str()),
    825                     published.relay_count,
    826                     published.acknowledged_relay_count,
    827                     format!(
    828                         "refresh completed with {} repaired, {} failed, {} unchanged, {} skipped",
    829                         repair_summary.repaired,
    830                         repair_summary.failed,
    831                         repair_summary.unchanged,
    832                         repair_summary.skipped
    833                     ),
    834                 )
    835                 .with_attempt_id(attempt_id.clone())
    836                 .with_planned_repair_relays(refresh_relay_urls.clone()),
    837             );
    838             return Ok(MycRefreshedNip89Output {
    839                 attempt_id,
    840                 status,
    841                 force,
    842                 differing_fields,
    843                 live_groups,
    844                 relay_states,
    845                 relay_summary,
    846                 repair_summary,
    847                 repair_results,
    848                 remaining_repair_relays,
    849                 published: Some(published),
    850             });
    851         }
    852         Err(error) => {
    853             let repair_results =
    854                 build_repair_results(&context, &relay_states, &refresh_relays, None, Some(&error));
    855             let repair_summary = summarize_repair_results(&repair_results);
    856             record_refresh_repair_audit(runtime, None, attempt_id.as_str(), &repair_results);
    857             runtime.record_operation_audit(
    858                 &MycOperationAuditRecord::new(
    859                     MycOperationAuditKind::DiscoveryHandlerRefresh,
    860                     MycOperationAuditOutcome::Rejected,
    861                     None,
    862                     compare_request_id,
    863                     relay_count,
    864                     relay_states
    865                         .iter()
    866                         .filter(|relay_state| {
    867                             relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available
    868                         })
    869                         .count(),
    870                     format!(
    871                         "refresh failed with {} repaired, {} failed, {} unchanged, {} skipped",
    872                         repair_summary.repaired,
    873                         repair_summary.failed,
    874                         repair_summary.unchanged,
    875                         repair_summary.skipped
    876                     ),
    877                 )
    878                 .with_attempt_id(attempt_id.clone())
    879                 .with_planned_repair_relays(refresh_relay_urls.clone()),
    880             );
    881             return Err(error.with_discovery_refresh_attempt_id(attempt_id));
    882         }
    883     }
    884 }
    885 
    886 fn build_discovery_outbox_record(
    887     event: RadrootsNostrEvent,
    888     relays: &[RadrootsNostrRelayUrl],
    889     event_id: &str,
    890     attempt_id: Option<&str>,
    891 ) -> Result<MycDeliveryOutboxRecord, MycError> {
    892     let mut record = MycDeliveryOutboxRecord::new(
    893         MycDeliveryOutboxKind::DiscoveryHandlerPublish,
    894         event,
    895         relays.to_vec(),
    896     )?
    897     .with_request_id(event_id.to_owned());
    898     if let Some(attempt_id) = attempt_id {
    899         record = record.with_attempt_id(attempt_id.to_owned());
    900     }
    901     Ok(record)
    902 }
    903 
    904 fn mark_discovery_outbox_publish_failed(
    905     runtime: &MycRuntime,
    906     outbox_record: &MycDeliveryOutboxRecord,
    907     error: MycError,
    908 ) -> MycError {
    909     let publish_attempt_count = error.publish_attempt_count().unwrap_or_default();
    910     let summary = publish_failure_summary(&error);
    911     match runtime.delivery_outbox_store().mark_failed(
    912         &outbox_record.job_id,
    913         publish_attempt_count,
    914         &summary,
    915     ) {
    916         Ok(_) => error,
    917         Err(outbox_error) => MycError::InvalidOperation(format!(
    918             "{error}; additionally failed to persist discovery publish failure to the outbox: {outbox_error}"
    919         )),
    920     }
    921 }
    922 
    923 fn record_discovery_publish_local_failure(
    924     runtime: &MycRuntime,
    925     relay_count: usize,
    926     event_id: &str,
    927     attempt_id: Option<&str>,
    928     summary: impl Into<String>,
    929 ) {
    930     let mut record = MycOperationAuditRecord::new(
    931         MycOperationAuditKind::DiscoveryHandlerPublish,
    932         MycOperationAuditOutcome::Rejected,
    933         None,
    934         Some(event_id),
    935         relay_count,
    936         0,
    937         summary.into(),
    938     );
    939     if let Some(attempt_id) = attempt_id {
    940         record = record.with_attempt_id(attempt_id);
    941     }
    942     runtime.record_operation_audit(&record);
    943 }
    944 
    945 fn record_discovery_publish_failure(
    946     runtime: &MycRuntime,
    947     relay_count: usize,
    948     event_id: &str,
    949     attempt_id: Option<&str>,
    950     error: &MycError,
    951 ) {
    952     let mut record = MycOperationAuditRecord::new(
    953         MycOperationAuditKind::DiscoveryHandlerPublish,
    954         MycOperationAuditOutcome::Rejected,
    955         None,
    956         Some(event_id),
    957         error
    958             .publish_rejection_counts()
    959             .map(|(publish_relay_count, _)| publish_relay_count)
    960             .unwrap_or(relay_count),
    961         error
    962             .publish_rejection_counts()
    963             .map(|(_, acknowledged)| acknowledged)
    964             .unwrap_or_default(),
    965         publish_failure_summary(error),
    966     );
    967     if let (Some(delivery_policy), Some(required_acknowledged_relay_count), Some(attempt_count)) = (
    968         error.publish_delivery_policy(),
    969         error.publish_required_acknowledged_relay_count(),
    970         error.publish_attempt_count(),
    971     ) {
    972         record = record.with_delivery_details(
    973             delivery_policy,
    974             required_acknowledged_relay_count,
    975             attempt_count,
    976         );
    977     }
    978     if let Some(attempt_id) = attempt_id {
    979         record = record.with_attempt_id(attempt_id);
    980     }
    981     runtime.record_operation_audit(&record);
    982 }
    983 
    984 fn record_discovery_post_publish_failure(
    985     runtime: &MycRuntime,
    986     event_id: &str,
    987     attempt_id: Option<&str>,
    988     publish_outcome: &MycPublishOutcome,
    989     summary: impl Into<String>,
    990 ) {
    991     let mut record = MycOperationAuditRecord::new(
    992         MycOperationAuditKind::DiscoveryHandlerPublish,
    993         MycOperationAuditOutcome::Rejected,
    994         None,
    995         Some(event_id),
    996         publish_outcome.relay_count,
    997         publish_outcome.acknowledged_relay_count,
    998         summary.into(),
    999     )
   1000     .with_delivery_details(
   1001         publish_outcome.delivery_policy,
   1002         publish_outcome.required_acknowledged_relay_count,
   1003         publish_outcome.attempt_count,
   1004     );
   1005     if let Some(attempt_id) = attempt_id {
   1006         record = record.with_attempt_id(attempt_id);
   1007     }
   1008     runtime.record_operation_audit(&record);
   1009 }
   1010 
   1011 fn record_discovery_publish_success(
   1012     runtime: &MycRuntime,
   1013     event_id: &str,
   1014     attempt_id: Option<&str>,
   1015     publish_outcome: &MycPublishOutcome,
   1016 ) {
   1017     let mut record = MycOperationAuditRecord::new(
   1018         MycOperationAuditKind::DiscoveryHandlerPublish,
   1019         MycOperationAuditOutcome::Succeeded,
   1020         None,
   1021         Some(event_id),
   1022         publish_outcome.relay_count,
   1023         publish_outcome.acknowledged_relay_count,
   1024         publish_outcome.relay_outcome_summary.clone(),
   1025     )
   1026     .with_delivery_details(
   1027         publish_outcome.delivery_policy,
   1028         publish_outcome.required_acknowledged_relay_count,
   1029         publish_outcome.attempt_count,
   1030     );
   1031     if let Some(attempt_id) = attempt_id {
   1032         record = record.with_attempt_id(attempt_id);
   1033     }
   1034     runtime.record_operation_audit(&record);
   1035 }
   1036 
   1037 fn publish_failure_summary(error: &MycError) -> String {
   1038     error
   1039         .publish_rejection_details()
   1040         .map(ToOwned::to_owned)
   1041         .unwrap_or_else(|| error.to_string())
   1042 }
   1043 
   1044 fn build_refresh_plan(
   1045     context: &MycDiscoveryContext,
   1046     relay_states: &[MycDiscoveryRelayState],
   1047     force: bool,
   1048 ) -> Result<MycDiscoveryRefreshPlan, MycError> {
   1049     let selected_relays = select_refresh_relays(context, relay_states, force)?;
   1050     Ok(MycDiscoveryRefreshPlan {
   1051         selected_relays: selected_relays.clone(),
   1052         planned_repair_relays: relay_urls_to_strings(&selected_relays),
   1053     })
   1054 }
   1055 
   1056 fn relay_urls_to_strings(relays: &[RadrootsNostrRelayUrl]) -> Vec<String> {
   1057     relays.iter().map(ToString::to_string).collect()
   1058 }
   1059 
   1060 fn select_refresh_relays(
   1061     context: &MycDiscoveryContext,
   1062     relay_states: &[MycDiscoveryRelayState],
   1063     force: bool,
   1064 ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1065     if context.publish_relays().len() != relay_states.len() {
   1066         return Err(MycError::InvalidOperation(
   1067             "discovery relay state count did not match configured publish relay count".to_owned(),
   1068         ));
   1069     }
   1070 
   1071     let mut repair_relays = Vec::new();
   1072     let mut matched_relays = Vec::new();
   1073 
   1074     for (relay, relay_state) in context.publish_relays().iter().zip(relay_states.iter()) {
   1075         if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
   1076             continue;
   1077         }
   1078 
   1079         match relay_state.live_status {
   1080             Some(MycDiscoveryLiveStatus::Missing | MycDiscoveryLiveStatus::Drifted) => {
   1081                 repair_relays.push(relay.clone());
   1082             }
   1083             Some(MycDiscoveryLiveStatus::Conflicted) => {
   1084                 if force {
   1085                     repair_relays.push(relay.clone());
   1086                 }
   1087             }
   1088             Some(MycDiscoveryLiveStatus::Matched) => {
   1089                 matched_relays.push(relay.clone());
   1090             }
   1091             None => {}
   1092         }
   1093     }
   1094 
   1095     if repair_relays.is_empty() && force {
   1096         Ok(matched_relays)
   1097     } else {
   1098         Ok(repair_relays)
   1099     }
   1100 }
   1101 
   1102 fn build_repair_results(
   1103     context: &MycDiscoveryContext,
   1104     relay_states: &[MycDiscoveryRelayState],
   1105     refresh_relays: &[RadrootsNostrRelayUrl],
   1106     publish_results: Option<&[MycRelayPublishResult]>,
   1107     publish_error: Option<&MycError>,
   1108 ) -> Vec<MycDiscoveryRelayRepairResult> {
   1109     let selected_relays = refresh_relays
   1110         .iter()
   1111         .map(ToString::to_string)
   1112         .collect::<BTreeSet<_>>();
   1113     let publish_results_by_relay = publish_results
   1114         .unwrap_or_default()
   1115         .iter()
   1116         .map(|result| (result.relay_url.clone(), result))
   1117         .collect::<BTreeMap<_, _>>();
   1118     let rejected_relays = publish_error
   1119         .and_then(MycError::publish_rejected_relays)
   1120         .unwrap_or_default()
   1121         .iter()
   1122         .cloned()
   1123         .collect::<BTreeSet<_>>();
   1124 
   1125     context
   1126         .publish_relays()
   1127         .iter()
   1128         .zip(relay_states.iter())
   1129         .map(|(relay, relay_state)| {
   1130             let relay_url = relay.to_string();
   1131             if selected_relays.contains(&relay_url) {
   1132                 if let Some(result) = publish_results_by_relay.get(&relay_url) {
   1133                     return MycDiscoveryRelayRepairResult {
   1134                         relay_url,
   1135                         outcome: if result.acknowledged {
   1136                             MycDiscoveryRepairOutcome::Repaired
   1137                         } else {
   1138                             MycDiscoveryRepairOutcome::Failed
   1139                         },
   1140                         detail: result.detail.clone(),
   1141                     };
   1142                 }
   1143 
   1144                 if rejected_relays.contains(&relay_url) {
   1145                     return MycDiscoveryRelayRepairResult {
   1146                         relay_url,
   1147                         outcome: MycDiscoveryRepairOutcome::Failed,
   1148                         detail: Some(
   1149                             publish_error
   1150                                 .and_then(MycError::publish_rejection_details)
   1151                                 .map(ToOwned::to_owned)
   1152                                 .unwrap_or_else(|| "targeted refresh publish failed".to_owned()),
   1153                         ),
   1154                     };
   1155                 }
   1156 
   1157                 return MycDiscoveryRelayRepairResult {
   1158                     relay_url,
   1159                     outcome: MycDiscoveryRepairOutcome::Failed,
   1160                     detail: Some("no relay publish result was reported".to_owned()),
   1161                 };
   1162             }
   1163 
   1164             if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
   1165                 return MycDiscoveryRelayRepairResult {
   1166                     relay_url,
   1167                     outcome: MycDiscoveryRepairOutcome::Skipped,
   1168                     detail: relay_state.fetch_error.clone(),
   1169                 };
   1170             }
   1171 
   1172             match relay_state.live_status {
   1173                 Some(MycDiscoveryLiveStatus::Matched) => MycDiscoveryRelayRepairResult {
   1174                     relay_url,
   1175                     outcome: MycDiscoveryRepairOutcome::Unchanged,
   1176                     detail: None,
   1177                 },
   1178                 _ => MycDiscoveryRelayRepairResult {
   1179                     relay_url,
   1180                     outcome: MycDiscoveryRepairOutcome::Skipped,
   1181                     detail: None,
   1182                 },
   1183             }
   1184         })
   1185         .collect()
   1186 }
   1187 
   1188 fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) -> Vec<String> {
   1189     repair_results
   1190         .iter()
   1191         .filter(|result| result.outcome == MycDiscoveryRepairOutcome::Failed)
   1192         .map(|result| result.relay_url.clone())
   1193         .collect()
   1194 }
   1195 
   1196 fn summarize_repair_results(
   1197     repair_results: &[MycDiscoveryRelayRepairResult],
   1198 ) -> MycDiscoveryRepairSummary {
   1199     let mut summary = MycDiscoveryRepairSummary::default();
   1200     for result in repair_results {
   1201         match result.outcome {
   1202             MycDiscoveryRepairOutcome::Repaired => summary.repaired += 1,
   1203             MycDiscoveryRepairOutcome::Failed => summary.failed += 1,
   1204             MycDiscoveryRepairOutcome::Unchanged => summary.unchanged += 1,
   1205             MycDiscoveryRepairOutcome::Skipped => summary.skipped += 1,
   1206         }
   1207     }
   1208     summary
   1209 }
   1210 
   1211 fn record_refresh_repair_audit(
   1212     runtime: &MycRuntime,
   1213     request_id: Option<String>,
   1214     attempt_id: &str,
   1215     repair_results: &[MycDiscoveryRelayRepairResult],
   1216 ) {
   1217     for result in repair_results {
   1218         let (outcome, acknowledged_relay_count) = match result.outcome {
   1219             MycDiscoveryRepairOutcome::Repaired => (MycOperationAuditOutcome::Succeeded, 1),
   1220             MycDiscoveryRepairOutcome::Failed => (MycOperationAuditOutcome::Rejected, 0),
   1221             MycDiscoveryRepairOutcome::Unchanged => (MycOperationAuditOutcome::Matched, 0),
   1222             MycDiscoveryRepairOutcome::Skipped => (MycOperationAuditOutcome::Skipped, 0),
   1223         };
   1224 
   1225         runtime.record_operation_audit(
   1226             &MycOperationAuditRecord::new(
   1227                 MycOperationAuditKind::DiscoveryHandlerRepair,
   1228                 outcome,
   1229                 None,
   1230                 request_id.as_deref(),
   1231                 1,
   1232                 acknowledged_relay_count,
   1233                 result
   1234                     .detail
   1235                     .clone()
   1236                     .unwrap_or_else(|| result.relay_url.clone()),
   1237             )
   1238             .with_attempt_id(attempt_id)
   1239             .with_relay_url(result.relay_url.clone()),
   1240         );
   1241     }
   1242 }
   1243 
   1244 async fn fetch_live_nip89_state_for_runtime(
   1245     runtime: &MycRuntime,
   1246     context: &MycDiscoveryContext,
   1247     attempt_id: Option<&str>,
   1248 ) -> Result<MycFetchedLiveNip89State, MycError> {
   1249     match fetch_live_nip89_state(context).await {
   1250         Ok(fetched) => {
   1251             let unavailable_relays = fetched
   1252                 .relay_states
   1253                 .iter()
   1254                 .filter(|relay_state| {
   1255                     relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable
   1256                 })
   1257                 .collect::<Vec<_>>();
   1258             if !unavailable_relays.is_empty() {
   1259                 let mut record = MycOperationAuditRecord::new(
   1260                     MycOperationAuditKind::DiscoveryHandlerFetch,
   1261                     MycOperationAuditOutcome::Unavailable,
   1262                     None,
   1263                     latest_live_event_id(&fetched.live_groups),
   1264                     fetched.relay_states.len(),
   1265                     fetched.relay_states.len() - unavailable_relays.len(),
   1266                     summarize_unavailable_relays(&fetched.relay_states),
   1267                 );
   1268                 if let Some(attempt_id) = attempt_id {
   1269                     record = record.with_attempt_id(attempt_id);
   1270                 }
   1271                 runtime.record_operation_audit(&record);
   1272             }
   1273             Ok(fetched)
   1274         }
   1275         Err(MycError::DiscoveryFetchUnavailable {
   1276             relay_count,
   1277             details,
   1278         }) => {
   1279             let mut record = MycOperationAuditRecord::new(
   1280                 MycOperationAuditKind::DiscoveryHandlerFetch,
   1281                 MycOperationAuditOutcome::Unavailable,
   1282                 None,
   1283                 None,
   1284                 relay_count,
   1285                 0,
   1286                 details.clone(),
   1287             );
   1288             if let Some(attempt_id) = attempt_id {
   1289                 record = record.with_attempt_id(attempt_id);
   1290             }
   1291             runtime.record_operation_audit(&record);
   1292             Err(MycError::DiscoveryFetchUnavailable {
   1293                 relay_count,
   1294                 details,
   1295             })
   1296         }
   1297         Err(error) => Err(error),
   1298     }
   1299 }
   1300 
   1301 pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> {
   1302     let output_dir = output_dir.as_ref().to_path_buf();
   1303     let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME);
   1304     let manifest = read_json_file::<MycDiscoveryBundleManifest>(&manifest_path)?;
   1305     let nip05_path = output_dir.join(&manifest.nip05_relative_path);
   1306     let nip05_document = read_json_file::<MycNip05Document>(&nip05_path)?;
   1307     let nip89_handler_path = output_dir.join(&manifest.nip89_relative_path);
   1308     let nip89_handler = read_json_file::<MycNip89HandlerDocument>(&nip89_handler_path)?;
   1309 
   1310     let bundle = MycDiscoveryBundleOutput {
   1311         output_dir,
   1312         manifest_path,
   1313         nip05_path,
   1314         nip89_handler_path,
   1315         manifest,
   1316         nip05_document,
   1317         nip89_handler,
   1318     };
   1319     bundle.validate()?;
   1320     Ok(bundle)
   1321 }
   1322 
   1323 async fn fetch_live_nip89_state(
   1324     context: &MycDiscoveryContext,
   1325 ) -> Result<MycFetchedLiveNip89State, MycError> {
   1326     let relay_count = context.publish_relays().len();
   1327     let mut pending = context
   1328         .publish_relays()
   1329         .iter()
   1330         .cloned()
   1331         .enumerate()
   1332         .collect::<Vec<_>>()
   1333         .into_iter();
   1334     let mut join_set = JoinSet::new();
   1335     let max_concurrency = relay_count.min(DISCOVERY_RELAY_FETCH_CONCURRENCY_LIMIT);
   1336 
   1337     while join_set.len() < max_concurrency {
   1338         let Some((relay_index, relay)) = pending.next() else {
   1339             break;
   1340         };
   1341         spawn_live_nip89_relay_fetch(&mut join_set, context.clone(), relay_index, relay);
   1342     }
   1343 
   1344     let mut fetched = std::iter::repeat_with(|| None)
   1345         .take(relay_count)
   1346         .collect::<Vec<Option<MycRelayFetchTaskOutput>>>();
   1347 
   1348     while let Some(joined) = join_set.join_next().await {
   1349         let output = joined.map_err(|error| {
   1350             MycError::InvalidOperation(format!("discovery relay fetch task failed: {error}"))
   1351         })??;
   1352         let relay_index = output.relay_index;
   1353         fetched[relay_index] = Some(output);
   1354 
   1355         while join_set.len() < max_concurrency {
   1356             let Some((relay_index, relay)) = pending.next() else {
   1357                 break;
   1358             };
   1359             spawn_live_nip89_relay_fetch(&mut join_set, context.clone(), relay_index, relay);
   1360         }
   1361     }
   1362 
   1363     let mut relay_states = Vec::with_capacity(relay_count);
   1364     let mut all_events = Vec::new();
   1365     for fetched_relay in fetched {
   1366         let fetched_relay = fetched_relay.ok_or_else(|| {
   1367             MycError::InvalidOperation("missing discovery relay fetch result".to_owned())
   1368         })?;
   1369         all_events.extend(fetched_relay.relay_events.into_iter());
   1370         relay_states.push(fetched_relay.relay_state);
   1371     }
   1372 
   1373     let available_relay_count = relay_states
   1374         .iter()
   1375         .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available)
   1376         .count();
   1377     if available_relay_count == 0 {
   1378         return Err(MycError::DiscoveryFetchUnavailable {
   1379             relay_count: relay_states.len(),
   1380             details: summarize_unavailable_relays(&relay_states),
   1381         });
   1382     }
   1383 
   1384     Ok(MycFetchedLiveNip89State {
   1385         live_groups: group_live_nip89_events(all_events)?,
   1386         relay_states,
   1387     })
   1388 }
   1389 
   1390 fn spawn_live_nip89_relay_fetch(
   1391     join_set: &mut JoinSet<Result<MycRelayFetchTaskOutput, MycError>>,
   1392     context: MycDiscoveryContext,
   1393     relay_index: usize,
   1394     relay: RadrootsNostrRelayUrl,
   1395 ) {
   1396     join_set.spawn(async move { fetch_live_nip89_relay_state(&context, relay_index, relay).await });
   1397 }
   1398 
   1399 async fn fetch_live_nip89_relay_state(
   1400     context: &MycDiscoveryContext,
   1401     relay_index: usize,
   1402     relay: RadrootsNostrRelayUrl,
   1403 ) -> Result<MycRelayFetchTaskOutput, MycError> {
   1404     let relay_url = relay.to_string();
   1405     match fetch_live_nip89_events_for_relay(context, &relay).await {
   1406         Ok(relay_events) => {
   1407             let live_groups = group_live_nip89_events(relay_events.clone())?;
   1408             Ok(MycRelayFetchTaskOutput {
   1409                 relay_index,
   1410                 relay_events,
   1411                 relay_state: MycLiveNip89RelayState {
   1412                     relay_url,
   1413                     fetch_status: MycDiscoveryRelayFetchStatus::Available,
   1414                     fetch_error: None,
   1415                     live_groups,
   1416                 },
   1417             })
   1418         }
   1419         Err(error) => Ok(MycRelayFetchTaskOutput {
   1420             relay_index,
   1421             relay_events: Vec::new(),
   1422             relay_state: MycLiveNip89RelayState {
   1423                 relay_url,
   1424                 fetch_status: MycDiscoveryRelayFetchStatus::Unavailable,
   1425                 fetch_error: Some(error.to_string()),
   1426                 live_groups: Vec::new(),
   1427             },
   1428         }),
   1429     }
   1430 }
   1431 
   1432 async fn fetch_live_nip89_events_for_relay(
   1433     context: &MycDiscoveryContext,
   1434     relay: &RadrootsNostrRelayUrl,
   1435 ) -> Result<Vec<MycSourcedLiveNip89Event>, MycError> {
   1436     let client = context.app_identity().nostr_client();
   1437     let _ = client.add_relay(relay.as_str()).await?;
   1438     client
   1439         .try_connect_relay(
   1440             relay.as_str(),
   1441             Duration::from_secs(context.connect_timeout_secs()),
   1442         )
   1443         .await
   1444         .map_err(RadrootsNostrError::from)?;
   1445 
   1446     let mut filter = RadrootsNostrFilter::new()
   1447         .author(context.app_identity().public_key())
   1448         .kind(RadrootsNostrKind::Custom(31_990));
   1449     filter = radroots_nostr_filter_tag(filter, "d", vec![context.handler_identifier().to_owned()])?;
   1450     filter = radroots_nostr_filter_tag(filter, "k", vec![NIP46_RPC_KIND.to_string()])?;
   1451 
   1452     let mut events = client
   1453         .fetch_events(filter, Duration::from_secs(context.connect_timeout_secs()))
   1454         .await?;
   1455     events.sort_by(|left, right| {
   1456         left.created_at
   1457             .as_secs()
   1458             .cmp(&right.created_at.as_secs())
   1459             .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
   1460     });
   1461     Ok(events
   1462         .into_iter()
   1463         .map(|event| MycSourcedLiveNip89Event {
   1464             source_relay: relay.to_string(),
   1465             event,
   1466         })
   1467         .collect())
   1468 }
   1469 
   1470 fn compare_live_handler(
   1471     local_handler: &MycNormalizedNip89Handler,
   1472     live_groups: &[MycLiveNip89Group],
   1473 ) -> (MycDiscoveryLiveStatus, Vec<String>) {
   1474     if live_groups.is_empty() {
   1475         return (
   1476             MycDiscoveryLiveStatus::Missing,
   1477             vec!["live_groups".to_owned()],
   1478         );
   1479     }
   1480     if live_groups.len() > 1 {
   1481         return (
   1482             MycDiscoveryLiveStatus::Conflicted,
   1483             vec!["live_groups".to_owned()],
   1484         );
   1485     }
   1486 
   1487     let live_group = &live_groups[0];
   1488 
   1489     let mut differing_fields = Vec::new();
   1490     if live_group.handler.author_public_key_hex != local_handler.author_public_key_hex {
   1491         differing_fields.push("author_public_key_hex".to_owned());
   1492     }
   1493     if live_group.handler.kinds != local_handler.kinds {
   1494         differing_fields.push("kinds".to_owned());
   1495     }
   1496     if live_group.handler.identifier != local_handler.identifier {
   1497         differing_fields.push("identifier".to_owned());
   1498     }
   1499     if live_group.handler.relays != local_handler.relays {
   1500         differing_fields.push("relays".to_owned());
   1501     }
   1502     if live_group.handler.nostrconnect_url != local_handler.nostrconnect_url {
   1503         differing_fields.push("nostrconnect_url".to_owned());
   1504     }
   1505     if live_group.handler.metadata != local_handler.metadata {
   1506         differing_fields.push("metadata".to_owned());
   1507     }
   1508 
   1509     if differing_fields.is_empty() {
   1510         (MycDiscoveryLiveStatus::Matched, differing_fields)
   1511     } else {
   1512         (MycDiscoveryLiveStatus::Drifted, differing_fields)
   1513     }
   1514 }
   1515 
   1516 fn compare_status_to_audit_outcome(status: MycDiscoveryLiveStatus) -> MycOperationAuditOutcome {
   1517     match status {
   1518         MycDiscoveryLiveStatus::Missing => MycOperationAuditOutcome::Missing,
   1519         MycDiscoveryLiveStatus::Matched => MycOperationAuditOutcome::Matched,
   1520         MycDiscoveryLiveStatus::Drifted => MycOperationAuditOutcome::Drifted,
   1521         MycDiscoveryLiveStatus::Conflicted => MycOperationAuditOutcome::Conflicted,
   1522     }
   1523 }
   1524 
   1525 fn describe_compare_status(
   1526     status: MycDiscoveryLiveStatus,
   1527     differing_fields: &[String],
   1528     live_groups: &[MycLiveNip89Group],
   1529     relay_summary: &MycDiscoveryRelaySummary,
   1530 ) -> String {
   1531     let base = match status {
   1532         MycDiscoveryLiveStatus::Missing => {
   1533             "no live NIP-89 handler was found for the configured discovery identity".to_owned()
   1534         }
   1535         MycDiscoveryLiveStatus::Matched => {
   1536             "local discovery handler matches the latest live NIP-89 handler".to_owned()
   1537         }
   1538         MycDiscoveryLiveStatus::Drifted => format!(
   1539             "local discovery handler differs from live state in: {}",
   1540             differing_fields.join(", ")
   1541         ),
   1542         MycDiscoveryLiveStatus::Conflicted => format!(
   1543             "found {} conflicting live NIP-89 handler states across {} events (matched relays: {}, drifted relays: {}, missing relays: {}, conflicted relays: {})",
   1544             live_groups.len(),
   1545             live_groups
   1546                 .iter()
   1547                 .map(|group| group.events.len())
   1548                 .sum::<usize>(),
   1549             relay_summary.matched_relays.len(),
   1550             relay_summary.drifted_relays.len(),
   1551             relay_summary.missing_relays.len(),
   1552             relay_summary.conflicted_relays.len(),
   1553         ),
   1554     };
   1555 
   1556     if relay_summary.unavailable_relays.is_empty() {
   1557         base
   1558     } else {
   1559         format!(
   1560             "{base}; unavailable relays: {}",
   1561             relay_summary.unavailable_relays.join(", ")
   1562         )
   1563     }
   1564 }
   1565 
   1566 fn normalize_live_nip89_handler(
   1567     event: &RadrootsNostrEvent,
   1568 ) -> Result<MycNormalizedNip89Handler, MycError> {
   1569     if event.kind != RadrootsNostrKind::Custom(31_990) {
   1570         return Err(MycError::InvalidDiscoveryEvent(format!(
   1571             "expected kind 31990 but found kind {}",
   1572             event.kind.as_u16()
   1573         )));
   1574     }
   1575 
   1576     let identifier = event
   1577         .tags
   1578         .iter()
   1579         .find_map(|tag| radroots_nostr_tag_first_value(tag, "d"))
   1580         .map(|value| value.trim().to_owned())
   1581         .filter(|value| !value.is_empty())
   1582         .ok_or_else(|| {
   1583             MycError::InvalidDiscoveryEvent(
   1584                 "live handler event is missing a non-empty `d` tag".to_owned(),
   1585             )
   1586         })?;
   1587 
   1588     let mut kinds = event
   1589         .tags
   1590         .iter()
   1591         .filter_map(|tag| radroots_nostr_tag_first_value(tag, "k"))
   1592         .map(|value| {
   1593             value.parse::<u32>().map_err(|error| {
   1594                 MycError::InvalidDiscoveryEvent(format!(
   1595                     "failed to parse live handler kind `{value}`: {error}"
   1596                 ))
   1597             })
   1598         })
   1599         .collect::<Result<Vec<_>, _>>()?;
   1600     if kinds.is_empty() {
   1601         return Err(MycError::InvalidDiscoveryEvent(
   1602             "live handler event is missing `k` tags".to_owned(),
   1603         ));
   1604     }
   1605     kinds.sort_unstable();
   1606     kinds.dedup();
   1607 
   1608     let relays = normalize_string_list(
   1609         event
   1610             .tags
   1611             .iter()
   1612             .filter_map(|tag| radroots_nostr_tag_first_value(tag, "relay"))
   1613             .collect(),
   1614     );
   1615     let nostrconnect_url = normalize_optional_string(
   1616         event
   1617             .tags
   1618             .iter()
   1619             .find_map(|tag| radroots_nostr_tag_first_value(tag, "nostrconnect_url")),
   1620     );
   1621     let metadata = if event.content.trim().is_empty() {
   1622         None
   1623     } else {
   1624         Some(
   1625             serde_json::from_str::<RadrootsNostrMetadata>(&event.content).map_err(|error| {
   1626                 MycError::InvalidDiscoveryEvent(format!(
   1627                     "failed to parse live handler metadata: {error}"
   1628                 ))
   1629             })?,
   1630         )
   1631     };
   1632 
   1633     Ok(MycNormalizedNip89Handler {
   1634         author_public_key_hex: event.pubkey.to_hex(),
   1635         kinds,
   1636         identifier,
   1637         relays,
   1638         nostrconnect_url,
   1639         metadata: normalize_metadata(metadata),
   1640     })
   1641 }
   1642 
   1643 fn group_live_nip89_events(
   1644     events: Vec<MycSourcedLiveNip89Event>,
   1645 ) -> Result<Vec<MycLiveNip89Group>, MycError> {
   1646     let mut groups = Vec::<MycLiveNip89Group>::new();
   1647     for sourced_event in events {
   1648         let handler = normalize_live_nip89_handler(&sourced_event.event)?;
   1649         let source_relay = sourced_event.source_relay;
   1650         let live_event = MycLiveNip89Event {
   1651             event_id_hex: sourced_event.event.id.to_hex(),
   1652             created_at_unix: sourced_event.event.created_at.as_secs(),
   1653             source_relays: vec![source_relay.clone()],
   1654             handler: handler.clone(),
   1655         };
   1656         if let Some(existing_group) = groups.iter_mut().find(|group| group.handler == handler) {
   1657             if let Some(existing_event) = existing_group
   1658                 .events
   1659                 .iter_mut()
   1660                 .find(|event| event.event_id_hex == live_event.event_id_hex)
   1661             {
   1662                 existing_event.source_relays = normalize_string_list(
   1663                     existing_event
   1664                         .source_relays
   1665                         .iter()
   1666                         .cloned()
   1667                         .chain(std::iter::once(source_relay.clone()))
   1668                         .collect(),
   1669                 );
   1670             } else {
   1671                 existing_group.events.push(live_event);
   1672             }
   1673             existing_group.source_relays = normalize_string_list(
   1674                 existing_group
   1675                     .source_relays
   1676                     .iter()
   1677                     .cloned()
   1678                     .chain(std::iter::once(source_relay))
   1679                     .collect(),
   1680             );
   1681         } else {
   1682             groups.push(MycLiveNip89Group {
   1683                 handler: handler.clone(),
   1684                 source_relays: vec![source_relay],
   1685                 events: vec![live_event],
   1686             });
   1687         }
   1688     }
   1689 
   1690     for group in &mut groups {
   1691         group.source_relays = normalize_string_list(group.source_relays.clone());
   1692         group.events.sort_by(|left, right| {
   1693             left.created_at_unix
   1694                 .cmp(&right.created_at_unix)
   1695                 .then_with(|| left.event_id_hex.cmp(&right.event_id_hex))
   1696         });
   1697         for event in &mut group.events {
   1698             event.source_relays = normalize_string_list(event.source_relays.clone());
   1699         }
   1700     }
   1701 
   1702     groups.sort_by(|left, right| {
   1703         latest_group_sort_key(right)
   1704             .cmp(&latest_group_sort_key(left))
   1705             .then_with(|| left.handler.identifier.cmp(&right.handler.identifier))
   1706             .then_with(|| {
   1707                 left.handler
   1708                     .author_public_key_hex
   1709                     .cmp(&right.handler.author_public_key_hex)
   1710             })
   1711     });
   1712 
   1713     Ok(groups)
   1714 }
   1715 
   1716 fn build_relay_diffs(
   1717     local_handler: &MycNormalizedNip89Handler,
   1718     relay_states: &[MycLiveNip89RelayState],
   1719 ) -> Vec<MycDiscoveryRelayState> {
   1720     relay_states
   1721         .iter()
   1722         .map(|relay_state| {
   1723             let (live_status, differing_fields) =
   1724                 if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
   1725                     (None, Vec::new())
   1726                 } else {
   1727                     let (status, differing_fields) =
   1728                         compare_live_handler(local_handler, &relay_state.live_groups);
   1729                     (Some(status), differing_fields)
   1730                 };
   1731             MycDiscoveryRelayState {
   1732                 relay_url: relay_state.relay_url.clone(),
   1733                 fetch_status: relay_state.fetch_status,
   1734                 fetch_error: relay_state.fetch_error.clone(),
   1735                 live_status,
   1736                 differing_fields,
   1737                 live_groups: relay_state.live_groups.clone(),
   1738             }
   1739         })
   1740         .collect()
   1741 }
   1742 
   1743 fn summarize_relay_diffs(relay_states: &[MycDiscoveryRelayState]) -> MycDiscoveryRelaySummary {
   1744     let mut summary = MycDiscoveryRelaySummary {
   1745         total_relays: relay_states.len(),
   1746         ..MycDiscoveryRelaySummary::default()
   1747     };
   1748 
   1749     for relay_state in relay_states {
   1750         if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
   1751             summary
   1752                 .unavailable_relays
   1753                 .push(relay_state.relay_url.clone());
   1754             continue;
   1755         }
   1756         match relay_state.live_status {
   1757             Some(MycDiscoveryLiveStatus::Missing) => {
   1758                 summary.missing_relays.push(relay_state.relay_url.clone())
   1759             }
   1760             Some(MycDiscoveryLiveStatus::Matched) => {
   1761                 summary.matched_relays.push(relay_state.relay_url.clone())
   1762             }
   1763             Some(MycDiscoveryLiveStatus::Drifted) => {
   1764                 summary.drifted_relays.push(relay_state.relay_url.clone())
   1765             }
   1766             Some(MycDiscoveryLiveStatus::Conflicted) => summary
   1767                 .conflicted_relays
   1768                 .push(relay_state.relay_url.clone()),
   1769             None => {}
   1770         }
   1771     }
   1772 
   1773     summary
   1774 }
   1775 
   1776 fn summarize_unavailable_relays(relay_states: &[MycLiveNip89RelayState]) -> String {
   1777     let unavailable = relay_states
   1778         .iter()
   1779         .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable)
   1780         .map(|relay_state| {
   1781             let details = relay_state
   1782                 .fetch_error
   1783                 .as_deref()
   1784                 .unwrap_or("unknown relay fetch failure");
   1785             format!("{}: {details}", relay_state.relay_url)
   1786         })
   1787         .collect::<Vec<_>>();
   1788 
   1789     if unavailable.is_empty() {
   1790         "all configured discovery relays were available".to_owned()
   1791     } else {
   1792         format!("unavailable discovery relays: {}", unavailable.join("; "))
   1793     }
   1794 }
   1795 
   1796 fn latest_group_sort_key(group: &MycLiveNip89Group) -> (u64, &str) {
   1797     group
   1798         .events
   1799         .last()
   1800         .map(|event| (event.created_at_unix, event.event_id_hex.as_str()))
   1801         .unwrap_or((0, ""))
   1802 }
   1803 
   1804 fn latest_live_event_id(live_groups: &[MycLiveNip89Group]) -> Option<&str> {
   1805     live_groups
   1806         .first()
   1807         .and_then(|group| group.events.last())
   1808         .map(|event| event.event_id_hex.as_str())
   1809 }
   1810 
   1811 fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> {
   1812     let mut metadata = RadrootsNostrMetadata::default();
   1813     metadata.name = sanitize_optional_string(config.name.as_deref());
   1814     metadata.display_name = sanitize_optional_string(config.display_name.as_deref());
   1815     metadata.about = sanitize_optional_string(config.about.as_deref());
   1816     metadata.website = sanitize_optional_string(config.website.as_deref());
   1817     metadata.picture = sanitize_optional_string(config.picture.as_deref());
   1818     if metadata.name.is_none()
   1819         && metadata.display_name.is_none()
   1820         && metadata.about.is_none()
   1821         && metadata.website.is_none()
   1822         && metadata.picture.is_none()
   1823     {
   1824         return None;
   1825     }
   1826     Some(metadata)
   1827 }
   1828 
   1829 fn sanitize_optional_string(value: Option<&str>) -> Option<String> {
   1830     let trimmed = value?.trim();
   1831     if trimmed.is_empty() {
   1832         None
   1833     } else {
   1834         Some(trimmed.to_owned())
   1835     }
   1836 }
   1837 
   1838 fn normalize_optional_string(value: Option<String>) -> Option<String> {
   1839     sanitize_optional_string(value.as_deref())
   1840 }
   1841 
   1842 fn normalize_string_list(values: Vec<String>) -> Vec<String> {
   1843     let mut values = values
   1844         .into_iter()
   1845         .filter_map(|value| normalize_optional_string(Some(value)))
   1846         .collect::<Vec<_>>();
   1847     values.sort();
   1848     values.dedup();
   1849     values
   1850 }
   1851 
   1852 fn normalize_metadata(metadata: Option<RadrootsNostrMetadata>) -> Option<RadrootsNostrMetadata> {
   1853     let mut metadata = metadata?;
   1854     metadata.name = sanitize_optional_string(metadata.name.as_deref());
   1855     metadata.display_name = sanitize_optional_string(metadata.display_name.as_deref());
   1856     metadata.about = sanitize_optional_string(metadata.about.as_deref());
   1857     metadata.website = sanitize_optional_string(metadata.website.as_deref());
   1858     metadata.picture = sanitize_optional_string(metadata.picture.as_deref());
   1859     if !radroots_nostr_metadata_has_fields(&metadata) {
   1860         return None;
   1861     }
   1862     Some(metadata)
   1863 }
   1864 
   1865 fn write_pretty_json<T>(path: &Path, value: &T) -> Result<(), MycError>
   1866 where
   1867     T: Serialize,
   1868 {
   1869     if let Some(parent) = path.parent() {
   1870         if !parent.as_os_str().is_empty() {
   1871             fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo {
   1872                 path: parent.to_path_buf(),
   1873                 source,
   1874             })?;
   1875         }
   1876     }
   1877     let encoded = serde_json::to_string_pretty(value)?;
   1878     fs::write(path, encoded).map_err(|source| MycError::DiscoveryIo {
   1879         path: path.to_path_buf(),
   1880         source,
   1881     })?;
   1882     Ok(())
   1883 }
   1884 
   1885 fn read_json_file<T>(path: &Path) -> Result<T, MycError>
   1886 where
   1887     T: serde::de::DeserializeOwned,
   1888 {
   1889     let encoded = fs::read_to_string(path).map_err(|source| MycError::DiscoveryIo {
   1890         path: path.to_path_buf(),
   1891         source,
   1892     })?;
   1893     serde_json::from_str(&encoded).map_err(|source| MycError::DiscoveryParse {
   1894         path: path.to_path_buf(),
   1895         source,
   1896     })
   1897 }
   1898 
   1899 fn prepare_staged_output_dir(output_dir: &Path) -> Result<PathBuf, MycError> {
   1900     let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
   1901     fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo {
   1902         path: parent.to_path_buf(),
   1903         source,
   1904     })?;
   1905 
   1906     let bundle_name = output_dir
   1907         .file_name()
   1908         .and_then(|name| name.to_str())
   1909         .unwrap_or("discovery");
   1910     let staged_output_dir = parent.join(format!(
   1911         ".{bundle_name}.staging-{}-{}",
   1912         std::process::id(),
   1913         now_unix_nanos()
   1914     ));
   1915     remove_path_if_exists(&staged_output_dir)?;
   1916     fs::create_dir_all(&staged_output_dir).map_err(|source| MycError::DiscoveryIo {
   1917         path: staged_output_dir.clone(),
   1918         source,
   1919     })?;
   1920     Ok(staged_output_dir)
   1921 }
   1922 
   1923 fn replace_directory_atomically(
   1924     staged_output_dir: &Path,
   1925     output_dir: &Path,
   1926 ) -> Result<(), MycError> {
   1927     let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
   1928     let bundle_name = output_dir
   1929         .file_name()
   1930         .and_then(|name| name.to_str())
   1931         .unwrap_or("discovery");
   1932     let backup_dir = parent.join(format!(
   1933         ".{bundle_name}.backup-{}-{}",
   1934         std::process::id(),
   1935         now_unix_nanos()
   1936     ));
   1937     let had_existing_output = output_dir.exists();
   1938 
   1939     if had_existing_output {
   1940         remove_path_if_exists(&backup_dir)?;
   1941         fs::rename(output_dir, &backup_dir).map_err(|source| MycError::DiscoveryIo {
   1942             path: output_dir.to_path_buf(),
   1943             source,
   1944         })?;
   1945     }
   1946 
   1947     match fs::rename(staged_output_dir, output_dir) {
   1948         Ok(()) => {
   1949             if had_existing_output {
   1950                 remove_path_if_exists(&backup_dir)?;
   1951             }
   1952             Ok(())
   1953         }
   1954         Err(source) => {
   1955             let staged_cleanup_result = remove_path_if_exists(staged_output_dir);
   1956             if had_existing_output && !output_dir.exists() {
   1957                 let _ = fs::rename(&backup_dir, output_dir);
   1958             }
   1959             if let Err(cleanup_error) = staged_cleanup_result {
   1960                 return Err(MycError::InvalidDiscoveryBundle(format!(
   1961                     "failed to swap staged bundle into place: {source}; additionally failed to clean staged output: {cleanup_error}"
   1962                 )));
   1963             }
   1964             Err(MycError::DiscoveryIo {
   1965                 path: output_dir.to_path_buf(),
   1966                 source,
   1967             })
   1968         }
   1969     }
   1970 }
   1971 
   1972 fn remove_path_if_exists(path: &Path) -> Result<(), MycError> {
   1973     let metadata = match fs::metadata(path) {
   1974         Ok(metadata) => metadata,
   1975         Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
   1976         Err(source) => {
   1977             return Err(MycError::DiscoveryIo {
   1978                 path: path.to_path_buf(),
   1979                 source,
   1980             });
   1981         }
   1982     };
   1983 
   1984     if metadata.is_dir() {
   1985         fs::remove_dir_all(path).map_err(|source| MycError::DiscoveryIo {
   1986             path: path.to_path_buf(),
   1987             source,
   1988         })?;
   1989     } else {
   1990         fs::remove_file(path).map_err(|source| MycError::DiscoveryIo {
   1991             path: path.to_path_buf(),
   1992             source,
   1993         })?;
   1994     }
   1995     Ok(())
   1996 }
   1997 
   1998 fn now_unix_nanos() -> u128 {
   1999     SystemTime::now()
   2000         .duration_since(UNIX_EPOCH)
   2001         .expect("system clock is before unix epoch")
   2002         .as_nanos()
   2003 }
   2004 
   2005 impl MycDiscoveryBundleOutput {
   2006     fn validate(&self) -> Result<(), MycError> {
   2007         if self.manifest.version != DISCOVERY_BUNDLE_VERSION {
   2008             return Err(MycError::InvalidDiscoveryBundle(format!(
   2009                 "unsupported bundle version `{}`",
   2010                 self.manifest.version
   2011             )));
   2012         }
   2013         if self.manifest.domain.trim().is_empty() {
   2014             return Err(MycError::InvalidDiscoveryBundle(
   2015                 "bundle domain must not be empty".to_owned(),
   2016             ));
   2017         }
   2018         if self.manifest.author_public_key_hex.trim().is_empty()
   2019             || self.manifest.signer_public_key_hex.trim().is_empty()
   2020         {
   2021             return Err(MycError::InvalidDiscoveryBundle(
   2022                 "bundle author and signer pubkeys must not be empty".to_owned(),
   2023             ));
   2024         }
   2025         if self.manifest.nip05_relative_path != DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH {
   2026             return Err(MycError::InvalidDiscoveryBundle(format!(
   2027                 "bundle manifest nip05_relative_path must be `{DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH}`"
   2028             )));
   2029         }
   2030         if self.manifest.nip89_relative_path != DISCOVERY_BUNDLE_NIP89_FILE_NAME {
   2031             return Err(MycError::InvalidDiscoveryBundle(format!(
   2032                 "bundle manifest nip89_relative_path must be `{DISCOVERY_BUNDLE_NIP89_FILE_NAME}`"
   2033             )));
   2034         }
   2035         if self.nip05_path != self.output_dir.join(&self.manifest.nip05_relative_path) {
   2036             return Err(MycError::InvalidDiscoveryBundle(
   2037                 "bundle nip05 path does not match the manifest".to_owned(),
   2038             ));
   2039         }
   2040         if self.nip89_handler_path != self.output_dir.join(&self.manifest.nip89_relative_path) {
   2041             return Err(MycError::InvalidDiscoveryBundle(
   2042                 "bundle NIP-89 handler path does not match the manifest".to_owned(),
   2043             ));
   2044         }
   2045         if self.nip05_document.names.get("_").map(String::as_str)
   2046             != Some(self.manifest.author_public_key_hex.as_str())
   2047         {
   2048             return Err(MycError::InvalidDiscoveryBundle(
   2049                 "bundle nip05 names._ does not match the manifest author pubkey".to_owned(),
   2050             ));
   2051         }
   2052         if self.nip05_document.nip46.relays != self.manifest.public_relays {
   2053             return Err(MycError::InvalidDiscoveryBundle(
   2054                 "bundle nip05 relays do not match the manifest public relays".to_owned(),
   2055             ));
   2056         }
   2057         if self.nip05_document.nip46.nostrconnect_url != self.manifest.nostrconnect_url {
   2058             return Err(MycError::InvalidDiscoveryBundle(
   2059                 "bundle nip05 nostrconnect_url does not match the manifest".to_owned(),
   2060             ));
   2061         }
   2062         if self.nip89_handler.kinds != vec![NIP46_RPC_KIND] {
   2063             return Err(MycError::InvalidDiscoveryBundle(
   2064                 "bundle NIP-89 handler kinds must be [24133]".to_owned(),
   2065             ));
   2066         }
   2067         if self.nip89_handler.identifier.trim().is_empty() {
   2068             return Err(MycError::InvalidDiscoveryBundle(
   2069                 "bundle NIP-89 handler identifier must not be empty".to_owned(),
   2070             ));
   2071         }
   2072         if self.nip89_handler.relays != self.manifest.public_relays {
   2073             return Err(MycError::InvalidDiscoveryBundle(
   2074                 "bundle NIP-89 handler relays do not match the manifest public relays".to_owned(),
   2075             ));
   2076         }
   2077         if self.nip89_handler.nostrconnect_url != self.manifest.nostrconnect_url {
   2078             return Err(MycError::InvalidDiscoveryBundle(
   2079                 "bundle NIP-89 handler nostrconnect_url does not match the manifest".to_owned(),
   2080             ));
   2081         }
   2082         Ok(())
   2083     }
   2084 }
   2085 
   2086 fn render_nostrconnect_url(
   2087     template: &str,
   2088     signer_identity: &MycActiveIdentity,
   2089     public_relays: &[RadrootsNostrRelayUrl],
   2090 ) -> Result<String, MycError> {
   2091     let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri {
   2092         remote_signer_public_key: signer_identity.public_key(),
   2093         relays: public_relays.to_vec(),
   2094         secret: None,
   2095     })
   2096     .to_string();
   2097     let encoded_bunker_uri: String =
   2098         url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect();
   2099     let rendered = template.replace("<nostrconnect>", &encoded_bunker_uri);
   2100     nostr::Url::parse(&rendered).map_err(|error| {
   2101         MycError::InvalidOperation(format!(
   2102             "failed to render discovery.nostrconnect_url_template: {error}"
   2103         ))
   2104     })?;
   2105     Ok(rendered)
   2106 }
   2107 
   2108 #[cfg(test)]
   2109 mod tests {
   2110     use std::fs;
   2111     use std::path::{Path, PathBuf};
   2112 
   2113     use nostr::JsonUtil;
   2114     use radroots_identity::RadrootsIdentity;
   2115 
   2116     use crate::config::MycConfig;
   2117 
   2118     use super::{MycDiscoveryContext, build_metadata, verify_bundle, write_pretty_json};
   2119     use crate::MycError;
   2120     use crate::app::MycRuntime;
   2121 
   2122     fn write_identity(path: &Path, secret_key: &str) {
   2123         let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
   2124         crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
   2125     }
   2126 
   2127     fn runtime() -> MycRuntime {
   2128         let temp = tempfile::tempdir().expect("tempdir").keep();
   2129         let mut config = MycConfig::default();
   2130         config.paths.state_dir = PathBuf::from(&temp).join("state");
   2131         config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
   2132         config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
   2133         config.discovery.enabled = true;
   2134         config.discovery.domain = Some("signer.example.com".to_owned());
   2135         config.discovery.handler_identifier = "myc".to_owned();
   2136         config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()];
   2137         config.discovery.publish_relays = vec!["wss://publish.example.com".to_owned()];
   2138         config.discovery.nostrconnect_url_template =
   2139             Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned());
   2140         config.discovery.nip05_output_path =
   2141             Some(PathBuf::from(&temp).join("public/.well-known/nostr.json"));
   2142         config.discovery.metadata.name = Some("myc".to_owned());
   2143         config.discovery.metadata.about = Some("remote signer".to_owned());
   2144         config.discovery.app_identity_path = Some(PathBuf::from(&temp).join("app.json"));
   2145         write_identity(
   2146             &config.paths.signer_identity_path,
   2147             "1111111111111111111111111111111111111111111111111111111111111111",
   2148         );
   2149         write_identity(
   2150             &config.paths.user_identity_path,
   2151             "2222222222222222222222222222222222222222222222222222222222222222",
   2152         );
   2153         write_identity(
   2154             config
   2155                 .discovery
   2156                 .app_identity_path
   2157                 .as_ref()
   2158                 .expect("app identity path"),
   2159             "3333333333333333333333333333333333333333333333333333333333333333",
   2160         );
   2161         MycRuntime::bootstrap(config).expect("runtime")
   2162     }
   2163 
   2164     #[test]
   2165     fn build_metadata_ignores_blank_fields() {
   2166         let mut metadata = crate::config::MycDiscoveryMetadataConfig::default();
   2167         metadata.name = Some("   ".to_owned());
   2168         metadata.about = Some(" ready ".to_owned());
   2169 
   2170         let built = build_metadata(&metadata).expect("metadata");
   2171 
   2172         assert!(built.name.is_none());
   2173         assert_eq!(built.about.as_deref(), Some("ready"));
   2174     }
   2175 
   2176     #[test]
   2177     fn render_nip05_document_matches_appendix_shape() {
   2178         let runtime = runtime();
   2179         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2180 
   2181         let document = context.render_nip05_document();
   2182 
   2183         assert_eq!(document.names.len(), 1);
   2184         assert_eq!(
   2185             document.names.get("_"),
   2186             Some(&context.app_identity().public_key_hex())
   2187         );
   2188         assert_eq!(
   2189             document.nip46.relays,
   2190             vec!["wss://relay.example.com".to_owned()]
   2191         );
   2192         assert!(
   2193             document
   2194                 .nip46
   2195                 .nostrconnect_url
   2196                 .as_deref()
   2197                 .expect("nostrconnect url")
   2198                 .contains("bunker%3A%2F%2F")
   2199         );
   2200     }
   2201 
   2202     #[test]
   2203     fn render_signed_nip89_event_uses_app_identity_author() {
   2204         let runtime = runtime();
   2205         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2206 
   2207         let output = context.render_nip89_output().expect("rendered nip89");
   2208 
   2209         assert_eq!(
   2210             output.author_public_key_hex,
   2211             context.app_identity().public_key_hex()
   2212         );
   2213         assert_eq!(
   2214             output.signer_public_key_hex,
   2215             context.signer_identity().public_key_hex()
   2216         );
   2217         assert_eq!(output.event.pubkey, context.app_identity().public_key());
   2218         assert_eq!(output.event.kind.as_u16(), 31_990);
   2219         let event_json = output.event.as_json();
   2220         assert!(event_json.contains("\"24133\""));
   2221         assert!(event_json.contains("\"nostrconnect_url\""));
   2222     }
   2223 
   2224     #[test]
   2225     fn write_nip05_document_writes_pretty_json_artifact() {
   2226         let runtime = runtime();
   2227         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2228         let output_path = context
   2229             .nip05_output_path()
   2230             .expect("configured output path")
   2231             .to_path_buf();
   2232 
   2233         let output = context
   2234             .write_nip05_document(&output_path)
   2235             .expect("write nip05 document");
   2236 
   2237         let written = fs::read_to_string(&output_path).expect("read output");
   2238         assert_eq!(output.output_path.as_deref(), Some(output_path.as_path()));
   2239         assert!(written.contains("\"names\""));
   2240         assert!(written.contains("\"nip46\""));
   2241         assert!(written.contains(&context.app_identity().public_key_hex()));
   2242     }
   2243 
   2244     #[test]
   2245     fn write_bundle_writes_deterministic_artifacts() {
   2246         let runtime = runtime();
   2247         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2248         let bundle_dir = runtime.paths().state_dir.join("bundle");
   2249 
   2250         let first = context
   2251             .write_bundle(&bundle_dir)
   2252             .expect("first bundle write");
   2253         let manifest_first = fs::read_to_string(&first.manifest_path).expect("manifest");
   2254         let nip05_first = fs::read_to_string(&first.nip05_path).expect("nip05");
   2255         let nip89_first = fs::read_to_string(&first.nip89_handler_path).expect("nip89");
   2256 
   2257         let second = context
   2258             .write_bundle(&bundle_dir)
   2259             .expect("second bundle write");
   2260         let manifest_second = fs::read_to_string(&second.manifest_path).expect("manifest");
   2261         let nip05_second = fs::read_to_string(&second.nip05_path).expect("nip05");
   2262         let nip89_second = fs::read_to_string(&second.nip89_handler_path).expect("nip89");
   2263 
   2264         assert_eq!(first.manifest.version, 1);
   2265         assert_eq!(first.manifest.nip05_relative_path, ".well-known/nostr.json");
   2266         assert_eq!(first.manifest.nip89_relative_path, "nip89-handler.json");
   2267         assert_eq!(first.nip05_path, bundle_dir.join(".well-known/nostr.json"));
   2268         assert_eq!(
   2269             first.nip89_handler_path,
   2270             bundle_dir.join("nip89-handler.json")
   2271         );
   2272         assert_eq!(manifest_first, manifest_second);
   2273         assert_eq!(nip05_first, nip05_second);
   2274         assert_eq!(nip89_first, nip89_second);
   2275     }
   2276 
   2277     #[test]
   2278     fn write_bundle_replaces_existing_directory_without_leaving_stale_files() {
   2279         let runtime = runtime();
   2280         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2281         let bundle_dir = runtime.paths().state_dir.join("bundle");
   2282         fs::create_dir_all(&bundle_dir).expect("create old bundle dir");
   2283         fs::write(bundle_dir.join("stale.txt"), "stale").expect("write stale file");
   2284 
   2285         let bundle = context.write_bundle(&bundle_dir).expect("write bundle");
   2286 
   2287         assert_eq!(bundle.output_dir, bundle_dir);
   2288         assert!(!bundle.output_dir.join("stale.txt").exists());
   2289         assert!(bundle.manifest_path.exists());
   2290         assert!(bundle.nip05_path.exists());
   2291         assert!(bundle.nip89_handler_path.exists());
   2292     }
   2293 
   2294     #[test]
   2295     fn verify_bundle_rejects_tampered_nip05_author() {
   2296         let runtime = runtime();
   2297         let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
   2298         let bundle_dir = runtime.paths().state_dir.join("bundle");
   2299         let bundle = context.write_bundle(&bundle_dir).expect("write bundle");
   2300         let mut tampered = bundle.nip05_document.clone();
   2301         tampered.names.insert("_".to_owned(), "deadbeef".to_owned());
   2302         write_pretty_json(&bundle.nip05_path, &tampered).expect("rewrite tampered nip05");
   2303 
   2304         let error = verify_bundle(&bundle_dir).expect_err("bundle should be invalid");
   2305 
   2306         assert!(matches!(error, MycError::InvalidDiscoveryBundle(_)));
   2307         assert!(
   2308             error
   2309                 .to_string()
   2310                 .contains("bundle nip05 names._ does not match the manifest author pubkey")
   2311         );
   2312     }
   2313 }