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 }