myc

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

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:
MCargo.lock | 11+++++++++++
MCargo.toml | 3++-
Msrc/audit.rs | 1+
Msrc/config.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/discovery.rs | 480+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/error.rs | 6++++++
Msrc/lib.rs | 10++++++++--
Msrc/transport.rs | 27++++++++++++++++++++++++++-
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,