commit 48a89feb288bb9596ee53c03f9b68acb5f21c2a8
parent cee3ab7d493abcf4cd9ec9350c6aff437037a6db
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 13:27:49 +0000
discovery: add live nip89 sync
- add live handler fetch and semantic normalization for the configured discovery identity
- add diff and refresh commands that compare local state with published handler metadata
- record discovery compare and refresh outcomes in runtime audit and operator summaries
- validate with cargo metadata --format-version 1 --no-deps cargo check --locked cargo test --locked and cargo fmt --check
Diffstat:
6 files changed, 411 insertions(+), 10 deletions(-)
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -295,7 +295,10 @@ impl MycSignerContext {
fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
match record.outcome {
- crate::audit::MycOperationAuditOutcome::Succeeded => tracing::info!(
+ crate::audit::MycOperationAuditOutcome::Succeeded
+ | crate::audit::MycOperationAuditOutcome::Missing
+ | crate::audit::MycOperationAuditOutcome::Matched
+ | crate::audit::MycOperationAuditOutcome::Skipped => tracing::info!(
operation = ?record.operation,
outcome = ?record.outcome,
connection_id = record.connection_id.as_deref().unwrap_or(""),
@@ -306,7 +309,8 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
"recorded myc operation audit"
),
crate::audit::MycOperationAuditOutcome::Rejected
- | crate::audit::MycOperationAuditOutcome::Restored => tracing::warn!(
+ | crate::audit::MycOperationAuditOutcome::Restored
+ | crate::audit::MycOperationAuditOutcome::Drifted => tracing::warn!(
operation = ?record.operation,
outcome = ?record.outcome,
connection_id = record.connection_id.as_deref().unwrap_or(""),
diff --git a/src/audit.rs b/src/audit.rs
@@ -21,6 +21,8 @@ pub enum MycOperationAuditKind {
AuthReplayPublish,
AuthReplayRestore,
DiscoveryHandlerPublish,
+ DiscoveryHandlerCompare,
+ DiscoveryHandlerRefresh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -29,6 +31,10 @@ pub enum MycOperationAuditOutcome {
Succeeded,
Rejected,
Restored,
+ Missing,
+ Matched,
+ Drifted,
+ Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/src/cli.rs b/src/cli.rs
@@ -13,7 +13,10 @@ use crate::app::MycRuntime;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
use crate::config::{DEFAULT_CONFIG_PATH, MycConfig};
use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values};
-use crate::discovery::{MycDiscoveryContext, publish_nip89_event, verify_bundle};
+use crate::discovery::{
+ MycDiscoveryContext, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89,
+ verify_bundle,
+};
use crate::error::MycError;
use crate::logging;
@@ -127,6 +130,12 @@ pub enum MycDiscoveryCommand {
#[arg(long)]
dir: PathBuf,
},
+ InspectLiveNip89,
+ DiffLiveNip89,
+ RefreshNip89 {
+ #[arg(long)]
+ force: bool,
+ },
}
#[derive(Debug, Args)]
@@ -163,6 +172,10 @@ pub struct MycOperationOutcomeCounts {
pub succeeded: usize,
pub rejected: usize,
pub restored: usize,
+ pub missing: usize,
+ pub matched: usize,
+ pub drifted: usize,
+ pub skipped: usize,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
@@ -283,6 +296,21 @@ pub async fn run_from_env() -> Result<(), MycError> {
let output = verify_bundle(dir)?;
print_json(&output)
}
+ MycDiscoveryCommand::InspectLiveNip89 => {
+ let runtime = MycRuntime::bootstrap(config.clone())?;
+ let output = fetch_live_nip89(&runtime).await?;
+ print_json(&output)
+ }
+ MycDiscoveryCommand::DiffLiveNip89 => {
+ let runtime = MycRuntime::bootstrap(config.clone())?;
+ let output = diff_live_nip89(&runtime).await?;
+ print_json(&output)
+ }
+ MycDiscoveryCommand::RefreshNip89 { force } => {
+ let runtime = MycRuntime::bootstrap(config.clone())?;
+ let output = refresh_nip89(&runtime, force).await?;
+ print_json(&output)
+ }
MycDiscoveryCommand::RenderNip05 { out, stdout } => {
let runtime = MycRuntime::bootstrap(config.clone())?;
if stdout && out.is_some() {
@@ -467,6 +495,10 @@ fn increment_outcome_counts(
MycOperationAuditOutcome::Succeeded => counts.succeeded += 1,
MycOperationAuditOutcome::Rejected => counts.rejected += 1,
MycOperationAuditOutcome::Restored => counts.restored += 1,
+ MycOperationAuditOutcome::Missing => counts.missing += 1,
+ MycOperationAuditOutcome::Matched => counts.matched += 1,
+ MycOperationAuditOutcome::Drifted => counts.drifted += 1,
+ MycOperationAuditOutcome::Skipped => counts.skipped += 1,
}
}
@@ -477,6 +509,8 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String {
MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(),
MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(),
MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(),
+ MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(),
+ MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(),
}
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -1,12 +1,14 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
- RadrootsNostrApplicationHandlerSpec, RadrootsNostrEvent, RadrootsNostrMetadata,
- RadrootsNostrRelayUrl, radroots_nostr_build_application_handler_event,
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrEvent,
+ RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl,
+ radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag,
+ radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value,
};
use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
use serde::{Deserialize, Serialize};
@@ -77,6 +79,58 @@ pub struct MycPublishedNip89Output {
pub event: RadrootsNostrEvent,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycDiscoveryLiveStatus {
+ Missing,
+ Matched,
+ Drifted,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MycNormalizedNip89Handler {
+ pub author_public_key_hex: String,
+ pub kinds: Vec<u32>,
+ pub identifier: String,
+ pub relays: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nostrconnect_url: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub metadata: Option<RadrootsNostrMetadata>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MycLiveNip89Event {
+ pub event_id_hex: String,
+ pub created_at_unix: u64,
+ pub handler: MycNormalizedNip89Handler,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycFetchedLiveNip89Output {
+ pub author_public_key_hex: String,
+ pub publish_relays: Vec<String>,
+ pub handler_identifier: String,
+ pub live_event: Option<MycLiveNip89Event>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycDiscoveryDiffOutput {
+ pub status: MycDiscoveryLiveStatus,
+ pub local_handler: MycNormalizedNip89Handler,
+ pub live_event: Option<MycLiveNip89Event>,
+ pub differing_fields: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRefreshedNip89Output {
+ pub status: MycDiscoveryLiveStatus,
+ pub force: bool,
+ pub differing_fields: Vec<String>,
+ pub live_event: Option<MycLiveNip89Event>,
+ pub published: Option<MycPublishedNip89Output>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MycNip89HandlerDocument {
pub kinds: Vec<u32>,
@@ -167,6 +221,10 @@ impl MycDiscoveryContext {
self.domain.as_str()
}
+ pub fn handler_identifier(&self) -> &str {
+ self.handler_identifier.as_str()
+ }
+
pub fn publish_relays(&self) -> &[RadrootsNostrRelayUrl] {
self.publish_relays.as_slice()
}
@@ -248,6 +306,19 @@ impl MycDiscoveryContext {
}
}
+ pub fn render_normalized_nip89_handler(&self) -> MycNormalizedNip89Handler {
+ MycNormalizedNip89Handler {
+ author_public_key_hex: self.app_identity.public_key_hex(),
+ kinds: vec![NIP46_RPC_KIND],
+ identifier: self.handler_identifier.clone(),
+ relays: normalize_string_list(
+ self.public_relays.iter().map(ToString::to_string).collect(),
+ ),
+ nostrconnect_url: normalize_optional_string(self.nostrconnect_url.clone()),
+ metadata: normalize_metadata(self.metadata.clone()),
+ }
+ }
+
pub fn render_bundle_manifest(&self) -> MycDiscoveryBundleManifest {
MycDiscoveryBundleManifest {
version: DISCOVERY_BUNDLE_VERSION,
@@ -382,6 +453,85 @@ pub async fn publish_nip89_event(
})
}
+pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> {
+ let context = MycDiscoveryContext::from_runtime(runtime)?;
+ let live_event = fetch_latest_live_nip89_event(&context).await?;
+ Ok(MycFetchedLiveNip89Output {
+ author_public_key_hex: context.app_identity().public_key_hex(),
+ publish_relays: context
+ .publish_relays()
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
+ handler_identifier: context.handler_identifier().to_owned(),
+ live_event,
+ })
+}
+
+pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> {
+ let context = MycDiscoveryContext::from_runtime(runtime)?;
+ let local_handler = context.render_normalized_nip89_handler();
+ let live_event = fetch_latest_live_nip89_event(&context).await?;
+ let (status, differing_fields) = compare_live_handler(&local_handler, live_event.as_ref());
+ Ok(MycDiscoveryDiffOutput {
+ status,
+ local_handler,
+ live_event,
+ differing_fields,
+ })
+}
+
+pub async fn refresh_nip89(
+ runtime: &MycRuntime,
+ force: bool,
+) -> Result<MycRefreshedNip89Output, MycError> {
+ let context = MycDiscoveryContext::from_runtime(runtime)?;
+ let local_handler = context.render_normalized_nip89_handler();
+ let live_event = fetch_latest_live_nip89_event(&context).await?;
+ let (status, differing_fields) = compare_live_handler(&local_handler, live_event.as_ref());
+ let relay_count = context.publish_relays().len();
+ let compare_request_id = live_event.as_ref().map(|event| event.event_id_hex.as_str());
+ let compare_summary = describe_compare_status(status, &differing_fields);
+
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerCompare,
+ compare_status_to_audit_outcome(status),
+ None,
+ compare_request_id,
+ relay_count,
+ relay_count,
+ compare_summary,
+ ));
+
+ if status == MycDiscoveryLiveStatus::Matched && !force {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerRefresh,
+ MycOperationAuditOutcome::Skipped,
+ None,
+ compare_request_id,
+ relay_count,
+ relay_count,
+ "local discovery handler already matches live state".to_owned(),
+ ));
+ return Ok(MycRefreshedNip89Output {
+ status,
+ force,
+ differing_fields,
+ live_event,
+ published: None,
+ });
+ }
+
+ let published = publish_nip89_event(runtime).await?;
+ Ok(MycRefreshedNip89Output {
+ status,
+ force,
+ differing_fields,
+ live_event,
+ published: Some(published),
+ })
+}
+
pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> {
let output_dir = output_dir.as_ref().to_path_buf();
let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME);
@@ -404,6 +554,182 @@ pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleO
Ok(bundle)
}
+async fn fetch_latest_live_nip89_event(
+ context: &MycDiscoveryContext,
+) -> Result<Option<MycLiveNip89Event>, MycError> {
+ let client = RadrootsNostrClient::from_identity(context.app_identity());
+ for relay in context.publish_relays() {
+ let _ = client.add_relay(relay.as_str()).await?;
+ }
+ client.connect().await;
+ client
+ .wait_for_connection(Duration::from_secs(context.connect_timeout_secs()))
+ .await;
+
+ let mut filter = RadrootsNostrFilter::new()
+ .author(context.app_identity().public_key())
+ .kind(RadrootsNostrKind::Custom(31_990));
+ filter = radroots_nostr_filter_tag(filter, "d", vec![context.handler_identifier().to_owned()])?;
+ filter = radroots_nostr_filter_tag(filter, "k", vec![NIP46_RPC_KIND.to_string()])?;
+
+ let mut events = client
+ .fetch_events(filter, Duration::from_secs(context.connect_timeout_secs()))
+ .await?;
+ events.sort_by(|left, right| {
+ left.created_at
+ .as_secs()
+ .cmp(&right.created_at.as_secs())
+ .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
+ });
+ let Some(event) = events.pop() else {
+ return Ok(None);
+ };
+
+ Ok(Some(MycLiveNip89Event {
+ event_id_hex: event.id.to_hex(),
+ created_at_unix: event.created_at.as_secs(),
+ handler: normalize_live_nip89_handler(&event)?,
+ }))
+}
+
+fn compare_live_handler(
+ local_handler: &MycNormalizedNip89Handler,
+ live_event: Option<&MycLiveNip89Event>,
+) -> (MycDiscoveryLiveStatus, Vec<String>) {
+ let Some(live_event) = live_event else {
+ return (
+ MycDiscoveryLiveStatus::Missing,
+ vec!["live_event".to_owned()],
+ );
+ };
+
+ let mut differing_fields = Vec::new();
+ if live_event.handler.author_public_key_hex != local_handler.author_public_key_hex {
+ differing_fields.push("author_public_key_hex".to_owned());
+ }
+ if live_event.handler.kinds != local_handler.kinds {
+ differing_fields.push("kinds".to_owned());
+ }
+ if live_event.handler.identifier != local_handler.identifier {
+ differing_fields.push("identifier".to_owned());
+ }
+ if live_event.handler.relays != local_handler.relays {
+ differing_fields.push("relays".to_owned());
+ }
+ if live_event.handler.nostrconnect_url != local_handler.nostrconnect_url {
+ differing_fields.push("nostrconnect_url".to_owned());
+ }
+ if live_event.handler.metadata != local_handler.metadata {
+ differing_fields.push("metadata".to_owned());
+ }
+
+ if differing_fields.is_empty() {
+ (MycDiscoveryLiveStatus::Matched, differing_fields)
+ } else {
+ (MycDiscoveryLiveStatus::Drifted, differing_fields)
+ }
+}
+
+fn compare_status_to_audit_outcome(status: MycDiscoveryLiveStatus) -> MycOperationAuditOutcome {
+ match status {
+ MycDiscoveryLiveStatus::Missing => MycOperationAuditOutcome::Missing,
+ MycDiscoveryLiveStatus::Matched => MycOperationAuditOutcome::Matched,
+ MycDiscoveryLiveStatus::Drifted => MycOperationAuditOutcome::Drifted,
+ }
+}
+
+fn describe_compare_status(status: MycDiscoveryLiveStatus, differing_fields: &[String]) -> String {
+ match status {
+ MycDiscoveryLiveStatus::Missing => {
+ "no live NIP-89 handler was found for the configured discovery identity".to_owned()
+ }
+ MycDiscoveryLiveStatus::Matched => {
+ "local discovery handler matches the latest live NIP-89 handler".to_owned()
+ }
+ MycDiscoveryLiveStatus::Drifted => format!(
+ "local discovery handler differs from live state in: {}",
+ differing_fields.join(", ")
+ ),
+ }
+}
+
+fn normalize_live_nip89_handler(
+ event: &RadrootsNostrEvent,
+) -> Result<MycNormalizedNip89Handler, MycError> {
+ if event.kind != RadrootsNostrKind::Custom(31_990) {
+ return Err(MycError::InvalidDiscoveryEvent(format!(
+ "expected kind 31990 but found kind {}",
+ event.kind.as_u16()
+ )));
+ }
+
+ let identifier = event
+ .tags
+ .iter()
+ .find_map(|tag| radroots_nostr_tag_first_value(tag, "d"))
+ .map(|value| value.trim().to_owned())
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| {
+ MycError::InvalidDiscoveryEvent(
+ "live handler event is missing a non-empty `d` tag".to_owned(),
+ )
+ })?;
+
+ let mut kinds = event
+ .tags
+ .iter()
+ .filter_map(|tag| radroots_nostr_tag_first_value(tag, "k"))
+ .map(|value| {
+ value.parse::<u32>().map_err(|error| {
+ MycError::InvalidDiscoveryEvent(format!(
+ "failed to parse live handler kind `{value}`: {error}"
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ if kinds.is_empty() {
+ return Err(MycError::InvalidDiscoveryEvent(
+ "live handler event is missing `k` tags".to_owned(),
+ ));
+ }
+ kinds.sort_unstable();
+ kinds.dedup();
+
+ let relays = normalize_string_list(
+ event
+ .tags
+ .iter()
+ .filter_map(|tag| radroots_nostr_tag_first_value(tag, "relay"))
+ .collect(),
+ );
+ let nostrconnect_url = normalize_optional_string(
+ event
+ .tags
+ .iter()
+ .find_map(|tag| radroots_nostr_tag_first_value(tag, "nostrconnect_url")),
+ );
+ let metadata = if event.content.trim().is_empty() {
+ None
+ } else {
+ Some(
+ serde_json::from_str::<RadrootsNostrMetadata>(&event.content).map_err(|error| {
+ MycError::InvalidDiscoveryEvent(format!(
+ "failed to parse live handler metadata: {error}"
+ ))
+ })?,
+ )
+ };
+
+ Ok(MycNormalizedNip89Handler {
+ author_public_key_hex: event.pubkey.to_hex(),
+ kinds,
+ identifier,
+ relays,
+ nostrconnect_url,
+ metadata: normalize_metadata(metadata),
+ })
+}
+
fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> {
let mut metadata = RadrootsNostrMetadata::default();
metadata.name = sanitize_optional_string(config.name.as_deref());
@@ -431,6 +757,33 @@ fn sanitize_optional_string(value: Option<&str>) -> Option<String> {
}
}
+fn normalize_optional_string(value: Option<String>) -> Option<String> {
+ sanitize_optional_string(value.as_deref())
+}
+
+fn normalize_string_list(values: Vec<String>) -> Vec<String> {
+ let mut values = values
+ .into_iter()
+ .filter_map(|value| normalize_optional_string(Some(value)))
+ .collect::<Vec<_>>();
+ values.sort();
+ values.dedup();
+ values
+}
+
+fn normalize_metadata(metadata: Option<RadrootsNostrMetadata>) -> Option<RadrootsNostrMetadata> {
+ let mut metadata = metadata?;
+ metadata.name = sanitize_optional_string(metadata.name.as_deref());
+ metadata.display_name = sanitize_optional_string(metadata.display_name.as_deref());
+ metadata.about = sanitize_optional_string(metadata.about.as_deref());
+ metadata.website = sanitize_optional_string(metadata.website.as_deref());
+ metadata.picture = sanitize_optional_string(metadata.picture.as_deref());
+ if !radroots_nostr_metadata_has_fields(&metadata) {
+ return None;
+ }
+ Some(metadata)
+}
+
fn write_pretty_json<T>(path: &Path, value: &T) -> Result<(), MycError>
where
T: Serialize,
diff --git a/src/error.rs b/src/error.rs
@@ -71,6 +71,8 @@ pub enum MycError {
},
#[error("invalid discovery bundle: {0}")]
InvalidDiscoveryBundle(String),
+ #[error("invalid discovery event: {0}")]
+ InvalidDiscoveryEvent(String),
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
diff --git a/src/lib.rs b/src/lib.rs
@@ -22,10 +22,12 @@ pub use config::{
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use discovery::{
- MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycNip05Document,
- MycNip05DocumentSection, MycNip89HandlerDocument, MycPublishedNip89Output,
- MycRenderedNip05Output, MycRenderedNip89Output, publish_nip89_event, render_nip05_output,
- verify_bundle,
+ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
+ MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycFetchedLiveNip89Output, MycLiveNip89Event,
+ MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument, MycNormalizedNip89Handler,
+ MycPublishedNip89Output, MycRefreshedNip89Output, MycRenderedNip05Output,
+ MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89,
+ render_nip05_output, verify_bundle,
};
pub use error::MycError;
pub use transport::{MycNostrTransport, MycTransportSnapshot};