From 49e08b4b683020dd4d9e9df03e73d18168b4efe2 Mon Sep 17 00:00:00 2001 From: Harsh Date: Sun, 7 Jun 2026 02:52:22 +0530 Subject: [PATCH 1/2] feat: CEP-22 - config, capability advertisement, and server gate --- src/gateway/mod.rs | 1 + src/proxy/mod.rs | 1 + src/transport/client/mod.rs | 62 ++++++ src/transport/oversized_transfer/constants.rs | 6 +- src/transport/oversized_transfer/mod.rs | 129 ++++++++++- src/transport/oversized_transfer/receiver.rs | 4 +- src/transport/server/announcement_manager.rs | 7 +- src/transport/server/correlation_store.rs | 2 +- src/transport/server/mod.rs | 206 +++++++++++++++--- 9 files changed, 381 insertions(+), 37 deletions(-) diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index b452d9c..789545a 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -143,6 +143,7 @@ mod tests { bootstrap_relay_urls: None, publish_relay_list: true, profile_metadata: None, + oversized_transfer: Default::default(), }; let config = GatewayConfig { nostr_config }; diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index 532fdc8..7af69a1 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -124,6 +124,7 @@ mod tests { timeout: Duration::from_secs(60), discovery_relay_urls: None, fallback_operational_relay_urls: None, + oversized_transfer: Default::default(), }; let config = ProxyConfig { nostr_config }; diff --git a/src/transport/client/mod.rs b/src/transport/client/mod.rs index f6def7d..d114455 100644 --- a/src/transport/client/mod.rs +++ b/src/transport/client/mod.rs @@ -28,6 +28,7 @@ use crate::encryption; use crate::relay::{RelayPool, RelayPoolTrait}; use crate::transport::base::BaseTransport; use crate::transport::discovery_tags::{parse_discovered_peer_capabilities, PeerCapabilities}; +use crate::transport::oversized_transfer::OversizedTransferConfig; const LOG_TARGET: &str = "contextvm_sdk::transport::client"; @@ -59,6 +60,8 @@ pub struct NostrClientTransportConfig { pub discovery_relay_urls: Option>, /// Non-authoritative operational relays probed in parallel with CEP-17 discovery. pub fallback_operational_relay_urls: Option>, + /// CEP-22 oversized payload transfer configuration. Disabled by default. + pub oversized_transfer: OversizedTransferConfig, } impl Default for NostrClientTransportConfig { @@ -72,6 +75,7 @@ impl Default for NostrClientTransportConfig { timeout: Duration::from_secs(30), discovery_relay_urls: None, fallback_operational_relay_urls: None, + oversized_transfer: OversizedTransferConfig::default(), } } } @@ -117,6 +121,16 @@ impl NostrClientTransportConfig { self.fallback_operational_relay_urls = Some(urls); self } + /// Set the full CEP-22 oversized payload transfer configuration. + pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self { + self.oversized_transfer = config; + self + } + /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default. + pub fn with_oversized_enabled(mut self, enabled: bool) -> Self { + self.oversized_transfer.enabled = enabled; + self + } } /// Client-side Nostr transport for sending MCP requests and receiving responses. @@ -599,6 +613,13 @@ impl NostrClientTransport { )); } } + // CEP-22: advertise oversized-transfer support when enabled. + if self.config.oversized_transfer.enabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + )); + } tags } @@ -1220,6 +1241,47 @@ mod tests { assert_eq!(names, vec!["support_encryption"]); } + #[test] + fn client_capability_tags_oversized_disabled_by_default() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + assert!(!t.config.oversized_transfer.enabled); + let names = tag_names(&t.get_client_capability_tags()); + assert!( + !names.contains(&"support_oversized_transfer".to_string()), + "oversized tag must not be advertised when disabled" + ); + } + + #[test] + fn client_capability_tags_oversized_enabled() { + let mut t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + t.config.oversized_transfer.enabled = true; + let names = tag_names(&t.get_client_capability_tags()); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "oversized tag must be advertised when enabled" + ); + } + + #[test] + fn client_capability_tags_oversized_enabled_without_encryption() { + // Tag is emitted independently of the encryption capability tags. + let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + t.config.oversized_transfer.enabled = true; + let names = tag_names(&t.get_client_capability_tags()); + assert_eq!(names, vec!["support_oversized_transfer"]); + } + + #[test] + fn client_config_oversized_builders() { + let cfg = NostrClientTransportConfig::default().with_oversized_enabled(true); + assert!(cfg.oversized_transfer.enabled); + let cfg = NostrClientTransportConfig::default() + .with_oversized_transfer(OversizedTransferConfig::enabled().with_chunk_size(1024)); + assert!(cfg.oversized_transfer.enabled); + assert_eq!(cfg.oversized_transfer.chunk_size, 1024); + } + #[test] fn client_discovery_tags_sent_once() { let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); diff --git a/src/transport/oversized_transfer/constants.rs b/src/transport/oversized_transfer/constants.rs index ab308dc..e541d01 100644 --- a/src/transport/oversized_transfer/constants.rs +++ b/src/transport/oversized_transfer/constants.rs @@ -33,8 +33,8 @@ pub const DEFAULT_MAX_CONCURRENT_TRANSFERS: usize = 64; /// Default hard timeout for an in-flight transfer (milliseconds). /// -/// Not enforced by the PR-1 pure engine (no timers yet); wired into the -/// transport watchdog in a later PR. +/// Not enforced by the pure engine (no timers yet); wired into the +/// transport watchdog once transport integration lands. pub const DEFAULT_TRANSFER_TIMEOUT_MS: u64 = 5 * 60 * 1000; /// Default maximum forward gap between the next expected chunk and an @@ -46,7 +46,7 @@ pub const DEFAULT_MAX_OUT_OF_ORDER_CHUNKS: usize = 42; /// Default timeout a sender waits for an `accept` frame before giving up (milliseconds). /// -/// Used by the sender handshake in a later PR; defined here for parity. +/// Used by the sender handshake once transport integration lands; defined here for parity. pub const DEFAULT_ACCEPT_TIMEOUT_MS: u64 = 30_000; /// Canonical progress slot for the `start` frame. diff --git a/src/transport/oversized_transfer/mod.rs b/src/transport/oversized_transfer/mod.rs index 66759ac..88a665b 100644 --- a/src/transport/oversized_transfer/mod.rs +++ b/src/transport/oversized_transfer/mod.rs @@ -9,8 +9,9 @@ //! //! This module is the **pure engine**: building frames ([`codec`]) and //! reassembling them ([`receiver`]). It carries no transport, I/O, or timers — -//! those are wired in by the client and server transports in later PRs. Until -//! then the module is intentionally unused by the rest of the crate. +//! those are wired in by the client and server transports once transport +//! integration lands. Until then the module is intentionally unused by the +//! rest of the crate. //! //! ``` //! use contextvm_sdk::transport::oversized_transfer::{ @@ -55,3 +56,127 @@ pub use constants::*; pub use errors::OversizedTransferError; pub use frame::{CompletionMode, OversizedFrame}; pub use receiver::{OversizedTransferReceiver, TransferPolicy}; + +/// CEP-22 oversized-transfer configuration shared by both transports. +/// +/// Bundles the capability gate plus the sender/receiver tuning knobs (D6) so the +/// nine numeric defaults don't clutter the flat transport configs. Attached to +/// [`NostrServerTransportConfig`](crate::transport::NostrServerTransportConfig) +/// and [`NostrClientTransportConfig`](crate::transport::NostrClientTransportConfig) +/// via their `with_oversized_transfer` / `with_oversized_enabled` builders. +/// +/// **Disabled by default** — until a peer opts in, no `support_oversized_transfer` +/// capability is advertised and the server never learns or activates the feature. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct OversizedTransferConfig { + /// Master gate. When `false` (default) the capability is neither advertised + /// nor activated, and the server does not learn a client's flag. + pub enabled: bool, + /// Serialized byte length at or above which the sender switches to oversized transfer. + pub threshold: usize, + /// Per-chunk data size (bytes). + pub chunk_size: usize, + /// Upper bound on the total reassembled payload a receiver will accept (bytes). + pub max_transfer_bytes: u64, + /// Upper bound on the number of chunks a receiver will accept. + pub max_transfer_chunks: u64, + /// Upper bound on concurrently active receiver-side transfers. + pub max_concurrent_transfers: usize, + /// Hard timeout for an in-flight transfer (milliseconds). + pub transfer_timeout_ms: u64, + /// Maximum forward gap between the next expected chunk and an out-of-order + /// chunk that will still be buffered. + pub max_out_of_order_window: u64, + /// Maximum number of buffered out-of-order chunks. + pub max_out_of_order_chunks: usize, + /// Timeout a sender waits for an `accept` frame before giving up (milliseconds). + pub accept_timeout_ms: u64, +} + +impl Default for OversizedTransferConfig { + fn default() -> Self { + Self { + enabled: false, + threshold: DEFAULT_OVERSIZED_THRESHOLD, + chunk_size: DEFAULT_CHUNK_SIZE, + max_transfer_bytes: DEFAULT_MAX_TRANSFER_BYTES, + max_transfer_chunks: DEFAULT_MAX_TRANSFER_CHUNKS, + max_concurrent_transfers: DEFAULT_MAX_CONCURRENT_TRANSFERS, + transfer_timeout_ms: DEFAULT_TRANSFER_TIMEOUT_MS, + max_out_of_order_window: DEFAULT_MAX_OUT_OF_ORDER_WINDOW, + max_out_of_order_chunks: DEFAULT_MAX_OUT_OF_ORDER_CHUNKS, + accept_timeout_ms: DEFAULT_ACCEPT_TIMEOUT_MS, + } + } +} + +impl OversizedTransferConfig { + /// An explicitly enabled config with all other knobs at their defaults. + pub fn enabled() -> Self { + Self { + enabled: true, + ..Self::default() + } + } + + /// Enable or disable oversized transfer. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Set the serialized-byte threshold at which the sender fragments. + pub fn with_threshold(mut self, threshold: usize) -> Self { + self.threshold = threshold; + self + } + + /// Set the per-chunk data size (bytes). + pub fn with_chunk_size(mut self, chunk_size: usize) -> Self { + self.chunk_size = chunk_size; + self + } + + /// Set the upper bound on the total reassembled payload (bytes). + pub fn with_max_transfer_bytes(mut self, max: u64) -> Self { + self.max_transfer_bytes = max; + self + } + + /// Set the upper bound on the number of chunks a receiver will accept. + pub fn with_max_transfer_chunks(mut self, max: u64) -> Self { + self.max_transfer_chunks = max; + self + } + + /// Set the upper bound on concurrently active receiver-side transfers. + pub fn with_max_concurrent_transfers(mut self, max: usize) -> Self { + self.max_concurrent_transfers = max; + self + } + + /// Set the hard per-transfer timeout (milliseconds). + pub fn with_transfer_timeout_ms(mut self, ms: u64) -> Self { + self.transfer_timeout_ms = ms; + self + } + + /// Set the maximum forward gap for buffering out-of-order chunks. + pub fn with_max_out_of_order_window(mut self, window: u64) -> Self { + self.max_out_of_order_window = window; + self + } + + /// Set the maximum number of buffered out-of-order chunks. + pub fn with_max_out_of_order_chunks(mut self, max: usize) -> Self { + self.max_out_of_order_chunks = max; + self + } + + /// Set the sender's `accept`-frame wait timeout (milliseconds). + pub fn with_accept_timeout_ms(mut self, ms: u64) -> Self { + self.accept_timeout_ms = ms; + self + } +} diff --git a/src/transport/oversized_transfer/receiver.rs b/src/transport/oversized_transfer/receiver.rs index 4b4f4e7..bc4b44f 100644 --- a/src/transport/oversized_transfer/receiver.rs +++ b/src/transport/oversized_transfer/receiver.rs @@ -5,7 +5,7 @@ //! and returns the reassembled [`JsonRpcMessage`] once a transfer completes and //! passes byte-length, SHA-256, and JSON-RPC validation. //! -//! This PR-1 engine is **pure and synchronous**: it owns no timers. The hard +//! This engine is **pure and synchronous**: it owns no timers. The hard //! per-transfer watchdog (`transfer_timeout_ms`) and the sender-side //! accept-waiter are added when the engine is wired into the transport. @@ -42,7 +42,7 @@ pub struct TransferPolicy { /// Maximum number of buffered out-of-order chunks. pub max_out_of_order_chunks: usize, /// Hard per-transfer timeout (milliseconds). Reserved for the transport - /// watchdog in a later PR; the pure engine does not enforce it. + /// watchdog; the pure engine does not enforce it. pub transfer_timeout_ms: u64, } diff --git a/src/transport/server/announcement_manager.rs b/src/transport/server/announcement_manager.rs index 40f075a..fa36459 100644 --- a/src/transport/server/announcement_manager.rs +++ b/src/transport/server/announcement_manager.rs @@ -39,7 +39,7 @@ pub(crate) struct AnnouncementManager { gift_wrap_mode: GiftWrapMode, /// User-provided extra tags (e.g. PMI discovery for CEP-8). extra_common_tags: Vec, - /// Transport-owned internal tags (future CEP-22 oversized support signal). + /// Transport-owned internal tags (e.g. CEP-22 oversized support signal). internal_common_tags: Vec, /// CEP-8 pricing tags for capability list responses. pricing_tags: Vec, @@ -277,7 +277,6 @@ impl AnnouncementManager { /// Set transport-owned internal common tags (e.g. CEP-22 oversized support). /// /// Invalidates the common tags cache. - #[allow(dead_code)] // API reserved for CEP-22 oversized transfer integration pub fn set_internal_common_tags(&mut self, tags: Vec) { self.internal_common_tags = tags; *self @@ -645,6 +644,7 @@ impl AnnouncementManager { CommonTagsSnapshot { server_info: self.server_info.clone(), extra_common_tags: self.extra_common_tags.clone(), + internal_common_tags: self.internal_common_tags.clone(), encryption_mode: self.encryption_mode, gift_wrap_mode: self.gift_wrap_mode, } @@ -882,6 +882,8 @@ pub(crate) struct CommonTagsSnapshot { pub server_info: Option, /// User-provided extra common tags. pub extra_common_tags: Vec, + /// Transport-owned internal common tags (e.g. CEP-22 oversized support). + pub internal_common_tags: Vec, /// Encryption mode for capability tag decisions. pub encryption_mode: EncryptionMode, /// Gift-wrap mode for ephemeral tag decisions. @@ -915,6 +917,7 @@ impl CommonTagsSnapshot { } } tags.extend(self.extra_common_tags.iter().cloned()); + tags.extend(self.internal_common_tags.iter().cloned()); } } diff --git a/src/transport/server/correlation_store.rs b/src/transport/server/correlation_store.rs index 3a68ddb..8fca3d5 100644 --- a/src/transport/server/correlation_store.rs +++ b/src/transport/server/correlation_store.rs @@ -20,7 +20,7 @@ pub struct RouteEntry { /// Optional progress token for this request. pub progress_token: Option, /// The outer gift-wrap event kind that carried this request (e.g. 1059 or 21059). - /// Populated from the inbound event in a later PR; `None` until then. + /// Populated from the inbound event once wrap-kind mirroring is wired; `None` until then. pub wrap_kind: Option, /// When the route was registered. pub registered_at: Instant, diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs index 008a5a0..a0d9457 100644 --- a/src/transport/server/mod.rs +++ b/src/transport/server/mod.rs @@ -29,9 +29,23 @@ use crate::encryption; use crate::relay::{RelayPool, RelayPoolTrait}; use crate::transport::base::BaseTransport; use crate::transport::discovery_tags::learn_peer_capabilities; +use crate::transport::oversized_transfer::OversizedTransferConfig; const LOG_TARGET: &str = "contextvm_sdk::transport::server"; +/// CEP-22: the `support_oversized_transfer` capability tags to advertise, or +/// empty when oversized transfer is disabled. +fn oversized_support_tags(config: &NostrServerTransportConfig) -> Vec { + if config.oversized_transfer.enabled { + vec![Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + )] + } else { + Vec::new() + } +} + /// Configuration for the server transport. #[derive(Debug, Clone)] #[non_exhaustive] @@ -75,6 +89,8 @@ pub struct NostrServerTransportConfig { pub publish_relay_list: bool, /// Optional NIP-01 profile metadata (kind 0) to publish at startup. pub profile_metadata: Option, + /// CEP-22 oversized payload transfer configuration. Disabled by default. + pub oversized_transfer: OversizedTransferConfig, } impl Default for NostrServerTransportConfig { @@ -95,6 +111,7 @@ impl Default for NostrServerTransportConfig { bootstrap_relay_urls: None, publish_relay_list: true, profile_metadata: None, + oversized_transfer: OversizedTransferConfig::default(), } } } @@ -202,6 +219,16 @@ impl NostrServerTransportConfig { self.profile_metadata = Some(metadata); self } + /// Set the full CEP-22 oversized payload transfer configuration. + pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self { + self.oversized_transfer = config; + self + } + /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default. + pub fn with_oversized_enabled(mut self, enabled: bool) -> Self { + self.oversized_transfer.enabled = enabled; + self + } } /// An incoming MCP request with metadata for routing the response. @@ -246,19 +273,22 @@ impl NostrServerTransport { gift_wrap_mode = ?config.gift_wrap_mode, "Created server transport" ); + let mut announcement_manager = announcement_manager::AnnouncementManager::new( + Arc::clone(&relay_pool), + config.server_info.clone(), + 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(), + ); + // CEP-22: advertise oversized-transfer support in announcements + first responses. + announcement_manager.set_internal_common_tags(oversized_support_tags(&config)); Ok(Self { - announcement_manager: announcement_manager::AnnouncementManager::new( - Arc::clone(&relay_pool), - config.server_info.clone(), - 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(), - ), + announcement_manager, base: BaseTransport { relay_pool, encryption_mode: config.encryption_mode, @@ -293,19 +323,22 @@ impl NostrServerTransport { encryption_mode = ?config.encryption_mode, "Created server transport (with_relay_pool)" ); + let mut announcement_manager = announcement_manager::AnnouncementManager::new( + Arc::clone(&relay_pool), + config.server_info.clone(), + 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(), + ); + // CEP-22: advertise oversized-transfer support in announcements + first responses. + announcement_manager.set_internal_common_tags(oversized_support_tags(&config)); Ok(Self { - announcement_manager: announcement_manager::AnnouncementManager::new( - Arc::clone(&relay_pool), - config.server_info.clone(), - 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(), - ), + announcement_manager, base: BaseTransport { relay_pool, encryption_mode: config.encryption_mode, @@ -379,6 +412,7 @@ impl NostrServerTransport { let encryption_mode = self.config.encryption_mode; let gift_wrap_mode = self.config.gift_wrap_mode; let is_announced_server = self.config.is_announced_server; + let oversized_enabled = self.config.oversized_transfer.enabled; let common_tags_snapshot = self.announcement_manager.common_tags_snapshot(); let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone(); let event_loop_token = self.cancellation_token.child_token(); @@ -395,6 +429,7 @@ impl NostrServerTransport { encryption_mode, gift_wrap_mode, is_announced_server, + oversized_enabled, common_tags_snapshot, seen_gift_wrap_ids, event_loop_token, @@ -872,6 +907,7 @@ impl NostrServerTransport { encryption_mode: EncryptionMode, gift_wrap_mode: GiftWrapMode, is_announced_server: bool, + oversized_enabled: bool, common_tags_snapshot: announcement_manager::CommonTagsSnapshot, seen_gift_wrap_ids: Arc>>, cancel: CancellationToken, @@ -1153,9 +1189,7 @@ impl NostrServerTransport { let discovered = learn_peer_capabilities(&inner_tags); session.supports_encryption |= discovered.supports_encryption; session.supports_ephemeral_encryption |= discovered.supports_ephemeral_encryption; - // Only learn oversized support if CEP-22 is enabled on this server - // TODO: wire from config when CEP-22 lands - let oversized_enabled = false; + // CEP-22: only learn oversized support if it is enabled on this server. session.supports_oversized_transfer |= oversized_enabled && discovered.supports_oversized_transfer; @@ -1610,6 +1644,7 @@ mod tests { let snapshot = announcement_manager::CommonTagsSnapshot { server_info: None, extra_common_tags: vec![], + internal_common_tags: vec![], encryption_mode: EncryptionMode::Optional, gift_wrap_mode: GiftWrapMode::Optional, }; @@ -1627,6 +1662,7 @@ mod tests { let snapshot = announcement_manager::CommonTagsSnapshot { server_info: None, extra_common_tags: vec![], + internal_common_tags: vec![], encryption_mode: EncryptionMode::Disabled, gift_wrap_mode: GiftWrapMode::Optional, }; @@ -1724,6 +1760,7 @@ mod tests { let snapshot = announcement_manager::CommonTagsSnapshot { server_info: None, extra_common_tags: vec![], + internal_common_tags: vec![], encryption_mode: EncryptionMode::Optional, gift_wrap_mode: GiftWrapMode::Optional, }; @@ -1747,6 +1784,7 @@ mod tests { let snapshot = announcement_manager::CommonTagsSnapshot { server_info: Some(server_info), extra_common_tags: vec![], + internal_common_tags: vec![], encryption_mode: EncryptionMode::Disabled, gift_wrap_mode: GiftWrapMode::Optional, }; @@ -1768,6 +1806,7 @@ mod tests { let snapshot = announcement_manager::CommonTagsSnapshot { server_info: None, extra_common_tags: extra_tags, + internal_common_tags: vec![], encryption_mode: EncryptionMode::Disabled, gift_wrap_mode: GiftWrapMode::Optional, }; @@ -1811,4 +1850,117 @@ mod tests { let config = NostrServerTransportConfig::default(); assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional); } + + // ── CEP-22 oversized transfer capability advertisement ────── + + fn first_tag_values(tags: &[Tag]) -> Vec { + tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect() + } + + async fn make_server_with_oversized(enabled: bool) -> NostrServerTransport { + let config = NostrServerTransportConfig { + oversized_transfer: OversizedTransferConfig::default().with_enabled(enabled), + ..Default::default() + }; + let pool: Arc = Arc::new(crate::relay::mock::MockRelayPool::new()); + NostrServerTransport::with_relay_pool(config, pool) + .await + .expect("server transport construction") + } + + #[test] + fn test_oversized_disabled_by_default() { + let config = NostrServerTransportConfig::default(); + assert!(!config.oversized_transfer.enabled); + } + + #[test] + fn test_oversized_support_tags_helper() { + let mut config = NostrServerTransportConfig::default(); + assert!(oversized_support_tags(&config).is_empty()); + config.oversized_transfer.enabled = true; + let names = first_tag_values(&oversized_support_tags(&config)); + assert_eq!(names, vec!["support_oversized_transfer"]); + } + + #[test] + fn test_oversized_builders() { + let config = NostrServerTransportConfig::default().with_oversized_enabled(true); + assert!(config.oversized_transfer.enabled); + let config = NostrServerTransportConfig::default() + .with_oversized_transfer(OversizedTransferConfig::enabled().with_threshold(123)); + assert!(config.oversized_transfer.enabled); + assert_eq!(config.oversized_transfer.threshold, 123); + } + + #[tokio::test] + async fn test_announcement_includes_oversized_tag_when_enabled() { + let server = make_server_with_oversized(true).await; + let names = first_tag_values(&server.announcement_manager.get_common_tags()); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "announcement common tags must advertise oversized support when enabled" + ); + } + + #[tokio::test] + async fn test_announcement_omits_oversized_tag_when_disabled() { + let server = make_server_with_oversized(false).await; + let names = first_tag_values(&server.announcement_manager.get_common_tags()); + assert!( + !names.contains(&"support_oversized_transfer".to_string()), + "announcement must not advertise oversized support when disabled" + ); + } + + #[tokio::test] + async fn test_first_response_snapshot_includes_oversized_tag_when_enabled() { + let server = make_server_with_oversized(true).await; + let snapshot = server.announcement_manager.common_tags_snapshot(); + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let names = first_tag_values(&tags); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "first-response replay must carry the oversized tag when enabled" + ); + } + + #[tokio::test] + async fn test_first_response_snapshot_omits_oversized_tag_when_disabled() { + let server = make_server_with_oversized(false).await; + let snapshot = server.announcement_manager.common_tags_snapshot(); + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let names = first_tag_values(&tags); + assert!(!names.contains(&"support_oversized_transfer".to_string())); + } + + #[test] + fn test_server_learns_client_oversized_only_when_enabled() { + // Demonstrates the gate's truth table: `learn_peer_capabilities` parses the + // client tag, and `enabled && supports` yields the learned flag. This does + // NOT pin the production gate at the `session.supports_oversized_transfer |=` + // line in `event_loop` - it reimplements that expression here, so a + // regression that hardcodes the flag in `event_loop` would not be caught. + let oversized_tag = Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + ); + let discovered = learn_peer_capabilities(&[oversized_tag]); + assert!(discovered.supports_oversized_transfer); + + // Disabled server: client flag must be ignored. + let mut session = ClientSession::new(false); + let oversized_enabled = false; + session.supports_oversized_transfer |= + oversized_enabled && discovered.supports_oversized_transfer; + assert!(!session.supports_oversized_transfer); + + // Enabled server: client flag is learned. + let oversized_enabled = true; + session.supports_oversized_transfer |= + oversized_enabled && discovered.supports_oversized_transfer; + assert!(session.supports_oversized_transfer); + } } From 00e2f85503f07ba823874a28fc2900b6104760bf Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 8 Jun 2026 16:27:02 +0530 Subject: [PATCH 2/2] fix: address review - SessionSnapshot capabilities, real gate tests, rename get_capability_tags --- src/transport/server/announcement_manager.rs | 16 +- src/transport/server/mod.rs | 17 +- src/transport/server/session_store.rs | 46 +++++ tests/transport_integration.rs | 178 +++++++++++++++++++ 4 files changed, 245 insertions(+), 12 deletions(-) diff --git a/src/transport/server/announcement_manager.rs b/src/transport/server/announcement_manager.rs index fa36459..c680899 100644 --- a/src/transport/server/announcement_manager.rs +++ b/src/transport/server/announcement_manager.rs @@ -203,8 +203,10 @@ impl AnnouncementManager { tags } - /// Build capability tags based on encryption and gift-wrap mode. - pub fn get_capability_tags(&self) -> Vec { + /// Build the **encryption** capability tags based on encryption and gift-wrap + /// mode. Returns encryption capability tags only; CEP-22 oversized transfer and + /// other capabilities are contributed separately via `internal_common_tags`. + pub fn get_encryption_capability_tags(&self) -> Vec { let mut tags = Vec::new(); if self.encryption_mode != EncryptionMode::Disabled { tags.push(Tag::custom( @@ -234,7 +236,7 @@ impl AnnouncementManager { return cached.clone(); } let mut tags = self.get_server_info_tags(); - tags.extend(self.get_capability_tags()); + tags.extend(self.get_encryption_capability_tags()); tags.extend(self.extra_common_tags.iter().cloned()); tags.extend(self.internal_common_tags.iter().cloned()); *cache = Some(tags.clone()); @@ -990,7 +992,7 @@ mod tests { #[test] fn capability_tags_encryption_enabled() { let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None); - let tags = mgr.get_capability_tags(); + let tags = mgr.get_encryption_capability_tags(); let names: Vec = tags.iter().map(tag_name).collect(); assert!(names.contains(&tags::SUPPORT_ENCRYPTION.to_string())); } @@ -998,7 +1000,7 @@ mod tests { #[test] fn capability_tags_ephemeral_enabled() { let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, None); - let tags = mgr.get_capability_tags(); + let tags = mgr.get_encryption_capability_tags(); let names: Vec = tags.iter().map(tag_name).collect(); assert!(names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string())); } @@ -1006,7 +1008,7 @@ mod tests { #[test] fn capability_tags_ephemeral_excluded() { let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None); - let tags = mgr.get_capability_tags(); + let tags = mgr.get_encryption_capability_tags(); let names: Vec = tags.iter().map(tag_name).collect(); assert!( !names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string()), @@ -1017,7 +1019,7 @@ mod tests { #[test] fn capability_tags_encryption_disabled() { let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, None); - let tags = mgr.get_capability_tags(); + let tags = mgr.get_encryption_capability_tags(); assert!( tags.is_empty(), "Disabled encryption should produce no capability tags" diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs index a0d9457..6063728 100644 --- a/src/transport/server/mod.rs +++ b/src/transport/server/mod.rs @@ -745,6 +745,13 @@ impl NostrServerTransport { self.message_rx.take() } + /// Read-only snapshot of a client's learned session state, or `None` if no + /// session exists for that public key (hex). Exposes learned peer + /// capabilities (encryption, ephemeral encryption, CEP-22 oversized transfer). + pub async fn session_snapshot(&self, client_pubkey: &str) -> Option { + self.sessions.get_session(client_pubkey).await + } + /// Sets extra discovery tags to include in announcements and first-response discovery replay. pub fn set_announcement_extra_tags(&mut self, tags: Vec) { self.announcement_manager.set_extra_common_tags(tags); @@ -1938,11 +1945,11 @@ mod tests { #[test] fn test_server_learns_client_oversized_only_when_enabled() { - // Demonstrates the gate's truth table: `learn_peer_capabilities` parses the - // client tag, and `enabled && supports` yields the learned flag. This does - // NOT pin the production gate at the `session.supports_oversized_transfer |=` - // line in `event_loop` - it reimplements that expression here, so a - // regression that hardcodes the flag in `event_loop` would not be caught. + // Unit-level check that `learn_peer_capabilities` parses the client tag and + // the `enabled && supports` truth table holds. The production gate in + // `event_loop` is exercised end-to-end by the integration tests + // `server_gate_allows_oversized_when_enabled` / + // `server_gate_blocks_oversized_when_disabled` in tests/transport_integration.rs. let oversized_tag = Tag::custom( TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), Vec::::new(), diff --git a/src/transport/server/session_store.rs b/src/transport/server/session_store.rs index a208789..daf8141 100644 --- a/src/transport/server/session_store.rs +++ b/src/transport/server/session_store.rs @@ -109,6 +109,9 @@ impl SessionStore { is_encrypted: s.is_encrypted, has_sent_common_tags: s.has_sent_common_tags, supports_ephemeral_gift_wrap: s.supports_ephemeral_gift_wrap, + supports_encryption: s.supports_encryption, + supports_ephemeral_encryption: s.supports_ephemeral_encryption, + supports_oversized_transfer: s.supports_oversized_transfer, }) } @@ -162,6 +165,9 @@ impl SessionStore { is_encrypted: s.is_encrypted, has_sent_common_tags: s.has_sent_common_tags, supports_ephemeral_gift_wrap: s.supports_ephemeral_gift_wrap, + supports_encryption: s.supports_encryption, + supports_ephemeral_encryption: s.supports_ephemeral_encryption, + supports_oversized_transfer: s.supports_oversized_transfer, }, ) }) @@ -235,6 +241,12 @@ pub struct SessionSnapshot { pub has_sent_common_tags: bool, /// Whether the peer advertised support for ephemeral gift wraps (CEP-19) pub supports_ephemeral_gift_wrap: bool, + /// Whether the peer advertised encryption support (CEP-35 learned capability) + pub supports_encryption: bool, + /// Whether the peer advertised ephemeral-encryption support (CEP-35 learned capability) + pub supports_ephemeral_encryption: bool, + /// Whether the peer advertised CEP-22 oversized-transfer support (learned, gated by server config) + pub supports_oversized_transfer: bool, } #[cfg(test)] @@ -351,6 +363,40 @@ mod tests { assert!(!session.supports_oversized_transfer); } + #[tokio::test] + async fn snapshot_surfaces_learned_capabilities() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + // A fresh snapshot reports every capability as false. + let snap = store.get_session("client-1").await.unwrap(); + assert!(!snap.supports_encryption); + assert!(!snap.supports_ephemeral_encryption); + assert!(!snap.supports_oversized_transfer); + + // Learned capabilities must round-trip through the snapshot. + { + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + session.supports_encryption = true; + session.supports_ephemeral_encryption = true; + session.supports_oversized_transfer = true; + } + + let snap = store.get_session("client-1").await.unwrap(); + assert!(snap.supports_encryption); + assert!(snap.supports_ephemeral_encryption); + assert!(snap.supports_oversized_transfer); + + // get_all_sessions exposes the same fields. + let all = store.get_all_sessions().await; + let (_, snap_all) = all.iter().find(|(k, _)| k == "client-1").unwrap(); + assert!(snap_all.supports_encryption); + assert!(snap_all.supports_ephemeral_encryption); + assert!(snap_all.supports_oversized_transfer); + } + #[tokio::test] async fn has_sent_common_tags_flag() { let store = SessionStore::new(); diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs index 101effc..f6f2946 100644 --- a/tests/transport_integration.rs +++ b/tests/transport_integration.rs @@ -2661,6 +2661,184 @@ async fn server_learns_capabilities_from_client_request() { assert_eq!(incoming.client_pubkey, incoming2.client_pubkey); } +/// CEP-22: with oversized transfer enabled on the server, the server learns the +/// client's advertised `support_oversized_transfer` flag as a request flows +/// through the real `event_loop` gate. Exercises the production gate end-to-end +/// (not the inline truth-table unit test). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_allows_oversized_when_enabled() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + // The session-state update precedes the IncomingRequest dispatch, so the + // learned flag is committed by the time we receive the request. + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + snap.supports_oversized_transfer, + "server with oversized enabled must learn the client's advertised support" + ); +} + +/// CEP-22: with oversized transfer disabled on the server, the gate drops the +/// client's advertised `support_oversized_transfer` flag — the session never +/// records support even though the client advertised it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_blocks_oversized_when_disabled() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(false), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + !snap.supports_oversized_transfer, + "server with oversized disabled must not learn the client's advertised support" + ); +} + +/// CEP-22: even with oversized transfer enabled, the server only learns support +/// the client actually advertised. A client that does not advertise the tag +/// leaves the session flag false. Pins the `&& discovered.supports_oversized_transfer` +/// operand of the gate — a regression dropping it would learn `true` regardless. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_blocks_oversized_when_client_does_not_advertise() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(false), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + !snap.supports_oversized_transfer, + "server must not learn oversized support the client never advertised" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn server_disabled_encryption_omits_encryption_tags() { let (client_pool, server_pool) = MockRelayPool::create_pair();