Skip to main content

contextvm_sdk/transport/server/
mod.rs

1//! Server-side Nostr transport for ContextVM.
2//!
3//! Listens for incoming MCP requests from clients over Nostr, manages multi-client
4//! sessions, handles request/response correlation, and optionally publishes
5//! server announcements.
6
7pub(crate) mod announcement_manager;
8pub mod correlation_store;
9pub mod session_store;
10
11pub use correlation_store::{RouteEntry, ServerEventRouteStore};
12pub use session_store::{SessionSnapshot, SessionStore};
13use tokio::sync::Mutex as AsyncMutex;
14use tokio::sync::RwLock;
15
16use std::collections::HashMap;
17use std::num::NonZeroUsize;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{Arc, Mutex};
20use std::time::{Duration, Instant};
21
22use lru::LruCache;
23use nostr_sdk::prelude::*;
24use tokio_util::sync::CancellationToken;
25
26use crate::core::constants::*;
27use crate::core::error::{Error, Result};
28use crate::core::types::*;
29use crate::core::validation;
30use crate::encryption;
31use crate::relay::{RelayPool, RelayPoolTrait};
32use crate::transport::base::BaseTransport;
33use crate::transport::discovery_tags::learn_peer_capabilities;
34use crate::transport::open_stream::{
35    open_stream_frame_from_notification, FrameOutcome, KeepaliveAction, OnAbortHook, OnCloseHook,
36    OpenStreamConfig, OpenStreamFrame, OpenStreamReceiver, OpenStreamRegistryPolicy,
37    OpenStreamWriter, OpenStreamWriterOptions, PublishFrame,
38};
39use crate::transport::oversized_transfer::{
40    build_oversized_frames, progress_token_string, resolve_safe_chunk_size, OversizedFrame,
41    OversizedSenderOptions, OversizedTransferConfig, OversizedTransferReceiver, TransferPolicy,
42    ACCEPT_PROGRESS,
43};
44
45const LOG_TARGET: &str = "contextvm_sdk::transport::server";
46
47/// CEP-22: the `support_oversized_transfer` capability tags to advertise, or
48/// empty when oversized transfer is disabled.
49fn oversized_support_tags(config: &NostrServerTransportConfig) -> Vec<Tag> {
50    if config.oversized_transfer.enabled {
51        vec![Tag::custom(
52            TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()),
53            Vec::<String>::new(),
54        )]
55    } else {
56        Vec::new()
57    }
58}
59
60/// CEP-41: the `support_open_stream` capability tag to advertise, or empty when
61/// open-stream is disabled. Mirrors [`oversized_support_tags`].
62fn open_stream_support_tags(config: &OpenStreamConfig) -> Vec<Tag> {
63    if config.enabled {
64        vec![Tag::custom(
65            TagKind::Custom(tags::SUPPORT_OPEN_STREAM.into()),
66            Vec::<String>::new(),
67        )]
68    } else {
69        Vec::new()
70    }
71}
72
73/// CEP-22 + CEP-41: the internal capability tags advertised on announcements and
74/// replayed on the first response to each client.
75fn internal_common_capability_tags(config: &NostrServerTransportConfig) -> Vec<Tag> {
76    let mut tags = oversized_support_tags(config);
77    tags.extend(open_stream_support_tags(&config.open_stream));
78    tags
79}
80
81/// CEP-22: build the empty per-peer reassembly store, bounded to `max_sessions`
82/// peers (one [`OversizedTransferReceiver`] per client pubkey, inserted lazily by
83/// the inbound event loop).
84fn new_oversized_receiver_store(
85    max_sessions: usize,
86) -> Arc<RwLock<LruCache<String, OversizedTransferReceiver>>> {
87    Arc::new(RwLock::new(LruCache::new(
88        NonZeroUsize::new(max_sessions).unwrap_or(NonZeroUsize::new(1).unwrap()),
89    )))
90}
91
92/// CEP-41: build the empty per-peer open-stream reader store, bounded to
93/// `max_sessions` peers. Mirrors [`new_oversized_receiver_store`]; one
94/// [`OpenStreamReceiver`] per client pubkey is inserted lazily for inbound
95/// (client→server) streams.
96///
97/// Uses a [`tokio::sync::Mutex`] rather than an `RwLock`: the registry's
98/// `FnOnce` lifecycle hooks are `Send` but not `Sync`, so a shared-read lock
99/// could not be made `Sync`; the store is write-only anyway (`process_frame`
100/// needs `&mut`), so exclusive access loses nothing.
101fn new_open_stream_receiver_store(
102    max_sessions: usize,
103) -> Arc<AsyncMutex<LruCache<String, OpenStreamReceiver>>> {
104    Arc::new(AsyncMutex::new(LruCache::new(
105        NonZeroUsize::new(max_sessions).unwrap_or(NonZeroUsize::new(1).unwrap()),
106    )))
107}
108
109/// CEP-41: response-routing fields captured at writer creation, while the
110/// request's event route is fresh.
111///
112/// The deferred final response is delivered from this snapshot (via
113/// [`NostrServerTransport::send_open_stream_deferred_response`]) rather than from
114/// `event_routes`, so a stream that outlives `request_timeout` — after which the
115/// route is swept — still delivers its response. `mirrored_wrap_kind` mirrors
116/// the inbound gift-wrap kind for CEP-19, exactly as `send_response` does.
117#[derive(Clone)]
118struct RouteSnapshot {
119    client_pubkey: PublicKey,
120    original_request_id: serde_json::Value,
121    is_encrypted: bool,
122    mirrored_wrap_kind: Option<u16>,
123}
124
125/// CEP-41: the per-stream coordination slot for a server→client writer, keyed by
126/// request `event_id` in [`ServerOpenStreamState::slots`].
127///
128/// A single mutex over the whole map serializes the two writers of the deferred
129/// final response — `send_response` (the worker task) and the writer's
130/// close/abort hook (the tool task) — against the [`terminated`](Self::terminated)
131/// flag, so the response is never both stashed *and* dropped under a race.
132struct OpenStreamSlot {
133    writer: OpenStreamWriter,
134    snapshot: RouteSnapshot,
135    /// The final response, stashed by `send_response` when it arrives before the
136    /// stream closes (ordering A).
137    pending_response: Option<JsonRpcMessage>,
138    /// Set by the writer's close/abort hook once the stream is terminal. When
139    /// `send_response` arrives after this (ordering B), it delivers immediately.
140    terminated: bool,
141}
142
143/// CEP-41: the open-stream runtime state shared between the server transport and
144/// its spawned event loop. Bundled so the event-loop signature stays manageable.
145#[derive(Clone)]
146struct ServerOpenStreamState {
147    /// Master gate (`config.open_stream.enabled`).
148    enabled: bool,
149    /// Reader admission/buffering/keepalive policy projected from config.
150    policy: OpenStreamRegistryPolicy,
151    /// Per-peer reader engines for inbound (client→server) streams.
152    receiver: Arc<AsyncMutex<LruCache<String, OpenStreamReceiver>>>,
153    /// Per-stream writer + deferred-response slots, keyed by `event_id`.
154    slots: Arc<Mutex<HashMap<String, OpenStreamSlot>>>,
155    /// `progress_token → event_id`, so inbound control frames and the keepalive
156    /// sweep resolve the writer/route without consulting the route store.
157    token_to_event: Arc<Mutex<HashMap<String, String>>>,
158    /// Monotonic `progress` source for server-*as-reader* control frames
159    /// (`accept`/`pong`/`ping` on inbound client→server streams, where no writer
160    /// owns the counter). Per-token monotonicity holds even though it is shared.
161    control_progress: Arc<AtomicU64>,
162}
163
164impl ServerOpenStreamState {
165    fn new(config: &OpenStreamConfig, max_sessions: usize) -> Self {
166        Self {
167            enabled: config.enabled,
168            policy: config.into(),
169            receiver: new_open_stream_receiver_store(max_sessions),
170            slots: Arc::new(Mutex::new(HashMap::new())),
171            token_to_event: Arc::new(Mutex::new(HashMap::new())),
172            control_progress: Arc::new(AtomicU64::new(0)),
173        }
174    }
175
176    /// Next monotonic control-frame `progress` (1, 2, 3, …) for the reader path.
177    fn next_control_progress(&self) -> u64 {
178        self.control_progress.fetch_add(1, Ordering::SeqCst) + 1
179    }
180
181    /// Lock-poison-tolerant access to the slots map.
182    fn lock_slots(&self) -> std::sync::MutexGuard<'_, HashMap<String, OpenStreamSlot>> {
183        match self.slots.lock() {
184            Ok(g) => g,
185            Err(p) => p.into_inner(),
186        }
187    }
188
189    fn lock_token_index(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
190        match self.token_to_event.lock() {
191            Ok(g) => g,
192            Err(p) => p.into_inner(),
193        }
194    }
195
196    /// Clone the active writer for `event_id`, if any (for inbound ping/abort
197    /// routing and the worker's extensions injection).
198    fn writer_for(&self, event_id: &str) -> Option<OpenStreamWriter> {
199        self.lock_slots().get(event_id).map(|s| s.writer.clone())
200    }
201
202    /// Resolve `progress_token → event_id`.
203    fn event_id_for_token(&self, token: &str) -> Option<String> {
204        self.lock_token_index().get(token).cloned()
205    }
206}
207
208/// CEP-41: the outcome of the response-deferral decision in `send_response`.
209enum OpenStreamDeferral {
210    /// The response was stashed; the writer's close/abort hook will flush it.
211    Deferred,
212    /// The stream is already terminal — deliver this response now from the snapshot.
213    SendNow {
214        snapshot: RouteSnapshot,
215        response: JsonRpcMessage,
216    },
217    /// No active stream for this event — send the response through the normal path.
218    Passthrough(JsonRpcMessage),
219}
220
221/// Configuration for the server transport.
222#[derive(Debug, Clone)]
223#[non_exhaustive]
224pub struct NostrServerTransportConfig {
225    /// Relay URLs to connect to.
226    pub relay_urls: Vec<String>,
227    /// Encryption mode.
228    pub encryption_mode: EncryptionMode,
229    /// Gift-wrap kind selection policy (CEP-19).
230    pub gift_wrap_mode: GiftWrapMode,
231    /// Server information for announcements.
232    pub server_info: Option<ServerInfo>,
233    /// Whether this server publishes public announcements (CEP-6).
234    pub is_announced_server: bool,
235    /// Allowed client public keys (hex). Empty = allow all.
236    pub allowed_public_keys: Vec<String>,
237    /// Capabilities excluded from pubkey whitelisting.
238    pub excluded_capabilities: Vec<CapabilityExclusion>,
239    /// Maximum number of concurrent client sessions (LRU-bounded, default: 1000).
240    pub max_sessions: usize,
241    /// Session cleanup interval (default: 60s).
242    pub cleanup_interval: Duration,
243    /// Session timeout (default: 300s).
244    pub session_timeout: Duration,
245    /// Correlation-retention TTL for server-side event routes (default: 60s).
246    ///
247    /// Stale route entries older than this are swept from the correlation store.
248    /// This prevents leaks -- rmcp owns actual request timeout and cancellation.
249    /// Keep this value above your rmcp request timeout to avoid premature cleanup.
250    pub request_timeout: Duration,
251    /// Explicit relay URLs to advertise in kind 10002 (NIP-65 relay list).
252    ///
253    /// Falls back to the transport's `relay_urls` when omitted.
254    pub relay_list_urls: Option<Vec<String>>,
255    /// Additional publication targets for discoverability events.
256    ///
257    /// Merged with `relay_list_urls` when computing where to send events.
258    /// Defaults to [`DEFAULT_BOOTSTRAP_RELAY_URLS`] when omitted.
259    pub bootstrap_relay_urls: Option<Vec<String>>,
260    /// Whether to publish a relay list event (kind 10002). Default: `true`.
261    pub publish_relay_list: bool,
262    /// Optional NIP-01 profile metadata (kind 0) to publish at startup.
263    pub profile_metadata: Option<ProfileMetadata>,
264    /// CEP-22 oversized payload transfer configuration. Enabled by default.
265    pub oversized_transfer: OversizedTransferConfig,
266    /// CEP-41 open-stream configuration. Disabled by default (opt-in).
267    ///
268    /// When enabled, drives capability advertisement/learning, server→client
269    /// writers, response deferral, and the keepalive sweep. Opt in with
270    /// `OpenStreamConfig::enabled()` / `with_enabled(true)`.
271    pub open_stream: OpenStreamConfig,
272}
273
274impl Default for NostrServerTransportConfig {
275    fn default() -> Self {
276        Self {
277            relay_urls: vec!["wss://relay.damus.io".to_string()],
278            encryption_mode: EncryptionMode::Optional,
279            gift_wrap_mode: GiftWrapMode::Optional,
280            server_info: None,
281            is_announced_server: false,
282            allowed_public_keys: Vec::new(),
283            excluded_capabilities: Vec::new(),
284            max_sessions: session_store::DEFAULT_MAX_SESSIONS,
285            cleanup_interval: Duration::from_secs(60),
286            session_timeout: Duration::from_secs(300),
287            request_timeout: Duration::from_secs(60),
288            relay_list_urls: None,
289            bootstrap_relay_urls: None,
290            publish_relay_list: true,
291            profile_metadata: None,
292            oversized_transfer: OversizedTransferConfig::default(),
293            open_stream: OpenStreamConfig::default(),
294        }
295    }
296}
297
298/// Server-side Nostr transport — receives MCP requests and sends responses.
299pub struct NostrServerTransport {
300    /// Relay pool for publishing and subscribing.
301    base: BaseTransport,
302    /// Configuration for this server transport.
303    config: NostrServerTransportConfig,
304    /// Manages tag composition and publishing for CEP-6 announcements and CEP-35 discovery.
305    announcement_manager: announcement_manager::AnnouncementManager,
306    /// Client sessions.
307    sessions: SessionStore,
308    /// Reverse lookup: event_id → client route.
309    event_routes: ServerEventRouteStore,
310    /// CEP-19: Track the incoming gift-wrap kind per request for mirroring.
311    request_wrap_kinds: Arc<RwLock<HashMap<String, Option<u16>>>>,
312    /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`).
313    /// Duplicate outer ids are skipped before decrypt; ids are inserted only after success
314    /// so failed decrypt/verify can be retried on redelivery.
315    seen_gift_wrap_ids: Arc<Mutex<LruCache<EventId, ()>>>,
316    /// CEP-22: per-peer reassembly engines for inbound oversized transfers, keyed
317    /// by client pubkey (hex) and bounded to `max_sessions` peers. Each receiver
318    /// enforces the configured per-peer admission policy. Populated by the inbound
319    /// event loop; cleared on [`close`](Self::close).
320    oversized_receiver: Arc<RwLock<LruCache<String, OversizedTransferReceiver>>>,
321    /// CEP-41: open-stream runtime state (writers, deferred responses, per-peer
322    /// reader engines, `progress_token → event_id` index). Inert when
323    /// `open_stream.enabled` is `false`.
324    open_stream: ServerOpenStreamState,
325    /// Channel for incoming MCP messages (consumed by the MCP server).
326    message_tx: Option<tokio::sync::mpsc::UnboundedSender<IncomingRequest>>,
327    message_rx: Option<tokio::sync::mpsc::UnboundedReceiver<IncomingRequest>>,
328    /// Token used to cancel spawned tasks (event loop + cleanup) on close().
329    cancellation_token: CancellationToken,
330    /// Handles for spawned tasks (event loop + cleanup).
331    task_handles: Vec<tokio::task::JoinHandle<()>>,
332}
333
334impl NostrServerTransportConfig {
335    /// Set the encryption mode.
336    pub fn with_encryption_mode(mut self, mode: EncryptionMode) -> Self {
337        self.encryption_mode = mode;
338        self
339    }
340    /// Set the gift-wrap mode (CEP-19).
341    pub fn with_gift_wrap_mode(mut self, mode: GiftWrapMode) -> Self {
342        self.gift_wrap_mode = mode;
343        self
344    }
345    /// Set server information for announcements.
346    pub fn with_server_info(mut self, info: ServerInfo) -> Self {
347        self.server_info = Some(info);
348        self
349    }
350    /// Enable or disable public announcement publishing (CEP-6).
351    pub fn with_announced_server(mut self, announced: bool) -> Self {
352        self.is_announced_server = announced;
353        self
354    }
355    /// Set the allowed client public keys (hex). Empty = allow all.
356    pub fn with_allowed_public_keys(mut self, keys: Vec<String>) -> Self {
357        self.allowed_public_keys = keys;
358        self
359    }
360    /// Set capabilities excluded from pubkey whitelisting.
361    pub fn with_excluded_capabilities(mut self, caps: Vec<CapabilityExclusion>) -> Self {
362        self.excluded_capabilities = caps;
363        self
364    }
365    /// Set the maximum number of concurrent client sessions.
366    pub fn with_max_sessions(mut self, max: usize) -> Self {
367        self.max_sessions = max;
368        self
369    }
370    /// Set the relay URLs to connect to.
371    pub fn with_relay_urls(mut self, urls: Vec<String>) -> Self {
372        self.relay_urls = urls;
373        self
374    }
375    /// Set the session cleanup interval.
376    pub fn with_cleanup_interval(mut self, interval: Duration) -> Self {
377        self.cleanup_interval = interval;
378        self
379    }
380    /// Set the session timeout.
381    pub fn with_session_timeout(mut self, timeout: Duration) -> Self {
382        self.session_timeout = timeout;
383        self
384    }
385    /// Set the correlation-retention TTL for event routes.
386    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
387        self.request_timeout = timeout;
388        self
389    }
390    /// Set explicit relay URLs to advertise in the relay list event (kind 10002).
391    pub fn with_relay_list_urls(mut self, urls: Vec<String>) -> Self {
392        self.relay_list_urls = Some(urls);
393        self
394    }
395    /// Set additional bootstrap relay URLs for discoverability event publication.
396    pub fn with_bootstrap_relay_urls(mut self, urls: Vec<String>) -> Self {
397        self.bootstrap_relay_urls = Some(urls);
398        self
399    }
400    /// Enable or disable relay list publication (kind 10002).
401    pub fn with_publish_relay_list(mut self, publish: bool) -> Self {
402        self.publish_relay_list = publish;
403        self
404    }
405    /// Set NIP-01 profile metadata (kind 0) for publication at startup.
406    pub fn with_profile_metadata(mut self, metadata: ProfileMetadata) -> Self {
407        self.profile_metadata = Some(metadata);
408        self
409    }
410    /// Set the full CEP-22 oversized payload transfer configuration.
411    pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self {
412        self.oversized_transfer = config;
413        self
414    }
415    /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default.
416    pub fn with_oversized_enabled(mut self, enabled: bool) -> Self {
417        self.oversized_transfer.enabled = enabled;
418        self
419    }
420    /// Set the full CEP-41 open-stream configuration (disabled by default; opt in
421    /// with `OpenStreamConfig::enabled()`).
422    pub fn with_open_stream(mut self, config: OpenStreamConfig) -> Self {
423        self.open_stream = config;
424        self
425    }
426}
427
428/// An incoming MCP request with metadata for routing the response.
429#[derive(Debug)]
430#[non_exhaustive]
431pub struct IncomingRequest {
432    /// The parsed MCP message.
433    pub message: JsonRpcMessage,
434    /// The client's public key (hex).
435    pub client_pubkey: String,
436    /// The Nostr event ID (for response correlation).
437    pub event_id: String,
438    /// Whether the original message was encrypted.
439    pub is_encrypted: bool,
440    /// The inbound (client-signed) Nostr event, if this request carried one.
441    ///
442    /// `Some` for real client requests: the inner, signature-verified gift-wrap
443    /// event for encrypted requests, the outer event for plaintext, or the
444    /// carrying frame event for CEP-22 oversized reassembly. `None` only for
445    /// requests the transport synthesizes itself (announcement /
446    /// initialization drives), which carry no real Nostr event. Handlers reach
447    /// it via [`InboundEvent`] in request
448    /// `extensions`; see that type's docs.
449    pub event: Option<Event>,
450}
451
452/// The Nostr public key (hex) of the client that issued the current request.
453///
454/// [`NostrServerWorker`](crate::rmcp_transport::NostrServerWorker) injects this
455/// into every inbound request's rmcp `extensions` typemap, so a tool, resource,
456/// or prompt handler can identify its caller:
457///
458/// ```ignore
459/// use contextvm_sdk::transport::server::ClientPubkey;
460/// use rmcp::service::RequestContext;
461/// use rmcp::RoleServer;
462///
463/// let caller = ctx.extensions.get::<ClientPubkey>().map(|c| c.0.clone());
464/// ```
465///
466/// The value is the raw hex pubkey (the same string as
467/// [`IncomingRequest::client_pubkey`]). It is present on every real inbound
468/// request, unlike `OpenStreamWriter`, which is injected only for open-stream
469/// `tools/call` calls. This is purely a local affordance — it is never
470/// serialized onto the wire. The inbound Nostr event id is available separately
471/// as the rmcp request id (`ctx.id`), which the worker rewrites to the event id.
472#[derive(Debug, Clone)]
473pub struct ClientPubkey(pub String);
474
475/// The inbound (client-signed) Nostr event for the current request.
476///
477/// Injected into a request's rmcp `extensions` typemap by
478/// [`NostrServerWorker`](crate::rmcp_transport::NostrServerWorker) alongside
479/// [`ClientPubkey`], so a tool, resource, or prompt handler can read the full
480/// signed event — its `id`, `pubkey`, `sig`, … — to bind a tool call to the
481/// publishing event, store it for later return, or audit it:
482///
483/// ```ignore
484/// use contextvm_sdk::transport::server::InboundEvent;
485/// use rmcp::service::RequestContext;
486/// use rmcp::RoleServer;
487///
488/// if let Some(ev) = ctx.extensions.get::<InboundEvent>() {
489///     let sig_hex = ev.0.sig.to_string(); // Schnorr signature, hex
490///     // …
491/// }
492/// ```
493///
494/// For gift-wrapped requests this is the **inner**, signature-verified event
495/// (the same one whose `pubkey` is surfaced as [`ClientPubkey`], so `ev.0.pubkey`
496/// and `ClientPubkey` agree by construction). For plaintext requests it is the
497/// outer request event. For CEP-22 oversized requests it is the carrying `end`
498/// frame's event (a `notifications/progress` event signed by the client whose
499/// `id` is the correlation id) — the request was chunked, so no single dedicated
500/// request event exists. It is injected only for real client requests; synthetic
501/// transport-internal requests (announcement / initialization drives) carry no
502/// event, so `extensions.get::<InboundEvent>()` returns `None` for them. This is
503/// purely a local affordance — it is never
504/// serialized onto the wire. The event id is also reachable as the rmcp request
505/// id (`ctx.id`); this type additionally exposes `sig`, which the server cannot
506/// reconstruct without the client's private key.
507#[derive(Debug, Clone)]
508pub struct InboundEvent(pub Event);
509
510impl NostrServerTransport {
511    /// Create a new server transport.
512    pub async fn new<T>(signer: T, config: NostrServerTransportConfig) -> Result<Self>
513    where
514        T: IntoNostrSigner,
515    {
516        let relay_pool: Arc<dyn RelayPoolTrait> =
517            Arc::new(RelayPool::new(signer).await.map_err(|error| {
518                tracing::error!(
519                    target: LOG_TARGET,
520                    error = %error,
521                    "Failed to initialize relay pool for server transport"
522                );
523                error
524            })?);
525        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
526        let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new(
527            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
528        )));
529
530        tracing::info!(
531            target: LOG_TARGET,
532            relay_count = config.relay_urls.len(),
533            announced = config.is_announced_server,
534            encryption_mode = ?config.encryption_mode,
535            gift_wrap_mode = ?config.gift_wrap_mode,
536            "Created server transport"
537        );
538        let mut announcement_manager = announcement_manager::AnnouncementManager::new(
539            Arc::clone(&relay_pool),
540            config.server_info.clone(),
541            config.encryption_mode,
542            config.gift_wrap_mode,
543            tx.clone(),
544            config.relay_urls.clone(),
545            config.relay_list_urls.clone(),
546            config.bootstrap_relay_urls.clone(),
547            config.publish_relay_list,
548            config.profile_metadata.clone(),
549        );
550        // CEP-22 + CEP-41: advertise oversized-transfer and open-stream support in
551        // announcements + first responses (each gated by its own config flag).
552        announcement_manager.set_internal_common_tags(internal_common_capability_tags(&config));
553        Ok(Self {
554            announcement_manager,
555            base: BaseTransport {
556                relay_pool,
557                encryption_mode: config.encryption_mode,
558                is_connected: false,
559            },
560            sessions: SessionStore::with_capacity(config.max_sessions),
561            oversized_receiver: new_oversized_receiver_store(config.max_sessions),
562            open_stream: ServerOpenStreamState::new(&config.open_stream, config.max_sessions),
563            config,
564            event_routes: ServerEventRouteStore::new(),
565            request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())),
566            seen_gift_wrap_ids,
567            message_tx: Some(tx),
568            message_rx: Some(rx),
569            cancellation_token: CancellationToken::new(),
570            task_handles: Vec::new(),
571        })
572    }
573
574    /// Like [`new`](Self::new) but accepts an existing relay pool.
575    pub async fn with_relay_pool(
576        config: NostrServerTransportConfig,
577        relay_pool: Arc<dyn RelayPoolTrait>,
578    ) -> Result<Self> {
579        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
580        let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new(
581            NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"),
582        )));
583
584        tracing::info!(
585            target: LOG_TARGET,
586            relay_count = config.relay_urls.len(),
587            announced = config.is_announced_server,
588            encryption_mode = ?config.encryption_mode,
589            "Created server transport (with_relay_pool)"
590        );
591        let mut announcement_manager = announcement_manager::AnnouncementManager::new(
592            Arc::clone(&relay_pool),
593            config.server_info.clone(),
594            config.encryption_mode,
595            config.gift_wrap_mode,
596            tx.clone(),
597            config.relay_urls.clone(),
598            config.relay_list_urls.clone(),
599            config.bootstrap_relay_urls.clone(),
600            config.publish_relay_list,
601            config.profile_metadata.clone(),
602        );
603        // CEP-22 + CEP-41: advertise oversized-transfer and open-stream support in
604        // announcements + first responses (each gated by its own config flag).
605        announcement_manager.set_internal_common_tags(internal_common_capability_tags(&config));
606        Ok(Self {
607            announcement_manager,
608            base: BaseTransport {
609                relay_pool,
610                encryption_mode: config.encryption_mode,
611                is_connected: false,
612            },
613            sessions: SessionStore::with_capacity(config.max_sessions),
614            oversized_receiver: new_oversized_receiver_store(config.max_sessions),
615            open_stream: ServerOpenStreamState::new(&config.open_stream, config.max_sessions),
616            config,
617            request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())),
618            event_routes: ServerEventRouteStore::new(),
619            seen_gift_wrap_ids,
620            message_tx: Some(tx),
621            message_rx: Some(rx),
622            cancellation_token: CancellationToken::new(),
623            task_handles: Vec::new(),
624        })
625    }
626
627    /// Start listening for incoming requests.
628    pub async fn start(&mut self) -> Result<()> {
629        self.base
630            .connect(&self.config.relay_urls)
631            .await
632            .map_err(|error| {
633                tracing::error!(
634                    target: LOG_TARGET,
635                    error = %error,
636                    "Failed to connect server transport to relays"
637                );
638                error
639            })?;
640
641        let pubkey = self.base.get_public_key().await.map_err(|error| {
642            tracing::error!(
643                target: LOG_TARGET,
644                error = %error,
645                "Failed to fetch server transport public key"
646            );
647            error
648        })?;
649        tracing::info!(
650            target: LOG_TARGET,
651            pubkey = %pubkey.to_hex(),
652            "Server transport started"
653        );
654
655        self.base
656            .subscribe_for_pubkey(&pubkey)
657            .await
658            .map_err(|error| {
659                tracing::error!(
660                    target: LOG_TARGET,
661                    error = %error,
662                    pubkey = %pubkey.to_hex(),
663                    "Failed to subscribe server transport for pubkey"
664                );
665                error
666            })?;
667
668        // Spawn event loop with cancellation support
669        let relay_pool = Arc::clone(&self.base.relay_pool);
670        let sessions = self.sessions.clone();
671        let event_routes = self.event_routes.clone();
672        let request_wrap_kinds = self.request_wrap_kinds.clone();
673        let tx = self
674            .message_tx
675            .as_ref()
676            .expect("message_tx must exist before start()")
677            .clone();
678        let allowed = self.config.allowed_public_keys.clone();
679        let excluded = self.config.excluded_capabilities.clone();
680        let encryption_mode = self.config.encryption_mode;
681        let gift_wrap_mode = self.config.gift_wrap_mode;
682        let is_announced_server = self.config.is_announced_server;
683        let oversized_enabled = self.config.oversized_transfer.enabled;
684        let oversized_receiver = self.oversized_receiver.clone();
685        let transfer_policy: TransferPolicy = (&self.config.oversized_transfer).into();
686        let common_tags_snapshot = self.announcement_manager.common_tags_snapshot();
687        let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone();
688        let open_stream = self.open_stream.clone();
689        let event_loop_token = self.cancellation_token.child_token();
690
691        let event_loop_handle = tokio::spawn(async move {
692            Self::event_loop(
693                relay_pool,
694                sessions,
695                event_routes,
696                request_wrap_kinds,
697                tx,
698                allowed,
699                excluded,
700                encryption_mode,
701                gift_wrap_mode,
702                is_announced_server,
703                oversized_enabled,
704                oversized_receiver,
705                transfer_policy,
706                common_tags_snapshot,
707                seen_gift_wrap_ids,
708                open_stream,
709                event_loop_token,
710            )
711            .await;
712        });
713
714        // Spawn session cleanup with cancellation support
715        let sessions_cleanup = self.sessions.clone();
716        let event_routes_cleanup = self.event_routes.clone();
717        let request_wrap_kinds_cleanup = self.request_wrap_kinds.clone();
718        let cleanup_interval = self.config.cleanup_interval;
719        let session_timeout = self.config.session_timeout;
720        let request_timeout = self.config.request_timeout;
721        let cleanup_token = self.cancellation_token.child_token();
722
723        let cleanup_handle = tokio::spawn(async move {
724            let mut interval = tokio::time::interval(cleanup_interval);
725            loop {
726                tokio::select! {
727                    _ = cleanup_token.cancelled() => {
728                        tracing::info!(
729                            target: LOG_TARGET,
730                            "Server cleanup task cancelled"
731                        );
732                        break;
733                    }
734                    _ = interval.tick() => {
735                        let cleaned = Self::cleanup_sessions(
736                            &sessions_cleanup,
737                            &event_routes_cleanup,
738                            &request_wrap_kinds_cleanup,
739                            session_timeout,
740                        )
741                        .await;
742                        if cleaned > 0 {
743                            tracing::info!(
744                                target: LOG_TARGET,
745                                cleaned_sessions = cleaned,
746                                "Cleaned up inactive sessions"
747                            );
748                        }
749                    }
750                }
751
752                // Sweep stale route entries in active sessions (rmcp handles timeout errors).
753                let swept_event_ids = event_routes_cleanup
754                    .sweep_stale_routes(request_timeout)
755                    .await;
756                if !swept_event_ids.is_empty() {
757                    let mut kinds_w = request_wrap_kinds_cleanup.write().await;
758                    for event_id in &swept_event_ids {
759                        kinds_w.remove(event_id);
760                    }
761                    drop(kinds_w);
762                    tracing::warn!(
763                        target: LOG_TARGET,
764                        swept = swept_event_ids.len(),
765                        timeout_secs = request_timeout.as_secs(),
766                        "Swept stale event routes (rmcp handles timeout errors)"
767                    );
768                }
769            }
770        });
771
772        self.task_handles.push(event_loop_handle);
773        self.task_handles.push(cleanup_handle);
774
775        tracing::info!(
776            target: LOG_TARGET,
777            relay_count = self.config.relay_urls.len(),
778            cleanup_interval_secs = self.config.cleanup_interval.as_secs(),
779            session_timeout_secs = self.config.session_timeout.as_secs(),
780            "Server transport loops spawned"
781        );
782        Ok(())
783    }
784
785    /// Close the transport — cancels event loop and cleanup tasks, then disconnects.
786    pub async fn close(&mut self) -> Result<()> {
787        self.cancellation_token.cancel();
788        for handle in self.task_handles.drain(..) {
789            let _ = handle.await;
790        }
791        self.announcement_manager.shutdown();
792        self.message_tx.take();
793        self.base.disconnect().await?;
794        self.sessions.clear().await;
795        self.event_routes.clear().await;
796        self.oversized_receiver.write().await.clear();
797        // CEP-41: dispose every inbound reader session and drop all writer /
798        // deferred-response state.
799        {
800            let mut receivers = self.open_stream.receiver.lock().await;
801            for (_, receiver) in receivers.iter_mut() {
802                receiver.clear();
803            }
804            receivers.clear();
805        }
806        for (_, slot) in self.open_stream.lock_slots().drain() {
807            slot.writer.dispose();
808        }
809        self.open_stream.lock_token_index().clear();
810        Ok(())
811    }
812
813    /// Send a response back to the client that sent the original request.
814    pub async fn send_response(&self, event_id: &str, mut response: JsonRpcMessage) -> Result<()> {
815        // CEP-41: response deferral. Decide BEFORE consuming the route — for a
816        // started stream the final response rides the captured snapshot, not the
817        // (possibly-swept) event route.
818        if self.open_stream.enabled {
819            match self.try_defer_open_stream_response(event_id, response) {
820                // Stashed (ordering A) — the close/abort hook flushes it later.
821                OpenStreamDeferral::Deferred => return Ok(()),
822                // Stream already closed (ordering B) — deliver from the snapshot now.
823                OpenStreamDeferral::SendNow { snapshot, response } => {
824                    return self
825                        .send_open_stream_deferred_response(event_id, &snapshot, response)
826                        .await;
827                }
828                // No active stream for this event — fall through to the normal path.
829                OpenStreamDeferral::Passthrough(returned) => response = returned,
830            }
831        }
832
833        // Consume the route up-front so only one concurrent responder can proceed
834        // for a given event_id.
835        let route = self.event_routes.pop(event_id).await.ok_or_else(|| {
836            tracing::error!(
837                target: LOG_TARGET,
838                event_id = %event_id,
839                "No client found for response correlation"
840            );
841            Error::Other(format!("No client found for event {event_id}"))
842        })?;
843
844        let client_pubkey_hex = route.client_pubkey;
845        let original_request_id = route.original_request_id;
846        let progress_token = route.progress_token;
847
848        let mut sessions_w = self.sessions.write().await;
849        let session = sessions_w.get_mut(&client_pubkey_hex).ok_or_else(|| {
850            tracing::error!(
851                target: LOG_TARGET,
852                client_pubkey = %client_pubkey_hex,
853                "No session for correlated client"
854            );
855            Error::Other(format!("No session for client {client_pubkey_hex}"))
856        })?;
857
858        // Restore original request ID
859        match &mut response {
860            JsonRpcMessage::Response(r) => r.id = original_request_id.clone(),
861            JsonRpcMessage::ErrorResponse(r) => r.id = original_request_id.clone(),
862            _ => {}
863        }
864
865        // CEP-22: serialize once, *after* id restoration. The threshold check and
866        // the oversized split must both derive from this exact post-restoration
867        // string so the client reassembles bytes whose `id` matches the digest.
868        let serialized = serde_json::to_string(&response)?;
869
870        let is_encrypted = session.is_encrypted;
871        // CEP-22: capture the peer's oversized support while the session lock is held.
872        let supports_oversized_transfer = session.supports_oversized_transfer;
873
874        // CEP-35: include discovery tags on first response to this client
875        let discovery_tags = self.take_pending_server_discovery_tags(session);
876        drop(sessions_w);
877
878        // CEP-19: Look up the incoming wrap kind for mirroring
879        let mirrored_wrap_kind = self
880            .request_wrap_kinds
881            .read()
882            .await
883            .get(event_id)
884            .copied()
885            .flatten();
886
887        let client_pubkey = PublicKey::from_hex(&client_pubkey_hex).map_err(|error| {
888            tracing::error!(
889                target: LOG_TARGET,
890                error = %error,
891                client_pubkey = %client_pubkey_hex,
892                "Invalid client pubkey in session map"
893            );
894            Error::Other(error.to_string())
895        })?;
896
897        let event_id_parsed = EventId::from_hex(event_id).map_err(|error| {
898            tracing::error!(
899                target: LOG_TARGET,
900                error = %error,
901                event_id = %event_id,
902                "Invalid event id while sending response"
903            );
904            Error::Other(error.to_string())
905        })?;
906
907        let base_tags = BaseTransport::create_response_tags(&client_pubkey, &event_id_parsed);
908        let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]);
909        let gift_wrap_kind = Self::select_outbound_gift_wrap_kind(
910            self.config.gift_wrap_mode,
911            is_encrypted,
912            mirrored_wrap_kind,
913        );
914
915        // CEP-22: a response is eligible for oversized fragmentation only when the
916        // feature is enabled, the peer advertised support, and the request carried a
917        // progressToken to address the frames with.
918        let oversized_eligible = self.config.oversized_transfer.enabled
919            && progress_token.is_some()
920            && supports_oversized_transfer;
921        let threshold = self.config.oversized_transfer.threshold;
922
923        // CEP-22: relay size limits apply to the *published* Nostr event, so decide
924        // on the published byte size, not the raw payload (mirrors TS
925        // `measurePublishedMcpMessageSize`). The raw serialized length is a cheap
926        // lower bound: at/above the threshold the response is conclusively oversized
927        // and we fragment without building a single event — an escape-heavy payload
928        // could otherwise overflow NIP-44's plaintext limit while measuring. Below
929        // it, build the single event once, measure it, and reuse it when it fits.
930        let mut reuse_event: Option<Event> = None;
931        let fragment = if !oversized_eligible {
932            false
933        } else if serialized.len() >= threshold {
934            true
935        } else {
936            match self
937                .base
938                .prepare_mcp_message(
939                    &response,
940                    &client_pubkey,
941                    CTXVM_MESSAGES_KIND,
942                    tags.clone(),
943                    Some(is_encrypted),
944                    gift_wrap_kind,
945                )
946                .await
947            {
948                Ok((_id, publishable)) => {
949                    let published_len = serde_json::to_string(&publishable)
950                        .map(|s| s.len())
951                        .unwrap_or(usize::MAX);
952                    if published_len > threshold {
953                        true
954                    } else {
955                        reuse_event = Some(publishable);
956                        false
957                    }
958                }
959                // Could not build one event (e.g. NIP-44 plaintext overflow from an
960                // escape-heavy payload) → it cannot be sent as a single event.
961                Err(error) => {
962                    tracing::debug!(
963                        target: LOG_TARGET,
964                        error = %error,
965                        event_id = %event_id,
966                        "Single-event build failed; sending response as oversized transfer"
967                    );
968                    true
969                }
970            }
971        };
972
973        // Both paths converge on the cleanup tail below — neither early-returns on
974        // success.
975        let send_result: Result<()> = if fragment {
976            self.send_oversized_response(
977                &serialized,
978                progress_token.as_deref().unwrap_or_default(),
979                &client_pubkey,
980                &base_tags,
981                tags,
982                is_encrypted,
983                gift_wrap_kind,
984            )
985            .await
986        } else if let Some(publishable) = reuse_event {
987            // Reuse the event already built for the size check — no re-encryption.
988            self.base
989                .relay_pool
990                .publish_event(&publishable)
991                .await
992                .map(|_| ())
993        } else {
994            self.base
995                .send_mcp_message(
996                    &response,
997                    &client_pubkey,
998                    CTXVM_MESSAGES_KIND,
999                    tags,
1000                    Some(is_encrypted),
1001                    gift_wrap_kind,
1002                )
1003                .await
1004                .map(|_| ())
1005        };
1006
1007        if let Err(error) = send_result {
1008            tracing::error!(
1009                target: LOG_TARGET,
1010                error = %error,
1011                client_pubkey = %client_pubkey_hex,
1012                event_id = %event_id,
1013                "Failed to publish response message"
1014            );
1015
1016            // Re-register route on publish failure so caller can retry.
1017            self.event_routes
1018                .register(
1019                    event_id.to_string(),
1020                    client_pubkey_hex,
1021                    original_request_id,
1022                    progress_token,
1023                )
1024                .await;
1025
1026            return Err(error);
1027        }
1028
1029        // Clean up wrap-kind tracking
1030        self.request_wrap_kinds.write().await.remove(event_id);
1031
1032        let mut sessions = self.sessions.write().await;
1033        if let Some(session) = sessions.get_mut(&client_pubkey_hex) {
1034            // Clean up progress token
1035            if let Some(token) = progress_token {
1036                session.pending_requests.remove(&token);
1037            }
1038            session.event_to_progress_token.remove(event_id);
1039            session.pending_requests.remove(event_id);
1040        }
1041        drop(sessions);
1042
1043        tracing::debug!(
1044            target: LOG_TARGET,
1045            client_pubkey = %client_pubkey_hex,
1046            event_id = %event_id,
1047            encrypted = is_encrypted,
1048            "Sent server response and cleaned correlation state"
1049        );
1050        Ok(())
1051    }
1052
1053    /// CEP-41: clone the active writer for `event_id` so the rmcp worker can inject
1054    /// it into the request's `extensions` typemap before dispatch. Returns
1055    /// `None` when open-stream is disabled or no writer exists for this request.
1056    #[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
1057    pub(crate) fn get_open_stream_writer(&self, event_id: &str) -> Option<OpenStreamWriter> {
1058        if !self.open_stream.enabled {
1059            return None;
1060        }
1061        self.open_stream.writer_for(event_id)
1062    }
1063
1064    /// CEP-41: decide how `send_response` should handle the final response for a
1065    /// (possibly) streaming request. Run under the slots lock so the stash/flush
1066    /// decision is consistent against the close/abort hook's `terminated` flag.
1067    fn try_defer_open_stream_response(
1068        &self,
1069        event_id: &str,
1070        response: JsonRpcMessage,
1071    ) -> OpenStreamDeferral {
1072        let mut slots = self.open_stream.lock_slots();
1073        let Some(slot) = slots.get_mut(event_id) else {
1074            return OpenStreamDeferral::Passthrough(response);
1075        };
1076
1077        if !slot.writer.has_started() {
1078            // The request carried a progressToken but the tool never streamed.
1079            // Drop the writer and send normally (progress-token-conflict guard —
1080            // a deferred-but-never-closed stream would otherwise hang the response).
1081            let token = slot.writer.progress_token().to_string();
1082            slots.remove(event_id);
1083            drop(slots);
1084            self.open_stream.lock_token_index().remove(&token);
1085            return OpenStreamDeferral::Passthrough(response);
1086        }
1087
1088        if slot.terminated {
1089            // Ordering B (the common case): the stream already closed/aborted —
1090            // deliver now from the captured snapshot (the route may be swept).
1091            let snapshot = slot.snapshot.clone();
1092            let token = slot.writer.progress_token().to_string();
1093            slots.remove(event_id);
1094            drop(slots);
1095            self.open_stream.lock_token_index().remove(&token);
1096            OpenStreamDeferral::SendNow { snapshot, response }
1097        } else {
1098            // Ordering A: the stream is still open — hold the response; the
1099            // close/abort hook flushes it from the snapshot when the stream ends.
1100            slot.pending_response = Some(response);
1101            OpenStreamDeferral::Deferred
1102        }
1103    }
1104
1105    /// CEP-41: deliver a deferred final response from a captured [`RouteSnapshot`],
1106    /// never consulting `event_routes` (route-lifetime-independent; the route may already be gone).
1107    async fn send_open_stream_deferred_response(
1108        &self,
1109        event_id: &str,
1110        snapshot: &RouteSnapshot,
1111        response: JsonRpcMessage,
1112    ) -> Result<()> {
1113        Self::publish_open_stream_deferred_response(
1114            &self.base,
1115            self.config.gift_wrap_mode,
1116            event_id,
1117            snapshot,
1118            response,
1119        )
1120        .await
1121    }
1122
1123    /// CEP-41 (static): the actual deferred-response publish, callable from both the
1124    /// `&self` path and the writer's close/abort hook (which has no `self`).
1125    async fn publish_open_stream_deferred_response(
1126        base: &BaseTransport,
1127        gift_wrap_mode: GiftWrapMode,
1128        event_id: &str,
1129        snapshot: &RouteSnapshot,
1130        mut response: JsonRpcMessage,
1131    ) -> Result<()> {
1132        // Restore the original request id (the normal path restores it from the
1133        // popped route; here it comes from the snapshot).
1134        match &mut response {
1135            JsonRpcMessage::Response(r) => r.id = snapshot.original_request_id.clone(),
1136            JsonRpcMessage::ErrorResponse(r) => r.id = snapshot.original_request_id.clone(),
1137            _ => {}
1138        }
1139        let event_id_parsed = EventId::from_hex(event_id).map_err(|error| {
1140            Error::Other(format!("Invalid event id for deferred response: {error}"))
1141        })?;
1142        // Correlate via the `e` tag exactly like a normal response so the client's
1143        // correlation gate accepts it.
1144        let tags = BaseTransport::create_response_tags(&snapshot.client_pubkey, &event_id_parsed);
1145        let gift_wrap_kind = Self::select_outbound_gift_wrap_kind(
1146            gift_wrap_mode,
1147            snapshot.is_encrypted,
1148            snapshot.mirrored_wrap_kind,
1149        );
1150        base.send_mcp_message(
1151            &response,
1152            &snapshot.client_pubkey,
1153            CTXVM_MESSAGES_KIND,
1154            tags,
1155            Some(snapshot.is_encrypted),
1156            gift_wrap_kind,
1157        )
1158        .await
1159        .map(|_| ())
1160    }
1161
1162    /// CEP-41 (static): the writer's close/abort hook. Marks the stream terminal
1163    /// and, when the response already arrived (ordering A), flushes it from the
1164    /// snapshot. Ordering B leaves the terminal slot for `send_response`.
1165    async fn flush_open_stream_response(
1166        state: &ServerOpenStreamState,
1167        base: &BaseTransport,
1168        gift_wrap_mode: GiftWrapMode,
1169        event_id: &str,
1170    ) {
1171        let ready = {
1172            let mut slots = state.lock_slots();
1173            match slots.get_mut(event_id) {
1174                Some(slot) => {
1175                    slot.terminated = true;
1176                    slot.pending_response.take().map(|response| {
1177                        (
1178                            slot.snapshot.clone(),
1179                            slot.writer.progress_token().to_string(),
1180                            response,
1181                        )
1182                    })
1183                }
1184                None => None,
1185            }
1186        };
1187
1188        let Some((snapshot, token, response)) = ready else {
1189            // Ordering B: the response has not arrived yet. Leave the terminal slot
1190            // in place; `send_response` will deliver it from the snapshot.
1191            return;
1192        };
1193
1194        // Ordering A: the response was stashed before the stream closed — remove
1195        // the slot and deliver it now.
1196        state.lock_slots().remove(event_id);
1197        state.lock_token_index().remove(&token);
1198        if let Err(error) = Self::publish_open_stream_deferred_response(
1199            base,
1200            gift_wrap_mode,
1201            event_id,
1202            &snapshot,
1203            response,
1204        )
1205        .await
1206        {
1207            tracing::error!(
1208                target: LOG_TARGET,
1209                error = %error,
1210                event_id = %event_id,
1211                "Failed to flush deferred open-stream response"
1212            );
1213        }
1214    }
1215
1216    /// CEP-22: publish a response as an ordered oversized-transfer frame sequence.
1217    ///
1218    /// Splits the post-restoration `serialized` string into `start → chunks… →
1219    /// end` frames (digest and split both derived from that exact string) and
1220    /// publishes each as a `notifications/progress` event to `recipient`. The
1221    /// server never reserves the `accept` slot or waits for a handshake — it only
1222    /// fragments for peers already known to support the feature. One-shot
1223    /// discovery tags ride the `start` frame only (`start_tags`); every later
1224    /// frame carries bare recipient + `e`-tags (`base_tags`).
1225    #[allow(clippy::too_many_arguments)]
1226    async fn send_oversized_response(
1227        &self,
1228        serialized: &str,
1229        progress_token: &str,
1230        recipient: &PublicKey,
1231        base_tags: &[Tag],
1232        start_tags: Vec<Tag>,
1233        is_encrypted: bool,
1234        gift_wrap_kind: Option<u16>,
1235    ) -> Result<()> {
1236        // CEP-22: derive a per-chunk payload budget so every published frame stays
1237        // under the threshold even after the JSON-RPC envelope, signature, and
1238        // (when encrypted) gift-wrap expansion. Mirrors TS `resolveSafeOversizedChunkSize`.
1239        // Continuation (chunk) frames carry the response `p`+`e` tags (`base_tags`),
1240        // so size against those — not the bare recipient tag — or the budget would
1241        // be ~70 bytes optimistic.
1242        let chunk_size = resolve_safe_chunk_size(
1243            self.config.oversized_transfer.chunk_size,
1244            &self.base,
1245            recipient,
1246            base_tags,
1247            is_encrypted,
1248            Kind::Custom(gift_wrap_kind.unwrap_or(GIFT_WRAP_KIND)),
1249            self.config.oversized_transfer.threshold,
1250        )
1251        .await?;
1252        let options = OversizedSenderOptions::new(progress_token).with_chunk_size(chunk_size);
1253        let frames = build_oversized_frames(serialized, &options)?.into_ordered();
1254
1255        // Discovery tags ride the start frame; `take` yields them once, then the
1256        // remaining frames fall back to bare recipient + `e`-tags.
1257        let mut start_tags = Some(start_tags);
1258        for frame in frames {
1259            let tags = start_tags.take().unwrap_or_else(|| base_tags.to_vec());
1260            let message = JsonRpcMessage::Notification(frame);
1261            self.base
1262                .send_mcp_message(
1263                    &message,
1264                    recipient,
1265                    CTXVM_MESSAGES_KIND,
1266                    tags,
1267                    Some(is_encrypted),
1268                    gift_wrap_kind,
1269                )
1270                .await?;
1271        }
1272        Ok(())
1273    }
1274
1275    /// Send a notification to a specific client.
1276    pub async fn send_notification(
1277        &self,
1278        client_pubkey_hex: &str,
1279        notification: &JsonRpcMessage,
1280        correlated_event_id: Option<&str>,
1281    ) -> Result<()> {
1282        let mut sessions = self.sessions.write().await;
1283        let session = sessions
1284            .get_mut(client_pubkey_hex)
1285            .ok_or_else(|| Error::Other(format!("No session for {client_pubkey_hex}")))?;
1286        let is_encrypted = session.is_encrypted;
1287        let supports_ephemeral = session.supports_ephemeral_gift_wrap;
1288
1289        // CEP-35: include discovery tags on first message to this client
1290        let discovery_tags = self.take_pending_server_discovery_tags(session);
1291        drop(sessions);
1292
1293        let client_pubkey =
1294            PublicKey::from_hex(client_pubkey_hex).map_err(|e| Error::Other(e.to_string()))?;
1295
1296        let mut base_tags = BaseTransport::create_recipient_tags(&client_pubkey);
1297        if let Some(eid) = correlated_event_id {
1298            let event_id = EventId::from_hex(eid).map_err(|e| Error::Other(e.to_string()))?;
1299            base_tags.push(Tag::event(event_id));
1300        }
1301
1302        let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]);
1303
1304        // CEP-19: Look up mirrored wrap kind from correlated request
1305        let correlated_wrap_kind = if let Some(event_id) = correlated_event_id {
1306            self.request_wrap_kinds
1307                .read()
1308                .await
1309                .get(event_id)
1310                .copied()
1311                .flatten()
1312        } else {
1313            None
1314        };
1315
1316        self.base
1317            .send_mcp_message(
1318                notification,
1319                &client_pubkey,
1320                CTXVM_MESSAGES_KIND,
1321                tags,
1322                Some(is_encrypted),
1323                Self::select_outbound_notification_gift_wrap_kind(
1324                    self.config.gift_wrap_mode,
1325                    is_encrypted,
1326                    correlated_wrap_kind,
1327                    supports_ephemeral,
1328                ),
1329            )
1330            .await?;
1331
1332        Ok(())
1333    }
1334
1335    /// Broadcast a notification to all initialized clients.
1336    pub async fn broadcast_notification(&self, notification: &JsonRpcMessage) -> Result<()> {
1337        let sessions = self.sessions.read().await;
1338        let initialized: Vec<String> = sessions
1339            .iter()
1340            .filter(|(_, s)| s.is_initialized)
1341            .map(|(k, _)| k.clone())
1342            .collect();
1343        drop(sessions);
1344
1345        for pubkey in initialized {
1346            if let Err(error) = self.send_notification(&pubkey, notification, None).await {
1347                tracing::error!(
1348                    target: LOG_TARGET,
1349                    error = %error,
1350                    client_pubkey = %pubkey,
1351                    "Failed to send notification"
1352                );
1353            }
1354        }
1355        Ok(())
1356    }
1357
1358    /// Take the message receiver for consuming incoming requests.
1359    pub fn take_message_receiver(
1360        &mut self,
1361    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<IncomingRequest>> {
1362        self.message_rx.take()
1363    }
1364
1365    /// Read-only snapshot of a client's learned session state, or `None` if no
1366    /// session exists for that public key (hex). Exposes learned peer
1367    /// capabilities (encryption, ephemeral encryption, CEP-22 oversized transfer).
1368    pub async fn session_snapshot(&self, client_pubkey: &str) -> Option<SessionSnapshot> {
1369        self.sessions.get_session(client_pubkey).await
1370    }
1371
1372    /// Sets extra discovery tags to include in announcements and first-response discovery replay.
1373    pub fn set_announcement_extra_tags(&mut self, tags: Vec<Tag>) {
1374        self.announcement_manager.set_extra_common_tags(tags);
1375    }
1376
1377    /// Sets pricing tags to include in announcement/list events and capability list responses.
1378    pub fn set_announcement_pricing_tags(&mut self, tags: Vec<Tag>) {
1379        self.announcement_manager.set_pricing_tags(tags);
1380    }
1381
1382    /// Publish server announcement (kind 11316).
1383    pub async fn announce(&self) -> Result<EventId> {
1384        self.announcement_manager.announce().await
1385    }
1386
1387    /// Publish tools list (kind 11317).
1388    pub async fn publish_tools(&self, tools: Vec<serde_json::Value>) -> Result<EventId> {
1389        self.announcement_manager.publish_tools(tools).await
1390    }
1391
1392    /// Publish resources list (kind 11318).
1393    pub async fn publish_resources(&self, resources: Vec<serde_json::Value>) -> Result<EventId> {
1394        self.announcement_manager.publish_resources(resources).await
1395    }
1396
1397    /// Publish prompts list (kind 11320).
1398    pub async fn publish_prompts(&self, prompts: Vec<serde_json::Value>) -> Result<EventId> {
1399        self.announcement_manager.publish_prompts(prompts).await
1400    }
1401
1402    /// Publish resource templates list (kind 11319).
1403    pub async fn publish_resource_templates(
1404        &self,
1405        templates: Vec<serde_json::Value>,
1406    ) -> Result<EventId> {
1407        self.announcement_manager
1408            .publish_resource_templates(templates)
1409            .await
1410    }
1411
1412    /// Delete server announcements (NIP-09 kind 5).
1413    pub async fn delete_announcements(&self, reason: &str) -> Result<()> {
1414        self.announcement_manager.delete_announcements(reason).await
1415    }
1416
1417    /// Spawn the CEP-6 auto-publish task if `is_announced_server` is set.
1418    ///
1419    /// Called by the rmcp worker after `start()` — not in `start()` itself —
1420    /// because the auto-publish flow injects synthetic MCP requests that
1421    /// require an rmcp handler to produce responses.
1422    #[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
1423    pub(crate) fn spawn_announcements(&mut self) {
1424        if self.config.is_announced_server {
1425            let handle = self
1426                .announcement_manager
1427                .spawn_publish_public_announcements(self.cancellation_token.child_token());
1428            self.task_handles.push(handle);
1429        }
1430        self.spawn_discoverability_publication();
1431    }
1432
1433    /// Spawn profile metadata and relay-list publication for direct transport users.
1434    ///
1435    /// This publishes kind 0 and kind 10002 discoverability events when configured.
1436    /// It intentionally does not spawn CEP-6 capability announcement tasks because
1437    /// those inject synthetic MCP requests that require an rmcp worker.
1438    pub fn spawn_discoverability_publication(&mut self) {
1439        let handle = self.announcement_manager.spawn_publish_discoverability();
1440        self.task_handles.push(handle);
1441    }
1442
1443    /// Forward an announcement response to the announcement manager for publishing.
1444    ///
1445    /// Called by the worker when a response with the announcement sentinel ID arrives.
1446    #[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
1447    pub(crate) async fn handle_announcement_response(
1448        &self,
1449        response: JsonRpcMessage,
1450    ) -> Result<()> {
1451        self.announcement_manager
1452            .handle_announcement_response(response)
1453            .await
1454    }
1455
1456    /// Publish tools list from rmcp typed tool descriptors.
1457    #[cfg(feature = "rmcp")]
1458    pub async fn publish_tools_typed(&self, tools: Vec<rmcp::model::Tool>) -> Result<EventId> {
1459        self.announcement_manager.publish_tools_typed(tools).await
1460    }
1461
1462    /// Publish resources list from rmcp typed resource descriptors.
1463    #[cfg(feature = "rmcp")]
1464    pub async fn publish_resources_typed(
1465        &self,
1466        resources: Vec<rmcp::model::Resource>,
1467    ) -> Result<EventId> {
1468        self.announcement_manager
1469            .publish_resources_typed(resources)
1470            .await
1471    }
1472
1473    /// Publish prompts list from rmcp typed prompt descriptors.
1474    #[cfg(feature = "rmcp")]
1475    pub async fn publish_prompts_typed(
1476        &self,
1477        prompts: Vec<rmcp::model::Prompt>,
1478    ) -> Result<EventId> {
1479        self.announcement_manager
1480            .publish_prompts_typed(prompts)
1481            .await
1482    }
1483
1484    /// Publish resource templates list from rmcp typed template descriptors.
1485    #[cfg(feature = "rmcp")]
1486    pub async fn publish_resource_templates_typed(
1487        &self,
1488        templates: Vec<rmcp::model::ResourceTemplate>,
1489    ) -> Result<EventId> {
1490        self.announcement_manager
1491            .publish_resource_templates_typed(templates)
1492            .await
1493    }
1494
1495    // ── CEP-35 discovery tag helpers ──────────────────────────────
1496
1497    /// One-shot: returns common tags if not yet sent to this client, empty otherwise.
1498    fn take_pending_server_discovery_tags(&self, session: &mut ClientSession) -> Vec<Tag> {
1499        if session.has_sent_common_tags {
1500            return vec![];
1501        }
1502        session.has_sent_common_tags = true;
1503        self.announcement_manager.get_common_tags()
1504    }
1505
1506    // ── Internal ────────────────────────────────────────────────
1507
1508    fn is_capability_excluded(
1509        excluded: &[CapabilityExclusion],
1510        method: &str,
1511        name: Option<&str>,
1512    ) -> bool {
1513        // Always allow fundamental MCP methods
1514        if method == "initialize" || method == "notifications/initialized" {
1515            return true;
1516        }
1517
1518        excluded.iter().any(|excl| {
1519            if excl.method != method {
1520                return false;
1521            }
1522            match (&excl.name, name) {
1523                (Some(excl_name), Some(req_name)) => excl_name == req_name,
1524                (None, _) => true, // method-only match
1525                _ => false,
1526            }
1527        })
1528    }
1529
1530    /// CEP-22: one watchdog sweep over the per-peer reassembly engines. Reaps
1531    /// transfers past their hard deadline — local-only (no abort frame is
1532    /// emitted): the requester's own timeout fails the call, and late frames
1533    /// are orphan-ignored — then drops now-empty receivers so long-gone peers
1534    /// stop pinning LRU slots (admission recreates them on demand).
1535    async fn sweep_oversized_receivers(
1536        oversized_receiver: &Arc<RwLock<LruCache<String, OversizedTransferReceiver>>>,
1537    ) {
1538        let mut receivers = oversized_receiver.write().await;
1539        let mut empty_peers: Vec<String> = Vec::new();
1540        for (peer, receiver) in receivers.iter_mut() {
1541            for token in receiver.remove_expired() {
1542                tracing::warn!(
1543                    target: LOG_TARGET,
1544                    client_pubkey = %peer,
1545                    token = %token,
1546                    "Oversized transfer reaped by watchdog"
1547                );
1548            }
1549            if receiver.active_transfer_count() == 0 {
1550                empty_peers.push(peer.clone());
1551            }
1552        }
1553        // Keys collected first, popped after: never mutate mid-iteration.
1554        for peer in empty_peers {
1555            receivers.pop(&peer);
1556        }
1557    }
1558
1559    #[allow(clippy::too_many_arguments)]
1560    async fn event_loop(
1561        relay_pool: Arc<dyn RelayPoolTrait>,
1562        sessions: SessionStore,
1563        event_routes: ServerEventRouteStore,
1564        request_wrap_kinds: Arc<RwLock<HashMap<String, Option<u16>>>>,
1565        tx: tokio::sync::mpsc::UnboundedSender<IncomingRequest>,
1566        allowed_pubkeys: Vec<String>,
1567        excluded_capabilities: Vec<CapabilityExclusion>,
1568        encryption_mode: EncryptionMode,
1569        gift_wrap_mode: GiftWrapMode,
1570        is_announced_server: bool,
1571        oversized_enabled: bool,
1572        oversized_receiver: Arc<RwLock<LruCache<String, OversizedTransferReceiver>>>,
1573        transfer_policy: TransferPolicy,
1574        common_tags_snapshot: announcement_manager::CommonTagsSnapshot,
1575        seen_gift_wrap_ids: Arc<Mutex<LruCache<EventId, ()>>>,
1576        open_stream: ServerOpenStreamState,
1577        cancel: CancellationToken,
1578    ) {
1579        let mut notifications = relay_pool.notifications();
1580
1581        // CEP-22: receiver-side watchdog sweep. Same clamp formula as the
1582        // client's correlation sweep; the arm is disabled entirely when the
1583        // feature is off or the deadline is 0 (no watchdog).
1584        let watchdog_enabled = oversized_enabled && transfer_policy.transfer_timeout_ms != 0;
1585        let sweep_interval = (Duration::from_millis(transfer_policy.transfer_timeout_ms) / 2)
1586            .clamp(Duration::from_secs(1), Duration::from_secs(30));
1587        let mut sweep_timer =
1588            tokio::time::interval_at(tokio::time::Instant::now() + sweep_interval, sweep_interval);
1589
1590        // CEP-41: keepalive sweep for server-as-reader sessions. Cadence = half the
1591        // idle timeout, clamped to [1s, 30s] (the idle→probe→abort machine only
1592        // needs sub-idle granularity). Armed only when open-stream is enabled.
1593        let open_stream_sweep_enabled =
1594            open_stream.enabled && open_stream.policy.idle_timeout_ms != 0;
1595        let open_stream_sweep_interval =
1596            (Duration::from_millis(open_stream.policy.idle_timeout_ms) / 2)
1597                .clamp(Duration::from_secs(1), Duration::from_secs(30));
1598        let mut open_stream_sweep_timer = tokio::time::interval_at(
1599            tokio::time::Instant::now() + open_stream_sweep_interval,
1600            open_stream_sweep_interval,
1601        );
1602
1603        loop {
1604            let notification = tokio::select! {
1605                _ = cancel.cancelled() => {
1606                    tracing::info!(
1607                        target: LOG_TARGET,
1608                        "Server event loop cancelled"
1609                    );
1610                    break;
1611                }
1612                _ = sweep_timer.tick(), if watchdog_enabled => {
1613                    Self::sweep_oversized_receivers(&oversized_receiver).await;
1614                    continue;
1615                }
1616                _ = open_stream_sweep_timer.tick(), if open_stream_sweep_enabled => {
1617                    Self::sweep_open_stream_sessions(
1618                        &open_stream,
1619                        &relay_pool,
1620                        encryption_mode,
1621                        gift_wrap_mode,
1622                        &sessions,
1623                    )
1624                    .await;
1625                    continue;
1626                }
1627                result = notifications.recv() => {
1628                    match result {
1629                        Ok(n) => n,
1630                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1631                            tracing::warn!(
1632                                target: LOG_TARGET,
1633                                skipped = n,
1634                                "Relay broadcast lagged, skipping missed events"
1635                            );
1636                            continue;
1637                        }
1638                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1639                    }
1640                }
1641            };
1642            if let RelayPoolNotification::Event { event, .. } = notification {
1643                let is_gift_wrap = event.kind == Kind::Custom(GIFT_WRAP_KIND)
1644                    || event.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND);
1645                let outer_kind: u16 = event.kind.as_u16();
1646
1647                // CEP-19: Drop gift-wraps that violate the configured gift-wrap mode
1648                if is_gift_wrap && !gift_wrap_mode.allows_kind(outer_kind) {
1649                    tracing::warn!(
1650                        target: LOG_TARGET,
1651                        event_id = %event.id.to_hex(),
1652                        event_kind = outer_kind,
1653                        configured_mode = ?gift_wrap_mode,
1654                        "Dropping gift-wrap because it violates gift_wrap_mode policy"
1655                    );
1656                    continue;
1657                }
1658
1659                let (content, sender_pubkey, event_id, is_encrypted, inner_tags, inbound_event) =
1660                    if is_gift_wrap {
1661                        if encryption_mode == EncryptionMode::Disabled {
1662                            tracing::warn!(
1663                                target: LOG_TARGET,
1664                                event_id = %event.id.to_hex(),
1665                                sender_pubkey = %event.pubkey.to_hex(),
1666                                "Received encrypted message but encryption is disabled"
1667                            );
1668                            continue;
1669                        }
1670                        {
1671                            let guard = match seen_gift_wrap_ids.lock() {
1672                                Ok(g) => g,
1673                                Err(poisoned) => poisoned.into_inner(),
1674                            };
1675                            if guard.contains(&event.id) {
1676                                tracing::debug!(
1677                                    target: LOG_TARGET,
1678                                    event_id = %event.id.to_hex(),
1679                                    "Skipping duplicate gift-wrap (outer id)"
1680                                );
1681                                continue;
1682                            }
1683                        }
1684                        // Single-layer NIP-44 decrypt (matches JS/TS SDK)
1685                        let signer = match relay_pool.signer().await {
1686                            Ok(s) => s,
1687                            Err(error) => {
1688                                tracing::error!(
1689                                    target: LOG_TARGET,
1690                                    error = %error,
1691                                    "Failed to get signer"
1692                                );
1693                                continue;
1694                            }
1695                        };
1696                        match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await {
1697                            Ok(decrypted_json) => {
1698                                // The decrypted content is JSON of the inner signed event.
1699                                // Use the INNER event's ID for correlation — the client
1700                                // registers the inner event ID in its correlation store.
1701                                match serde_json::from_str::<Event>(&decrypted_json) {
1702                                    Ok(inner) => {
1703                                        if let Err(e) = inner.verify() {
1704                                            tracing::warn!(
1705                                                "Inner event signature verification failed: {e}"
1706                                            );
1707                                            continue;
1708                                        }
1709                                        {
1710                                            let mut guard = match seen_gift_wrap_ids.lock() {
1711                                                Ok(g) => g,
1712                                                Err(poisoned) => poisoned.into_inner(),
1713                                            };
1714                                            guard.put(event.id, ());
1715                                        }
1716                                        let inbound = inner.clone();
1717                                        let inner_tags: Vec<Tag> = inner.tags.to_vec();
1718                                        (
1719                                            inner.content,
1720                                            inner.pubkey.to_hex(),
1721                                            inner.id.to_hex(),
1722                                            true,
1723                                            inner_tags,
1724                                            Some(inbound),
1725                                        )
1726                                    }
1727                                    Err(error) => {
1728                                        tracing::error!(
1729                                            target: LOG_TARGET,
1730                                            error = %error,
1731                                            "Failed to parse inner event"
1732                                        );
1733                                        continue;
1734                                    }
1735                                }
1736                            }
1737                            Err(error) => {
1738                                tracing::error!(
1739                                    target: LOG_TARGET,
1740                                    error = %error,
1741                                    "Failed to decrypt"
1742                                );
1743                                continue;
1744                            }
1745                        }
1746                    } else {
1747                        if encryption_mode == EncryptionMode::Required {
1748                            tracing::warn!(
1749                                target: LOG_TARGET,
1750                                sender_pubkey = %event.pubkey.to_hex(),
1751                                "Received unencrypted message but encryption is required"
1752                            );
1753                            continue;
1754                        }
1755                        // Verify the signature (and that `id` matches content) before
1756                        // trusting `pubkey`/`id` for handler identity + correlation.
1757                        // Redundant against the default RelayPool (it verifies inbound
1758                        // signatures), but keeps caller identity independent of the
1759                        // pool impl — a custom RelayPoolTrait may skip verification.
1760                        if let Err(e) = event.verify() {
1761                            tracing::warn!(
1762                                target: LOG_TARGET,
1763                                "Plaintext event signature verification failed: {e}"
1764                            );
1765                            continue;
1766                        }
1767                        let inbound = (*event).clone();
1768                        (
1769                            event.content.clone(),
1770                            event.pubkey.to_hex(),
1771                            event.id.to_hex(),
1772                            false,
1773                            event.tags.to_vec(),
1774                            Some(inbound),
1775                        )
1776                    };
1777
1778                // Parse MCP message
1779                let mcp_msg = match validation::validate_and_parse(&content) {
1780                    Some(msg) => msg,
1781                    None => {
1782                        tracing::warn!(
1783                            target: LOG_TARGET,
1784                            sender_pubkey = %sender_pubkey,
1785                            "Invalid MCP message"
1786                        );
1787                        continue;
1788                    }
1789                };
1790
1791                // Authorization check
1792                if !allowed_pubkeys.is_empty() {
1793                    let method = mcp_msg.method().unwrap_or("");
1794                    let name = match &mcp_msg {
1795                        JsonRpcMessage::Request(r) => r
1796                            .params
1797                            .as_ref()
1798                            .and_then(|p| p.get("name"))
1799                            .and_then(|n| n.as_str()),
1800                        _ => None,
1801                    };
1802
1803                    let is_excluded =
1804                        Self::is_capability_excluded(&excluded_capabilities, method, name);
1805
1806                    if !allowed_pubkeys.contains(&sender_pubkey) && !is_excluded {
1807                        tracing::warn!(
1808                            target: LOG_TARGET,
1809                            sender_pubkey = %sender_pubkey,
1810                            method = method,
1811                            "Unauthorized request"
1812                        );
1813
1814                        // Send a JSON-RPC error back for Request messages so the
1815                        // client doesn't hang indefinitely (announced servers only).
1816                        if is_announced_server {
1817                            if let JsonRpcMessage::Request(ref req) = mcp_msg {
1818                                if let Ok(client_pk) = PublicKey::from_hex(&sender_pubkey) {
1819                                    let event_id_parsed = EventId::from_hex(&event_id)
1820                                        .unwrap_or(EventId::all_zeros());
1821                                    let mut tags = BaseTransport::create_response_tags(
1822                                        &client_pk,
1823                                        &event_id_parsed,
1824                                    );
1825
1826                                    // CEP-19: Inject common discovery tags on first response
1827                                    let has_sent = sessions
1828                                        .get_session(&sender_pubkey)
1829                                        .await
1830                                        .is_some_and(|s| s.has_sent_common_tags);
1831                                    if !has_sent {
1832                                        common_tags_snapshot.append_common_response_tags(&mut tags);
1833                                        sessions.mark_common_tags_sent(&sender_pubkey).await;
1834                                    }
1835
1836                                    let error_response =
1837                                        JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse {
1838                                            jsonrpc: "2.0".to_string(),
1839                                            id: req.id.clone(),
1840                                            error: JsonRpcError {
1841                                                code: -32000,
1842                                                message: "Unauthorized".to_string(),
1843                                                data: None,
1844                                            },
1845                                        });
1846
1847                                    let base = BaseTransport {
1848                                        relay_pool: Arc::clone(&relay_pool),
1849                                        encryption_mode,
1850                                        is_connected: true,
1851                                    };
1852                                    if let Err(e) = base
1853                                        .send_mcp_message(
1854                                            &error_response,
1855                                            &client_pk,
1856                                            CTXVM_MESSAGES_KIND,
1857                                            tags,
1858                                            Some(is_encrypted),
1859                                            Self::select_outbound_gift_wrap_kind(
1860                                                gift_wrap_mode,
1861                                                is_encrypted,
1862                                                if is_gift_wrap { Some(outer_kind) } else { None },
1863                                            ),
1864                                        )
1865                                        .await
1866                                    {
1867                                        tracing::error!(
1868                                            target: LOG_TARGET,
1869                                            error = %e,
1870                                            sender_pubkey = %sender_pubkey,
1871                                            "Failed to send unauthorized error response"
1872                                        );
1873                                    }
1874                                }
1875                            }
1876                        } // if is_announced_server
1877
1878                        continue;
1879                    }
1880                }
1881
1882                // Session management
1883                let on_evicted_cb = sessions.eviction_callback();
1884                let mut sessions_w = sessions.write().await;
1885                if !sessions_w.contains(&sender_pubkey) {
1886                    let evicted =
1887                        sessions_w.push(sender_pubkey.clone(), ClientSession::new(is_encrypted));
1888                    SessionStore::handle_eviction(
1889                        &sender_pubkey,
1890                        evicted,
1891                        &mut sessions_w,
1892                        on_evicted_cb.as_ref(),
1893                        &event_routes,
1894                    )
1895                    .await;
1896                }
1897                let session = sessions_w.get_mut(&sender_pubkey).unwrap();
1898                session.update_activity();
1899                session.is_encrypted = is_encrypted;
1900
1901                // CEP-19: Mark ephemeral support if client used kind 21059
1902                if is_gift_wrap && outer_kind == EPHEMERAL_GIFT_WRAP_KIND {
1903                    session.supports_ephemeral_gift_wrap = true;
1904                }
1905
1906                // CEP-35: learn client capabilities from inner event tags
1907                let discovered = learn_peer_capabilities(&inner_tags);
1908                session.supports_encryption |= discovered.supports_encryption;
1909                session.supports_ephemeral_encryption |= discovered.supports_ephemeral_encryption;
1910                // CEP-22: snapshot the flag BEFORE the learning gate mutates
1911                // it — the very `start` frame carries the client's support tag, so
1912                // without this snapshot the first transfer would never get an `accept`.
1913                let client_already_supported = session.supports_oversized_transfer;
1914                // CEP-22: only learn oversized support if it is enabled on this server.
1915                session.supports_oversized_transfer |=
1916                    oversized_enabled && discovered.supports_oversized_transfer;
1917                // CEP-41: learn the client's open-stream support (gated on enabled).
1918                // Captured AFTER the OR-learn so the very `start` frame that carries
1919                // the support tag still elicits an `accept`.
1920                session.supports_open_stream |=
1921                    open_stream.enabled && discovered.supports_open_stream;
1922                let client_supports_open_stream = session.supports_open_stream;
1923
1924                // CEP-22: intercept oversized-transfer frames before request
1925                // correlation/dispatch. A disabled server forwards raw progress
1926                // notifications as before.
1927                if oversized_enabled {
1928                    if let JsonRpcMessage::Notification(ref n) = mcp_msg {
1929                        if OversizedTransferReceiver::is_oversized_frame(n) {
1930                            drop(sessions_w);
1931                            Self::handle_oversized_frame(
1932                                n,
1933                                &sender_pubkey,
1934                                &event_id,
1935                                is_encrypted,
1936                                is_gift_wrap,
1937                                outer_kind,
1938                                client_already_supported,
1939                                &oversized_receiver,
1940                                transfer_policy,
1941                                &relay_pool,
1942                                encryption_mode,
1943                                gift_wrap_mode,
1944                                &event_routes,
1945                                &request_wrap_kinds,
1946                                &tx,
1947                                &open_stream,
1948                                inbound_event,
1949                            )
1950                            .await;
1951                            continue;
1952                        }
1953                    }
1954                }
1955
1956                // CEP-41: intercept open-stream frames beside the oversized branch.
1957                // Type-disjoint from oversized (`is_open_stream_frame` vs
1958                // `is_oversized_frame` claim distinct `cvm.type`s), so order is
1959                // irrelevant. A disabled server forwards the raw notification.
1960                if open_stream.enabled {
1961                    if let JsonRpcMessage::Notification(ref n) = mcp_msg {
1962                        if OpenStreamReceiver::is_open_stream_frame(n) {
1963                            drop(sessions_w);
1964                            Self::handle_open_stream_frame(
1965                                &open_stream,
1966                                &relay_pool,
1967                                encryption_mode,
1968                                gift_wrap_mode,
1969                                n,
1970                                &sender_pubkey,
1971                                &event_id,
1972                                is_encrypted,
1973                                is_gift_wrap,
1974                                outer_kind,
1975                                client_supports_open_stream,
1976                            )
1977                            .await;
1978                            continue;
1979                        }
1980                    }
1981                }
1982
1983                // Track request for correlation
1984                if let JsonRpcMessage::Request(ref req) = mcp_msg {
1985                    let original_id = req.id.clone();
1986
1987                    // Extract progress token from _meta if present. String or
1988                    // number (rmcp issues numbers): without numeric acceptance
1989                    // the response eligibility gate in `send_response` never
1990                    // opens for rmcp clients. Normalized to its stringified form
1991                    // for routing and frame addressing (the wire keeps emitting
1992                    // string tokens).
1993                    let progress_token = req
1994                        .params
1995                        .as_ref()
1996                        .and_then(|p| p.get("_meta"))
1997                        .and_then(|m| m.get("progressToken"))
1998                        .and_then(progress_token_string);
1999
2000                    // Duplicate into session fields (kept for backward compat).
2001                    session
2002                        .pending_requests
2003                        .insert(event_id.clone(), original_id.clone());
2004                    if let Some(ref token) = progress_token {
2005                        session
2006                            .pending_requests
2007                            .insert(token.clone(), serde_json::json!(event_id));
2008                        session
2009                            .event_to_progress_token
2010                            .insert(event_id.clone(), token.clone());
2011                    }
2012
2013                    drop(sessions_w);
2014
2015                    // CEP-19: Record the incoming wrap kind for response mirroring
2016                    {
2017                        let mut kinds_w = request_wrap_kinds.write().await;
2018                        kinds_w.insert(
2019                            event_id.clone(),
2020                            if is_gift_wrap { Some(outer_kind) } else { None },
2021                        );
2022                    }
2023
2024                    // CEP-41: capture the route fields for the writer's snapshot
2025                    // BEFORE they are moved into the route store.
2026                    let writer_request_id = original_id.clone();
2027                    let writer_token = progress_token.clone();
2028
2029                    event_routes
2030                        .register(
2031                            event_id.clone(),
2032                            sender_pubkey.clone(),
2033                            original_id,
2034                            progress_token,
2035                        )
2036                        .await;
2037
2038                    // CEP-41: a `tools/call` carrying a progressToken gets a
2039                    // server→client writer, captured with a route snapshot (so the
2040                    // deferred response survives a route sweep) and injected into
2041                    // the tool via the rmcp request extensions.
2042                    if open_stream.enabled && req.method == "tools/call" {
2043                        if let Some(token) = writer_token {
2044                            Self::create_open_stream_writer(
2045                                &open_stream,
2046                                &relay_pool,
2047                                encryption_mode,
2048                                gift_wrap_mode,
2049                                &event_id,
2050                                &sender_pubkey,
2051                                &token,
2052                                writer_request_id,
2053                                is_encrypted,
2054                                if is_gift_wrap { Some(outer_kind) } else { None },
2055                            );
2056                        }
2057                    }
2058                } else {
2059                    drop(sessions_w);
2060                }
2061
2062                // Handle initialized notification (re-acquire for write)
2063                if let JsonRpcMessage::Notification(ref n) = mcp_msg {
2064                    if n.method == "notifications/initialized" {
2065                        let mut sessions_w2 = sessions.write().await;
2066                        if let Some(session) = sessions_w2.get_mut(&sender_pubkey) {
2067                            session.is_initialized = true;
2068                        }
2069                    }
2070                }
2071
2072                // Forward to consumer
2073                let _ = tx.send(IncomingRequest {
2074                    message: mcp_msg,
2075                    client_pubkey: sender_pubkey,
2076                    event_id,
2077                    is_encrypted,
2078                    event: inbound_event,
2079                });
2080            }
2081        }
2082    }
2083
2084    /// CEP-22 server inbound: process one oversized-transfer frame.
2085    ///
2086    /// Emits an `accept` on the opening frame when the client's support is not yet
2087    /// known, feeds the frame to this peer's reassembler, and — on the
2088    /// `end` frame — registers a response route and dispatches the reassembled
2089    /// request as a synthetic [`IncomingRequest`] (keyed by the end frame's real
2090    /// carrying event id, collision-free against the reserved sentinels).
2091    #[allow(clippy::too_many_arguments)]
2092    async fn handle_oversized_frame(
2093        frame: &JsonRpcNotification,
2094        sender_pubkey: &str,
2095        event_id: &str,
2096        is_encrypted: bool,
2097        is_gift_wrap: bool,
2098        outer_kind: u16,
2099        client_already_supported: bool,
2100        oversized_receiver: &Arc<RwLock<LruCache<String, OversizedTransferReceiver>>>,
2101        transfer_policy: TransferPolicy,
2102        relay_pool: &Arc<dyn RelayPoolTrait>,
2103        encryption_mode: EncryptionMode,
2104        gift_wrap_mode: GiftWrapMode,
2105        event_routes: &ServerEventRouteStore,
2106        request_wrap_kinds: &Arc<RwLock<HashMap<String, Option<u16>>>>,
2107        tx: &tokio::sync::mpsc::UnboundedSender<IncomingRequest>,
2108        open_stream: &ServerOpenStreamState,
2109        inbound_event: Option<Event>,
2110    ) {
2111        // The outer progressToken keys the transfer (needed for accept + route).
2112        // String or number — defensive only: every known sender stringifies
2113        // tokens into frames.
2114        let token = frame
2115            .params
2116            .as_ref()
2117            .and_then(|p| p.get("progressToken"))
2118            .and_then(progress_token_string);
2119
2120        // 1. Emit `accept` on the opening frame if support is not yet known.
2121        let is_start = frame
2122            .params
2123            .as_ref()
2124            .and_then(|p| p.get("cvm"))
2125            .and_then(OversizedFrame::from_cvm_value)
2126            .is_some_and(|f| matches!(f, OversizedFrame::Start { .. }));
2127        let issued_accept = is_start && !client_already_supported && token.is_some();
2128        if issued_accept {
2129            if let Some(ref token) = token {
2130                Self::emit_accept_frame(
2131                    token,
2132                    sender_pubkey,
2133                    event_id,
2134                    is_encrypted,
2135                    is_gift_wrap,
2136                    outer_kind,
2137                    relay_pool,
2138                    encryption_mode,
2139                    gift_wrap_mode,
2140                )
2141                .await;
2142            }
2143        }
2144
2145        // 2. Feed the frame to this peer's reassembler (process_frame is sync; the
2146        // write guard is held only across the sync call, never an await). When we
2147        // issued an `accept`, the sender reserved progress slot 2 and its chunks
2148        // begin at slot 3 — but we never *receive* an `accept`, so feed a synthetic
2149        // one into our own receiver to align chunk-slot tracking with the handshake
2150        // layout (otherwise the frontier sticks at slot 2 and chunks pile up as
2151        // out-of-order until the gap exceeds the window).
2152        let outcome = {
2153            let mut store = oversized_receiver.write().await;
2154            if !store.contains(sender_pubkey) {
2155                store.put(
2156                    sender_pubkey.to_string(),
2157                    OversizedTransferReceiver::with_policy(transfer_policy),
2158                );
2159            }
2160            let receiver = store.get_mut(sender_pubkey).unwrap();
2161            let outcome = receiver.process_frame(frame);
2162            if issued_accept && matches!(outcome, Ok(None)) {
2163                if let Some(ref token) = token {
2164                    if let Ok(accept) = OversizedFrame::Accept.into_progress_notification(
2165                        token,
2166                        ACCEPT_PROGRESS,
2167                        None,
2168                    ) {
2169                        let _ = receiver.process_frame(&accept);
2170                    }
2171                }
2172            }
2173            outcome
2174        };
2175
2176        match outcome {
2177            // start/accept/chunk consumed — nothing to dispatch yet.
2178            Ok(None) => {}
2179            // The `end` frame: reassembled request ready to dispatch.
2180            Ok(Some(message)) => {
2181                let original_id = message.id().cloned().unwrap_or(serde_json::Value::Null);
2182                // CEP-41: extract the writer info from the reassembled `tools/call`
2183                // before `message` is moved. The oversized reassembly path bypasses
2184                // the regular request path, so the writer must be created HERE too
2185                // (mirrors TS `handleIncomingRequest`, which oversized re-enters).
2186                let writer_token = match &message {
2187                    JsonRpcMessage::Request(req) if req.method == "tools/call" => req
2188                        .params
2189                        .as_ref()
2190                        .and_then(|p| p.get("_meta"))
2191                        .and_then(|m| m.get("progressToken"))
2192                        .and_then(progress_token_string),
2193                    _ => None,
2194                };
2195                let writer_request_id = original_id.clone();
2196                // Mirror the incoming wrap kind for the eventual response (CEP-19).
2197                {
2198                    let mut kinds_w = request_wrap_kinds.write().await;
2199                    kinds_w.insert(
2200                        event_id.to_string(),
2201                        if is_gift_wrap { Some(outer_kind) } else { None },
2202                    );
2203                }
2204                event_routes
2205                    .register(
2206                        event_id.to_string(),
2207                        sender_pubkey.to_string(),
2208                        original_id,
2209                        token,
2210                    )
2211                    .await;
2212                if open_stream.enabled {
2213                    if let Some(progress_token) = writer_token {
2214                        Self::create_open_stream_writer(
2215                            open_stream,
2216                            relay_pool,
2217                            encryption_mode,
2218                            gift_wrap_mode,
2219                            event_id,
2220                            sender_pubkey,
2221                            &progress_token,
2222                            writer_request_id,
2223                            is_encrypted,
2224                            if is_gift_wrap { Some(outer_kind) } else { None },
2225                        );
2226                    }
2227                }
2228                let _ = tx.send(IncomingRequest {
2229                    message,
2230                    client_pubkey: sender_pubkey.to_string(),
2231                    event_id: event_id.to_string(),
2232                    is_encrypted,
2233                    event: inbound_event,
2234                });
2235            }
2236            // Clean up locally, let the peer's own timeout fire.
2237            Err(error) => {
2238                tracing::warn!(
2239                    target: LOG_TARGET,
2240                    error = %error,
2241                    sender_pubkey = %sender_pubkey,
2242                    "Oversized transfer frame rejected; cleaning up locally"
2243                );
2244            }
2245        }
2246    }
2247
2248    /// CEP-22: publish a single `accept` frame back to `sender_pubkey`, e-tagged to
2249    /// the `start` frame's carrying event. Best-effort — failures are logged only
2250    /// (the sender falls back to its own accept timeout).
2251    #[allow(clippy::too_many_arguments)]
2252    async fn emit_accept_frame(
2253        token: &str,
2254        sender_pubkey: &str,
2255        start_event_id: &str,
2256        is_encrypted: bool,
2257        is_gift_wrap: bool,
2258        outer_kind: u16,
2259        relay_pool: &Arc<dyn RelayPoolTrait>,
2260        encryption_mode: EncryptionMode,
2261        gift_wrap_mode: GiftWrapMode,
2262    ) {
2263        let client_pk = match PublicKey::from_hex(sender_pubkey) {
2264            Ok(pk) => pk,
2265            Err(_) => return,
2266        };
2267        let event_id_parsed = EventId::from_hex(start_event_id).unwrap_or(EventId::all_zeros());
2268        let accept = match OversizedFrame::Accept.into_progress_notification(
2269            token,
2270            ACCEPT_PROGRESS,
2271            Some("oversized request accepted"),
2272        ) {
2273            Ok(n) => JsonRpcMessage::Notification(n),
2274            Err(error) => {
2275                tracing::error!(
2276                    target: LOG_TARGET,
2277                    error = %error,
2278                    "Failed to build oversized-transfer accept frame"
2279                );
2280                return;
2281            }
2282        };
2283        let tags = BaseTransport::create_response_tags(&client_pk, &event_id_parsed);
2284        let base = BaseTransport {
2285            relay_pool: Arc::clone(relay_pool),
2286            encryption_mode,
2287            is_connected: true,
2288        };
2289        if let Err(error) = base
2290            .send_mcp_message(
2291                &accept,
2292                &client_pk,
2293                CTXVM_MESSAGES_KIND,
2294                tags,
2295                Some(is_encrypted),
2296                Self::select_outbound_gift_wrap_kind(
2297                    gift_wrap_mode,
2298                    is_encrypted,
2299                    if is_gift_wrap { Some(outer_kind) } else { None },
2300                ),
2301            )
2302            .await
2303        {
2304            tracing::error!(
2305                target: LOG_TARGET,
2306                error = %error,
2307                sender_pubkey = %sender_pubkey,
2308                "Failed to send oversized-transfer accept frame"
2309            );
2310        }
2311    }
2312
2313    /// CEP-41: create a server→client [`OpenStreamWriter`] for a `tools/call`
2314    /// carrying a `progressToken`, capture its [`RouteSnapshot`], register the
2315    /// `progress_token → event_id` index, and store the slot. The writer is later
2316    /// injected into the tool via the rmcp request `extensions`; its
2317    /// close/abort hooks flush the deferred final response from the snapshot.
2318    #[allow(clippy::too_many_arguments)]
2319    fn create_open_stream_writer(
2320        state: &ServerOpenStreamState,
2321        relay_pool: &Arc<dyn RelayPoolTrait>,
2322        encryption_mode: EncryptionMode,
2323        gift_wrap_mode: GiftWrapMode,
2324        event_id: &str,
2325        client_pubkey_hex: &str,
2326        progress_token: &str,
2327        original_request_id: serde_json::Value,
2328        is_encrypted: bool,
2329        mirrored_wrap_kind: Option<u16>,
2330    ) {
2331        let client_pubkey = match PublicKey::from_hex(client_pubkey_hex) {
2332            Ok(pk) => pk,
2333            Err(_) => return,
2334        };
2335        let event_id_parsed = match EventId::from_hex(event_id) {
2336            Ok(id) => id,
2337            Err(_) => return,
2338        };
2339        let gift_wrap_kind =
2340            Self::select_outbound_gift_wrap_kind(gift_wrap_mode, is_encrypted, mirrored_wrap_kind);
2341
2342        // Publish closure: every frame is e-tagged to the request event (so the
2343        // client can keep its pending correlation alive) and mirrors the inbound
2344        // gift-wrap kind (CEP-19). `send_notification`'s one-shot discovery tags
2345        // are not replayed — they already rode the initialize response / stream
2346        // start by the time a tool streams.
2347        let publish_relay_pool = Arc::clone(relay_pool);
2348        let publish_frame: PublishFrame = Arc::new(move |notification: JsonRpcNotification| {
2349            let relay_pool = Arc::clone(&publish_relay_pool);
2350            Box::pin(async move {
2351                let base = BaseTransport {
2352                    relay_pool,
2353                    encryption_mode,
2354                    is_connected: true,
2355                };
2356                let tags = BaseTransport::create_response_tags(&client_pubkey, &event_id_parsed);
2357                let message = JsonRpcMessage::Notification(notification);
2358                base.send_mcp_message(
2359                    &message,
2360                    &client_pubkey,
2361                    CTXVM_MESSAGES_KIND,
2362                    tags,
2363                    Some(is_encrypted),
2364                    gift_wrap_kind,
2365                )
2366                .await
2367            })
2368        });
2369
2370        // Terminal hooks flush any deferred final response from the snapshot.
2371        let on_close: OnCloseHook = {
2372            let state = state.clone();
2373            let relay_pool = Arc::clone(relay_pool);
2374            let event_id = event_id.to_string();
2375            Arc::new(move || {
2376                let state = state.clone();
2377                let relay_pool = Arc::clone(&relay_pool);
2378                let event_id = event_id.clone();
2379                Box::pin(async move {
2380                    let base = BaseTransport {
2381                        relay_pool,
2382                        encryption_mode,
2383                        is_connected: true,
2384                    };
2385                    Self::flush_open_stream_response(&state, &base, gift_wrap_mode, &event_id)
2386                        .await;
2387                })
2388            })
2389        };
2390        let on_abort: OnAbortHook = {
2391            let state = state.clone();
2392            let relay_pool = Arc::clone(relay_pool);
2393            let event_id = event_id.to_string();
2394            Arc::new(move |_reason| {
2395                let state = state.clone();
2396                let relay_pool = Arc::clone(&relay_pool);
2397                let event_id = event_id.clone();
2398                Box::pin(async move {
2399                    let base = BaseTransport {
2400                        relay_pool,
2401                        encryption_mode,
2402                        is_connected: true,
2403                    };
2404                    Self::flush_open_stream_response(&state, &base, gift_wrap_mode, &event_id)
2405                        .await;
2406                })
2407            })
2408        };
2409
2410        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
2411            progress_token: progress_token.to_string(),
2412            publish_frame,
2413            content_type: None,
2414            on_close: Some(on_close),
2415            on_abort: Some(on_abort),
2416            // CEP-41 sender-side keepalive: reuse the reader idle/probe knobs (one
2417            // idle/probe pair per stream, per spec). `None` when idle is disabled,
2418            // keeping the writer keepalive in lockstep with the sweep gate
2419            // (`open_stream_sweep_enabled` is false when `idle_timeout_ms == 0`).
2420            idle_timeout: (state.policy.idle_timeout_ms != 0)
2421                .then(|| Duration::from_millis(state.policy.idle_timeout_ms)),
2422            probe_timeout: Duration::from_millis(state.policy.probe_timeout_ms),
2423        });
2424        let snapshot = RouteSnapshot {
2425            client_pubkey,
2426            original_request_id,
2427            is_encrypted,
2428            mirrored_wrap_kind,
2429        };
2430        state.lock_slots().insert(
2431            event_id.to_string(),
2432            OpenStreamSlot {
2433                writer,
2434                snapshot,
2435                pending_response: None,
2436                terminated: false,
2437            },
2438        );
2439        state
2440            .lock_token_index()
2441            .insert(progress_token.to_string(), event_id.to_string());
2442    }
2443
2444    /// CEP-41 inbound interception (beside the oversized branch). Routes control
2445    /// frames to the active writer (`ping → pong`, `abort → abort`) and otherwise
2446    /// drives the server-as-reader engine (`start`/`pong`/`chunk`/`close`).
2447    #[allow(clippy::too_many_arguments)]
2448    async fn handle_open_stream_frame(
2449        state: &ServerOpenStreamState,
2450        relay_pool: &Arc<dyn RelayPoolTrait>,
2451        encryption_mode: EncryptionMode,
2452        gift_wrap_mode: GiftWrapMode,
2453        notification: &JsonRpcNotification,
2454        sender_pubkey: &str,
2455        event_id: &str,
2456        is_encrypted: bool,
2457        is_gift_wrap: bool,
2458        outer_kind: u16,
2459        client_supports_open_stream: bool,
2460    ) {
2461        let token = notification
2462            .params
2463            .as_ref()
2464            .and_then(|p| p.get("progressToken"))
2465            .and_then(progress_token_string);
2466        // An active server→client writer owns this token's control frames.
2467        let writer = token
2468            .as_deref()
2469            .and_then(|t| state.event_id_for_token(t))
2470            .and_then(|eid| state.writer_for(&eid));
2471
2472        match open_stream_frame_from_notification(notification) {
2473            Some(OpenStreamFrame::Ping { nonce }) => {
2474                if let Some(writer) = writer {
2475                    let _ = writer.pong(nonce).await;
2476                } else {
2477                    Self::feed_open_stream_reader(
2478                        state,
2479                        relay_pool,
2480                        encryption_mode,
2481                        gift_wrap_mode,
2482                        notification,
2483                        sender_pubkey,
2484                        event_id,
2485                        is_encrypted,
2486                        is_gift_wrap,
2487                        outer_kind,
2488                    )
2489                    .await;
2490                }
2491            }
2492            Some(OpenStreamFrame::Abort { reason }) => {
2493                if let Some(writer) = writer {
2494                    let _ = writer.abort(reason).await;
2495                } else {
2496                    Self::feed_open_stream_reader(
2497                        state,
2498                        relay_pool,
2499                        encryption_mode,
2500                        gift_wrap_mode,
2501                        notification,
2502                        sender_pubkey,
2503                        event_id,
2504                        is_encrypted,
2505                        is_gift_wrap,
2506                        outer_kind,
2507                    )
2508                    .await;
2509                }
2510            }
2511            Some(OpenStreamFrame::Pong { nonce }) => {
2512                // CEP-41: a `pong` for a stream the server is *writing* (server→client)
2513                // acknowledges our keepalive probe (completing the round trip the reader
2514                // sweep opened). Only intercept when a writer owns the token;
2515                // client→server streams fall through to the reader engine below.
2516                if let Some(writer) = writer {
2517                    writer.ack_probe(&nonce);
2518                } else {
2519                    Self::feed_open_stream_reader(
2520                        state,
2521                        relay_pool,
2522                        encryption_mode,
2523                        gift_wrap_mode,
2524                        notification,
2525                        sender_pubkey,
2526                        event_id,
2527                        is_encrypted,
2528                        is_gift_wrap,
2529                        outer_kind,
2530                    )
2531                    .await;
2532                }
2533            }
2534            Some(OpenStreamFrame::Start { .. }) => {
2535                Self::feed_open_stream_reader(
2536                    state,
2537                    relay_pool,
2538                    encryption_mode,
2539                    gift_wrap_mode,
2540                    notification,
2541                    sender_pubkey,
2542                    event_id,
2543                    is_encrypted,
2544                    is_gift_wrap,
2545                    outer_kind,
2546                )
2547                .await;
2548                // Stateless accept: only for clients that advertised support.
2549                if client_supports_open_stream {
2550                    if let Some(token) = token.as_deref() {
2551                        Self::publish_open_stream_control_frame(
2552                            state,
2553                            relay_pool,
2554                            encryption_mode,
2555                            gift_wrap_mode,
2556                            OpenStreamFrame::Accept,
2557                            token,
2558                            sender_pubkey,
2559                            Some(event_id),
2560                            is_encrypted,
2561                            is_gift_wrap,
2562                            outer_kind,
2563                        )
2564                        .await;
2565                    }
2566                }
2567            }
2568            // pong / chunk / close / accept → server-as-reader engine.
2569            _ => {
2570                Self::feed_open_stream_reader(
2571                    state,
2572                    relay_pool,
2573                    encryption_mode,
2574                    gift_wrap_mode,
2575                    notification,
2576                    sender_pubkey,
2577                    event_id,
2578                    is_encrypted,
2579                    is_gift_wrap,
2580                    outer_kind,
2581                )
2582                .await;
2583            }
2584        }
2585    }
2586
2587    /// CEP-41 server-as-reader: feed an inbound frame to this peer's reader engine
2588    /// (created on demand) and publish a `pong` if its session asks for one.
2589    #[allow(clippy::too_many_arguments)]
2590    async fn feed_open_stream_reader(
2591        state: &ServerOpenStreamState,
2592        relay_pool: &Arc<dyn RelayPoolTrait>,
2593        encryption_mode: EncryptionMode,
2594        gift_wrap_mode: GiftWrapMode,
2595        notification: &JsonRpcNotification,
2596        sender_pubkey: &str,
2597        event_id: &str,
2598        is_encrypted: bool,
2599        is_gift_wrap: bool,
2600        outer_kind: u16,
2601    ) {
2602        let outcome = {
2603            let mut store = state.receiver.lock().await;
2604            if !store.contains(sender_pubkey) {
2605                store.put(
2606                    sender_pubkey.to_string(),
2607                    OpenStreamReceiver::with_policy(state.policy),
2608                );
2609            }
2610            let receiver = store
2611                .get_mut(sender_pubkey)
2612                .expect("open-stream receiver present after insert");
2613            receiver.process_frame(notification).await
2614        };
2615        match outcome {
2616            Ok(FrameOutcome::SendPong(nonce)) => {
2617                if let Some(token) = notification
2618                    .params
2619                    .as_ref()
2620                    .and_then(|p| p.get("progressToken"))
2621                    .and_then(progress_token_string)
2622                {
2623                    Self::publish_open_stream_control_frame(
2624                        state,
2625                        relay_pool,
2626                        encryption_mode,
2627                        gift_wrap_mode,
2628                        OpenStreamFrame::Pong { nonce },
2629                        &token,
2630                        sender_pubkey,
2631                        Some(event_id),
2632                        is_encrypted,
2633                        is_gift_wrap,
2634                        outer_kind,
2635                    )
2636                    .await;
2637                }
2638            }
2639            Ok(_) => {}
2640            Err(error) => {
2641                tracing::warn!(
2642                    target: LOG_TARGET,
2643                    error = %error,
2644                    sender_pubkey = %sender_pubkey,
2645                    "Inbound open-stream frame rejected by server reader engine"
2646                );
2647            }
2648        }
2649    }
2650
2651    /// CEP-41: publish one server→client control frame (`accept`/`pong`/`ping`) on
2652    /// the server-as-reader path, e-tagged to `correlated_event_id` and mirroring
2653    /// the inbound gift-wrap kind.
2654    #[allow(clippy::too_many_arguments)]
2655    async fn publish_open_stream_control_frame(
2656        state: &ServerOpenStreamState,
2657        relay_pool: &Arc<dyn RelayPoolTrait>,
2658        encryption_mode: EncryptionMode,
2659        gift_wrap_mode: GiftWrapMode,
2660        frame: OpenStreamFrame,
2661        token: &str,
2662        recipient_pubkey: &str,
2663        correlated_event_id: Option<&str>,
2664        is_encrypted: bool,
2665        is_gift_wrap: bool,
2666        outer_kind: u16,
2667    ) {
2668        let recipient = match PublicKey::from_hex(recipient_pubkey) {
2669            Ok(pk) => pk,
2670            Err(_) => return,
2671        };
2672        let progress = state.next_control_progress();
2673        let notification = match frame.into_progress_notification(token, progress, None) {
2674            Ok(n) => n,
2675            Err(error) => {
2676                tracing::error!(
2677                    target: LOG_TARGET,
2678                    error = %error,
2679                    "Failed to build open-stream control frame"
2680                );
2681                return;
2682            }
2683        };
2684        let mut tags = BaseTransport::create_recipient_tags(&recipient);
2685        // The `e`-tag is present only when the frame correlates to a known request
2686        // event (accept/pong reply to an inbound frame); the keepalive ping for a
2687        // server-as-reader session has no correlation and is sent recipient-only.
2688        if let Some(eid) = correlated_event_id.and_then(|id| EventId::from_hex(id).ok()) {
2689            tags.push(Tag::event(eid));
2690        }
2691        let base = BaseTransport {
2692            relay_pool: Arc::clone(relay_pool),
2693            encryption_mode,
2694            is_connected: true,
2695        };
2696        let gift_wrap_kind = Self::select_outbound_gift_wrap_kind(
2697            gift_wrap_mode,
2698            is_encrypted,
2699            if is_gift_wrap { Some(outer_kind) } else { None },
2700        );
2701        if let Err(error) = base
2702            .send_mcp_message(
2703                &JsonRpcMessage::Notification(notification),
2704                &recipient,
2705                CTXVM_MESSAGES_KIND,
2706                tags,
2707                Some(is_encrypted),
2708                gift_wrap_kind,
2709            )
2710            .await
2711        {
2712            tracing::warn!(
2713                target: LOG_TARGET,
2714                error = %error,
2715                "Failed to publish open-stream control frame"
2716            );
2717        }
2718    }
2719
2720    /// CEP-41: one keepalive sweep over the server-as-reader sessions (mirrors
2721    /// [`sweep_oversized_receivers`]). Drives each session's pure `tick`: idle →
2722    /// publish `ping`; probe/grace deadline → the reader aborted, so abort the
2723    /// paired writer too if one exists. Drops now-empty peer receivers.
2724    async fn sweep_open_stream_sessions(
2725        state: &ServerOpenStreamState,
2726        relay_pool: &Arc<dyn RelayPoolTrait>,
2727        encryption_mode: EncryptionMode,
2728        gift_wrap_mode: GiftWrapMode,
2729        sessions: &SessionStore,
2730    ) {
2731        let now = Instant::now();
2732        let mut actions: Vec<(String, String, KeepaliveAction)> = Vec::new();
2733        {
2734            let mut store = state.receiver.lock().await;
2735            let mut empty_peers = Vec::new();
2736            for (peer, receiver) in store.iter_mut() {
2737                for (token, action) in receiver.registry_mut().tick_all(now) {
2738                    actions.push((peer.clone(), token, action));
2739                }
2740                if receiver.active_stream_count() == 0 {
2741                    empty_peers.push(peer.clone());
2742                }
2743            }
2744            for peer in empty_peers {
2745                store.pop(&peer);
2746            }
2747        }
2748
2749        let probe_is_encrypted = encryption_mode != EncryptionMode::Disabled;
2750        for (peer, token, action) in actions {
2751            match action {
2752                KeepaliveAction::SendPing(nonce) => {
2753                    // Server-as-reader sessions have no `token_to_event` entry
2754                    // (that index is only populated for server→client writers), so
2755                    // this ping is uncorrelated until bidirectional streaming wires
2756                    // a reader-side event id through.
2757                    let correlated = state.event_id_for_token(&token);
2758                    Self::publish_open_stream_control_frame(
2759                        state,
2760                        relay_pool,
2761                        encryption_mode,
2762                        gift_wrap_mode,
2763                        OpenStreamFrame::Ping { nonce },
2764                        &token,
2765                        &peer,
2766                        correlated.as_deref(),
2767                        probe_is_encrypted,
2768                        false,
2769                        0,
2770                    )
2771                    .await;
2772                }
2773                KeepaliveAction::Abort(reason) => {
2774                    if let Some(eid) = state.event_id_for_token(&token) {
2775                        if let Some(writer) = state.writer_for(&eid) {
2776                            let _ = writer.abort(Some(reason)).await;
2777                        }
2778                    }
2779                }
2780                KeepaliveAction::None => {}
2781            }
2782        }
2783
2784        // CEP-41: writer (server→client) keepalive — each producer stream MUST
2785        // maintain an idle timeout (CEP-41 §Timeout and Keepalive). A client that
2786        // vanishes without `abort` is probed here and aborted on a missing `pong`;
2787        // the writer's `on_abort` hook then flushes any deferred final response.
2788        // This is the mirror of the reader sweep above for sender-side streams.
2789        // The client pubkey is captured up front: the slot may be removed by the
2790        // time we act on an `Abort` (the writer's on_abort flush drops it), and we
2791        // need it to release the dead client's session.
2792        let writer_actions: Vec<(String, PublicKey, KeepaliveAction)> = state
2793            .lock_slots()
2794            .iter()
2795            .map(|(event_id, slot)| {
2796                (
2797                    event_id.clone(),
2798                    slot.snapshot.client_pubkey,
2799                    slot.writer.tick(now),
2800                )
2801            })
2802            .filter(|(.., action)| !matches!(action, KeepaliveAction::None))
2803            .collect();
2804        for (event_id, client_pubkey, action) in writer_actions {
2805            let Some(writer) = state.writer_for(&event_id) else {
2806                continue;
2807            };
2808            match action {
2809                KeepaliveAction::SendPing(nonce) => {
2810                    if let Err(error) = writer.send_probe(nonce).await {
2811                        tracing::warn!(
2812                            target: LOG_TARGET,
2813                            error = %error,
2814                            event_id = %event_id,
2815                            "Failed to publish open-stream keepalive ping"
2816                        );
2817                    }
2818                }
2819                KeepaliveAction::Abort(reason) => {
2820                    if let Err(error) = writer.abort(Some(reason)).await {
2821                        tracing::warn!(
2822                            target: LOG_TARGET,
2823                            error = %error,
2824                            event_id = %event_id,
2825                            "Failed to abort open-stream writer on probe timeout"
2826                        );
2827                    }
2828                    // CEP-41: a probe timeout means the client is gone — release
2829                    // its session too ("release local state"), mirroring the TS
2830                    // `handleProbeTimeout`. Done after the abort so the abort frame
2831                    // is published first; eviction is independent of the
2832                    // snapshot-backed deferred-response flush. Only the sweep
2833                    // produces a writer `Abort`, so this path is probe-timeout only
2834                    // (a tool-initiated `close`/`abort` does not evict the session).
2835                    let pubkey_hex = client_pubkey.to_hex();
2836                    if sessions.remove_session(&pubkey_hex).await {
2837                        if let Some(cb) = sessions.eviction_callback() {
2838                            cb(pubkey_hex);
2839                        }
2840                    }
2841                }
2842                KeepaliveAction::None => {}
2843            }
2844        }
2845    }
2846
2847    async fn cleanup_sessions(
2848        sessions: &SessionStore,
2849        event_routes: &ServerEventRouteStore,
2850        request_wrap_kinds: &Arc<RwLock<HashMap<String, Option<u16>>>>,
2851        timeout: Duration,
2852    ) -> usize {
2853        let mut sessions_w = sessions.write().await;
2854        let mut cleaned = 0;
2855        let mut stale_event_ids = Vec::new();
2856
2857        // LruCache has no retain(); collect expired keys then pop each one.
2858        let expired_keys: Vec<String> = sessions_w
2859            .iter()
2860            .filter(|(_, session)| session.last_activity.elapsed() > timeout)
2861            .map(|(k, _)| k.clone())
2862            .collect();
2863
2864        for key in &expired_keys {
2865            if let Some(session) = sessions_w.pop(key) {
2866                stale_event_ids.extend(session.pending_requests.keys().cloned());
2867                stale_event_ids.extend(session.event_to_progress_token.keys().cloned());
2868                tracing::debug!(
2869                    target: LOG_TARGET,
2870                    client_pubkey = %key,
2871                    "Session expired"
2872                );
2873                cleaned += 1;
2874            }
2875        }
2876        drop(sessions_w);
2877
2878        {
2879            let mut kinds_w = request_wrap_kinds.write().await;
2880            for event_id in &stale_event_ids {
2881                kinds_w.remove(event_id);
2882            }
2883        }
2884
2885        for event_id in &stale_event_ids {
2886            event_routes.pop(event_id).await;
2887        }
2888
2889        cleaned
2890    }
2891
2892    /// CEP-19: Choose outbound gift-wrap kind for responses.
2893    /// If `is_encrypted` is false, return None (send plaintext).
2894    /// Otherwise mirror the kind used by the client, falling back to the mode default.
2895    fn select_outbound_gift_wrap_kind(
2896        mode: GiftWrapMode,
2897        is_encrypted: bool,
2898        mirrored_kind: Option<u16>,
2899    ) -> Option<u16> {
2900        if !is_encrypted {
2901            return None;
2902        }
2903        if let Some(kind) = mirrored_kind {
2904            if mode.allows_kind(kind) {
2905                return Some(kind);
2906            }
2907        }
2908        match mode {
2909            GiftWrapMode::Persistent => Some(GIFT_WRAP_KIND),
2910            GiftWrapMode::Ephemeral => Some(EPHEMERAL_GIFT_WRAP_KIND),
2911            GiftWrapMode::Optional => Some(GIFT_WRAP_KIND),
2912        }
2913    }
2914
2915    /// CEP-19: Choose outbound gift-wrap kind for notifications.
2916    fn select_outbound_notification_gift_wrap_kind(
2917        mode: GiftWrapMode,
2918        is_encrypted: bool,
2919        correlated_wrap_kind: Option<u16>,
2920        client_supports_ephemeral: bool,
2921    ) -> Option<u16> {
2922        if !is_encrypted {
2923            return None;
2924        }
2925        // Mirror correlated request kind if available
2926        if let Some(kind) = correlated_wrap_kind {
2927            if mode.allows_kind(kind) {
2928                return Some(kind);
2929            }
2930        }
2931        // Fall back based on learned ephemeral support
2932        if client_supports_ephemeral && mode.supports_ephemeral() {
2933            return Some(EPHEMERAL_GIFT_WRAP_KIND);
2934        }
2935        match mode {
2936            GiftWrapMode::Persistent => Some(GIFT_WRAP_KIND),
2937            GiftWrapMode::Ephemeral => Some(EPHEMERAL_GIFT_WRAP_KIND),
2938            GiftWrapMode::Optional => Some(GIFT_WRAP_KIND),
2939        }
2940    }
2941}
2942
2943#[cfg(test)]
2944mod tests {
2945    use super::*;
2946    use crate::relay::mock::MockRelayPool;
2947    use std::thread;
2948
2949    // ── Session management ──────────────────────────────────────
2950
2951    #[test]
2952    fn test_client_session_creation() {
2953        let session = ClientSession::new(true);
2954        assert!(!session.is_initialized);
2955        assert!(session.is_encrypted);
2956        assert!(!session.has_sent_common_tags);
2957        assert!(!session.supports_ephemeral_gift_wrap);
2958        assert!(session.pending_requests.is_empty());
2959        assert!(session.event_to_progress_token.is_empty());
2960    }
2961
2962    #[test]
2963    fn test_client_session_update_activity() {
2964        let mut session = ClientSession::new(false);
2965        let first = session.last_activity;
2966        thread::sleep(Duration::from_millis(10));
2967        session.update_activity();
2968        assert!(session.last_activity > first);
2969    }
2970
2971    #[tokio::test]
2972    async fn test_cleanup_sessions_removes_expired() {
2973        let sessions = SessionStore::new();
2974        let event_routes = ServerEventRouteStore::new();
2975
2976        // Insert a session with an old activity time
2977        let mut session = ClientSession::new(false);
2978        session
2979            .pending_requests
2980            .insert("evt1".to_string(), serde_json::json!(1));
2981        sessions.write().await.put("pubkey1".to_string(), session);
2982        event_routes
2983            .register(
2984                "evt1".to_string(),
2985                "pubkey1".to_string(),
2986                serde_json::json!(1),
2987                None,
2988            )
2989            .await;
2990
2991        let request_wrap_kinds = Arc::new(RwLock::new(HashMap::new()));
2992
2993        // With a long timeout, nothing should be cleaned
2994        let cleaned = NostrServerTransport::cleanup_sessions(
2995            &sessions,
2996            &event_routes,
2997            &request_wrap_kinds,
2998            Duration::from_secs(300),
2999        )
3000        .await;
3001        assert_eq!(cleaned, 0);
3002        assert_eq!(sessions.session_count().await, 1);
3003
3004        // With zero timeout, it should be cleaned
3005        thread::sleep(Duration::from_millis(5));
3006        let cleaned = NostrServerTransport::cleanup_sessions(
3007            &sessions,
3008            &event_routes,
3009            &request_wrap_kinds,
3010            Duration::from_millis(1),
3011        )
3012        .await;
3013        assert_eq!(cleaned, 1);
3014        assert_eq!(sessions.session_count().await, 0);
3015        assert!(event_routes.pop("evt1").await.is_none());
3016    }
3017
3018    #[tokio::test]
3019    async fn test_cleanup_preserves_active_sessions() {
3020        let sessions = SessionStore::new();
3021        let event_routes = ServerEventRouteStore::new();
3022        let request_wrap_kinds = Arc::new(RwLock::new(HashMap::new()));
3023
3024        sessions
3025            .get_or_create_session("active", false, &event_routes)
3026            .await;
3027
3028        let cleaned = NostrServerTransport::cleanup_sessions(
3029            &sessions,
3030            &event_routes,
3031            &request_wrap_kinds,
3032            Duration::from_secs(300),
3033        )
3034        .await;
3035        assert_eq!(cleaned, 0);
3036        assert_eq!(sessions.session_count().await, 1);
3037    }
3038
3039    // ── Request ID correlation ──────────────────────────────────
3040
3041    #[test]
3042    fn test_pending_request_tracking() {
3043        let mut session = ClientSession::new(false);
3044        session
3045            .pending_requests
3046            .insert("event_abc".to_string(), serde_json::json!(42));
3047        assert_eq!(
3048            session.pending_requests.get("event_abc"),
3049            Some(&serde_json::json!(42))
3050        );
3051    }
3052
3053    #[test]
3054    fn test_progress_token_tracking() {
3055        let mut session = ClientSession::new(false);
3056        session
3057            .event_to_progress_token
3058            .insert("evt1".to_string(), "token1".to_string());
3059        session
3060            .pending_requests
3061            .insert("token1".to_string(), serde_json::json!("evt1"));
3062        assert_eq!(
3063            session.event_to_progress_token.get("evt1"),
3064            Some(&"token1".to_string())
3065        );
3066    }
3067
3068    // ── Authorization (is_capability_excluded) ──────────────────
3069
3070    #[test]
3071    fn test_initialize_always_excluded() {
3072        assert!(NostrServerTransport::is_capability_excluded(
3073            &[],
3074            "initialize",
3075            None
3076        ));
3077        assert!(NostrServerTransport::is_capability_excluded(
3078            &[],
3079            "notifications/initialized",
3080            None
3081        ));
3082    }
3083
3084    #[test]
3085    fn test_method_excluded_without_name() {
3086        let exclusions = vec![CapabilityExclusion {
3087            method: "tools/list".to_string(),
3088            name: None,
3089        }];
3090        assert!(NostrServerTransport::is_capability_excluded(
3091            &exclusions,
3092            "tools/list",
3093            None
3094        ));
3095        assert!(NostrServerTransport::is_capability_excluded(
3096            &exclusions,
3097            "tools/list",
3098            Some("anything")
3099        ));
3100    }
3101
3102    #[test]
3103    fn test_method_excluded_with_name() {
3104        let exclusions = vec![CapabilityExclusion {
3105            method: "tools/call".to_string(),
3106            name: Some("get_weather".to_string()),
3107        }];
3108        assert!(NostrServerTransport::is_capability_excluded(
3109            &exclusions,
3110            "tools/call",
3111            Some("get_weather")
3112        ));
3113        assert!(!NostrServerTransport::is_capability_excluded(
3114            &exclusions,
3115            "tools/call",
3116            Some("other_tool")
3117        ));
3118        assert!(!NostrServerTransport::is_capability_excluded(
3119            &exclusions,
3120            "tools/call",
3121            None
3122        ));
3123    }
3124
3125    #[test]
3126    fn test_non_excluded_method() {
3127        let exclusions = vec![CapabilityExclusion {
3128            method: "tools/list".to_string(),
3129            name: None,
3130        }];
3131        assert!(!NostrServerTransport::is_capability_excluded(
3132            &exclusions,
3133            "tools/call",
3134            None
3135        ));
3136        assert!(!NostrServerTransport::is_capability_excluded(
3137            &exclusions,
3138            "resources/list",
3139            None
3140        ));
3141    }
3142
3143    #[test]
3144    fn test_empty_exclusions_non_init_method() {
3145        assert!(!NostrServerTransport::is_capability_excluded(
3146            &[],
3147            "tools/list",
3148            None
3149        ));
3150        assert!(!NostrServerTransport::is_capability_excluded(
3151            &[],
3152            "tools/call",
3153            Some("x")
3154        ));
3155    }
3156
3157    // ── Encryption mode enforcement ─────────────────────────────
3158
3159    #[test]
3160    fn test_encryption_mode_default() {
3161        let config = NostrServerTransportConfig::default();
3162        assert_eq!(config.encryption_mode, EncryptionMode::Optional);
3163    }
3164
3165    // ── Config defaults ─────────────────────────────────────────
3166
3167    #[test]
3168    fn test_config_defaults() {
3169        let config = NostrServerTransportConfig::default();
3170        assert_eq!(config.relay_urls, vec!["wss://relay.damus.io".to_string()]);
3171        assert!(!config.is_announced_server);
3172        assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional);
3173        assert!(config.allowed_public_keys.is_empty());
3174        assert!(config.excluded_capabilities.is_empty());
3175        assert_eq!(config.max_sessions, 1000);
3176        assert_eq!(config.cleanup_interval, Duration::from_secs(60));
3177        assert_eq!(config.session_timeout, Duration::from_secs(300));
3178        assert_eq!(config.request_timeout, Duration::from_secs(60));
3179        assert!(config.server_info.is_none());
3180        assert!(config.relay_list_urls.is_none());
3181        assert!(config.bootstrap_relay_urls.is_none());
3182        assert!(config.publish_relay_list);
3183        assert!(config.profile_metadata.is_none());
3184    }
3185
3186    #[tokio::test]
3187    async fn spawn_discoverability_publication_publishes_kind_0_and_10002_only() {
3188        let pool = Arc::new(MockRelayPool::new());
3189        let relay_pool: Arc<dyn RelayPoolTrait> = pool.clone();
3190        let config = NostrServerTransportConfig::default()
3191            .with_relay_urls(vec!["wss://relay.example.com".to_string()])
3192            .with_profile_metadata(ProfileMetadata::default().with_name("ffi-server"))
3193            .with_publish_relay_list(true);
3194        let mut transport = NostrServerTransport::with_relay_pool(config, relay_pool)
3195            .await
3196            .expect("transport should build");
3197
3198        transport.spawn_discoverability_publication();
3199        for handle in transport.task_handles.drain(..) {
3200            handle.await.expect("discoverability task should not panic");
3201        }
3202
3203        let events = pool.stored_events().await;
3204        assert!(
3205            events.iter().any(|e| e.kind == Kind::Custom(0)),
3206            "profile metadata should be published"
3207        );
3208        assert!(
3209            events
3210                .iter()
3211                .any(|e| e.kind == Kind::Custom(RELAY_LIST_METADATA_KIND)),
3212            "relay list should be published"
3213        );
3214        assert!(
3215            events
3216                .iter()
3217                .all(|e| e.kind != Kind::Custom(SERVER_ANNOUNCEMENT_KIND)),
3218            "direct discoverability publication must not emit CEP-6 announcements"
3219        );
3220    }
3221
3222    // ── CEP-19 helper logic ──────────────────────────────────────
3223
3224    #[test]
3225    fn test_select_outbound_gift_wrap_kind_plaintext() {
3226        assert_eq!(
3227            NostrServerTransport::select_outbound_gift_wrap_kind(
3228                GiftWrapMode::Optional,
3229                false,
3230                Some(GIFT_WRAP_KIND),
3231            ),
3232            None
3233        );
3234    }
3235
3236    #[test]
3237    fn test_select_outbound_gift_wrap_kind_mirrors_incoming() {
3238        assert_eq!(
3239            NostrServerTransport::select_outbound_gift_wrap_kind(
3240                GiftWrapMode::Optional,
3241                true,
3242                Some(EPHEMERAL_GIFT_WRAP_KIND),
3243            ),
3244            Some(EPHEMERAL_GIFT_WRAP_KIND)
3245        );
3246    }
3247
3248    #[test]
3249    fn test_select_outbound_gift_wrap_kind_persistent_mode_overrides_ephemeral() {
3250        assert_eq!(
3251            NostrServerTransport::select_outbound_gift_wrap_kind(
3252                GiftWrapMode::Persistent,
3253                true,
3254                Some(EPHEMERAL_GIFT_WRAP_KIND),
3255            ),
3256            Some(GIFT_WRAP_KIND)
3257        );
3258    }
3259
3260    #[test]
3261    fn test_append_common_response_tags_includes_encryption_when_optional() {
3262        let snapshot = announcement_manager::CommonTagsSnapshot {
3263            server_info: None,
3264            extra_common_tags: vec![],
3265            internal_common_tags: vec![],
3266            encryption_mode: EncryptionMode::Optional,
3267            gift_wrap_mode: GiftWrapMode::Optional,
3268        };
3269        let mut tags = Vec::new();
3270        snapshot.append_common_response_tags(&mut tags);
3271        let kinds: Vec<String> = tags.iter().map(|t| format!("{:?}", t.kind())).collect();
3272        assert!(
3273            kinds.iter().any(|k| k.contains("support_encryption")),
3274            "should include support_encryption tag"
3275        );
3276    }
3277
3278    #[test]
3279    fn test_append_common_response_tags_no_encryption_when_disabled() {
3280        let snapshot = announcement_manager::CommonTagsSnapshot {
3281            server_info: None,
3282            extra_common_tags: vec![],
3283            internal_common_tags: vec![],
3284            encryption_mode: EncryptionMode::Disabled,
3285            gift_wrap_mode: GiftWrapMode::Optional,
3286        };
3287        let mut tags = Vec::new();
3288        snapshot.append_common_response_tags(&mut tags);
3289        assert!(
3290            tags.is_empty(),
3291            "should not include encryption tags when encryption disabled"
3292        );
3293    }
3294
3295    #[test]
3296    fn test_select_outbound_notification_gift_wrap_kind_plaintext() {
3297        assert_eq!(
3298            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3299                GiftWrapMode::Optional,
3300                false,
3301                Some(EPHEMERAL_GIFT_WRAP_KIND),
3302                true,
3303            ),
3304            None
3305        );
3306    }
3307
3308    #[test]
3309    fn test_select_outbound_notification_gift_wrap_kind_mirrors_correlated() {
3310        assert_eq!(
3311            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3312                GiftWrapMode::Optional,
3313                true,
3314                Some(EPHEMERAL_GIFT_WRAP_KIND),
3315                false,
3316            ),
3317            Some(EPHEMERAL_GIFT_WRAP_KIND)
3318        );
3319    }
3320
3321    #[test]
3322    fn test_select_outbound_notification_gift_wrap_kind_falls_back_to_mode_if_correlated_not_allowed(
3323    ) {
3324        assert_eq!(
3325            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3326                GiftWrapMode::Ephemeral,
3327                true,
3328                Some(GIFT_WRAP_KIND),
3329                false,
3330            ),
3331            Some(EPHEMERAL_GIFT_WRAP_KIND)
3332        );
3333    }
3334
3335    #[test]
3336    fn test_select_outbound_notification_gift_wrap_kind_uses_ephemeral_if_supported() {
3337        assert_eq!(
3338            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3339                GiftWrapMode::Optional,
3340                true,
3341                None,
3342                true,
3343            ),
3344            Some(EPHEMERAL_GIFT_WRAP_KIND)
3345        );
3346    }
3347
3348    #[test]
3349    fn test_select_outbound_notification_gift_wrap_kind_uses_persistent_if_ephemeral_supported_but_mode_persistent(
3350    ) {
3351        assert_eq!(
3352            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3353                GiftWrapMode::Persistent,
3354                true,
3355                None,
3356                true,
3357            ),
3358            Some(GIFT_WRAP_KIND)
3359        );
3360    }
3361
3362    #[test]
3363    fn test_select_outbound_notification_gift_wrap_kind_uses_default_mode_if_ephemeral_not_supported(
3364    ) {
3365        assert_eq!(
3366            NostrServerTransport::select_outbound_notification_gift_wrap_kind(
3367                GiftWrapMode::Optional,
3368                true,
3369                None,
3370                false,
3371            ),
3372            Some(GIFT_WRAP_KIND)
3373        );
3374    }
3375
3376    #[test]
3377    fn test_append_common_response_tags_includes_ephemeral_tag() {
3378        let snapshot = announcement_manager::CommonTagsSnapshot {
3379            server_info: None,
3380            extra_common_tags: vec![],
3381            internal_common_tags: vec![],
3382            encryption_mode: EncryptionMode::Optional,
3383            gift_wrap_mode: GiftWrapMode::Optional,
3384        };
3385        let mut tags = Vec::new();
3386        snapshot.append_common_response_tags(&mut tags);
3387        let kinds: Vec<String> = tags.iter().map(|t| format!("{:?}", t.kind())).collect();
3388        assert!(
3389            kinds
3390                .iter()
3391                .any(|k| k.contains("support_encryption_ephemeral")),
3392            "should include support_encryption_ephemeral tag"
3393        );
3394    }
3395
3396    #[test]
3397    fn test_append_common_response_tags_includes_server_info() {
3398        let server_info = ServerInfo {
3399            name: Some("TestServer".to_string()),
3400            ..Default::default()
3401        };
3402        let snapshot = announcement_manager::CommonTagsSnapshot {
3403            server_info: Some(server_info),
3404            extra_common_tags: vec![],
3405            internal_common_tags: vec![],
3406            encryption_mode: EncryptionMode::Disabled,
3407            gift_wrap_mode: GiftWrapMode::Optional,
3408        };
3409        let mut tags = Vec::new();
3410        snapshot.append_common_response_tags(&mut tags);
3411        let tag_value = tags
3412            .iter()
3413            .find(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("name"))
3414            .and_then(|t| t.clone().to_vec().get(1).cloned());
3415        assert_eq!(tag_value.as_deref(), Some("TestServer"));
3416    }
3417
3418    #[test]
3419    fn test_append_common_response_tags_extra_tags() {
3420        let extra_tags = vec![Tag::custom(
3421            TagKind::Custom("custom_tag".into()),
3422            vec!["value".to_string()],
3423        )];
3424        let snapshot = announcement_manager::CommonTagsSnapshot {
3425            server_info: None,
3426            extra_common_tags: extra_tags,
3427            internal_common_tags: vec![],
3428            encryption_mode: EncryptionMode::Disabled,
3429            gift_wrap_mode: GiftWrapMode::Optional,
3430        };
3431        let mut tags = Vec::new();
3432        snapshot.append_common_response_tags(&mut tags);
3433        let tag_value = tags
3434            .iter()
3435            .find(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("custom_tag"))
3436            .and_then(|t| t.clone().to_vec().get(1).cloned());
3437        assert_eq!(tag_value.as_deref(), Some("value"));
3438    }
3439
3440    // ── CEP-35 discovery tag helpers ────────────────────────────
3441
3442    #[test]
3443    fn test_cep35_client_session_new_fields_default_false() {
3444        let session = ClientSession::new(false);
3445        assert!(!session.has_sent_common_tags);
3446        assert!(!session.supports_encryption);
3447        assert!(!session.supports_ephemeral_encryption);
3448        assert!(!session.supports_oversized_transfer);
3449    }
3450
3451    #[test]
3452    fn test_cep35_capability_or_assign() {
3453        let mut session = ClientSession::new(false);
3454
3455        session.supports_encryption |= true;
3456        session.supports_ephemeral_encryption |= false;
3457
3458        session.supports_encryption |= false;
3459        session.supports_ephemeral_encryption |= true;
3460
3461        assert!(session.supports_encryption, "OR-assign must not downgrade");
3462        assert!(session.supports_ephemeral_encryption);
3463        assert!(!session.supports_oversized_transfer);
3464    }
3465
3466    #[test]
3467    fn test_config_gift_wrap_mode_default() {
3468        let config = NostrServerTransportConfig::default();
3469        assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional);
3470    }
3471
3472    // ── CEP-22 oversized transfer capability advertisement ──────
3473
3474    fn first_tag_values(tags: &[Tag]) -> Vec<String> {
3475        tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect()
3476    }
3477
3478    async fn make_server_with_oversized(enabled: bool) -> NostrServerTransport {
3479        let config = NostrServerTransportConfig {
3480            oversized_transfer: OversizedTransferConfig::default().with_enabled(enabled),
3481            ..Default::default()
3482        };
3483        let pool: Arc<dyn RelayPoolTrait> = Arc::new(crate::relay::mock::MockRelayPool::new());
3484        NostrServerTransport::with_relay_pool(config, pool)
3485            .await
3486            .expect("server transport construction")
3487    }
3488
3489    #[test]
3490    fn test_oversized_enabled_by_default() {
3491        let config = NostrServerTransportConfig::default();
3492        assert!(config.oversized_transfer.enabled);
3493    }
3494
3495    #[test]
3496    fn test_oversized_support_tags_helper() {
3497        // Start from an explicit opt-out: the default is now enabled.
3498        let mut config = NostrServerTransportConfig::default().with_oversized_enabled(false);
3499        assert!(oversized_support_tags(&config).is_empty());
3500        config.oversized_transfer.enabled = true;
3501        let names = first_tag_values(&oversized_support_tags(&config));
3502        assert_eq!(names, vec!["support_oversized_transfer"]);
3503    }
3504
3505    #[test]
3506    fn test_oversized_builders() {
3507        let config = NostrServerTransportConfig::default().with_oversized_enabled(true);
3508        assert!(config.oversized_transfer.enabled);
3509        let config = NostrServerTransportConfig::default()
3510            .with_oversized_transfer(OversizedTransferConfig::enabled().with_threshold(123));
3511        assert!(config.oversized_transfer.enabled);
3512        assert_eq!(config.oversized_transfer.threshold, 123);
3513    }
3514
3515    #[tokio::test]
3516    async fn test_announcement_includes_oversized_tag_when_enabled() {
3517        let server = make_server_with_oversized(true).await;
3518        let names = first_tag_values(&server.announcement_manager.get_common_tags());
3519        assert!(
3520            names.contains(&"support_oversized_transfer".to_string()),
3521            "announcement common tags must advertise oversized support when enabled"
3522        );
3523    }
3524
3525    #[tokio::test]
3526    async fn test_announcement_omits_oversized_tag_when_disabled() {
3527        let server = make_server_with_oversized(false).await;
3528        let names = first_tag_values(&server.announcement_manager.get_common_tags());
3529        assert!(
3530            !names.contains(&"support_oversized_transfer".to_string()),
3531            "announcement must not advertise oversized support when disabled"
3532        );
3533    }
3534
3535    #[tokio::test]
3536    async fn test_first_response_snapshot_includes_oversized_tag_when_enabled() {
3537        let server = make_server_with_oversized(true).await;
3538        let snapshot = server.announcement_manager.common_tags_snapshot();
3539        let mut tags = Vec::new();
3540        snapshot.append_common_response_tags(&mut tags);
3541        let names = first_tag_values(&tags);
3542        assert!(
3543            names.contains(&"support_oversized_transfer".to_string()),
3544            "first-response replay must carry the oversized tag when enabled"
3545        );
3546    }
3547
3548    #[tokio::test]
3549    async fn test_first_response_snapshot_omits_oversized_tag_when_disabled() {
3550        let server = make_server_with_oversized(false).await;
3551        let snapshot = server.announcement_manager.common_tags_snapshot();
3552        let mut tags = Vec::new();
3553        snapshot.append_common_response_tags(&mut tags);
3554        let names = first_tag_values(&tags);
3555        assert!(!names.contains(&"support_oversized_transfer".to_string()));
3556    }
3557
3558    #[test]
3559    fn test_server_learns_client_oversized_only_when_enabled() {
3560        // Unit-level check that `learn_peer_capabilities` parses the client tag and
3561        // the `enabled && supports` truth table holds. The production gate in
3562        // `event_loop` is exercised end-to-end by the integration tests
3563        // `server_gate_allows_oversized_when_enabled` /
3564        // `server_gate_blocks_oversized_when_disabled` in tests/transport_integration.rs.
3565        let oversized_tag = Tag::custom(
3566            TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()),
3567            Vec::<String>::new(),
3568        );
3569        let discovered = learn_peer_capabilities(&[oversized_tag]);
3570        assert!(discovered.supports_oversized_transfer);
3571
3572        // Disabled server: client flag must be ignored.
3573        let mut session = ClientSession::new(false);
3574        let oversized_enabled = false;
3575        session.supports_oversized_transfer |=
3576            oversized_enabled && discovered.supports_oversized_transfer;
3577        assert!(!session.supports_oversized_transfer);
3578
3579        // Enabled server: client flag is learned.
3580        let oversized_enabled = true;
3581        session.supports_oversized_transfer |=
3582            oversized_enabled && discovered.supports_oversized_transfer;
3583        assert!(session.supports_oversized_transfer);
3584    }
3585
3586    // ── CEP-41 open-stream capability advertisement ─────────────
3587
3588    #[test]
3589    fn test_open_stream_support_tags_helper() {
3590        // Disabled (the default) → no tag; enabled → the single-element tag.
3591        assert!(open_stream_support_tags(&OpenStreamConfig::default()).is_empty());
3592        let names = first_tag_values(&open_stream_support_tags(&OpenStreamConfig::enabled()));
3593        assert_eq!(names, vec!["support_open_stream"]);
3594    }
3595
3596    #[test]
3597    fn test_internal_common_capability_tags_merges_both() {
3598        let config = NostrServerTransportConfig::default()
3599            .with_oversized_enabled(true)
3600            .with_open_stream(OpenStreamConfig::enabled());
3601        let names = first_tag_values(&internal_common_capability_tags(&config));
3602        assert!(names.contains(&"support_oversized_transfer".to_string()));
3603        assert!(names.contains(&"support_open_stream".to_string()));
3604    }
3605
3606    #[tokio::test]
3607    async fn test_announcement_includes_open_stream_tag_when_enabled() {
3608        let config = NostrServerTransportConfig {
3609            open_stream: OpenStreamConfig::enabled(),
3610            ..Default::default()
3611        };
3612        let pool: Arc<dyn RelayPoolTrait> = Arc::new(crate::relay::mock::MockRelayPool::new());
3613        let server = NostrServerTransport::with_relay_pool(config, pool)
3614            .await
3615            .expect("server transport construction");
3616        let names = first_tag_values(&server.announcement_manager.get_common_tags());
3617        assert!(
3618            names.contains(&"support_open_stream".to_string()),
3619            "announcement must advertise open-stream support when enabled"
3620        );
3621    }
3622
3623    #[tokio::test]
3624    async fn test_announcement_omits_open_stream_tag_when_disabled() {
3625        // The default config has open-stream disabled (opt-in).
3626        let pool: Arc<dyn RelayPoolTrait> = Arc::new(crate::relay::mock::MockRelayPool::new());
3627        let server =
3628            NostrServerTransport::with_relay_pool(NostrServerTransportConfig::default(), pool)
3629                .await
3630                .expect("server transport construction");
3631        let names = first_tag_values(&server.announcement_manager.get_common_tags());
3632        assert!(!names.contains(&"support_open_stream".to_string()));
3633    }
3634
3635    #[test]
3636    fn test_server_learns_client_open_stream_only_when_enabled() {
3637        let open_stream_tag = Tag::custom(
3638            TagKind::Custom(tags::SUPPORT_OPEN_STREAM.into()),
3639            Vec::<String>::new(),
3640        );
3641        let discovered = learn_peer_capabilities(&[open_stream_tag]);
3642        assert!(discovered.supports_open_stream);
3643
3644        // Disabled server: the client flag is ignored.
3645        let mut session = ClientSession::new(false);
3646        let open_stream_enabled = false;
3647        session.supports_open_stream |= open_stream_enabled && discovered.supports_open_stream;
3648        assert!(!session.supports_open_stream);
3649
3650        // Enabled server: the client flag is learned.
3651        let open_stream_enabled = true;
3652        session.supports_open_stream |= open_stream_enabled && discovered.supports_open_stream;
3653        assert!(session.supports_open_stream);
3654    }
3655
3656    // ── CEP-41 response deferral (try_defer_open_stream_response) ───────
3657
3658    /// A no-op writer (publishes nothing) for exercising the deferral decision.
3659    fn deferral_test_writer(token: &str) -> OpenStreamWriter {
3660        let publish_frame: PublishFrame = Arc::new(|_frame: JsonRpcNotification| {
3661            Box::pin(async move { Ok(EventId::all_zeros()) })
3662        });
3663        OpenStreamWriter::new(OpenStreamWriterOptions {
3664            progress_token: token.to_string(),
3665            publish_frame,
3666            content_type: None,
3667            on_close: None,
3668            on_abort: None,
3669            idle_timeout: None,
3670            probe_timeout: Duration::from_millis(20_000),
3671        })
3672    }
3673
3674    /// Install a writer slot + `token → event_id` index entry, mirroring
3675    /// `create_open_stream_writer`.
3676    fn install_slot(
3677        state: &ServerOpenStreamState,
3678        event_id: &str,
3679        writer: OpenStreamWriter,
3680        terminated: bool,
3681    ) {
3682        let token = writer.progress_token().to_string();
3683        let snapshot = RouteSnapshot {
3684            client_pubkey: Keys::generate().public_key(),
3685            original_request_id: serde_json::json!(1),
3686            is_encrypted: false,
3687            mirrored_wrap_kind: None,
3688        };
3689        state.lock_slots().insert(
3690            event_id.to_string(),
3691            OpenStreamSlot {
3692                writer,
3693                snapshot,
3694                pending_response: None,
3695                terminated,
3696            },
3697        );
3698        state.lock_token_index().insert(token, event_id.to_string());
3699    }
3700
3701    fn dummy_response() -> JsonRpcMessage {
3702        JsonRpcMessage::Response(JsonRpcResponse {
3703            jsonrpc: "2.0".to_string(),
3704            id: serde_json::json!(1),
3705            result: serde_json::json!({ "ok": true }),
3706        })
3707    }
3708
3709    #[tokio::test]
3710    async fn try_defer_open_stream_response_branch_coverage() {
3711        let config = NostrServerTransportConfig::default()
3712            .with_open_stream(OpenStreamConfig::default().with_enabled(true));
3713        let pool: Arc<dyn RelayPoolTrait> = Arc::new(MockRelayPool::new());
3714        let transport = NostrServerTransport::with_relay_pool(config, pool)
3715            .await
3716            .expect("server transport");
3717
3718        // No slot for the event (`slots.get_mut` is None) → Passthrough.
3719        assert!(matches!(
3720            transport.try_defer_open_stream_response("evt-none", dummy_response()),
3721            OpenStreamDeferral::Passthrough(_)
3722        ));
3723
3724        // `!writer.has_started()` — writer created (progressToken present) but the
3725        // tool never streamed → drop the writer and Passthrough. The slot AND the
3726        // token index entry must both be removed so the unused writer cannot leak.
3727        install_slot(
3728            &transport.open_stream,
3729            "evt-unstarted",
3730            deferral_test_writer("tok-unstarted"),
3731            false,
3732        );
3733        assert!(matches!(
3734            transport.try_defer_open_stream_response("evt-unstarted", dummy_response()),
3735            OpenStreamDeferral::Passthrough(_)
3736        ));
3737        assert!(
3738            transport
3739                .open_stream
3740                .lock_slots()
3741                .get("evt-unstarted")
3742                .is_none(),
3743            "unstarted writer slot must be removed (no leak)"
3744        );
3745        assert!(
3746            transport
3747                .open_stream
3748                .lock_token_index()
3749                .get("tok-unstarted")
3750                .is_none(),
3751            "unstarted writer token index must be removed (no leak)"
3752        );
3753
3754        // `slot.terminated` (the function's "Ordering B") — started writer whose
3755        // stream already closed/aborted → deliver now from the snapshot (SendNow);
3756        // the slot + token index are freed.
3757        let terminal = deferral_test_writer("tok-terminal");
3758        terminal.start().await.expect("start");
3759        install_slot(&transport.open_stream, "evt-terminal", terminal, true);
3760        assert!(matches!(
3761            transport.try_defer_open_stream_response("evt-terminal", dummy_response()),
3762            OpenStreamDeferral::SendNow { .. }
3763        ));
3764        assert!(transport
3765            .open_stream
3766            .lock_slots()
3767            .get("evt-terminal")
3768            .is_none());
3769        assert!(transport
3770            .open_stream
3771            .lock_token_index()
3772            .get("tok-terminal")
3773            .is_none());
3774
3775        // `else` of `slot.terminated` (the function's "Ordering A") — started
3776        // writer, stream still open → Deferred. The response is stashed and the
3777        // slot retained for the close/abort hook to flush.
3778        let open = deferral_test_writer("tok-open");
3779        open.start().await.expect("start");
3780        install_slot(&transport.open_stream, "evt-open", open, false);
3781        assert!(matches!(
3782            transport.try_defer_open_stream_response("evt-open", dummy_response()),
3783            OpenStreamDeferral::Deferred
3784        ));
3785        {
3786            let slots = transport.open_stream.lock_slots();
3787            let slot = slots.get("evt-open").expect("deferred slot retained");
3788            assert!(
3789                slot.pending_response.is_some(),
3790                "the deferred response must be stashed for the hook to flush"
3791            );
3792        }
3793
3794        // Disabled gate — a server with open-stream disabled never exposes a
3795        // writer, so `send_response` never reaches the deferral decision at all.
3796        let disabled = NostrServerTransport::with_relay_pool(
3797            NostrServerTransportConfig::default()
3798                .with_open_stream(OpenStreamConfig::default().with_enabled(false)),
3799            Arc::new(MockRelayPool::new()) as Arc<dyn RelayPoolTrait>,
3800        )
3801        .await
3802        .expect("disabled server transport");
3803        install_slot(
3804            &disabled.open_stream,
3805            "evt-disabled",
3806            deferral_test_writer("tok-disabled"),
3807            false,
3808        );
3809        assert!(
3810            disabled.get_open_stream_writer("evt-disabled").is_none(),
3811            "a disabled server must not expose writers (deferral never attempted)"
3812        );
3813    }
3814
3815    // ── CEP-41 writer keepalive sweep (server→client, silent-client abort) ──
3816
3817    /// A keepalive-armed writer whose publish closure is a no-op. The sweep
3818    /// drives `tick`; a missing `pong` aborts it on the probe deadline.
3819    fn keepalive_test_writer(token: &str, idle_ms: u64, probe_ms: u64) -> OpenStreamWriter {
3820        let publish_frame: PublishFrame = Arc::new(|_frame: JsonRpcNotification| {
3821            Box::pin(async move { Ok(EventId::all_zeros()) })
3822        });
3823        OpenStreamWriter::new(OpenStreamWriterOptions {
3824            progress_token: token.to_string(),
3825            publish_frame,
3826            content_type: None,
3827            on_close: None,
3828            on_abort: None,
3829            idle_timeout: Some(Duration::from_millis(idle_ms)),
3830            probe_timeout: Duration::from_millis(probe_ms),
3831        })
3832    }
3833
3834    /// Regression: a client that silently disappears (sends no `pong`, no
3835    /// `abort`) is detected by the server-writer keepalive sweep — the writer is
3836    /// probed after the idle window and aborted once the probe times out, and the
3837    /// dead client's session is evicted (CEP-41 "release local state", mirroring
3838    /// the TS `handleProbeTimeout`). This is the rs-sdk port of the TS 0.13.8 fix;
3839    /// without it the writer (and any upstream producer keyed on `is_active`)
3840    /// leaks indefinitely. `tokio::time::sleep` guarantees *at least* the requested
3841    /// duration, so the idle/probe margins are deterministic, not flaky.
3842    #[tokio::test]
3843    async fn sweep_aborts_writer_and_evicts_session_when_client_goes_silent() {
3844        let config = NostrServerTransportConfig::default()
3845            .with_open_stream(OpenStreamConfig::default().with_enabled(true));
3846        let pool: Arc<dyn RelayPoolTrait> = Arc::new(MockRelayPool::new());
3847        let mut transport = NostrServerTransport::with_relay_pool(config, pool)
3848            .await
3849            .expect("server transport");
3850
3851        let writer = keepalive_test_writer("tok-silent", 40, 60);
3852        writer.start().await.expect("start the stream");
3853        install_slot(&transport.open_stream, "evt-silent", writer.clone(), false);
3854        assert!(writer.is_active(), "writer starts active");
3855
3856        // Install a session for the slot's client and arm the eviction callback.
3857        let pubkey_hex = transport
3858            .open_stream
3859            .lock_slots()
3860            .get("evt-silent")
3861            .expect("slot")
3862            .snapshot
3863            .client_pubkey
3864            .to_hex();
3865        let evicted = Arc::new(std::sync::atomic::AtomicBool::new(false));
3866        let evicted_cb = evicted.clone();
3867        transport.sessions.set_eviction_callback(Arc::new(move |_| {
3868            evicted_cb.store(true, std::sync::atomic::Ordering::SeqCst);
3869        }));
3870        transport
3871            .sessions
3872            .get_or_create_session(&pubkey_hex, false, &transport.event_routes)
3873            .await;
3874        assert!(
3875            transport.sessions.get_session(&pubkey_hex).await.is_some(),
3876            "session present before the sweep"
3877        );
3878
3879        let relay_pool = Arc::clone(&transport.base.relay_pool);
3880        let encryption_mode = transport.config.encryption_mode;
3881        let gift_wrap_mode = transport.config.gift_wrap_mode;
3882
3883        // Past the idle window: the sweep probes (idle → SendPing). The writer is
3884        // NOT aborted yet — it is waiting for a `pong` the silent client never sends.
3885        tokio::time::sleep(Duration::from_millis(60)).await;
3886        NostrServerTransport::sweep_open_stream_sessions(
3887            &transport.open_stream,
3888            &relay_pool,
3889            encryption_mode,
3890            gift_wrap_mode,
3891            &transport.sessions,
3892        )
3893        .await;
3894        assert!(
3895            writer.is_active(),
3896            "writer must remain active while a probe is in flight"
3897        );
3898        assert!(
3899            transport.sessions.get_session(&pubkey_hex).await.is_some(),
3900            "session must survive a mere probe (client not yet declared dead)"
3901        );
3902
3903        // Past the probe timeout: the sweep aborts (probe deadline → Abort) and
3904        // evicts the dead client's session.
3905        tokio::time::sleep(Duration::from_millis(80)).await;
3906        NostrServerTransport::sweep_open_stream_sessions(
3907            &transport.open_stream,
3908            &relay_pool,
3909            encryption_mode,
3910            gift_wrap_mode,
3911            &transport.sessions,
3912        )
3913        .await;
3914        assert!(
3915            !writer.is_active(),
3916            "writer must abort after a silent client misses the probe deadline"
3917        );
3918        assert!(
3919            transport.sessions.get_session(&pubkey_hex).await.is_none(),
3920            "dead client's session must be evicted on probe timeout"
3921        );
3922        assert!(
3923            evicted.load(std::sync::atomic::Ordering::SeqCst),
3924            "eviction callback must fire on probe-timeout session release"
3925        );
3926    }
3927}