commit 3b61db85298979d10571aa8cf1b50e86bef1cb52
parent dbb30705811dbb2762eb7bf308c4bc4da8d60bb5
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 12:00:52 +0000
discovery: add metadata models and renderers
- add discovery config for domains relay hints app identity and nostrconnect templates
- add typed NIP-05 rendering and NIP-89 event construction around a separate discovery author key
- add one-shot signed handler publication support with runtime audit recording
- validate with cargo metadata --format-version 1 --no-deps cargo fmt --check cargo check --locked and cargo test --locked
Diffstat:
8 files changed, 823 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1047,6 +1047,7 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
+ "url",
]
[[package]]
@@ -1370,6 +1371,14 @@ dependencies = [
]
[[package]]
+name = "radroots-events-codec"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "radroots-core",
+ "radroots-events",
+]
+
+[[package]]
name = "radroots-identity"
version = "0.1.0-alpha.1"
dependencies = [
@@ -1398,6 +1407,8 @@ version = "0.1.0-alpha.1"
dependencies = [
"nostr",
"nostr-sdk",
+ "radroots-events",
+ "radroots-events-codec",
"radroots-identity",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
@@ -17,7 +17,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
clap = { version = "4.5", features = ["derive"] }
nostr = { version = "0.44.2", features = ["nip04", "nip44"] }
radroots-identity = { path = "../lib/crates/identity" }
-radroots-nostr = { path = "../lib/crates/nostr", features = ["client"] }
+radroots-nostr = { path = "../lib/crates/nostr", features = ["client", "events"] }
radroots-nostr-connect = { path = "../lib/crates/nostr-connect" }
radroots-nostr-signer = { path = "../lib/crates/nostr-signer" }
serde = { version = "1.0", features = ["derive"] }
@@ -27,6 +27,7 @@ tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "syn
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
+url = "2.5"
[dev-dependencies]
futures-util = "0.3.32"
diff --git a/src/audit.rs b/src/audit.rs
@@ -20,6 +20,7 @@ pub enum MycOperationAuditKind {
ConnectAcceptPublish,
AuthReplayPublish,
AuthReplayRestore,
+ DiscoveryHandlerPublish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/src/config.rs b/src/config.rs
@@ -18,6 +18,7 @@ pub struct MycConfig {
pub logging: MycLoggingConfig,
pub paths: MycPathsConfig,
pub audit: MycAuditConfig,
+ pub discovery: MycDiscoveryConfig,
pub policy: MycPolicyConfig,
pub transport: MycTransportConfig,
}
@@ -52,6 +53,30 @@ pub struct MycAuditConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
+pub struct MycDiscoveryConfig {
+ pub enabled: bool,
+ pub domain: Option<String>,
+ pub handler_identifier: String,
+ pub app_identity_path: Option<PathBuf>,
+ pub public_relays: Vec<String>,
+ pub publish_relays: Vec<String>,
+ pub nostrconnect_url_template: Option<String>,
+ pub nip05_output_path: Option<PathBuf>,
+ pub metadata: MycDiscoveryMetadataConfig,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct MycDiscoveryMetadataConfig {
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub website: Option<String>,
+ pub picture: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
pub struct MycTransportConfig {
pub enabled: bool,
pub connect_timeout_secs: u64,
@@ -78,6 +103,7 @@ impl Default for MycConfig {
logging: MycLoggingConfig::default(),
paths: MycPathsConfig::default(),
audit: MycAuditConfig::default(),
+ discovery: MycDiscoveryConfig::default(),
policy: MycPolicyConfig::default(),
transport: MycTransportConfig::default(),
}
@@ -130,6 +156,34 @@ impl Default for MycAuditConfig {
}
}
+impl Default for MycDiscoveryConfig {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ domain: None,
+ handler_identifier: "myc".to_owned(),
+ app_identity_path: None,
+ public_relays: Vec::new(),
+ publish_relays: Vec::new(),
+ nostrconnect_url_template: None,
+ nip05_output_path: None,
+ metadata: MycDiscoveryMetadataConfig::default(),
+ }
+ }
+}
+
+impl Default for MycDiscoveryMetadataConfig {
+ fn default() -> Self {
+ Self {
+ name: None,
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ }
+ }
+}
+
impl Default for MycPolicyConfig {
fn default() -> Self {
Self {
@@ -226,6 +280,8 @@ impl MycConfig {
));
}
+ self.discovery.validate(&self.transport)?;
+
if self.transport.connect_timeout_secs == 0 {
return Err(MycError::InvalidConfig(
"transport.connect_timeout_secs must be greater than zero".to_owned(),
@@ -267,6 +323,156 @@ impl MycTransportConfig {
}
}
+impl MycDiscoveryConfig {
+ pub fn parse_public_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
+ parse_discovery_relays(&self.public_relays, "discovery.public_relays")
+ }
+
+ pub fn parse_publish_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
+ parse_discovery_relays(&self.publish_relays, "discovery.publish_relays")
+ }
+
+ pub fn resolved_public_relays(
+ &self,
+ transport: &MycTransportConfig,
+ ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
+ let relays = if self.public_relays.is_empty() {
+ transport.parse_relays()?
+ } else {
+ self.parse_public_relays()?
+ };
+ Ok(normalize_discovery_relays(relays))
+ }
+
+ pub fn resolved_publish_relays(
+ &self,
+ transport: &MycTransportConfig,
+ ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
+ let relays = if self.publish_relays.is_empty() {
+ self.resolved_public_relays(transport)?
+ } else {
+ self.parse_publish_relays()?
+ };
+ Ok(normalize_discovery_relays(relays))
+ }
+
+ fn validate(&self, transport: &MycTransportConfig) -> Result<(), MycError> {
+ if !self.enabled {
+ return Ok(());
+ }
+
+ let domain = self.domain.as_deref().ok_or_else(|| {
+ MycError::InvalidConfig(
+ "discovery.domain must be set when discovery.enabled is true".to_owned(),
+ )
+ })?;
+ validate_discovery_domain(domain)?;
+
+ if self.handler_identifier.trim().is_empty() {
+ return Err(MycError::InvalidConfig(
+ "discovery.handler_identifier must not be empty when discovery.enabled is true"
+ .to_owned(),
+ ));
+ }
+
+ if let Some(path) = self.app_identity_path.as_ref() {
+ if path.as_os_str().is_empty() {
+ return Err(MycError::InvalidConfig(
+ "discovery.app_identity_path must not be empty".to_owned(),
+ ));
+ }
+ }
+
+ if let Some(template) = self.nostrconnect_url_template.as_deref() {
+ validate_nostrconnect_url_template(template)?;
+ }
+
+ if let Some(path) = self.nip05_output_path.as_ref() {
+ if path.as_os_str().is_empty() {
+ return Err(MycError::InvalidConfig(
+ "discovery.nip05_output_path must not be empty".to_owned(),
+ ));
+ }
+ }
+
+ if self.resolved_public_relays(transport)?.is_empty() {
+ return Err(MycError::InvalidConfig(
+ "discovery requires at least one public relay hint via discovery.public_relays or transport.relays".to_owned(),
+ ));
+ }
+
+ let _ = self.resolved_publish_relays(transport)?;
+ Ok(())
+ }
+}
+
+fn parse_discovery_relays(
+ values: &[String],
+ field_name: &str,
+) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
+ values
+ .iter()
+ .map(|value| {
+ RadrootsNostrRelayUrl::parse(value).map_err(|source| {
+ MycError::InvalidConfig(format!(
+ "{field_name} contains invalid relay url `{value}`: {source}"
+ ))
+ })
+ })
+ .collect()
+}
+
+fn normalize_discovery_relays(
+ mut relays: Vec<RadrootsNostrRelayUrl>,
+) -> Vec<RadrootsNostrRelayUrl> {
+ relays.sort_by(|left, right| left.as_str().cmp(right.as_str()));
+ relays.dedup_by(|left, right| left.as_str() == right.as_str());
+ relays
+}
+
+fn validate_discovery_domain(domain: &str) -> Result<(), MycError> {
+ let trimmed = domain.trim();
+ if trimmed.is_empty()
+ || trimmed.contains("://")
+ || trimmed.contains('/')
+ || trimmed.contains('?')
+ || trimmed.contains('#')
+ || trimmed.chars().any(char::is_whitespace)
+ {
+ return Err(MycError::InvalidConfig(format!(
+ "discovery.domain must be a bare host name without scheme or path: `{domain}`"
+ )));
+ }
+ Ok(())
+}
+
+fn validate_nostrconnect_url_template(template: &str) -> Result<(), MycError> {
+ let trimmed = template.trim();
+ if trimmed.is_empty() {
+ return Err(MycError::InvalidConfig(
+ "discovery.nostrconnect_url_template must not be empty when set".to_owned(),
+ ));
+ }
+ if !trimmed.contains("<nostrconnect>") {
+ return Err(MycError::InvalidConfig(
+ "discovery.nostrconnect_url_template must contain the `<nostrconnect>` placeholder"
+ .to_owned(),
+ ));
+ }
+ if !trimmed.starts_with("https://") {
+ return Err(MycError::InvalidConfig(
+ "discovery.nostrconnect_url_template must start with `https://`".to_owned(),
+ ));
+ }
+ let candidate = trimmed.replace("<nostrconnect>", "nostrconnect%3A%2F%2Fclient");
+ nostr::Url::parse(&candidate).map_err(|source| {
+ MycError::InvalidConfig(format!(
+ "discovery.nostrconnect_url_template is invalid: {source}"
+ ))
+ })?;
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -292,6 +498,13 @@ mod tests {
assert_eq!(config.audit.default_read_limit, 200);
assert_eq!(config.audit.max_active_file_bytes, 262_144);
assert_eq!(config.audit.max_archived_files, 8);
+ assert!(!config.discovery.enabled);
+ assert_eq!(config.discovery.handler_identifier, "myc");
+ assert!(config.discovery.domain.is_none());
+ assert!(config.discovery.public_relays.is_empty());
+ assert!(config.discovery.publish_relays.is_empty());
+ assert!(config.discovery.nostrconnect_url_template.is_none());
+ assert!(config.discovery.nip05_output_path.is_none());
assert!(!config.transport.enabled);
assert_eq!(config.transport.connect_timeout_secs, 10);
assert!(config.transport.relays.is_empty());
@@ -317,6 +530,23 @@ mod tests {
max_active_file_bytes = 4096
max_archived_files = 3
+ [discovery]
+ enabled = true
+ domain = "myc.example.com"
+ handler_identifier = "myc-main"
+ app_identity_path = "/tmp/myc-app.json"
+ public_relays = ["wss://relay.discovery.example.com"]
+ publish_relays = ["wss://relay.publish.example.com"]
+ nostrconnect_url_template = "https://myc.example.com/connect/<nostrconnect>"
+ nip05_output_path = "/tmp/nostr.json"
+
+ [discovery.metadata]
+ name = "myc"
+ display_name = "Mycorrhiza"
+ about = "NIP-46 signer"
+ website = "https://myc.example.com"
+ picture = "https://myc.example.com/logo.png"
+
[policy]
connection_approval = "not_required"
@@ -342,6 +572,34 @@ mod tests {
assert_eq!(config.audit.default_read_limit, 50);
assert_eq!(config.audit.max_active_file_bytes, 4096);
assert_eq!(config.audit.max_archived_files, 3);
+ assert!(config.discovery.enabled);
+ assert_eq!(config.discovery.domain.as_deref(), Some("myc.example.com"));
+ assert_eq!(config.discovery.handler_identifier, "myc-main");
+ assert_eq!(
+ config.discovery.app_identity_path,
+ Some(PathBuf::from("/tmp/myc-app.json"))
+ );
+ assert_eq!(
+ config.discovery.public_relays,
+ vec!["wss://relay.discovery.example.com".to_owned()]
+ );
+ assert_eq!(
+ config.discovery.publish_relays,
+ vec!["wss://relay.publish.example.com".to_owned()]
+ );
+ assert_eq!(
+ config.discovery.nostrconnect_url_template.as_deref(),
+ Some("https://myc.example.com/connect/<nostrconnect>")
+ );
+ assert_eq!(
+ config.discovery.nip05_output_path,
+ Some(PathBuf::from("/tmp/nostr.json"))
+ );
+ assert_eq!(config.discovery.metadata.name.as_deref(), Some("myc"));
+ assert_eq!(
+ config.discovery.metadata.display_name.as_deref(),
+ Some("Mycorrhiza")
+ );
assert_eq!(
config.policy.connection_approval,
MycConnectionApproval::NotRequired
@@ -397,4 +655,35 @@ mod tests {
let err = config.validate().expect_err("invalid audit read limit");
assert!(err.to_string().contains("audit.default_read_limit"));
}
+
+ #[test]
+ fn discovery_validation_requires_domain_and_relays_when_enabled() {
+ let mut config = MycConfig::default();
+ config.discovery.enabled = true;
+ config.transport.enabled = true;
+ config.transport.relays = vec!["wss://relay.example.com".to_owned()];
+
+ let err = config.validate().expect_err("missing discovery domain");
+ assert!(err.to_string().contains("discovery.domain"));
+
+ config.discovery.domain = Some("myc.example.com".to_owned());
+ config.transport.relays.clear();
+ let err = config.validate().expect_err("missing relay hints");
+ assert!(err.to_string().contains("at least one public relay hint"));
+ }
+
+ #[test]
+ fn discovery_validation_rejects_invalid_nostrconnect_template() {
+ let mut config = MycConfig::default();
+ config.discovery.enabled = true;
+ config.discovery.domain = Some("myc.example.com".to_owned());
+ config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()];
+ config.discovery.nostrconnect_url_template = Some("http://bad.example.com".to_owned());
+
+ let err = config.validate().expect_err("invalid discovery template");
+ assert!(
+ err.to_string()
+ .contains("discovery.nostrconnect_url_template")
+ );
+ }
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -0,0 +1,480 @@
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use radroots_identity::RadrootsIdentity;
+use radroots_nostr::prelude::{
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrEvent, RadrootsNostrMetadata,
+ RadrootsNostrRelayUrl, radroots_nostr_build_application_handler_event,
+};
+use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
+use serde::Serialize;
+
+use crate::app::MycRuntime;
+use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
+use crate::config::MycDiscoveryMetadataConfig;
+use crate::error::MycError;
+use crate::transport::MycNostrTransport;
+
+const NIP46_RPC_KIND: u32 = 24_133;
+
+#[derive(Clone)]
+pub struct MycDiscoveryContext {
+ app_identity: RadrootsIdentity,
+ signer_identity: RadrootsIdentity,
+ domain: String,
+ handler_identifier: String,
+ public_relays: Vec<RadrootsNostrRelayUrl>,
+ publish_relays: Vec<RadrootsNostrRelayUrl>,
+ nostrconnect_url: Option<String>,
+ metadata: Option<RadrootsNostrMetadata>,
+ nip05_output_path: Option<PathBuf>,
+ connect_timeout_secs: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycNip05Document {
+ pub names: BTreeMap<String, String>,
+ pub nip46: MycNip05DocumentSection,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycNip05DocumentSection {
+ pub relays: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nostrconnect_url: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRenderedNip05Output {
+ pub domain: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub output_path: Option<PathBuf>,
+ pub document: MycNip05Document,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRenderedNip89Output {
+ pub author_public_key_hex: String,
+ pub signer_public_key_hex: String,
+ pub publish_relays: Vec<String>,
+ pub event: RadrootsNostrEvent,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycPublishedNip89Output {
+ pub author_public_key_hex: String,
+ pub signer_public_key_hex: String,
+ pub publish_relays: Vec<String>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub relay_outcome_summary: String,
+ pub event: RadrootsNostrEvent,
+}
+
+impl MycDiscoveryContext {
+ pub fn from_runtime(runtime: &MycRuntime) -> Result<Self, MycError> {
+ let discovery = &runtime.config().discovery;
+ if !discovery.enabled {
+ return Err(MycError::InvalidOperation(
+ "discovery.enabled must be true to use discovery commands".to_owned(),
+ ));
+ }
+
+ let app_identity_path = discovery
+ .app_identity_path
+ .clone()
+ .unwrap_or_else(|| runtime.paths().signer_identity_path.clone());
+ let app_identity = RadrootsIdentity::load_from_path_auto(&app_identity_path)?;
+ let public_relays = discovery.resolved_public_relays(&runtime.config().transport)?;
+ let publish_relays = discovery.resolved_publish_relays(&runtime.config().transport)?;
+ let nostrconnect_url = discovery
+ .nostrconnect_url_template
+ .as_deref()
+ .map(|template| {
+ render_nostrconnect_url(template, runtime.signer_identity(), &public_relays)
+ })
+ .transpose()?;
+
+ Ok(Self {
+ app_identity,
+ signer_identity: runtime.signer_identity().clone(),
+ domain: discovery.domain.clone().ok_or_else(|| {
+ MycError::InvalidConfig(
+ "discovery.domain must be set when discovery.enabled is true".to_owned(),
+ )
+ })?,
+ handler_identifier: discovery.handler_identifier.clone(),
+ public_relays,
+ publish_relays,
+ nostrconnect_url,
+ metadata: build_metadata(&discovery.metadata),
+ nip05_output_path: discovery.nip05_output_path.clone(),
+ connect_timeout_secs: runtime.config().transport.connect_timeout_secs,
+ })
+ }
+
+ pub fn app_identity(&self) -> &RadrootsIdentity {
+ &self.app_identity
+ }
+
+ pub fn signer_identity(&self) -> &RadrootsIdentity {
+ &self.signer_identity
+ }
+
+ pub fn domain(&self) -> &str {
+ self.domain.as_str()
+ }
+
+ pub fn publish_relays(&self) -> &[RadrootsNostrRelayUrl] {
+ self.publish_relays.as_slice()
+ }
+
+ pub fn connect_timeout_secs(&self) -> u64 {
+ self.connect_timeout_secs
+ }
+
+ pub fn nip05_output_path(&self) -> Option<&Path> {
+ self.nip05_output_path.as_deref()
+ }
+
+ pub fn render_nip05_document(&self) -> MycNip05Document {
+ let mut names = BTreeMap::new();
+ names.insert("_".to_owned(), self.app_identity.public_key_hex());
+ MycNip05Document {
+ names,
+ nip46: MycNip05DocumentSection {
+ relays: self.public_relays.iter().map(ToString::to_string).collect(),
+ nostrconnect_url: self.nostrconnect_url.clone(),
+ },
+ }
+ }
+
+ pub fn render_nip05_json_pretty(&self) -> Result<String, MycError> {
+ Ok(serde_json::to_string_pretty(&self.render_nip05_document())?)
+ }
+
+ pub fn render_nip05_output(&self, output_path: Option<PathBuf>) -> MycRenderedNip05Output {
+ MycRenderedNip05Output {
+ domain: self.domain.clone(),
+ output_path,
+ document: self.render_nip05_document(),
+ }
+ }
+
+ pub fn write_nip05_document(
+ &self,
+ output_path: impl AsRef<Path>,
+ ) -> Result<MycRenderedNip05Output, MycError> {
+ let output_path = output_path.as_ref().to_path_buf();
+ if let Some(parent) = output_path.parent() {
+ if !parent.as_os_str().is_empty() {
+ fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ }
+ let json = self.render_nip05_json_pretty()?;
+ fs::write(&output_path, json).map_err(|source| MycError::DiscoveryIo {
+ path: output_path.clone(),
+ source,
+ })?;
+ Ok(self.render_nip05_output(Some(output_path)))
+ }
+
+ pub fn render_nip89_output(&self) -> Result<MycRenderedNip89Output, MycError> {
+ let event = self.build_signed_handler_event()?;
+ Ok(MycRenderedNip89Output {
+ author_public_key_hex: self.app_identity.public_key_hex(),
+ signer_public_key_hex: self.signer_identity.public_key_hex(),
+ publish_relays: self
+ .publish_relays
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
+ event,
+ })
+ }
+
+ pub fn build_signed_handler_event(&self) -> Result<RadrootsNostrEvent, MycError> {
+ let builder = radroots_nostr_build_application_handler_event(&self.build_handler_spec())?;
+ builder
+ .sign_with_keys(self.app_identity.keys())
+ .map_err(|error| {
+ MycError::InvalidOperation(format!(
+ "failed to sign NIP-89 application handler event: {error}"
+ ))
+ })
+ }
+
+ fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec {
+ let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND]);
+ spec.identifier = Some(self.handler_identifier.clone());
+ spec.metadata = self.metadata.clone();
+ spec.relays = self.public_relays.iter().map(ToString::to_string).collect();
+ spec.nostrconnect_url = self.nostrconnect_url.clone();
+ spec
+ }
+}
+
+pub fn render_nip05_output(
+ runtime: &MycRuntime,
+ output_path: Option<&Path>,
+) -> Result<MycRenderedNip05Output, MycError> {
+ let context = MycDiscoveryContext::from_runtime(runtime)?;
+ match output_path {
+ Some(path) => context.write_nip05_document(path),
+ None => Ok(context.render_nip05_output(None)),
+ }
+}
+
+pub async fn publish_nip89_event(
+ runtime: &MycRuntime,
+) -> Result<MycPublishedNip89Output, MycError> {
+ let context = MycDiscoveryContext::from_runtime(runtime)?;
+ let event = context.build_signed_handler_event()?;
+ let event_id = event.id.to_hex();
+ let publish_outcome = match MycNostrTransport::publish_event_once(
+ context.app_identity(),
+ context.publish_relays(),
+ context.connect_timeout_secs(),
+ &event,
+ )
+ .await
+ {
+ Ok(outcome) => outcome,
+ Err(error) => {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerPublish,
+ MycOperationAuditOutcome::Rejected,
+ None,
+ Some(event_id.as_str()),
+ error
+ .publish_rejection_counts()
+ .map(|(relay_count, _)| relay_count)
+ .unwrap_or(context.publish_relays().len()),
+ error
+ .publish_rejection_counts()
+ .map(|(_, acknowledged)| acknowledged)
+ .unwrap_or_default(),
+ error
+ .publish_rejection_details()
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| error.to_string()),
+ ));
+ return Err(error);
+ }
+ };
+
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerPublish,
+ MycOperationAuditOutcome::Succeeded,
+ None,
+ Some(event_id.as_str()),
+ publish_outcome.relay_count,
+ publish_outcome.acknowledged_relay_count,
+ publish_outcome.relay_outcome_summary.clone(),
+ ));
+
+ Ok(MycPublishedNip89Output {
+ author_public_key_hex: context.app_identity().public_key_hex(),
+ signer_public_key_hex: context.signer_identity().public_key_hex(),
+ publish_relays: context
+ .publish_relays()
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
+ relay_count: publish_outcome.relay_count,
+ acknowledged_relay_count: publish_outcome.acknowledged_relay_count,
+ relay_outcome_summary: publish_outcome.relay_outcome_summary,
+ event,
+ })
+}
+
+fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> {
+ let mut metadata = RadrootsNostrMetadata::default();
+ metadata.name = sanitize_optional_string(config.name.as_deref());
+ metadata.display_name = sanitize_optional_string(config.display_name.as_deref());
+ metadata.about = sanitize_optional_string(config.about.as_deref());
+ metadata.website = sanitize_optional_string(config.website.as_deref());
+ metadata.picture = sanitize_optional_string(config.picture.as_deref());
+ if metadata.name.is_none()
+ && metadata.display_name.is_none()
+ && metadata.about.is_none()
+ && metadata.website.is_none()
+ && metadata.picture.is_none()
+ {
+ return None;
+ }
+ Some(metadata)
+}
+
+fn sanitize_optional_string(value: Option<&str>) -> Option<String> {
+ let trimmed = value?.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+fn render_nostrconnect_url(
+ template: &str,
+ signer_identity: &RadrootsIdentity,
+ public_relays: &[RadrootsNostrRelayUrl],
+) -> Result<String, MycError> {
+ let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri {
+ remote_signer_public_key: signer_identity.public_key(),
+ relays: public_relays.to_vec(),
+ secret: None,
+ })
+ .to_string();
+ let encoded_bunker_uri: String =
+ url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect();
+ let rendered = template.replace("<nostrconnect>", &encoded_bunker_uri);
+ nostr::Url::parse(&rendered).map_err(|error| {
+ MycError::InvalidOperation(format!(
+ "failed to render discovery.nostrconnect_url_template: {error}"
+ ))
+ })?;
+ Ok(rendered)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::{Path, PathBuf};
+
+ use nostr::JsonUtil;
+ use radroots_identity::RadrootsIdentity;
+
+ use crate::config::MycConfig;
+
+ use super::{MycDiscoveryContext, build_metadata};
+ use crate::app::MycRuntime;
+
+ fn write_identity(path: &Path, secret_key: &str) {
+ RadrootsIdentity::from_secret_key_str(secret_key)
+ .expect("identity")
+ .save_json(path)
+ .expect("save identity");
+ }
+
+ fn runtime() -> MycRuntime {
+ let temp = tempfile::tempdir().expect("tempdir").keep();
+ let mut config = MycConfig::default();
+ config.paths.state_dir = PathBuf::from(&temp).join("state");
+ config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
+ config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
+ config.discovery.enabled = true;
+ config.discovery.domain = Some("signer.example.com".to_owned());
+ config.discovery.handler_identifier = "myc".to_owned();
+ config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()];
+ config.discovery.publish_relays = vec!["wss://publish.example.com".to_owned()];
+ config.discovery.nostrconnect_url_template =
+ Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned());
+ config.discovery.nip05_output_path =
+ Some(PathBuf::from(&temp).join("public/.well-known/nostr.json"));
+ config.discovery.metadata.name = Some("myc".to_owned());
+ config.discovery.metadata.about = Some("remote signer".to_owned());
+ config.discovery.app_identity_path = Some(PathBuf::from(&temp).join("app.json"));
+ write_identity(
+ &config.paths.signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &config.paths.user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ write_identity(
+ config
+ .discovery
+ .app_identity_path
+ .as_ref()
+ .expect("app identity path"),
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ );
+ MycRuntime::bootstrap(config).expect("runtime")
+ }
+
+ #[test]
+ fn build_metadata_ignores_blank_fields() {
+ let mut metadata = crate::config::MycDiscoveryMetadataConfig::default();
+ metadata.name = Some(" ".to_owned());
+ metadata.about = Some(" ready ".to_owned());
+
+ let built = build_metadata(&metadata).expect("metadata");
+
+ assert!(built.name.is_none());
+ assert_eq!(built.about.as_deref(), Some("ready"));
+ }
+
+ #[test]
+ fn render_nip05_document_matches_appendix_shape() {
+ let runtime = runtime();
+ let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
+
+ let document = context.render_nip05_document();
+
+ assert_eq!(document.names.len(), 1);
+ assert_eq!(
+ document.names.get("_"),
+ Some(&context.app_identity().public_key_hex())
+ );
+ assert_eq!(
+ document.nip46.relays,
+ vec!["wss://relay.example.com".to_owned()]
+ );
+ assert!(
+ document
+ .nip46
+ .nostrconnect_url
+ .as_deref()
+ .expect("nostrconnect url")
+ .contains("bunker%3A%2F%2F")
+ );
+ }
+
+ #[test]
+ fn render_signed_nip89_event_uses_app_identity_author() {
+ let runtime = runtime();
+ let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
+
+ let output = context.render_nip89_output().expect("rendered nip89");
+
+ assert_eq!(
+ output.author_public_key_hex,
+ context.app_identity().public_key_hex()
+ );
+ assert_eq!(
+ output.signer_public_key_hex,
+ context.signer_identity().public_key_hex()
+ );
+ assert_eq!(output.event.pubkey, context.app_identity().public_key());
+ assert_eq!(output.event.kind.as_u16(), 31_990);
+ let event_json = output.event.as_json();
+ assert!(event_json.contains("\"24133\""));
+ assert!(event_json.contains("\"nostrconnect_url\""));
+ }
+
+ #[test]
+ fn write_nip05_document_writes_pretty_json_artifact() {
+ let runtime = runtime();
+ let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context");
+ let output_path = context
+ .nip05_output_path()
+ .expect("configured output path")
+ .to_path_buf();
+
+ let output = context
+ .write_nip05_document(&output_path)
+ .expect("write nip05 document");
+
+ let written = fs::read_to_string(&output_path).expect("read output");
+ assert_eq!(output.output_path.as_deref(), Some(output_path.as_path()));
+ assert!(written.contains("\"names\""));
+ assert!(written.contains("\"nip46\""));
+ assert!(written.contains(&context.app_identity().public_key_hex()));
+ }
+}
diff --git a/src/error.rs b/src/error.rs
@@ -57,6 +57,12 @@ pub enum MycError {
#[source]
source: serde_json::Error,
},
+ #[error("discovery io error at {path}: {source}")]
+ DiscoveryIo {
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
diff --git a/src/lib.rs b/src/lib.rs
@@ -5,6 +5,7 @@ pub mod audit;
pub mod cli;
pub mod config;
pub mod control;
+pub mod discovery;
pub mod error;
pub mod logging;
pub mod transport;
@@ -15,10 +16,15 @@ pub use audit::{
MycOperationAuditStore,
};
pub use config::{
- DEFAULT_CONFIG_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycLoggingConfig,
- MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig,
+ DEFAULT_CONFIG_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycDiscoveryConfig,
+ MycDiscoveryMetadataConfig, MycLoggingConfig, MycPathsConfig, MycPolicyConfig,
+ MycServiceConfig, MycTransportConfig,
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
+pub use discovery::{
+ MycDiscoveryContext, MycNip05Document, MycNip05DocumentSection, MycPublishedNip89Output,
+ MycRenderedNip05Output, MycRenderedNip89Output, publish_nip89_event, render_nip05_output,
+};
pub use error::MycError;
pub use transport::{MycNostrTransport, MycTransportSnapshot};
diff --git a/src/transport.rs b/src/transport.rs
@@ -4,7 +4,8 @@ use std::time::Duration;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
- RadrootsNostrClient, RadrootsNostrEventBuilder, RadrootsNostrOutput, RadrootsNostrRelayUrl,
+ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrOutput,
+ RadrootsNostrRelayUrl,
};
use crate::config::MycTransportConfig;
@@ -96,6 +97,30 @@ impl MycNostrTransport {
ensure_publish_confirmed(output, "one-shot Nostr publish")
}
+ pub async fn publish_event_once(
+ signer_identity: &RadrootsIdentity,
+ relays: &[RadrootsNostrRelayUrl],
+ connect_timeout_secs: u64,
+ event: &RadrootsNostrEvent,
+ ) -> Result<MycPublishOutcome, MycError> {
+ if relays.is_empty() {
+ return Err(MycError::InvalidOperation(
+ "cannot publish without at least one relay".to_owned(),
+ ));
+ }
+
+ let client = RadrootsNostrClient::from_identity(signer_identity);
+ for relay in relays {
+ let _ = client.add_relay(relay.as_str()).await?;
+ }
+ client.connect().await;
+ client
+ .wait_for_connection(Duration::from_secs(connect_timeout_secs))
+ .await;
+ let output = client.send_event(event).await?;
+ ensure_publish_confirmed(output, "one-shot Nostr publish")
+ }
+
pub fn snapshot(&self) -> MycTransportSnapshot {
MycTransportSnapshot {
enabled: true,