diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 9e637c9..b452d9c 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -139,6 +139,10 @@ mod tests { cleanup_interval: Duration::from_secs(120), session_timeout: Duration::from_secs(600), request_timeout: Duration::from_secs(60), + relay_list_urls: None, + bootstrap_relay_urls: None, + publish_relay_list: true, + profile_metadata: None, }; let config = GatewayConfig { nostr_config }; diff --git a/src/relay/mock.rs b/src/relay/mock.rs index 2b181d6..63bebc9 100644 --- a/src/relay/mock.rs +++ b/src/relay/mock.rs @@ -229,6 +229,11 @@ impl RelayPoolTrait for MockRelayPool { Ok(()) } + + /// Mock ignores target URLs — delegates to `publish()`. + async fn publish_to(&self, _urls: &[String], builder: EventBuilder) -> Result { + self.publish(builder).await + } } // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/src/relay/mod.rs b/src/relay/mod.rs index cfb0bc5..fed4339 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -34,6 +34,8 @@ pub trait RelayPoolTrait: Send + Sync { async fn public_key(&self) -> Result; /// Subscribe to events matching filters. async fn subscribe(&self, filters: Vec) -> Result<()>; + /// Sign and publish an event to specific relay URLs. + async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result; } /// Relay pool wrapper for managing Nostr relay connections. @@ -135,6 +137,16 @@ impl RelayPool { } Ok(()) } + + /// Sign and publish an event to specific relay URLs. + pub async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result { + let output = self + .client + .send_event_builder_to(urls, builder) + .await + .map_err(|e| Error::Transport(e.to_string()))?; + Ok(output.val) + } } #[async_trait] @@ -177,4 +189,8 @@ impl RelayPoolTrait for RelayPool { async fn subscribe(&self, filters: Vec) -> Result<()> { RelayPool::subscribe(self, filters).await } + + async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result { + RelayPool::publish_to(self, urls, builder).await + } } diff --git a/src/transport/server/announcement_manager.rs b/src/transport/server/announcement_manager.rs index bc81d62..d47b620 100644 --- a/src/transport/server/announcement_manager.rs +++ b/src/transport/server/announcement_manager.rs @@ -3,6 +3,7 @@ //! Encapsulates tag composition, caching, and publishing for CEP-6 server //! announcements (kinds 11316–11320) and CEP-35 first-response discovery. +use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -55,6 +56,37 @@ pub(crate) struct AnnouncementManager { /// rmcp worker — unused when the `rmcp` feature is disabled. #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] initialized: Mutex, + /// Transport's connected relay URLs (fallback for `relay_list_urls`). + relay_urls: Vec, + /// Explicit relay URLs to advertise in the kind 10002 relay list event. + relay_list_urls: Option>, + /// Additional bootstrap relay URLs for discoverability publication. + bootstrap_relay_urls: Option>, + /// Whether to publish a relay list event (kind 10002). + should_publish_relay_list: bool, + /// NIP-01 profile metadata (kind 0) to publish at startup. + profile_metadata: Option, +} + +/// Check whether a relay URL points to a local address. +/// +/// Used for smart bootstrap relay detection: default bootstrap relays are +/// skipped when all advertised relays are local and no explicit bootstrap +/// URLs were provided. +fn is_local_relay_url(url: &str) -> bool { + let without_proto = url + .strip_prefix("wss://") + .or_else(|| url.strip_prefix("ws://")) + .unwrap_or(url); + let lower = without_proto.to_lowercase(); + for prefix in &["localhost", "127.0.0.1", "0.0.0.0", "[::1]"] { + if let Some(rest) = lower.strip_prefix(prefix) { + if rest.is_empty() || rest.starts_with(':') || rest.starts_with('/') { + return true; + } + } + } + false } impl AnnouncementManager { @@ -63,12 +95,18 @@ impl AnnouncementManager { /// `dispatch_fn` is a clone of the transport's `message_tx` channel, used to /// inject synthetic MCP messages (initialize, notifications/initialized, /// capability list requests) during the auto-publish flow. + #[allow(clippy::too_many_arguments)] pub fn new( relay_pool: Arc, server_info: Option, encryption_mode: EncryptionMode, gift_wrap_mode: GiftWrapMode, dispatch_fn: tokio::sync::mpsc::UnboundedSender, + relay_urls: Vec, + relay_list_urls: Option>, + bootstrap_relay_urls: Option>, + should_publish_relay_list: bool, + profile_metadata: Option, ) -> Self { Self { relay_pool, @@ -82,6 +120,11 @@ impl AnnouncementManager { dispatch_fn: Some(dispatch_fn), init_notify: Arc::new(Notify::new()), initialized: Mutex::new(false), + relay_urls, + relay_list_urls, + bootstrap_relay_urls, + should_publish_relay_list, + profile_metadata, } } @@ -332,6 +375,211 @@ impl AnnouncementManager { self.publish_resource_templates(templates).await } + // ── Relay list + profile metadata (CEP-6) ───────────────────────── + + /// Returns the relay URLs to advertise in the kind 10002 relay list event. + /// + /// Uses `relay_list_urls` if explicitly configured, otherwise falls back + /// to the transport's connected `relay_urls`. + pub(crate) fn get_advertised_relay_urls(&self) -> &[String] { + self.relay_list_urls.as_deref().unwrap_or(&self.relay_urls) + } + + /// Compute relay URLs for discoverability event publication. + /// + /// Merges advertised relay URLs with bootstrap relay URLs, deduplicated. + /// Skips default bootstrap relays when all advertised URLs are local and + /// no explicit `bootstrap_relay_urls` were provided. + pub(crate) fn get_discoverability_publish_relay_urls(&self) -> Vec { + let advertised = self.get_advertised_relay_urls(); + let has_explicit_bootstrap = self.bootstrap_relay_urls.is_some(); + + let should_skip_bootstrap = !has_explicit_bootstrap + && !advertised.is_empty() + && advertised.iter().all(|url| is_local_relay_url(url)); + + let mut seen = HashSet::new(); + let mut result = Vec::new(); + + for url in advertised { + if seen.insert(url.clone()) { + result.push(url.clone()); + } + } + + if !should_skip_bootstrap { + let default_bootstrap: Vec = DEFAULT_BOOTSTRAP_RELAY_URLS + .iter() + .map(|s| (*s).to_string()) + .collect(); + let bootstrap = self + .bootstrap_relay_urls + .as_deref() + .unwrap_or(&default_bootstrap); + for url in bootstrap { + if seen.insert(url.clone()) { + result.push(url.clone()); + } + } + } + + result + } + + /// Publish relay list (kind 10002). + /// + /// No-op if `should_publish_relay_list` is false or no relay URLs are available. + /// Publishes to the merged advertised + bootstrap relay set. + #[cfg(test)] + pub(crate) async fn publish_relay_list(&self) -> Result<()> { + if !self.should_publish_relay_list { + return Ok(()); + } + let urls = self.get_advertised_relay_urls(); + if urls.is_empty() { + tracing::warn!(target: LOG_TARGET, "No relay URLs to publish relay list"); + return Ok(()); + } + let tags: Vec = urls + .iter() + .map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()])) + .collect(); + let builder = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags); + match self.publish_to_discoverability_relays(builder).await { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published relay list (kind 10002)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish relay list" + ), + } + Ok(()) + } + + /// Publish profile metadata (kind 0). + /// + /// No-op if `profile_metadata` is not configured. + /// Publishes to the merged advertised + bootstrap relay set. + #[cfg(test)] + pub(crate) async fn publish_profile_metadata(&self) -> Result<()> { + let metadata = match &self.profile_metadata { + Some(m) => m, + None => return Ok(()), + }; + let content = serde_json::to_string(metadata)?; + let builder = EventBuilder::new(Kind::Custom(0), content); + match self.publish_to_discoverability_relays(builder).await { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published profile metadata (kind 0)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish profile metadata" + ), + } + Ok(()) + } + + /// Publish an event to the discoverability relay set. + /// + /// Uses `get_discoverability_publish_relay_urls()` for targeted publication + /// when bootstrap relays are configured. Falls back to pool-wide publish + /// when the merged set is empty. + #[cfg(test)] + async fn publish_to_discoverability_relays(&self, builder: EventBuilder) -> Result { + let urls = self.get_discoverability_publish_relay_urls(); + if urls.is_empty() { + self.relay_pool.publish(builder).await + } else { + self.relay_pool.publish_to(&urls, builder).await + } + } + + /// Spawn a task to publish profile metadata and relay list. + /// + /// Unconditional — guards live inside the individual publish methods. + /// Event-building logic mirrors `publish_relay_list()` and `publish_profile_metadata()`. + /// Duplication is intentional — `&self` can't be moved into a spawned task in Rust. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) fn spawn_publish_discoverability(&self) -> tokio::task::JoinHandle<()> { + let relay_pool = Arc::clone(&self.relay_pool); + let target_urls = self.get_discoverability_publish_relay_urls(); + + // Build events before spawning (borrows self) to avoid sending &self + let profile_event = self.profile_metadata.as_ref().and_then(|metadata| { + serde_json::to_string(metadata) + .ok() + .map(|content| EventBuilder::new(Kind::Custom(0), content)) + }); + + let relay_list_event = if self.should_publish_relay_list { + let urls = self.get_advertised_relay_urls(); + if urls.is_empty() { + None + } else { + let tags: Vec = urls + .iter() + .map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()])) + .collect(); + Some(EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags)) + } + } else { + None + }; + + tokio::spawn(async move { + if let Some(builder) = profile_event { + let result = if target_urls.is_empty() { + relay_pool.publish(builder).await + } else { + relay_pool.publish_to(&target_urls, builder).await + }; + match result { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published profile metadata (kind 0)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish profile metadata" + ), + } + } + if let Some(builder) = relay_list_event { + let result = if target_urls.is_empty() { + relay_pool.publish(builder).await + } else { + relay_pool.publish_to(&target_urls, builder).await + }; + match result { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published relay list (kind 10002)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish relay list" + ), + } + } + tracing::info!( + target: LOG_TARGET, + "Discoverability event publishing complete" + ); + }) + } + // ── Event loop support ───────────────────────────────────────── /// Snapshot the tag state needed by the event loop. @@ -636,7 +884,18 @@ mod tests { use crate::relay::mock::MockRelayPool; let pool: Arc = Arc::new(MockRelayPool::new()); let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - AnnouncementManager::new(pool, server_info, encryption_mode, gift_wrap_mode, tx) + AnnouncementManager::new( + pool, + server_info, + encryption_mode, + gift_wrap_mode, + tx, + Vec::new(), + None, + None, + true, + None, + ) } // ── 1. Server info tags ──────────────────────────────────────── @@ -839,6 +1098,11 @@ mod tests { EncryptionMode::Disabled, GiftWrapMode::Optional, tx, + Vec::new(), + None, + None, + true, + None, ); (mgr, pool, rx) } @@ -1062,4 +1326,231 @@ mod tests { Some(NOTIFICATIONS_INITIALIZED_METHOD) ); } + + // ── 13. Relay list + profile metadata (CEP-6) ────────────────── + + fn make_manager_with_discoverability( + relay_urls: Vec, + relay_list_urls: Option>, + bootstrap_relay_urls: Option>, + publish_relay_list: bool, + profile_metadata: Option, + ) -> (AnnouncementManager, Arc) { + use crate::relay::mock::MockRelayPool; + let pool = Arc::new(MockRelayPool::new()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mgr = AnnouncementManager::new( + Arc::clone(&pool) as Arc, + None, + EncryptionMode::Disabled, + GiftWrapMode::Optional, + tx, + relay_urls, + relay_list_urls, + bootstrap_relay_urls, + publish_relay_list, + profile_metadata, + ); + (mgr, pool) + } + + #[test] + fn is_local_relay_url_detects_localhost() { + assert!(is_local_relay_url("ws://localhost:7777")); + assert!(is_local_relay_url("wss://localhost")); + assert!(is_local_relay_url("ws://127.0.0.1:8080")); + assert!(is_local_relay_url("ws://0.0.0.0:9999")); + assert!(is_local_relay_url("ws://[::1]:7777")); + } + + #[test] + fn is_local_relay_url_rejects_remote() { + assert!(!is_local_relay_url("wss://relay.damus.io")); + assert!(!is_local_relay_url("wss://relay.example.com")); + assert!(!is_local_relay_url("ws://10.0.0.1:7777")); + } + + #[test] + fn get_advertised_relay_urls_uses_relay_list_when_set() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + Some(vec!["wss://advertised.relay".into()]), + None, + true, + None, + ); + assert_eq!(mgr.get_advertised_relay_urls(), &["wss://advertised.relay"]); + } + + #[test] + fn get_advertised_relay_urls_falls_back_to_relay_urls() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + None, + None, + true, + None, + ); + assert_eq!(mgr.get_advertised_relay_urls(), &["wss://connected.relay"]); + } + + #[test] + fn get_discoverability_urls_merges_bootstrap() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://my.relay".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert!(urls.contains(&"wss://my.relay".to_string())); + // Should include default bootstrap relays + assert!(urls.len() > 1); + assert!(urls.contains(&DEFAULT_BOOTSTRAP_RELAY_URLS[0].to_string())); + } + + #[test] + fn get_discoverability_urls_skips_bootstrap_for_local_only() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["ws://127.0.0.1:7777".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert_eq!(urls, vec!["ws://127.0.0.1:7777"]); + } + + #[test] + fn get_discoverability_urls_keeps_explicit_bootstrap_even_for_local() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["ws://127.0.0.1:7777".into()], + None, + Some(vec!["wss://explicit-bootstrap.relay".into()]), + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert!(urls.contains(&"ws://127.0.0.1:7777".to_string())); + assert!(urls.contains(&"wss://explicit-bootstrap.relay".to_string())); + } + + #[test] + fn get_discoverability_urls_deduplicates() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://relay.damus.io".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + let damus_count = urls.iter().filter(|u| *u == "wss://relay.damus.io").count(); + assert_eq!(damus_count, 1, "should be deduplicated"); + } + + #[tokio::test] + async fn publish_relay_list_event_shape() { + let (mgr, pool) = make_manager_with_discoverability( + vec![ + "wss://relay1.example.com".into(), + "wss://relay2.example.com".into(), + ], + None, + None, + true, + None, + ); + mgr.publish_relay_list().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(RELAY_LIST_METADATA_KIND)); + assert_eq!(events[0].content, ""); + let tag_values: Vec = events[0] + .tags + .iter() + .filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r")) + .filter_map(|t| (*t).clone().to_vec().get(1).cloned()) + .collect(); + assert_eq!( + tag_values, + vec!["wss://relay1.example.com", "wss://relay2.example.com"] + ); + } + + #[tokio::test] + async fn publish_relay_list_uses_relay_list_urls_override() { + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + Some(vec!["wss://override.relay".into()]), + None, + true, + None, + ); + mgr.publish_relay_list().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + let tag_values: Vec = events[0] + .tags + .iter() + .filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r")) + .filter_map(|t| (*t).clone().to_vec().get(1).cloned()) + .collect(); + assert_eq!(tag_values, vec!["wss://override.relay"]); + } + + #[tokio::test] + async fn publish_relay_list_opt_out() { + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://relay.example.com".into()], + None, + None, + false, + None, + ); + mgr.publish_relay_list().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish when disabled" + ); + } + + #[tokio::test] + async fn publish_relay_list_empty_urls_no_publish() { + let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None); + mgr.publish_relay_list().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish with no URLs" + ); + } + + #[tokio::test] + async fn publish_profile_metadata_event_shape() { + let metadata = ProfileMetadata::default() + .with_name("Test Server") + .with_about("A test MCP server"); + let (mgr, pool) = + make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata)); + mgr.publish_profile_metadata().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(0)); + assert!(events[0].tags.is_empty()); + let parsed: ProfileMetadata = serde_json::from_str(&events[0].content).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Test Server")); + assert_eq!(parsed.about.as_deref(), Some("A test MCP server")); + } + + #[tokio::test] + async fn publish_profile_metadata_noop_when_unconfigured() { + let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None); + mgr.publish_profile_metadata().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish without profile metadata" + ); + } } diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs index 1eeb368..008a5a0 100644 --- a/src/transport/server/mod.rs +++ b/src/transport/server/mod.rs @@ -62,6 +62,19 @@ pub struct NostrServerTransportConfig { /// This prevents leaks -- rmcp owns actual request timeout and cancellation. /// Keep this value above your rmcp request timeout to avoid premature cleanup. pub request_timeout: Duration, + /// Explicit relay URLs to advertise in kind 10002 (NIP-65 relay list). + /// + /// Falls back to the transport's `relay_urls` when omitted. + pub relay_list_urls: Option>, + /// Additional publication targets for discoverability events. + /// + /// Merged with `relay_list_urls` when computing where to send events. + /// Defaults to [`DEFAULT_BOOTSTRAP_RELAY_URLS`] when omitted. + pub bootstrap_relay_urls: Option>, + /// Whether to publish a relay list event (kind 10002). Default: `true`. + pub publish_relay_list: bool, + /// Optional NIP-01 profile metadata (kind 0) to publish at startup. + pub profile_metadata: Option, } impl Default for NostrServerTransportConfig { @@ -78,6 +91,10 @@ impl Default for NostrServerTransportConfig { cleanup_interval: Duration::from_secs(60), session_timeout: Duration::from_secs(300), request_timeout: Duration::from_secs(60), + relay_list_urls: None, + bootstrap_relay_urls: None, + publish_relay_list: true, + profile_metadata: None, } } } @@ -165,6 +182,26 @@ impl NostrServerTransportConfig { self.request_timeout = timeout; self } + /// Set explicit relay URLs to advertise in the relay list event (kind 10002). + pub fn with_relay_list_urls(mut self, urls: Vec) -> Self { + self.relay_list_urls = Some(urls); + self + } + /// Set additional bootstrap relay URLs for discoverability event publication. + pub fn with_bootstrap_relay_urls(mut self, urls: Vec) -> Self { + self.bootstrap_relay_urls = Some(urls); + self + } + /// Enable or disable relay list publication (kind 10002). + pub fn with_publish_relay_list(mut self, publish: bool) -> Self { + self.publish_relay_list = publish; + self + } + /// Set NIP-01 profile metadata (kind 0) for publication at startup. + pub fn with_profile_metadata(mut self, metadata: ProfileMetadata) -> Self { + self.profile_metadata = Some(metadata); + self + } } /// An incoming MCP request with metadata for routing the response. @@ -216,6 +253,11 @@ impl NostrServerTransport { config.encryption_mode, config.gift_wrap_mode, tx.clone(), + config.relay_urls.clone(), + config.relay_list_urls.clone(), + config.bootstrap_relay_urls.clone(), + config.publish_relay_list, + config.profile_metadata.clone(), ), base: BaseTransport { relay_pool, @@ -258,6 +300,11 @@ impl NostrServerTransport { config.encryption_mode, config.gift_wrap_mode, tx.clone(), + config.relay_urls.clone(), + config.relay_list_urls.clone(), + config.bootstrap_relay_urls.clone(), + config.publish_relay_list, + config.profile_metadata.clone(), ), base: BaseTransport { relay_pool, @@ -721,6 +768,9 @@ impl NostrServerTransport { .spawn_publish_public_announcements(self.cancellation_token.child_token()); self.task_handles.push(handle); } + // Unconditional: publish profile metadata and relay list (guards inside methods) + let handle = self.announcement_manager.spawn_publish_discoverability(); + self.task_handles.push(handle); } /// Forward an announcement response to the announcement manager for publishing. @@ -1511,6 +1561,10 @@ mod tests { assert_eq!(config.session_timeout, Duration::from_secs(300)); assert_eq!(config.request_timeout, Duration::from_secs(60)); assert!(config.server_info.is_none()); + assert!(config.relay_list_urls.is_none()); + assert!(config.bootstrap_relay_urls.is_none()); + assert!(config.publish_relay_list); + assert!(config.profile_metadata.is_none()); } // ── CEP-19 helper logic ────────────────────────────────────── diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs index 7bcaf1a..37ad20a 100644 --- a/tests/transport_integration.rs +++ b/tests/transport_integration.rs @@ -120,6 +120,14 @@ impl RelayPoolTrait for TestRelayPool { async fn subscribe(&self, filters: Vec) -> contextvm_sdk::Result<()> { self.inner.subscribe(filters).await } + + async fn publish_to( + &self, + urls: &[String], + builder: EventBuilder, + ) -> contextvm_sdk::Result { + self.inner.publish_to(urls, builder).await + } } /// Let spawned event loops call `notifications()` before we publish anything.