From 0c0a37c2d4ca22a3ff678d369b15785a3e8ab126 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:03:43 +0530 Subject: [PATCH 01/17] refactor: extract server inbound notification dispatcher --- src/transport/nostr-server-transport.ts | 208 ++---------------- .../inbound-notification-dispatcher.ts | 205 +++++++++++++++++ 2 files changed, 228 insertions(+), 185 deletions(-) create mode 100644 src/transport/nostr-server/inbound-notification-dispatcher.ts diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 208b452..90b0496 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -59,7 +59,6 @@ import { import { OpenStreamReceiver, OpenStreamWriter, - buildOpenStreamAcceptFrame, buildOpenStreamAbortFrame, buildOpenStreamPingFrame, buildOpenStreamPongFrame, @@ -70,10 +69,10 @@ import { } from './oversized-transfer/constants.js'; import { learnPeerCapabilities } from './discovery-tags.js'; import { - sendAcceptFrame, sendOversizedServerResponse, } from './nostr-server/oversized-server-handler.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; +import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; /** * Options for configuring the NostrServerTransport. @@ -243,6 +242,7 @@ export class NostrServerTransport private readonly oversizedThreshold: number; private readonly oversizedChunkSize: number; private readonly openStreamEnabled: boolean; + private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; constructor(options: NostrServerTransportOptions) { super('nostr-server-transport', options); @@ -414,6 +414,21 @@ export class NostrServerTransport } this.announcementManager.setInternalCommonTags(internalCommonTags); + + this.inboundNotificationDispatcher = new InboundNotificationDispatcher({ + openStreamReceiver: this.openStreamReceiver, + oversizedReceiver: this.oversizedReceiver, + openStreamWriters: this.openStreamWriters, + correlationStore: this.correlationStore, + sendNotification: this.sendNotification.bind(this), + handleIncomingRequest: this.handleIncomingRequest.bind(this), + handleIncomingNotification: this.handleIncomingNotification.bind(this), + cleanupDroppedRequest: this.cleanupDroppedRequest.bind(this), + shouldInjectRequestEventId: this.shouldInjectRequestEventId, + injectClientPubkey: this.injectClientPubkey, + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); } /** @@ -1334,189 +1349,12 @@ export class NostrServerTransport } else if (isJSONRPCNotification(inboundMessage)) { this.handleIncomingNotification(event.pubkey, inboundMessage); - if ( - inboundMessage.method === 'notifications/progress' && - OpenStreamReceiver.isOpenStreamFrame(inboundMessage) - ) { - const frame = inboundMessage.params?.cvm as - | { frameType?: string; reason?: string } - | undefined; - - if (frame?.frameType === 'abort') { - const progressToken = String( - inboundMessage.params?.progressToken ?? '', - ); - const eventId = - this.correlationStore.getEventIdByProgressToken(progressToken); - const writer = eventId - ? this.openStreamWriters.get(eventId) - : undefined; - - if (writer) { - void writer.abort(frame.reason).catch((err: unknown) => { - this.logger.error( - 'Open stream abort propagation failed (server)', - { - error: err instanceof Error ? err.message : String(err), - pubkey: event.pubkey, - progressToken, - }, - ); - this.onerror?.( - err instanceof Error ? err : new Error(String(err)), - ); - }); - } - - return; - } - - if (frame?.frameType === 'ping') { - const progressToken = String( - inboundMessage.params?.progressToken ?? '', - ); - const nonce = - 'nonce' in frame && typeof frame.nonce === 'string' - ? frame.nonce - : ''; - const eventId = - this.correlationStore.getEventIdByProgressToken(progressToken); - const writer = eventId - ? this.openStreamWriters.get(eventId) - : undefined; - - if (writer) { - void writer.pong(nonce).catch((err: unknown) => { - this.logger.error('Open stream ping handling failed (server)', { - error: err instanceof Error ? err.message : String(err), - pubkey: event.pubkey, - progressToken, - }); - this.onerror?.( - err instanceof Error ? err : new Error(String(err)), - ); - }); - - return; - } - } - - this.openStreamReceiver - .processFrame(inboundMessage) - .then(async () => { - const frameType = frame?.frameType; - - if (frameType === 'start' && session.supportsOpenStream) { - await this.sendNotification(event.pubkey, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamAcceptFrame({ - progressToken: String( - inboundMessage.params?.progressToken ?? '', - ), - progress: Number(inboundMessage.params?.progress ?? 0) + 1, - }), - }); - } - }) - .catch((err: unknown) => { - this.logger.error('Open stream error (server)', { - error: err instanceof Error ? err.message : String(err), - pubkey: event.pubkey, - }); - this.onerror?.( - err instanceof Error ? err : new Error(String(err)), - ); - }); - return; - } - - if ( - inboundMessage.method === 'notifications/progress' && - OversizedTransferReceiver.isOversizedFrame(inboundMessage) - ) { - this.oversizedReceiver - .processFrame(inboundMessage) - .then(async (synthetic) => { - if (synthetic === null) { - if ( - ( - inboundMessage.params?.cvm as - | { frameType?: string } - | undefined - )?.frameType === 'start' && - shouldSendAccept - ) { - await sendAcceptFrame( - { - clientPubkey: event.pubkey, - progressToken: String( - inboundMessage.params?.progressToken ?? '', - ), - }, - { - sendNotification: this.sendNotification.bind(this), - }, - ).catch((err: unknown) => { - this.logger.error('Failed to send oversized accept', { - error: err instanceof Error ? err.message : String(err), - }); - }); - } - return; - } - - if (isJSONRPCRequest(synthetic)) { - this.handleIncomingRequest( - event, - event.id, - synthetic, - event.pubkey, - wrapKind, - ); - - if (this.shouldInjectRequestEventId) { - injectRequestEventId(synthetic, event.id); - } - - if (this.injectClientPubkey) { - injectClientPubkey(synthetic, event.pubkey); - } - } else if (isJSONRPCNotification(synthetic)) { - this.handleIncomingNotification(event.pubkey, synthetic); - } - - void dispatch(0, synthetic) - .then((forwarded) => { - if (!forwarded) { - this.cleanupDroppedRequest(synthetic); - } - }) - .catch((err: unknown) => { - this.logger.error( - 'Error dispatching reassembled oversized message', - { - error: err instanceof Error ? err.message : String(err), - pubkey: event.pubkey, - }, - ); - this.onerror?.( - err instanceof Error - ? err - : new Error('oversized dispatch failed'), - ); - }); - }) - .catch((err: unknown) => { - this.logger.error('Oversized transfer error (server)', { - error: err instanceof Error ? err.message : String(err), - }); - this.onerror?.( - err instanceof Error ? err : new Error(String(err)), - ); - }); - return; - } + const intercepted = this.inboundNotificationDispatcher.tryIntercept( + inboundMessage, + { event, session, shouldSendAccept, wrapKind }, + (msg) => dispatch(0, msg), + ); + if (intercepted) return; } void dispatch(0, inboundMessage) diff --git a/src/transport/nostr-server/inbound-notification-dispatcher.ts b/src/transport/nostr-server/inbound-notification-dispatcher.ts new file mode 100644 index 0000000..977d388 --- /dev/null +++ b/src/transport/nostr-server/inbound-notification-dispatcher.ts @@ -0,0 +1,205 @@ +import { + type JSONRPCMessage, + type JSONRPCNotification, + type JSONRPCRequest, + isJSONRPCRequest, + isJSONRPCNotification, +} from '@modelcontextprotocol/sdk/types.js'; +import { type NostrEvent } from 'nostr-tools'; +import { type Logger } from '../../core/utils/logger.js'; +import { + OpenStreamReceiver, + OpenStreamWriter, + buildOpenStreamAcceptFrame, +} from '../open-stream/index.js'; +import { OversizedTransferReceiver } from '../oversized-transfer/index.js'; +import { type CorrelationStore } from './correlation-store.js'; +import { type ClientSession } from './session-store.js'; +import { sendAcceptFrame } from './oversized-server-handler.js'; +import { injectClientPubkey, injectRequestEventId } from '../../core/utils/utils.js'; + +export interface InboundNotificationDispatcherDeps { + openStreamReceiver: OpenStreamReceiver; + oversizedReceiver: OversizedTransferReceiver; + openStreamWriters: Map; + correlationStore: CorrelationStore; + sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; + handleIncomingRequest: ( + event: NostrEvent, + eventId: string, + request: JSONRPCRequest, + clientPubkey: string, + wrapKind?: number, + ) => void; + handleIncomingNotification: (clientPubkey: string, notification: JSONRPCMessage) => void; + cleanupDroppedRequest: (message: JSONRPCMessage) => void; + shouldInjectRequestEventId: boolean; + injectClientPubkey: boolean; + logger: Logger; + onerror?: (error: Error) => void; +} + +export class InboundNotificationDispatcher { + constructor(private deps: InboundNotificationDispatcherDeps) {} + + /** + * Returns true if the notification was intercepted (CEP-22 or CEP-41). + * Returns false if it should fall through to normal middleware dispatch. + */ + public tryIntercept( + inboundMessage: JSONRPCNotification, + ctx: { + event: NostrEvent; + session: ClientSession; + shouldSendAccept: boolean; + wrapKind?: number; + }, + dispatch: (msg: JSONRPCMessage) => Promise, + ): boolean { + const { event, session, shouldSendAccept, wrapKind } = ctx; + + if ( + inboundMessage.method === 'notifications/progress' && + OpenStreamReceiver.isOpenStreamFrame(inboundMessage) + ) { + const frame = inboundMessage.params?.cvm as + | { frameType?: string; reason?: string } + | undefined; + + if (frame?.frameType === 'abort') { + const progressToken = String(inboundMessage.params?.progressToken ?? ''); + const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); + const writer = eventId ? this.deps.openStreamWriters.get(eventId) : undefined; + + if (writer) { + void writer.abort(frame.reason).catch((err: unknown) => { + this.deps.logger.error('Open stream abort propagation failed (server)', { + error: err instanceof Error ? err.message : String(err), + pubkey: event.pubkey, + progressToken, + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + } + + return true; + } + + if (frame?.frameType === 'ping') { + const progressToken = String(inboundMessage.params?.progressToken ?? ''); + const nonce = 'nonce' in frame && typeof frame.nonce === 'string' ? frame.nonce : ''; + const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); + const writer = eventId ? this.deps.openStreamWriters.get(eventId) : undefined; + + if (writer) { + void writer.pong(nonce).catch((err: unknown) => { + this.deps.logger.error('Open stream ping handling failed (server)', { + error: err instanceof Error ? err.message : String(err), + pubkey: event.pubkey, + progressToken, + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + + return true; + } + } + + this.deps.openStreamReceiver + .processFrame(inboundMessage) + .then(async () => { + const frameType = frame?.frameType; + + if (frameType === 'start' && session.supportsOpenStream) { + await this.deps.sendNotification(event.pubkey, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamAcceptFrame({ + progressToken: String(inboundMessage.params?.progressToken ?? ''), + progress: Number(inboundMessage.params?.progress ?? 0) + 1, + }), + }); + } + }) + .catch((err: unknown) => { + this.deps.logger.error('Open stream error (server)', { + error: err instanceof Error ? err.message : String(err), + pubkey: event.pubkey, + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + return true; + } + + if ( + inboundMessage.method === 'notifications/progress' && + OversizedTransferReceiver.isOversizedFrame(inboundMessage) + ) { + this.deps.oversizedReceiver + .processFrame(inboundMessage) + .then(async (synthetic) => { + if (synthetic === null) { + if ( + (inboundMessage.params?.cvm as { frameType?: string } | undefined)?.frameType === + 'start' && + shouldSendAccept + ) { + await sendAcceptFrame( + { + clientPubkey: event.pubkey, + progressToken: String(inboundMessage.params?.progressToken ?? ''), + }, + { + sendNotification: this.deps.sendNotification.bind(this.deps), + }, + ).catch((err: unknown) => { + this.deps.logger.error('Failed to send oversized accept', { + error: err instanceof Error ? err.message : String(err), + }); + }); + } + return; + } + + if (isJSONRPCRequest(synthetic)) { + this.deps.handleIncomingRequest(event, event.id, synthetic, event.pubkey, wrapKind); + + if (this.deps.shouldInjectRequestEventId) { + injectRequestEventId(synthetic, event.id); + } + + if (this.deps.injectClientPubkey) { + injectClientPubkey(synthetic, event.pubkey); + } + } else if (isJSONRPCNotification(synthetic)) { + this.deps.handleIncomingNotification(event.pubkey, synthetic); + } + + void dispatch(synthetic) + .then((forwarded) => { + if (!forwarded) { + this.deps.cleanupDroppedRequest(synthetic); + } + }) + .catch((err: unknown) => { + this.deps.logger.error('Error dispatching reassembled oversized message', { + error: err instanceof Error ? err.message : String(err), + pubkey: event.pubkey, + }); + this.deps.onerror?.( + err instanceof Error ? err : new Error('oversized dispatch failed'), + ); + }); + }) + .catch((err: unknown) => { + this.deps.logger.error('Oversized transfer error (server)', { + error: err instanceof Error ? err.message : String(err), + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + return true; + } + + return false; + } +} From f279d7c3eca18e68c66093fddddd7dc1123ff374 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:10:43 +0530 Subject: [PATCH 02/17] refactor: extract server outbound response router --- src/transport/nostr-server-transport.ts | 173 +++------------- .../nostr-server/outbound-response-router.ts | 194 ++++++++++++++++++ 2 files changed, 217 insertions(+), 150 deletions(-) create mode 100644 src/transport/nostr-server/outbound-response-router.ts diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 90b0496..cd4455f 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -1,9 +1,4 @@ import { - InitializeResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, type ListToolsResult, isJSONRPCRequest, isJSONRPCNotification, @@ -68,11 +63,9 @@ import { DEFAULT_OVERSIZED_THRESHOLD, } from './oversized-transfer/constants.js'; import { learnPeerCapabilities } from './discovery-tags.js'; -import { - sendOversizedServerResponse, -} from './nostr-server/oversized-server-handler.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; +import { OutboundResponseRouter } from './nostr-server/outbound-response-router.js'; /** * Options for configuring the NostrServerTransport. @@ -243,6 +236,7 @@ export class NostrServerTransport private readonly oversizedChunkSize: number; private readonly openStreamEnabled: boolean; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; + private readonly outboundResponseRouter: OutboundResponseRouter; constructor(options: NostrServerTransportOptions) { super('nostr-server-transport', options); @@ -429,6 +423,26 @@ export class NostrServerTransport logger: this.logger, onerror: (error) => this.onerror?.(error), }); + + this.outboundResponseRouter = new OutboundResponseRouter({ + correlationStore: this.correlationStore, + sessionStore: this.sessionStore, + announcementManager: this.announcementManager, + openStreamWriters: this.openStreamWriters, + pendingOpenStreamResponses: this.pendingOpenStreamResponses, + oversizedConfig: { + enabled: this.oversizedEnabled, + threshold: this.oversizedThreshold, + chunkSize: this.oversizedChunkSize, + }, + applyListToolsResultTransformers: this.applyListToolsResultTransformers.bind(this), + buildOutboundTags: this.buildServerOutboundTags.bind(this), + createResponseTags: this.createResponseTags.bind(this), + chooseGiftWrapKind: this.chooseServerOutboundGiftWrapKind.bind(this), + sendMcpMessage: this.sendMcpMessage.bind(this), + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); } /** @@ -814,148 +828,7 @@ export class NostrServerTransport private async handleResponse( response: JSONRPCResponse | JSONRPCErrorResponse, ): Promise { - // Handle special announcement responses - if (response.id === 'announcement') { - const wasHandled = - await this.announcementManager.handleAnnouncementResponse(response); - if (wasHandled && isJSONRPCResultResponse(response)) { - if (InitializeResultSchema.safeParse(response.result).success) { - this.logger.info('Initialized'); - } - } - return; - } - - // Find the event route using O(1) lookup - const nostrEventId = response.id as string; - const existingOpenStreamWriter = this.openStreamWriters.get(nostrEventId); - if (existingOpenStreamWriter && existingOpenStreamWriter.isActive) { - this.pendingOpenStreamResponses.set(nostrEventId, response); - return; - } - - const route = this.correlationStore.popEventRoute(nostrEventId); - - if (!route) { - this.onerror?.( - new Error(`No pending request found for response ID: ${response.id}`), - ); - return; - } - - const session = this.sessionStore.getSession(route.clientPubkey); - if (!session) { - this.onerror?.( - new Error(`No session found for client: ${route.clientPubkey}`), - ); - return; - } - - const parsedListToolsResult = isJSONRPCResultResponse(response) - ? ListToolsResultSchema.safeParse(response.result) - : null; - - const responseToSend = parsedListToolsResult?.success - ? { - ...response, - result: this.applyListToolsResultTransformers( - parsedListToolsResult.data, - ), - } - : response; - - // Restore the original request ID in the response - responseToSend.id = route.originalRequestId; - - // CEP-22 Oversized Transfer (proactive path for server responses) - if ( - this.oversizedEnabled && - route.progressToken && - session.supportsOversizedTransfer - ) { - // Serialize before restoring id so the client receives the correct id. - const serialized = JSON.stringify(responseToSend); - const byteLength = new TextEncoder().encode(serialized).byteLength; - if (byteLength > this.oversizedThreshold) { - const continuationFrameTags = this.createResponseTags( - route.clientPubkey, - nostrEventId, - ); - const startFrameTags = this.buildServerOutboundTags({ - baseTags: continuationFrameTags, - session, - }); - const giftWrapKind = this.chooseServerOutboundGiftWrapKind({ - session, - fallbackWrapKind: route.wrapKind, - }); - - await sendOversizedServerResponse( - { - serialized, - clientPubkey: route.clientPubkey, - progressToken: route.progressToken, - startFrameTags, - continuationFrameTags, - isEncrypted: session.isEncrypted, - giftWrapKind, - }, - { - chunkSizeBytes: this.oversizedChunkSize, - }, - { - sendMcpMessage: this.sendMcpMessage.bind(this), - logger: this.logger, - }, - ); - return; - } - } - - // Send the response back to the original requester - const tags = this.buildServerOutboundTags({ - baseTags: this.createResponseTags(route.clientPubkey, nostrEventId), - session, - }); - - const giftWrapKind = this.chooseServerOutboundGiftWrapKind({ - session, - fallbackWrapKind: route.wrapKind, - }); - - // Attach pricing tags to capability list responses so clients can access CEP-8 pricing - if (isJSONRPCResultResponse(responseToSend)) { - const result = responseToSend.result; - if ( - ListToolsResultSchema.safeParse(result).success || - ListResourcesResultSchema.safeParse(result).success || - ListResourceTemplatesResultSchema.safeParse(result).success || - ListPromptsResultSchema.safeParse(result).success - ) { - tags.push(...this.announcementManager.getPricingTags()); - } - } - - try { - await this.sendMcpMessage( - responseToSend, - route.clientPubkey, - CTXVM_MESSAGES_KIND, - tags, - session.isEncrypted, - undefined, - giftWrapKind, - ); - } catch (error) { - this.correlationStore.registerEventRoute( - nostrEventId, - route.clientPubkey, - route.originalRequestId, - route.progressToken, - route.wrapKind, - ); - throw error; - } + await this.outboundResponseRouter.route(response); } /** diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts new file mode 100644 index 0000000..2d76631 --- /dev/null +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -0,0 +1,194 @@ +import { + type JSONRPCResponse, + type JSONRPCErrorResponse, + isJSONRPCResultResponse, + InitializeResultSchema, + ListToolsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListPromptsResultSchema, + type ListToolsResult, + type JSONRPCMessage, +} from '@modelcontextprotocol/sdk/types.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { type CorrelationStore } from './correlation-store.js'; +import { type ClientSession, type SessionStore } from './session-store.js'; +import { type AnnouncementManager } from './announcement-manager.js'; +import { type OpenStreamWriter } from '../open-stream/index.js'; +import { CTXVM_MESSAGES_KIND } from '../../core/constants.js'; +import { sendOversizedServerResponse } from './oversized-server-handler.js'; + +export interface OutboundResponseRouterDeps { + correlationStore: CorrelationStore; + sessionStore: SessionStore; + announcementManager: AnnouncementManager; + openStreamWriters: Map; + pendingOpenStreamResponses: Map; + oversizedConfig: { enabled: boolean; threshold: number; chunkSize: number }; + applyListToolsResultTransformers: (result: ListToolsResult) => ListToolsResult; + buildOutboundTags: (params: { baseTags: readonly string[][]; session: ClientSession }) => string[][]; + createResponseTags: (clientPubkey: string, eventId: string) => string[][]; + chooseGiftWrapKind: (params: { session: ClientSession; fallbackWrapKind?: number }) => number | undefined; + sendMcpMessage: ( + message: JSONRPCMessage, + targetPubkey: string, + kind: number, + tags?: string[][], + encrypt?: boolean, + onCreateEvent?: (eventId: string) => void, + giftWrapKind?: number, + ) => Promise; + logger: Logger; + onerror?: (error: Error) => void; +} + +export class OutboundResponseRouter { + constructor(private deps: OutboundResponseRouterDeps) {} + + public async route( + response: JSONRPCResponse | JSONRPCErrorResponse, + ): Promise { + // Handle special announcement responses + if (response.id === 'announcement') { + const wasHandled = + await this.deps.announcementManager.handleAnnouncementResponse(response); + if (wasHandled && isJSONRPCResultResponse(response)) { + if (InitializeResultSchema.safeParse(response.result).success) { + this.deps.logger.info('Initialized'); + } + } + return; + } + + // Find the event route using O(1) lookup + const nostrEventId = response.id as string; + const existingOpenStreamWriter = this.deps.openStreamWriters.get(nostrEventId); + if (existingOpenStreamWriter && existingOpenStreamWriter.isActive) { + this.deps.pendingOpenStreamResponses.set(nostrEventId, response); + return; + } + + const route = this.deps.correlationStore.popEventRoute(nostrEventId); + + if (!route) { + this.deps.onerror?.( + new Error(`No pending request found for response ID: ${response.id}`), + ); + return; + } + + const session = this.deps.sessionStore.getSession(route.clientPubkey); + if (!session) { + this.deps.onerror?.( + new Error(`No session found for client: ${route.clientPubkey}`), + ); + return; + } + + const parsedListToolsResult = isJSONRPCResultResponse(response) + ? ListToolsResultSchema.safeParse(response.result) + : null; + + const responseToSend = parsedListToolsResult?.success + ? { + ...response, + result: this.deps.applyListToolsResultTransformers( + parsedListToolsResult.data, + ), + } + : response; + + // Restore the original request ID in the response + responseToSend.id = route.originalRequestId; + + // CEP-22 Oversized Transfer (proactive path for server responses) + if ( + this.deps.oversizedConfig.enabled && + route.progressToken && + session.supportsOversizedTransfer + ) { + // Serialize before restoring id so the client receives the correct id. + const serialized = JSON.stringify(responseToSend); + const byteLength = new TextEncoder().encode(serialized).byteLength; + if (byteLength > this.deps.oversizedConfig.threshold) { + const continuationFrameTags = this.deps.createResponseTags( + route.clientPubkey, + nostrEventId, + ); + const startFrameTags = this.deps.buildOutboundTags({ + baseTags: continuationFrameTags, + session, + }); + const giftWrapKind = this.deps.chooseGiftWrapKind({ + session, + fallbackWrapKind: route.wrapKind, + }); + + await sendOversizedServerResponse( + { + serialized, + clientPubkey: route.clientPubkey, + progressToken: route.progressToken, + startFrameTags, + continuationFrameTags, + isEncrypted: session.isEncrypted, + giftWrapKind, + }, + { + chunkSizeBytes: this.deps.oversizedConfig.chunkSize, + }, + { + sendMcpMessage: this.deps.sendMcpMessage, + logger: this.deps.logger, + }, + ); + return; + } + } + + // Send the response back to the original requester + const tags = this.deps.buildOutboundTags({ + baseTags: this.deps.createResponseTags(route.clientPubkey, nostrEventId), + session, + }); + + const giftWrapKind = this.deps.chooseGiftWrapKind({ + session, + fallbackWrapKind: route.wrapKind, + }); + + // Attach pricing tags to capability list responses so clients can access CEP-8 pricing + if (isJSONRPCResultResponse(responseToSend)) { + const result = responseToSend.result; + if ( + ListToolsResultSchema.safeParse(result).success || + ListResourcesResultSchema.safeParse(result).success || + ListResourceTemplatesResultSchema.safeParse(result).success || + ListPromptsResultSchema.safeParse(result).success + ) { + tags.push(...this.deps.announcementManager.getPricingTags()); + } + } + + try { + await this.deps.sendMcpMessage( + responseToSend, + route.clientPubkey, + CTXVM_MESSAGES_KIND, + tags, + session.isEncrypted, + undefined, + giftWrapKind, + ); + } catch (error) { + this.deps.correlationStore.registerEventRoute( + nostrEventId, + route.clientPubkey, + route.originalRequestId, + route.progressToken, + route.wrapKind, + ); + throw error; + } + } +} From 1f378962ffb1750c69764a47b5af370eb2376f3f Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:18:46 +0530 Subject: [PATCH 03/17] refactor: extract shared capability negotiator --- src/transport/capability-negotiator.ts | 204 ++++++++++++++++++++++++ src/transport/discovery-tags.ts | 98 ------------ src/transport/nostr-client-transport.ts | 121 +++----------- src/transport/nostr-server-transport.ts | 74 ++------- 4 files changed, 243 insertions(+), 254 deletions(-) create mode 100644 src/transport/capability-negotiator.ts delete mode 100644 src/transport/discovery-tags.ts diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts new file mode 100644 index 0000000..6d046b6 --- /dev/null +++ b/src/transport/capability-negotiator.ts @@ -0,0 +1,204 @@ +import { NOSTR_TAGS, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../core/constants.js'; +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { type NostrEvent } from 'nostr-tools'; +import { type ClientSession } from './nostr-server/session-store.js'; +import { queryTags } from '../core/utils/utils.js'; + +const NON_DISCOVERY_TAG_NAMES = new Set(['e', 'p']); + +export interface DiscoveredPeerCapabilities { + discoveryTags: string[][]; + supportsEncryption: boolean; + supportsEphemeralEncryption: boolean; + supportsOversizedTransfer: boolean; + supportsOpenStream: boolean; +} + +export interface PeerCapabilities { + supportsEncryption: boolean; + supportsEphemeralEncryption: boolean; + supportsOversizedTransfer: boolean; + supportsOpenStream: boolean; +} + +function cloneTag(tag: readonly string[]): string[] { + return [...tag]; +} + +export function hasSingleTag( + tags: readonly (readonly string[])[], + tag: string, +): boolean { + return tags.some((t) => t.length === 1 && t[0] === tag); +} + +export function hasEventTag( + event: NostrEvent | undefined, + tag: string, +): boolean { + return Array.isArray(event?.tags) && hasSingleTag(event.tags, tag); +} + +export function getDiscoveryTags(tags: readonly string[][]): string[][] { + return tags + .filter((tag) => { + const tagName = tag[0]; + return typeof tagName === 'string' && !NON_DISCOVERY_TAG_NAMES.has(tagName); + }) + .map((tag) => cloneTag(tag)); +} + +export function parseDiscoveredPeerCapabilities( + tags: readonly string[][], +): DiscoveredPeerCapabilities { + const discoveryTags = getDiscoveryTags(tags); + const capabilities = learnPeerCapabilities(discoveryTags); + + return { + discoveryTags, + ...capabilities, + }; +} + +export function learnPeerCapabilities( + eventTags: readonly (readonly string[])[], +): PeerCapabilities { + return { + supportsEncryption: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_ENCRYPTION), + supportsEphemeralEncryption: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL), + supportsOversizedTransfer: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_OVERSIZED_TRANSFER), + supportsOpenStream: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_OPEN_STREAM), + }; +} + +export class ServerCapabilityNegotiator { + constructor( + private deps: { + getCommonTags: () => string[][]; + composeOutboundTags: (params: { + baseTags: readonly string[][]; + discoveryTags: readonly string[][]; + negotiationTags: readonly string[][]; + }) => string[][]; + giftWrapMode: GiftWrapMode; + }, + ) {} + + public takePendingDiscoveryTags(session: ClientSession): string[][] { + if (session.hasSentCommonTags) { + return []; + } + session.hasSentCommonTags = true; + return this.deps.getCommonTags(); + } + + public buildOutboundTags(params: { + baseTags: readonly string[][]; + session: ClientSession; + includeDiscovery?: boolean; + negotiationTags?: readonly string[][]; + }): string[][] { + const { baseTags, session, includeDiscovery = true, negotiationTags = [] } = params; + return this.deps.composeOutboundTags({ + baseTags, + discoveryTags: includeDiscovery ? this.takePendingDiscoveryTags(session) : [], + negotiationTags, + }); + } + + public chooseOutboundGiftWrapKind(params: { + session: ClientSession; + fallbackWrapKind?: number; + }): number | undefined { + const { session, fallbackWrapKind } = params; + + if (!session.isEncrypted) return undefined; + if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) return EPHEMERAL_GIFT_WRAP_KIND; + if (this.deps.giftWrapMode === GiftWrapMode.PERSISTENT) return GIFT_WRAP_KIND; + if (session.supportsEphemeralEncryption) return EPHEMERAL_GIFT_WRAP_KIND; + return fallbackWrapKind; + } +} + +export class ClientCapabilityNegotiator { + private hasSentDiscoveryTags = false; + private clientPmis?: readonly string[]; + public serverSupportsEphemeralGiftWraps = false; + public serverInitializeEvent?: NostrEvent; + + constructor( + private deps: { + encryptionMode: EncryptionMode; + giftWrapMode: GiftWrapMode; + oversizedEnabled: boolean; + openStreamEnabled: boolean; + composeOutboundTags: (params: { + baseTags: readonly string[][]; + discoveryTags: readonly string[][]; + negotiationTags: readonly string[][]; + }) => string[][]; + }, + ) {} + + public setClientPmis(pmis: readonly string[]): void { + this.clientPmis = pmis; + } + + public getCapabilityTags(): string[][] { + const tags: string[][] = []; + if (this.deps.encryptionMode !== EncryptionMode.DISABLED) { + tags.push([NOSTR_TAGS.SUPPORT_ENCRYPTION]); + } + if (this.deps.encryptionMode !== EncryptionMode.DISABLED && this.deps.giftWrapMode !== GiftWrapMode.PERSISTENT) { + tags.push([NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL]); + } + if (this.deps.oversizedEnabled) { + tags.push([NOSTR_TAGS.SUPPORT_OVERSIZED_TRANSFER]); + } + if (this.deps.openStreamEnabled) { + tags.push([NOSTR_TAGS.SUPPORT_OPEN_STREAM]); + } + return tags; + } + + public getNegotiationTags(): string[][] { + const tags: string[][] = []; + if (this.clientPmis) { + tags.push(...this.clientPmis.map((pmi) => ['pmi', pmi])); + } + return tags; + } + + public getPendingDiscoveryTags(): string[][] { + return this.hasSentDiscoveryTags ? [] : this.getCapabilityTags(); + } + + public buildOutboundTags(params: { + baseTags: readonly string[][]; + includeDiscovery: boolean; + }): string[][] { + const { baseTags, includeDiscovery } = params; + return this.deps.composeOutboundTags({ + baseTags, + discoveryTags: includeDiscovery ? this.getPendingDiscoveryTags() : [], + negotiationTags: includeDiscovery ? this.getNegotiationTags() : [], + }); + } + + public markDiscoveryTagsSent(): void { + if (this.getPendingDiscoveryTags().length > 0) { + this.hasSentDiscoveryTags = true; + } + } + + public chooseOutboundGiftWrapKind(): number { + if (this.deps.giftWrapMode === GiftWrapMode.PERSISTENT) return GIFT_WRAP_KIND; + if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) return EPHEMERAL_GIFT_WRAP_KIND; + if (this.serverSupportsEphemeralGiftWraps) return EPHEMERAL_GIFT_WRAP_KIND; + const supportsEphemeralFromInit = queryTags( + this.serverInitializeEvent, + NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, + ).isFlag; + return supportsEphemeralFromInit ? EPHEMERAL_GIFT_WRAP_KIND : GIFT_WRAP_KIND; + } +} diff --git a/src/transport/discovery-tags.ts b/src/transport/discovery-tags.ts deleted file mode 100644 index cbae081..0000000 --- a/src/transport/discovery-tags.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { NOSTR_TAGS } from '../core/constants.js'; -import { NostrEvent } from 'nostr-tools'; - -const NON_DISCOVERY_TAG_NAMES = new Set(['e', 'p']); - -/** - * Parsed capability flags discovered from peer discovery tags. - */ -export interface DiscoveredPeerCapabilities { - discoveryTags: string[][]; - supportsEncryption: boolean; - supportsEphemeralEncryption: boolean; - supportsOversizedTransfer: boolean; - supportsOpenStream: boolean; -} - -/** - * Capability flags learned from inbound peer discovery tags. - */ -export interface PeerCapabilities { - supportsEncryption: boolean; - supportsEphemeralEncryption: boolean; - supportsOversizedTransfer: boolean; - supportsOpenStream: boolean; -} - -function cloneTag(tag: readonly string[]): string[] { - return [...tag]; -} - -/** - * Returns true when a single-valued tag is present (e.g. ['support_oversized_transfer']). - */ -export function hasSingleTag( - tags: readonly (readonly string[])[], - tag: string, -): boolean { - return tags.some((t) => t.length === 1 && t[0] === tag); -} - -/** - * Returns true when an event contains the provided single-valued tag. - */ -export function hasEventTag( - event: NostrEvent | undefined, - tag: string, -): boolean { - return Array.isArray(event?.tags) && hasSingleTag(event.tags, tag); -} - -/** - * Returns cloned discovery tags by filtering out routing tags ('e', 'p'). - */ -export function getDiscoveryTags(tags: readonly string[][]): string[][] { - return tags - .filter((tag) => { - const tagName = tag[0]; - return ( - typeof tagName === 'string' && !NON_DISCOVERY_TAG_NAMES.has(tagName) - ); - }) - .map((tag) => cloneTag(tag)); -} - -/** - * Parses peer discovery tags into normalized capability flags. - */ -export function parseDiscoveredPeerCapabilities( - tags: readonly string[][], -): DiscoveredPeerCapabilities { - const discoveryTags = getDiscoveryTags(tags); - const capabilities = learnPeerCapabilities(discoveryTags); - - return { - discoveryTags, - ...capabilities, - }; -} - -/** - * Inspects inbound tags and returns discovered peer capabilities. - */ -export function learnPeerCapabilities( - eventTags: readonly (readonly string[])[], -): PeerCapabilities { - return { - supportsEncryption: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_ENCRYPTION), - supportsEphemeralEncryption: hasSingleTag( - eventTags, - NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, - ), - supportsOversizedTransfer: hasSingleTag( - eventTags, - NOSTR_TAGS.SUPPORT_OVERSIZED_TRANSFER, - ), - supportsOpenStream: hasSingleTag(eventTags, NOSTR_TAGS.SUPPORT_OPEN_STREAM), - }; -} diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 975a413..f9d01f9 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -34,7 +34,7 @@ import { getNostrEventTag } from '../core/utils/serializers.js'; import { NostrEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools/pure'; import { LogLevel } from '../core/utils/logger.js'; -import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; + import { ClientCorrelationStore, PendingRequest, @@ -62,7 +62,10 @@ import { DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, } from './open-stream/constants.js'; -import { parseDiscoveredPeerCapabilities } from './discovery-tags.js'; +import { + parseDiscoveredPeerCapabilities, + ClientCapabilityNegotiator, +} from './capability-negotiator.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -157,8 +160,6 @@ export class NostrClientTransport private readonly discoveryRelayUrls: readonly string[]; /** Optional non-authoritative operational relays used as a fast fallback. */ private readonly fallbackOperationalRelayUrls: readonly string[]; - /** Optional list of client-supported PMIs (ordered by preference). */ - private clientPmis: readonly string[] | undefined; /** The server's initialize event, if received */ private serverInitializeEvent: NostrEvent | undefined; @@ -171,17 +172,13 @@ export class NostrClientTransport /** The latest server resources/templates/list response event envelope, if received. */ private serverResourceTemplatesListEvent: NostrEvent | undefined; - /** Whether the server has advertised ephemeral gift wrap support via Nostr tags. */ - private serverSupportsEphemeralGiftWraps: boolean = false; - /** Whether the server has advertised CEP-22 oversized transfer support. */ private serverSupportsOversizedTransfer: boolean = false; /** Whether the server has advertised CEP-41 open stream support. */ private serverSupportsOpenStream: boolean = false; - /** Whether this client has already sent its discovery tags to the server. */ - private hasSentDiscoveryTags: boolean = false; + private readonly capabilityNegotiator: ClientCapabilityNegotiator; // Oversized-transfer sender settings private readonly oversizedEnabled: boolean; @@ -296,6 +293,14 @@ export class NostrClientTransport }, logger: this.logger, }); + + this.capabilityNegotiator = new ClientCapabilityNegotiator({ + encryptionMode: this.encryptionMode, + giftWrapMode: this.giftWrapMode, + oversizedEnabled: this.oversizedEnabled, + openStreamEnabled: this.openStreamEnabled, + composeOutboundTags: this.composeOutboundTags.bind(this), + }); } /** @@ -304,67 +309,7 @@ export class NostrClientTransport * Intended to be called by payments wrappers (e.g. `withClientPayments()`). */ public setClientPmis(pmis: readonly string[]): void { - this.clientPmis = pmis; - } - - private getClientCapabilityTags(): string[][] { - const tags: string[][] = []; - - if (this.encryptionMode !== EncryptionMode.DISABLED) { - tags.push([NOSTR_TAGS.SUPPORT_ENCRYPTION]); - } - - if ( - this.encryptionMode !== EncryptionMode.DISABLED && - this.giftWrapMode !== GiftWrapMode.PERSISTENT - ) { - tags.push([NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL]); - } - - if (this.oversizedEnabled) { - tags.push([NOSTR_TAGS.SUPPORT_OVERSIZED_TRANSFER]); - } - - if (this.openStreamEnabled) { - tags.push([NOSTR_TAGS.SUPPORT_OPEN_STREAM]); - } - - return tags; - } - - private getClientNegotiationTags(): string[][] { - const tags: string[][] = []; - - if (this.clientPmis) { - tags.push(...this.clientPmis.map((pmi) => ['pmi', pmi])); - } - - return tags; - } - - private getPendingClientDiscoveryTags(): string[][] { - return this.hasSentDiscoveryTags ? [] : this.getClientCapabilityTags(); - } - - private buildOutboundClientTags(params: { - baseTags: readonly string[][]; - includeDiscovery: boolean; - }): string[][] { - const { baseTags, includeDiscovery } = params; - - return this.composeOutboundTags({ - baseTags, - discoveryTags: includeDiscovery - ? this.getPendingClientDiscoveryTags() - : [], - negotiationTags: includeDiscovery ? this.getClientNegotiationTags() : [], - }); - } - - private markClientDiscoveryTagsSent(): void { - if (this.getPendingClientDiscoveryTags().length > 0) { - this.hasSentDiscoveryTags = true; - } + this.capabilityNegotiator.setClientPmis(pmis); } /** @@ -482,12 +427,12 @@ export class NostrClientTransport } } - const tags = this.buildOutboundClientTags({ + const tags = this.capabilityNegotiator.buildOutboundTags({ baseTags: this.createRecipientTags(this.serverPubkey), includeDiscovery: isRequest, }); - const giftWrapKind = this.chooseOutboundGiftWrapKind(); + const giftWrapKind = this.capabilityNegotiator.chooseOutboundGiftWrapKind(); const eventId = await this.sendMcpMessage( message, @@ -514,7 +459,7 @@ export class NostrClientTransport ); if (isRequest) { - this.markClientDiscoveryTagsSent(); + this.capabilityNegotiator.markDiscoveryTagsSent(); } return eventId; @@ -531,7 +476,7 @@ export class NostrClientTransport progressToken: string, ): Promise { const frameRecipientTags = this.createRecipientTags(this.serverPubkey); - const startFrameTags = this.buildOutboundClientTags({ + const startFrameTags = this.capabilityNegotiator.buildOutboundTags({ baseTags: frameRecipientTags, includeDiscovery: true, }); @@ -543,7 +488,7 @@ export class NostrClientTransport acceptTimeoutMs: this.oversizedAcceptTimeoutMs, serverPubkey: this.serverPubkey, serverSupportsOversizedTransfer: this.serverSupportsOversizedTransfer, - giftWrapKind: this.chooseOutboundGiftWrapKind(), + giftWrapKind: this.capabilityNegotiator.chooseOutboundGiftWrapKind(), startFrameTags, continuationFrameTags: frameRecipientTags, }, @@ -566,27 +511,7 @@ export class NostrClientTransport }); } - this.markClientDiscoveryTagsSent(); - } - - private chooseOutboundGiftWrapKind(): number { - // Strict modes are deterministic. - if (this.giftWrapMode === GiftWrapMode.PERSISTENT) return GIFT_WRAP_KIND; - if (this.giftWrapMode === GiftWrapMode.EPHEMERAL) - return EPHEMERAL_GIFT_WRAP_KIND; - - if (this.serverSupportsEphemeralGiftWraps) { - return EPHEMERAL_GIFT_WRAP_KIND; - } - - const supportsEphemeralFromInit = queryTags( - this.serverInitializeEvent, - NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, - ).isFlag; - - return supportsEphemeralFromInit - ? EPHEMERAL_GIFT_WRAP_KIND - : GIFT_WRAP_KIND; + this.capabilityNegotiator.markDiscoveryTagsSent(); } private getOriginalRequestContext( @@ -1066,7 +991,7 @@ export class NostrClientTransport return; } - this.serverSupportsEphemeralGiftWraps ||= + this.capabilityNegotiator.serverSupportsEphemeralGiftWraps ||= discovered.supportsEphemeralEncryption; this.serverSupportsOversizedTransfer ||= discovered.supportsOversizedTransfer; @@ -1074,6 +999,7 @@ export class NostrClientTransport if (!this.serverInitializeEvent) { this.serverInitializeEvent = event; + this.capabilityNegotiator.serverInitializeEvent = event; this.logger.info('Learned server discovery tags from inbound event', { eventId: event.id, }); @@ -1089,6 +1015,7 @@ export class NostrClientTransport if (!existingHasInitializeResult && currentHasInitializeResult) { this.serverInitializeEvent = event; + this.capabilityNegotiator.serverInitializeEvent = event; this.logger.info( 'Upgraded learned server discovery event to initialize response', { diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index cd4455f..b4e27b7 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -62,7 +62,10 @@ import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, } from './oversized-transfer/constants.js'; -import { learnPeerCapabilities } from './discovery-tags.js'; +import { + learnPeerCapabilities, + ServerCapabilityNegotiator, +} from './capability-negotiator.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; import { OutboundResponseRouter } from './nostr-server/outbound-response-router.js'; @@ -235,6 +238,7 @@ export class NostrServerTransport private readonly oversizedThreshold: number; private readonly oversizedChunkSize: number; private readonly openStreamEnabled: boolean; + private readonly capabilityNegotiator: ServerCapabilityNegotiator; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; private readonly outboundResponseRouter: OutboundResponseRouter; @@ -409,6 +413,12 @@ export class NostrServerTransport this.announcementManager.setInternalCommonTags(internalCommonTags); + this.capabilityNegotiator = new ServerCapabilityNegotiator({ + getCommonTags: this.announcementManager.getCommonTags.bind(this.announcementManager), + composeOutboundTags: this.composeOutboundTags.bind(this), + giftWrapMode: this.giftWrapMode, + }); + this.inboundNotificationDispatcher = new InboundNotificationDispatcher({ openStreamReceiver: this.openStreamReceiver, oversizedReceiver: this.oversizedReceiver, @@ -436,9 +446,9 @@ export class NostrServerTransport chunkSize: this.oversizedChunkSize, }, applyListToolsResultTransformers: this.applyListToolsResultTransformers.bind(this), - buildOutboundTags: this.buildServerOutboundTags.bind(this), + buildOutboundTags: this.capabilityNegotiator.buildOutboundTags.bind(this.capabilityNegotiator), createResponseTags: this.createResponseTags.bind(this), - chooseGiftWrapKind: this.chooseServerOutboundGiftWrapKind.bind(this), + chooseGiftWrapKind: this.capabilityNegotiator.chooseOutboundGiftWrapKind.bind(this.capabilityNegotiator), sendMcpMessage: this.sendMcpMessage.bind(this), logger: this.logger, onerror: (error) => this.onerror?.(error), @@ -620,61 +630,7 @@ export class NostrServerTransport return session; } - private takePendingServerDiscoveryTags(session: ClientSession): string[][] { - if (session.hasSentCommonTags) { - return []; - } - - session.hasSentCommonTags = true; - return this.announcementManager.getCommonTags(); - } - - private buildServerOutboundTags(params: { - baseTags: readonly string[][]; - session: ClientSession; - includeDiscovery?: boolean; - negotiationTags?: readonly string[][]; - }): string[][] { - const { - baseTags, - session, - includeDiscovery = true, - negotiationTags = [], - } = params; - - return this.composeOutboundTags({ - baseTags, - discoveryTags: includeDiscovery - ? this.takePendingServerDiscoveryTags(session) - : [], - negotiationTags, - }); - } - - private chooseServerOutboundGiftWrapKind(params: { - session: ClientSession; - fallbackWrapKind?: number; - }): number | undefined { - const { session, fallbackWrapKind } = params; - if (!session.isEncrypted) { - return undefined; - } - - if (this.giftWrapMode === GiftWrapMode.EPHEMERAL) { - return EPHEMERAL_GIFT_WRAP_KIND; - } - - if (this.giftWrapMode === GiftWrapMode.PERSISTENT) { - return GIFT_WRAP_KIND; - } - - if (session.supportsEphemeralEncryption) { - return EPHEMERAL_GIFT_WRAP_KIND; - } - - return fallbackWrapKind; - } private getRelayUrls(relayHandler: RelayHandler): string[] { return relayHandler.getRelayUrls(); @@ -921,12 +877,12 @@ export class NostrServerTransport baseTags.push([NOSTR_TAGS.EVENT_ID, correlatedEventId]); } - const tags = this.buildServerOutboundTags({ + const tags = this.capabilityNegotiator.buildOutboundTags({ baseTags, session, }); - const giftWrapKind = this.chooseServerOutboundGiftWrapKind({ + const giftWrapKind = this.capabilityNegotiator.chooseOutboundGiftWrapKind({ session, }); From 970a8bedf24ad8235c1d995a73bd51124b1f56f0 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:21:02 +0530 Subject: [PATCH 04/17] refactor: extract server open stream factory --- src/transport/nostr-server-transport.ts | 69 ++++++------------- .../nostr-server/open-stream-factory.ts | 69 +++++++++++++++++++ 2 files changed, 90 insertions(+), 48 deletions(-) create mode 100644 src/transport/nostr-server/open-stream-factory.ts diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index b4e27b7..f3eb762 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -69,6 +69,7 @@ import { import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; import { OutboundResponseRouter } from './nostr-server/outbound-response-router.js'; +import { ServerOpenStreamFactory } from './nostr-server/open-stream-factory.js'; /** * Options for configuring the NostrServerTransport. @@ -224,21 +225,13 @@ export class NostrServerTransport /** Receives inbound open-stream frames from clients (client→server notifications). */ private readonly openStreamReceiver: OpenStreamReceiver; - /** Pending final responses held until their CEP-41 stream terminates. */ - private readonly pendingOpenStreamResponses = new Map< - string, - JSONRPCResponse - >(); - - /** Active server-side CEP-41 writers keyed by inbound request event id. */ - private readonly openStreamWriters = new Map(); - // Oversized-transfer sender settings (for server→client responses) private readonly oversizedEnabled: boolean; private readonly oversizedThreshold: number; private readonly oversizedChunkSize: number; private readonly openStreamEnabled: boolean; private readonly capabilityNegotiator: ServerCapabilityNegotiator; + private readonly openStreamFactory: ServerOpenStreamFactory; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; private readonly outboundResponseRouter: OutboundResponseRouter; @@ -419,10 +412,18 @@ export class NostrServerTransport giftWrapMode: this.giftWrapMode, }); + this.openStreamFactory = new ServerOpenStreamFactory({ + openStreamEnabled: this.openStreamEnabled, + sendNotification: this.sendNotification.bind(this), + handleResponse: async (response) => { + await this.outboundResponseRouter.route(response); + }, + }); + this.inboundNotificationDispatcher = new InboundNotificationDispatcher({ openStreamReceiver: this.openStreamReceiver, oversizedReceiver: this.oversizedReceiver, - openStreamWriters: this.openStreamWriters, + openStreamWriters: this.openStreamFactory.getWritersMap(), correlationStore: this.correlationStore, sendNotification: this.sendNotification.bind(this), handleIncomingRequest: this.handleIncomingRequest.bind(this), @@ -438,8 +439,8 @@ export class NostrServerTransport correlationStore: this.correlationStore, sessionStore: this.sessionStore, announcementManager: this.announcementManager, - openStreamWriters: this.openStreamWriters, - pendingOpenStreamResponses: this.pendingOpenStreamResponses, + openStreamWriters: this.openStreamFactory.getWritersMap(), + pendingOpenStreamResponses: this.openStreamFactory.getPendingResponsesMap(), oversizedConfig: { enabled: this.oversizedEnabled, threshold: this.oversizedThreshold, @@ -561,8 +562,8 @@ export class NostrServerTransport this.seenEventIds.clear(); this.oversizedReceiver.clear(); this.openStreamReceiver.clear(); - this.pendingOpenStreamResponses.clear(); - this.openStreamWriters.clear(); + this.openStreamFactory.getPendingResponsesMap().clear(); + this.openStreamFactory.getWritersMap().clear(); this.onclose?.(); } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); @@ -699,39 +700,11 @@ export class NostrServerTransport this.shouldInjectRequestEventId ? event : undefined, ); - if (this.openStreamEnabled && progressToken) { - const writer = new OpenStreamWriter({ - progressToken: String(progressToken), - publishFrame: async (frame) => { - await this.sendNotification(clientPubkey, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: frame, - }); - return undefined; - }, - onClose: async (): Promise => { - await this.flushPendingOpenStreamResponse(eventId); - }, - onAbort: async (): Promise => { - await this.flushPendingOpenStreamResponse(eventId); - }, - }); - - this.openStreamWriters.set(eventId, writer); - } - } - - private async flushPendingOpenStreamResponse(eventId: string): Promise { - const pendingResponse = this.pendingOpenStreamResponses.get(eventId); - this.pendingOpenStreamResponses.delete(eventId); - this.openStreamWriters.delete(eventId); - - if (!pendingResponse) { - return; - } - - await this.handleResponse(pendingResponse); + this.openStreamFactory.createWriterIfEnabled( + eventId, + clientPubkey, + progressToken ? String(progressToken) : undefined, + ); } /** @@ -1167,7 +1140,7 @@ export class NostrServerTransport injectClientPubkey(inboundMessage, event.pubkey); } - const openStreamWriter = this.openStreamWriters.get(event.id); + const openStreamWriter = this.openStreamFactory.getWriter(event.id); if (openStreamWriter) { const params = inboundMessage.params ?? {}; inboundMessage.params = params; diff --git a/src/transport/nostr-server/open-stream-factory.ts b/src/transport/nostr-server/open-stream-factory.ts new file mode 100644 index 0000000..db7a2b0 --- /dev/null +++ b/src/transport/nostr-server/open-stream-factory.ts @@ -0,0 +1,69 @@ +import { type JSONRPCResponse, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { OpenStreamWriter } from '../open-stream/index.js'; + +export interface ServerOpenStreamFactoryDeps { + openStreamEnabled: boolean; + sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; + handleResponse: (response: JSONRPCResponse) => Promise; +} + +export class ServerOpenStreamFactory { + private readonly writers = new Map(); + private readonly pendingResponses = new Map(); + + constructor(private deps: ServerOpenStreamFactoryDeps) {} + + public getWriter(eventId: string): OpenStreamWriter | undefined { + return this.writers.get(eventId); + } + + public getWritersMap(): Map { + return this.writers; + } + + public getPendingResponsesMap(): Map { + return this.pendingResponses; + } + + public createWriterIfEnabled( + eventId: string, + clientPubkey: string, + progressToken?: string, + ): void { + if (!this.deps.openStreamEnabled || !progressToken) { + return; + } + + const writer = new OpenStreamWriter({ + progressToken, + publishFrame: async (frame) => { + await this.deps.sendNotification(clientPubkey, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: frame, + }); + return undefined; + }, + onClose: async (): Promise => { + await this.flushPendingResponse(eventId); + }, + onAbort: async (): Promise => { + await this.flushPendingResponse(eventId); + }, + }); + + this.writers.set(eventId, writer); + } + + public async flushPendingResponse(eventId: string): Promise { + const pendingResponse = this.pendingResponses.get(eventId); + this.pendingResponses.delete(eventId); + this.writers.delete(eventId); + + if (!pendingResponse) { + return; + } + + await this.deps.handleResponse(pendingResponse); + } +} From 14a95d11680e3cb9fa381f1542d5d0c419cffb41 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:25:13 +0530 Subject: [PATCH 05/17] refactor: extract client inbound notification dispatcher --- src/transport/nostr-client-transport.ts | 64 +++---------- .../inbound-notification-dispatcher.ts | 89 +++++++++++++++++++ 2 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 src/transport/nostr-client/inbound-notification-dispatcher.ts diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index f9d01f9..d2fa9cb 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -66,6 +66,7 @@ import { parseDiscoveredPeerCapabilities, ClientCapabilityNegotiator, } from './capability-negotiator.js'; +import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -179,6 +180,7 @@ export class NostrClientTransport private serverSupportsOpenStream: boolean = false; private readonly capabilityNegotiator: ClientCapabilityNegotiator; + private readonly inboundNotificationDispatcher: ClientInboundNotificationDispatcher; // Oversized-transfer sender settings private readonly oversizedEnabled: boolean; @@ -301,6 +303,15 @@ export class NostrClientTransport openStreamEnabled: this.openStreamEnabled, composeOutboundTags: this.composeOutboundTags.bind(this), }); + + this.inboundNotificationDispatcher = new ClientInboundNotificationDispatcher({ + openStreamReceiver: this.openStreamReceiver, + oversizedReceiver: this.oversizedReceiver, + handleResponse: this.handleResponse.bind(this), + handleNotification: this.handleNotification.bind(this), + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); } /** @@ -1114,58 +1125,7 @@ export class NostrClientTransport return; } - // CEP-22: intercept oversized-transfer frames and do NOT forward raw frames. - if ( - isJSONRPCNotification(mcpMessage) && - mcpMessage.method === 'notifications/progress' && - OpenStreamReceiver.isOpenStreamFrame(mcpMessage) - ) { - this.openStreamReceiver - .processFrame(mcpMessage) - .catch((err: unknown) => { - this.logger.error('Open stream error (client)', { - error: err instanceof Error ? err.message : String(err), - }); - this.onerror?.(err instanceof Error ? err : new Error(String(err))); - }); - return; - } - - if ( - isJSONRPCNotification(mcpMessage) && - mcpMessage.method === 'notifications/progress' && - OversizedTransferReceiver.isOversizedFrame(mcpMessage) - ) { - this.oversizedReceiver - .processFrame(mcpMessage) - .then((synthetic) => { - if (synthetic !== null) { - if ( - isJSONRPCResultResponse(synthetic) || - isJSONRPCErrorResponse(synthetic) - ) { - if (correlatedEventId) { - this.handleResponse(correlatedEventId, synthetic); - } else { - this.logger.warn( - 'Oversized response completed without correlation `e` tag', - { - eventId, - }, - ); - } - return; - } - - this.handleNotification(eventId, correlatedEventId, synthetic); - } - }) - .catch((err: unknown) => { - this.logger.error('Oversized transfer error (client)', { - error: err instanceof Error ? err.message : String(err), - }); - this.onerror?.(err instanceof Error ? err : new Error(String(err))); - }); + if (this.inboundNotificationDispatcher.tryIntercept(mcpMessage, eventId, correlatedEventId)) { return; } diff --git a/src/transport/nostr-client/inbound-notification-dispatcher.ts b/src/transport/nostr-client/inbound-notification-dispatcher.ts new file mode 100644 index 0000000..2e39f60 --- /dev/null +++ b/src/transport/nostr-client/inbound-notification-dispatcher.ts @@ -0,0 +1,89 @@ +import { + type JSONRPCMessage, + type JSONRPCResponse, + isJSONRPCNotification, + isJSONRPCResultResponse, + isJSONRPCErrorResponse, +} from '@modelcontextprotocol/sdk/types.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { OpenStreamReceiver } from '../open-stream/index.js'; +import { OversizedTransferReceiver } from '../oversized-transfer/index.js'; + +export interface ClientInboundNotificationDispatcherDeps { + openStreamReceiver: OpenStreamReceiver; + oversizedReceiver: OversizedTransferReceiver; + handleResponse: (correlatedEventId: string, synthetic: JSONRPCResponse) => void; + handleNotification: (eventId: string, correlatedEventId: string | undefined, synthetic: JSONRPCMessage) => void; + logger: Logger; + onerror?: (error: Error) => void; +} + +export class ClientInboundNotificationDispatcher { + constructor(private deps: ClientInboundNotificationDispatcherDeps) {} + + /** + * Returns true if the notification was intercepted (CEP-22 or CEP-41). + * Returns false if it should fall through to normal processing. + */ + public tryIntercept( + mcpMessage: JSONRPCMessage, + eventId: string, + correlatedEventId: string | undefined, + ): boolean { + if ( + isJSONRPCNotification(mcpMessage) && + mcpMessage.method === 'notifications/progress' && + OpenStreamReceiver.isOpenStreamFrame(mcpMessage) + ) { + this.deps.openStreamReceiver + .processFrame(mcpMessage) + .catch((err: unknown) => { + this.deps.logger.error('Open stream error (client)', { + error: err instanceof Error ? err.message : String(err), + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + return true; + } + + if ( + isJSONRPCNotification(mcpMessage) && + mcpMessage.method === 'notifications/progress' && + OversizedTransferReceiver.isOversizedFrame(mcpMessage) + ) { + this.deps.oversizedReceiver + .processFrame(mcpMessage) + .then((synthetic) => { + if (synthetic !== null) { + if ( + isJSONRPCResultResponse(synthetic) || + isJSONRPCErrorResponse(synthetic) + ) { + if (correlatedEventId) { + this.deps.handleResponse(correlatedEventId, synthetic); + } else { + this.deps.logger.warn( + 'Oversized response completed without correlation `e` tag', + { + eventId, + }, + ); + } + return; + } + + this.deps.handleNotification(eventId, correlatedEventId, synthetic); + } + }) + .catch((err: unknown) => { + this.deps.logger.error('Oversized transfer error (client)', { + error: err instanceof Error ? err.message : String(err), + }); + this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + return true; + } + + return false; + } +} From 51618538e4eac4232031d4c25471e478c85c1a66 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 7 May 2026 21:40:28 +0530 Subject: [PATCH 06/17] =?UTF-8?q?refactor:=20fix=20audit=20issues=20?= =?UTF-8?q?=E2=80=94=20encapsulate=20negotiator=20state,=20remove=20bind?= =?UTF-8?q?=20bug,=20clean=20whitespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/transport/capability-negotiator.ts | 23 ++++++++++++++++--- src/transport/nostr-client-transport.ts | 7 +++--- src/transport/nostr-server-transport.ts | 2 -- .../inbound-notification-dispatcher.ts | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts index 6d046b6..a9e9224 100644 --- a/src/transport/capability-negotiator.ts +++ b/src/transport/capability-negotiator.ts @@ -123,8 +123,8 @@ export class ServerCapabilityNegotiator { export class ClientCapabilityNegotiator { private hasSentDiscoveryTags = false; private clientPmis?: readonly string[]; - public serverSupportsEphemeralGiftWraps = false; - public serverInitializeEvent?: NostrEvent; + private serverSupportsEphemeralGiftWraps = false; + private _serverInitializeEvent?: NostrEvent; constructor( private deps: { @@ -144,6 +144,23 @@ export class ClientCapabilityNegotiator { this.clientPmis = pmis; } + /** + * Updates server capability flags from discovered peer tags. + * Called by the transport when it learns new capabilities from inbound events. + */ + public learnServerCapabilities(discovered: { + supportsEphemeralEncryption: boolean; + }): void { + this.serverSupportsEphemeralGiftWraps ||= discovered.supportsEphemeralEncryption; + } + + /** + * Records the server's initialize event for gift-wrap kind negotiation. + */ + public setServerInitializeEvent(event: NostrEvent): void { + this._serverInitializeEvent = event; + } + public getCapabilityTags(): string[][] { const tags: string[][] = []; if (this.deps.encryptionMode !== EncryptionMode.DISABLED) { @@ -196,7 +213,7 @@ export class ClientCapabilityNegotiator { if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) return EPHEMERAL_GIFT_WRAP_KIND; if (this.serverSupportsEphemeralGiftWraps) return EPHEMERAL_GIFT_WRAP_KIND; const supportsEphemeralFromInit = queryTags( - this.serverInitializeEvent, + this._serverInitializeEvent, NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, ).isFlag; return supportsEphemeralFromInit ? EPHEMERAL_GIFT_WRAP_KIND : GIFT_WRAP_KIND; diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index d2fa9cb..dddc93a 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -1002,15 +1002,14 @@ export class NostrClientTransport return; } - this.capabilityNegotiator.serverSupportsEphemeralGiftWraps ||= - discovered.supportsEphemeralEncryption; + this.capabilityNegotiator.learnServerCapabilities(discovered); this.serverSupportsOversizedTransfer ||= discovered.supportsOversizedTransfer; this.serverSupportsOpenStream ||= discovered.supportsOpenStream; if (!this.serverInitializeEvent) { this.serverInitializeEvent = event; - this.capabilityNegotiator.serverInitializeEvent = event; + this.capabilityNegotiator.setServerInitializeEvent(event); this.logger.info('Learned server discovery tags from inbound event', { eventId: event.id, }); @@ -1026,7 +1025,7 @@ export class NostrClientTransport if (!existingHasInitializeResult && currentHasInitializeResult) { this.serverInitializeEvent = event; - this.capabilityNegotiator.serverInitializeEvent = event; + this.capabilityNegotiator.setServerInitializeEvent(event); this.logger.info( 'Upgraded learned server discovery event to initialize response', { diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index f3eb762..c2256e9 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -631,8 +631,6 @@ export class NostrServerTransport return session; } - - private getRelayUrls(relayHandler: RelayHandler): string[] { return relayHandler.getRelayUrls(); } diff --git a/src/transport/nostr-server/inbound-notification-dispatcher.ts b/src/transport/nostr-server/inbound-notification-dispatcher.ts index 977d388..7c0c3ce 100644 --- a/src/transport/nostr-server/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-server/inbound-notification-dispatcher.ts @@ -150,7 +150,7 @@ export class InboundNotificationDispatcher { progressToken: String(inboundMessage.params?.progressToken ?? ''), }, { - sendNotification: this.deps.sendNotification.bind(this.deps), + sendNotification: this.deps.sendNotification, }, ).catch((err: unknown) => { this.deps.logger.error('Failed to send oversized accept', { From 239fd2c76e7fd85da5e11c80a4c0e4cbd22efe47 Mon Sep 17 00:00:00 2001 From: Khushvendra Singh Date: Thu, 7 May 2026 21:52:17 +0530 Subject: [PATCH 07/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/transport/nostr-server/outbound-response-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index 2d76631..f4bcaec 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -107,7 +107,7 @@ export class OutboundResponseRouter { route.progressToken && session.supportsOversizedTransfer ) { - // Serialize before restoring id so the client receives the correct id. + // Serialize after restoring the original request id so oversized transfer uses the correct id. const serialized = JSON.stringify(responseToSend); const byteLength = new TextEncoder().encode(serialized).byteLength; if (byteLength > this.deps.oversizedConfig.threshold) { From fa0cad165be6a5905326d760b40f72478d57a027 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Fri, 8 May 2026 18:47:11 +0530 Subject: [PATCH 08/17] refactor: encapsulate state and extract event pipelines - Encapsulated mutable map state within ServerOpenStreamFactory using deferIfStreamActive. - Moved OpenStreamReceiver setup into ServerOpenStreamFactory for tighter cohesion. - Extracted ServerEventPipeline and ClientEventPipeline to encapsulate event decryption, verification, and dedup logic. - Propagated JSDoc comments to all public APIs per AGENTS.md guidelines. - Addressed maintainer feedback regarding shared mutable state leaking from factories. --- src/transport/capability-negotiator.ts | 57 +++++ src/transport/nostr-client-transport.ts | 101 ++------- src/transport/nostr-client/event-pipeline.ts | 148 +++++++++++++ .../inbound-notification-dispatcher.ts | 6 + src/transport/nostr-server-transport.ts | 204 +++--------------- src/transport/nostr-server/event-pipeline.ts | 152 +++++++++++++ .../inbound-notification-dispatcher.ts | 12 +- .../nostr-server/open-stream-factory.ts | 119 +++++++++- .../nostr-server/outbound-response-router.ts | 16 +- 9 files changed, 535 insertions(+), 280 deletions(-) create mode 100644 src/transport/nostr-client/event-pipeline.ts create mode 100644 src/transport/nostr-server/event-pipeline.ts diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts index a9e9224..f3e8d68 100644 --- a/src/transport/capability-negotiator.ts +++ b/src/transport/capability-negotiator.ts @@ -6,6 +6,9 @@ import { queryTags } from '../core/utils/utils.js'; const NON_DISCOVERY_TAG_NAMES = new Set(['e', 'p']); +/** + * Result of parsing peer discovery tags. + */ export interface DiscoveredPeerCapabilities { discoveryTags: string[][]; supportsEncryption: boolean; @@ -14,6 +17,9 @@ export interface DiscoveredPeerCapabilities { supportsOpenStream: boolean; } +/** + * Capability flags derived from peer discovery tags. + */ export interface PeerCapabilities { supportsEncryption: boolean; supportsEphemeralEncryption: boolean; @@ -25,6 +31,9 @@ function cloneTag(tag: readonly string[]): string[] { return [...tag]; } +/** + * Checks if a set of tags contains a specific single-element tag. + */ export function hasSingleTag( tags: readonly (readonly string[])[], tag: string, @@ -32,6 +41,9 @@ export function hasSingleTag( return tags.some((t) => t.length === 1 && t[0] === tag); } +/** + * Checks if an event contains a specific single-element tag. + */ export function hasEventTag( event: NostrEvent | undefined, tag: string, @@ -39,6 +51,9 @@ export function hasEventTag( return Array.isArray(event?.tags) && hasSingleTag(event.tags, tag); } +/** + * Extracts capability discovery tags (omitting routing/correlation tags). + */ export function getDiscoveryTags(tags: readonly string[][]): string[][] { return tags .filter((tag) => { @@ -48,6 +63,9 @@ export function getDiscoveryTags(tags: readonly string[][]): string[][] { .map((tag) => cloneTag(tag)); } +/** + * Parses raw tags into discovery tags and capability flags. + */ export function parseDiscoveredPeerCapabilities( tags: readonly string[][], ): DiscoveredPeerCapabilities { @@ -60,6 +78,9 @@ export function parseDiscoveredPeerCapabilities( }; } +/** + * Determines capability flags from a list of tags. + */ export function learnPeerCapabilities( eventTags: readonly (readonly string[])[], ): PeerCapabilities { @@ -71,6 +92,9 @@ export function learnPeerCapabilities( }; } +/** + * Manages capability discovery and negotiation for the server transport. + */ export class ServerCapabilityNegotiator { constructor( private deps: { @@ -84,6 +108,9 @@ export class ServerCapabilityNegotiator { }, ) {} + /** + * Gets pending discovery tags to attach to the next outbound event for a session. + */ public takePendingDiscoveryTags(session: ClientSession): string[][] { if (session.hasSentCommonTags) { return []; @@ -92,6 +119,9 @@ export class ServerCapabilityNegotiator { return this.deps.getCommonTags(); } + /** + * Composes complete outbound tags including base tags, pending discovery, and negotiation tags. + */ public buildOutboundTags(params: { baseTags: readonly string[][]; session: ClientSession; @@ -106,6 +136,9 @@ export class ServerCapabilityNegotiator { }); } + /** + * Determines the appropriate gift-wrap kind (persistent or ephemeral) based on peer capabilities and policy. + */ public chooseOutboundGiftWrapKind(params: { session: ClientSession; fallbackWrapKind?: number; @@ -120,6 +153,9 @@ export class ServerCapabilityNegotiator { } } +/** + * Manages capability discovery and negotiation for the client transport. + */ export class ClientCapabilityNegotiator { private hasSentDiscoveryTags = false; private clientPmis?: readonly string[]; @@ -140,6 +176,9 @@ export class ClientCapabilityNegotiator { }, ) {} + /** + * Sets Package Manifest Identifiers (PMIs) to include in capability negotiation. + */ public setClientPmis(pmis: readonly string[]): void { this.clientPmis = pmis; } @@ -161,6 +200,9 @@ export class ClientCapabilityNegotiator { this._serverInitializeEvent = event; } + /** + * Gets the base capability tags supported by this client. + */ public getCapabilityTags(): string[][] { const tags: string[][] = []; if (this.deps.encryptionMode !== EncryptionMode.DISABLED) { @@ -178,6 +220,9 @@ export class ClientCapabilityNegotiator { return tags; } + /** + * Gets negotiation tags (like PMIs) to include in outbound discovery. + */ public getNegotiationTags(): string[][] { const tags: string[][] = []; if (this.clientPmis) { @@ -186,10 +231,16 @@ export class ClientCapabilityNegotiator { return tags; } + /** + * Gets capability discovery tags if they haven't been sent yet. + */ public getPendingDiscoveryTags(): string[][] { return this.hasSentDiscoveryTags ? [] : this.getCapabilityTags(); } + /** + * Composes outbound tags for a request, optionally including discovery. + */ public buildOutboundTags(params: { baseTags: readonly string[][]; includeDiscovery: boolean; @@ -202,12 +253,18 @@ export class ClientCapabilityNegotiator { }); } + /** + * Marks discovery tags as sent to prevent re-sending. + */ public markDiscoveryTagsSent(): void { if (this.getPendingDiscoveryTags().length > 0) { this.hasSentDiscoveryTags = true; } } + /** + * Chooses the appropriate gift-wrap kind based on learned server capabilities. + */ public chooseOutboundGiftWrapKind(): number { if (this.deps.giftWrapMode === GiftWrapMode.PERSISTENT) return GIFT_WRAP_KIND; if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) return EPHEMERAL_GIFT_WRAP_KIND; diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index dddc93a..9490e60 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -67,6 +67,7 @@ import { ClientCapabilityNegotiator, } from './capability-negotiator.js'; import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; +import { ClientEventPipeline } from './nostr-client/event-pipeline.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -181,6 +182,7 @@ export class NostrClientTransport private readonly capabilityNegotiator: ClientCapabilityNegotiator; private readonly inboundNotificationDispatcher: ClientInboundNotificationDispatcher; + private readonly eventPipeline: ClientEventPipeline; // Oversized-transfer sender settings private readonly oversizedEnabled: boolean; @@ -312,6 +314,15 @@ export class NostrClientTransport logger: this.logger, onerror: (error) => this.onerror?.(error), }); + + this.eventPipeline = new ClientEventPipeline({ + signer: this.signer, + seenEventIds: this.seenEventIds, + serverPubkey: this.serverPubkey, + giftWrapMode: this.giftWrapMode, + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); } /** @@ -686,95 +697,11 @@ export class NostrClientTransport */ private async processIncomingEvent(event: NostrEvent): Promise { try { - let nostrEvent = event; - - if ( - event.kind === GIFT_WRAP_KIND || - event.kind === EPHEMERAL_GIFT_WRAP_KIND - ) { - if (!this.isGiftWrapKindAllowed(event.kind)) { - this.logger.debug('Skipping gift wrap due to GiftWrapMode policy', { - eventId: event.id, - kind: event.kind, - }); - return; - } - - // Deduplicate gift-wrap envelopes before any expensive decryption. - if (this.seenEventIds.has(event.id)) { - this.logger.debug('Skipping duplicate gift-wrapped event', { - eventId: event.id, - }); - return; - } - this.seenEventIds.set(event.id, true); - - try { - const decryptedContent = await withTimeout( - decryptMessage(event, this.signer), - DEFAULT_TIMEOUT_MS, - 'Decrypt message timed out', - ); - nostrEvent = JSON.parse(decryptedContent) as NostrEvent; - - // Verify the inner event's cryptographic signature to prevent - // identity forgery. Without this check an attacker can place the - // server's pubkey inside the plaintext and spoof responses. (Fixes #64) - if (!verifyEvent(nostrEvent)) { - this.logger.error( - 'Rejecting decrypted inner event with invalid signature', - { - innerEventId: nostrEvent.id, - innerPubkey: nostrEvent.pubkey, - outerEventId: event.id, - }, - ); - return; - } - } catch (decryptError) { - this.logger.error('Failed to decrypt gift-wrapped event', { - error: - decryptError instanceof Error - ? decryptError.message - : String(decryptError), - stack: - decryptError instanceof Error ? decryptError.stack : undefined, - eventId: event.id, - pubkey: event.pubkey, - }); - this.onerror?.( - decryptError instanceof Error - ? decryptError - : new Error('Failed to decrypt gift-wrapped event'), - ); - return; - } - } - - if (nostrEvent.pubkey !== this.serverPubkey) { - this.logger.debug('Skipping event from unexpected server pubkey:', { - receivedPubkey: nostrEvent.pubkey, - expectedPubkey: this.serverPubkey, - eventId: nostrEvent.id, - }); + const unwrapped = await this.eventPipeline.unwrap(event); + if (!unwrapped) { return; } - - if ( - event.kind !== GIFT_WRAP_KIND && - event.kind !== EPHEMERAL_GIFT_WRAP_KIND - ) { - if (!verifyEvent(nostrEvent)) { - this.logger.error( - 'Rejecting unencrypted event with invalid signature', - { - eventId: nostrEvent.id, - pubkey: nostrEvent.pubkey, - }, - ); - return; - } - } + const nostrEvent = unwrapped.event; this.learnServerDiscovery(nostrEvent); diff --git a/src/transport/nostr-client/event-pipeline.ts b/src/transport/nostr-client/event-pipeline.ts new file mode 100644 index 0000000..2b09ea7 --- /dev/null +++ b/src/transport/nostr-client/event-pipeline.ts @@ -0,0 +1,148 @@ +import { type NostrEvent } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools/pure'; +import { type NostrSigner, GiftWrapMode } from '../../core/interfaces.js'; +import { type LruCache } from '../../core/utils/lru-cache.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { decryptMessage, DEFAULT_TIMEOUT_MS, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../../core/index.js'; +import { withTimeout } from '../../core/utils/utils.js'; + +export interface ClientEventPipelineDeps { + signer: NostrSigner; + seenEventIds: LruCache; + serverPubkey: string; + giftWrapMode: GiftWrapMode; + logger: Logger; + onerror?: (error: Error) => void; +} + +export interface UnwrappedClientEvent { + event: NostrEvent; +} + +export class ClientEventPipeline { + constructor(private deps: ClientEventPipelineDeps) {} + + /** + * Decrypts and verifies an inbound event, checking against the expected server pubkey. + * Returns the inner event or null if invalid/duplicate. + */ + public async unwrap(event: NostrEvent): Promise { + try { + let nostrEvent = event; + + if ( + event.kind === GIFT_WRAP_KIND || + event.kind === EPHEMERAL_GIFT_WRAP_KIND + ) { + if (!this.isGiftWrapKindAllowed(event.kind)) { + this.deps.logger.debug('Skipping gift wrap due to GiftWrapMode policy', { + eventId: event.id, + kind: event.kind, + }); + return null; + } + + // Deduplicate gift-wrap envelopes before any expensive decryption. + if (this.deps.seenEventIds.has(event.id)) { + this.deps.logger.debug('Skipping duplicate gift-wrapped event', { + eventId: event.id, + }); + return null; + } + this.deps.seenEventIds.set(event.id, true); + + try { + const decryptedContent = await withTimeout( + decryptMessage(event, this.deps.signer), + DEFAULT_TIMEOUT_MS, + 'Decrypt message timed out', + ); + nostrEvent = JSON.parse(decryptedContent) as NostrEvent; + + // Verify the inner event's cryptographic signature to prevent + // identity forgery. Without this check an attacker can place the + // server's pubkey inside the plaintext and spoof responses. (Fixes #64) + if (!verifyEvent(nostrEvent)) { + this.deps.logger.error( + 'Rejecting decrypted inner event with invalid signature', + { + innerEventId: nostrEvent.id, + innerPubkey: nostrEvent.pubkey, + outerEventId: event.id, + }, + ); + return null; + } + } catch (decryptError) { + this.deps.logger.error('Failed to decrypt gift-wrapped event', { + error: + decryptError instanceof Error + ? decryptError.message + : String(decryptError), + stack: + decryptError instanceof Error ? decryptError.stack : undefined, + eventId: event.id, + pubkey: event.pubkey, + }); + this.deps.onerror?.( + decryptError instanceof Error + ? decryptError + : new Error('Failed to decrypt gift-wrapped event'), + ); + return null; + } + } + + if (nostrEvent.pubkey !== this.deps.serverPubkey) { + this.deps.logger.debug('Skipping event from unexpected server pubkey:', { + receivedPubkey: nostrEvent.pubkey, + expectedPubkey: this.deps.serverPubkey, + eventId: nostrEvent.id, + }); + return null; + } + + if ( + event.kind !== GIFT_WRAP_KIND && + event.kind !== EPHEMERAL_GIFT_WRAP_KIND + ) { + if (!verifyEvent(nostrEvent)) { + this.deps.logger.error( + 'Rejecting unencrypted event with invalid signature', + { + eventId: nostrEvent.id, + pubkey: nostrEvent.pubkey, + }, + ); + return null; + } + } + + return { event: nostrEvent }; + } catch (error) { + this.deps.logger.error('Error in event pipeline unwrap (client)', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + eventId: event.id, + pubkey: event.pubkey, + kind: event.kind, + }); + this.deps.onerror?.( + error instanceof Error + ? error + : new Error('Failed to handle incoming Nostr event'), + ); + return null; + } + } + + private isGiftWrapKindAllowed(kind: number): boolean { + if (this.deps.giftWrapMode === GiftWrapMode.PERSISTENT) { + return kind === GIFT_WRAP_KIND; + } + if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) { + return kind === EPHEMERAL_GIFT_WRAP_KIND; + } + return true; + } +} diff --git a/src/transport/nostr-client/inbound-notification-dispatcher.ts b/src/transport/nostr-client/inbound-notification-dispatcher.ts index 2e39f60..de54e27 100644 --- a/src/transport/nostr-client/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-client/inbound-notification-dispatcher.ts @@ -9,6 +9,9 @@ import { type Logger } from '../../core/utils/logger.js'; import { OpenStreamReceiver } from '../open-stream/index.js'; import { OversizedTransferReceiver } from '../oversized-transfer/index.js'; +/** + * Dependencies for the ClientInboundNotificationDispatcher. + */ export interface ClientInboundNotificationDispatcherDeps { openStreamReceiver: OpenStreamReceiver; oversizedReceiver: OversizedTransferReceiver; @@ -18,6 +21,9 @@ export interface ClientInboundNotificationDispatcherDeps { onerror?: (error: Error) => void; } +/** + * Intercepts incoming transport-level notifications (CEP-22, CEP-41) for the client. + */ export class ClientInboundNotificationDispatcher { constructor(private deps: ClientInboundNotificationDispatcherDeps) {} diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index c2256e9..1d6f2a3 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -70,6 +70,7 @@ import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; import { OutboundResponseRouter } from './nostr-server/outbound-response-router.js'; import { ServerOpenStreamFactory } from './nostr-server/open-stream-factory.js'; +import { ServerEventPipeline } from './nostr-server/event-pipeline.js'; /** * Options for configuring the NostrServerTransport. @@ -222,9 +223,6 @@ export class NostrServerTransport /** Receives inbound oversized-transfer frames from clients (client→server requests). */ private readonly oversizedReceiver: OversizedTransferReceiver; - /** Receives inbound open-stream frames from clients (client→server notifications). */ - private readonly openStreamReceiver: OpenStreamReceiver; - // Oversized-transfer sender settings (for server→client responses) private readonly oversizedEnabled: boolean; private readonly oversizedThreshold: number; @@ -232,6 +230,7 @@ export class NostrServerTransport private readonly openStreamEnabled: boolean; private readonly capabilityNegotiator: ServerCapabilityNegotiator; private readonly openStreamFactory: ServerOpenStreamFactory; + private readonly eventPipeline: ServerEventPipeline; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; private readonly outboundResponseRouter: OutboundResponseRouter; @@ -339,59 +338,6 @@ export class NostrServerTransport this.logger, ); this.openStreamEnabled = options.openStream?.enabled ?? false; - this.openStreamReceiver = new OpenStreamReceiver({ - maxConcurrentStreams: options.openStream?.policy?.maxConcurrentStreams, - maxBufferedChunksPerStream: - options.openStream?.policy?.maxBufferedChunksPerStream, - maxBufferedBytesPerStream: - options.openStream?.policy?.maxBufferedBytesPerStream, - idleTimeoutMs: options.openStream?.policy?.idleTimeoutMs, - probeTimeoutMs: options.openStream?.policy?.probeTimeoutMs, - closeGracePeriodMs: options.openStream?.policy?.closeGracePeriodMs, - getSessionOptions: (progressToken) => { - let progress = 0; - - return { - sendPing: async (nonce: string): Promise => { - progress += 1; - await this.sendNotification(progressToken, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPingFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendPong: async (nonce: string): Promise => { - progress += 1; - await this.sendNotification(progressToken, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPongFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendAbort: async (reason?: string): Promise => { - progress += 1; - await this.sendNotification(progressToken, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamAbortFrame({ - progressToken, - progress, - reason, - }), - }); - }, - }; - }, - logger: this.logger, - }); // Advertise CEP-22 support so clients can skip the accept handshake. const internalCommonTags: string[][] = []; @@ -418,12 +364,15 @@ export class NostrServerTransport handleResponse: async (response) => { await this.outboundResponseRouter.route(response); }, + correlationStore: this.correlationStore, + policy: options.openStream?.policy, + logger: this.logger, }); this.inboundNotificationDispatcher = new InboundNotificationDispatcher({ - openStreamReceiver: this.openStreamReceiver, + openStreamReceiver: this.openStreamFactory.getReceiver(), oversizedReceiver: this.oversizedReceiver, - openStreamWriters: this.openStreamFactory.getWritersMap(), + openStreamFactory: this.openStreamFactory, correlationStore: this.correlationStore, sendNotification: this.sendNotification.bind(this), handleIncomingRequest: this.handleIncomingRequest.bind(this), @@ -439,8 +388,7 @@ export class NostrServerTransport correlationStore: this.correlationStore, sessionStore: this.sessionStore, announcementManager: this.announcementManager, - openStreamWriters: this.openStreamFactory.getWritersMap(), - pendingOpenStreamResponses: this.openStreamFactory.getPendingResponsesMap(), + openStreamFactory: this.openStreamFactory, oversizedConfig: { enabled: this.oversizedEnabled, threshold: this.oversizedThreshold, @@ -454,6 +402,15 @@ export class NostrServerTransport logger: this.logger, onerror: (error) => this.onerror?.(error), }); + + this.eventPipeline = new ServerEventPipeline({ + signer: this.signer, + seenEventIds: this.seenEventIds, + encryptionMode: this.encryptionMode, + giftWrapMode: this.giftWrapMode, + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); } /** @@ -561,9 +518,8 @@ export class NostrServerTransport this.correlationStore.clear(); this.seenEventIds.clear(); this.oversizedReceiver.clear(); - this.openStreamReceiver.clear(); - this.openStreamFactory.getPendingResponsesMap().clear(); - this.openStreamFactory.getWritersMap().clear(); + this.openStreamFactory.getReceiver().clear(); + this.openStreamFactory.clear(); this.onclose?.(); } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); @@ -875,122 +831,14 @@ export class NostrServerTransport * @param event The incoming Nostr event. */ private async processIncomingEvent(event: NostrEvent): Promise { - try { - if ( - event.kind === GIFT_WRAP_KIND || - event.kind === EPHEMERAL_GIFT_WRAP_KIND - ) { - if (!this.isGiftWrapKindAllowed(event.kind)) { - this.logger.debug('Skipping gift wrap due to GiftWrapMode policy', { - eventId: event.id, - kind: event.kind, - }); - return; - } - - // Deduplicate gift-wrap envelopes before any expensive decryption. - if (this.seenEventIds.has(event.id)) { - this.logger.debug('Skipping duplicate gift-wrapped event', { - eventId: event.id, - }); - return; - } - this.seenEventIds.set(event.id, true); - - await this.handleEncryptedEvent(event); - } else { - await this.handleUnencryptedEvent(event); - } - } catch (error) { - this.logger.error('Error in processIncomingEvent', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - eventId: event.id, - eventKind: event.kind, - }); - this.onerror?.(error instanceof Error ? error : new Error(String(error))); - } - } - - /** - * Handles encrypted (gift-wrapped) events. - * @param event The incoming gift-wrapped Nostr event. - */ - private async handleEncryptedEvent(event: NostrEvent): Promise { - if (this.encryptionMode === EncryptionMode.DISABLED) { - this.logger.error( - `Received encrypted message from ${event.pubkey} but encryption is disabled. Ignoring.`, - ); - return; - } - try { - const decryptedJson = await withTimeout( - decryptMessage(event, this.signer), - DEFAULT_TIMEOUT_MS, - 'Decrypt message timed out', + const unwrapped = await this.eventPipeline.unwrap(event); + if (unwrapped) { + await this.authorizeAndProcessEvent( + unwrapped.event, + unwrapped.isEncrypted, + unwrapped.wrapKind, ); - const currentEvent = JSON.parse(decryptedJson) as NostrEvent; - - // Verify the inner event's cryptographic signature to prevent identity - // forgery. Without this check an attacker can place any pubkey inside - // the plaintext and bypass allowlists. (Fixes #64) - if (!verifyEvent(currentEvent)) { - this.logger.error( - 'Rejecting decrypted inner event with invalid signature', - { - innerEventId: currentEvent.id, - innerPubkey: currentEvent.pubkey, - outerEventId: event.id, - }, - ); - return; - } - - // Deduplicate decrypted inner events before authorization and dispatch. - if (this.seenEventIds.has(currentEvent.id)) { - this.logger.debug('Skipping duplicate decrypted inner event', { - outerEventId: event.id, - innerEventId: currentEvent.id, - }); - return; - } - this.seenEventIds.set(currentEvent.id, true); - - await this.authorizeAndProcessEvent(currentEvent, true, event.kind); - } catch (error) { - this.logger.error('Failed to handle encrypted Nostr event', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - eventId: event.id, - pubkey: event.pubkey, - }); - this.onerror?.( - error instanceof Error - ? error - : new Error('Failed to handle encrypted Nostr event'), - ); - } - } - - /** - * Handles unencrypted events. - * @param event The incoming Nostr event. - */ - private async handleUnencryptedEvent(event: NostrEvent): Promise { - if (this.encryptionMode === EncryptionMode.REQUIRED) { - this.logger.error( - `Received unencrypted message from ${event.pubkey} but encryption is required. Ignoring.`, - ); - return; - } - if (!verifyEvent(event)) { - this.logger.error('Rejecting unencrypted event with invalid signature', { - eventId: event.id, - pubkey: event.pubkey, - }); - return; } - await this.authorizeAndProcessEvent(event, false); } /** @@ -1193,7 +1041,7 @@ export class NostrServerTransport sessionStore: this.sessionStore, correlationStore: this.correlationStore, oversizedReceiver: this.oversizedReceiver, - openStreamReceiver: this.openStreamReceiver, + openStreamReceiver: this.openStreamFactory.getReceiver(), }; } } diff --git a/src/transport/nostr-server/event-pipeline.ts b/src/transport/nostr-server/event-pipeline.ts new file mode 100644 index 0000000..4b381c9 --- /dev/null +++ b/src/transport/nostr-server/event-pipeline.ts @@ -0,0 +1,152 @@ +import { type NostrEvent } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools/pure'; +import { type NostrSigner, EncryptionMode, GiftWrapMode } from '../../core/interfaces.js'; +import { type LruCache } from '../../core/utils/lru-cache.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { decryptMessage, DEFAULT_TIMEOUT_MS, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../../core/index.js'; +import { withTimeout } from '../../core/utils/utils.js'; + +export interface ServerEventPipelineDeps { + signer: NostrSigner; + seenEventIds: LruCache; + encryptionMode: EncryptionMode; + giftWrapMode: GiftWrapMode; + logger: Logger; + onerror?: (error: Error) => void; +} + +export interface UnwrappedEvent { + event: NostrEvent; + isEncrypted: boolean; + wrapKind?: number; +} + +export class ServerEventPipeline { + constructor(private deps: ServerEventPipelineDeps) {} + + /** + * Decrypts and verifies an inbound event, returning the inner event or null if invalid/duplicate. + */ + public async unwrap(event: NostrEvent): Promise { + try { + if ( + event.kind === GIFT_WRAP_KIND || + event.kind === EPHEMERAL_GIFT_WRAP_KIND + ) { + if (!this.isGiftWrapKindAllowed(event.kind)) { + this.deps.logger.debug('Skipping gift wrap due to GiftWrapMode policy', { + eventId: event.id, + kind: event.kind, + }); + return null; + } + + // Deduplicate gift-wrap envelopes before any expensive decryption. + if (this.deps.seenEventIds.has(event.id)) { + this.deps.logger.debug('Skipping duplicate gift-wrapped event', { + eventId: event.id, + }); + return null; + } + this.deps.seenEventIds.set(event.id, true); + + return await this.handleEncryptedEvent(event); + } else { + return this.handleUnencryptedEvent(event); + } + } catch (error) { + this.deps.logger.error('Error in event pipeline unwrap', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + eventId: event.id, + eventKind: event.kind, + }); + this.deps.onerror?.(error instanceof Error ? error : new Error(String(error))); + return null; + } + } + + private isGiftWrapKindAllowed(kind: number): boolean { + if (this.deps.giftWrapMode === GiftWrapMode.PERSISTENT) { + return kind === GIFT_WRAP_KIND; + } + if (this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL) { + return kind === EPHEMERAL_GIFT_WRAP_KIND; + } + return true; + } + + private async handleEncryptedEvent(event: NostrEvent): Promise { + if (this.deps.encryptionMode === EncryptionMode.DISABLED) { + this.deps.logger.error( + `Received encrypted message from ${event.pubkey} but encryption is disabled. Ignoring.`, + ); + return null; + } + try { + const decryptedJson = await withTimeout( + decryptMessage(event, this.deps.signer), + DEFAULT_TIMEOUT_MS, + 'Decrypt message timed out', + ); + const currentEvent = JSON.parse(decryptedJson) as NostrEvent; + + // Verify the inner event's cryptographic signature to prevent identity + // forgery. Without this check an attacker can place any pubkey inside + // the plaintext and bypass allowlists. (Fixes #64) + if (!verifyEvent(currentEvent)) { + this.deps.logger.error( + 'Rejecting decrypted inner event with invalid signature', + { + innerEventId: currentEvent.id, + innerPubkey: currentEvent.pubkey, + outerEventId: event.id, + }, + ); + return null; + } + + // Deduplicate decrypted inner events before authorization and dispatch. + if (this.deps.seenEventIds.has(currentEvent.id)) { + this.deps.logger.debug('Skipping duplicate decrypted inner event', { + outerEventId: event.id, + innerEventId: currentEvent.id, + }); + return null; + } + this.deps.seenEventIds.set(currentEvent.id, true); + + return { event: currentEvent, isEncrypted: true, wrapKind: event.kind }; + } catch (error) { + this.deps.logger.error('Failed to handle encrypted Nostr event', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + eventId: event.id, + pubkey: event.pubkey, + }); + this.deps.onerror?.( + error instanceof Error + ? error + : new Error('Failed to handle encrypted Nostr event'), + ); + return null; + } + } + + private handleUnencryptedEvent(event: NostrEvent): UnwrappedEvent | null { + if (this.deps.encryptionMode === EncryptionMode.REQUIRED) { + this.deps.logger.error( + `Received unencrypted message from ${event.pubkey} but encryption is required. Ignoring.`, + ); + return null; + } + if (!verifyEvent(event)) { + this.deps.logger.error('Rejecting unencrypted event with invalid signature', { + eventId: event.id, + pubkey: event.pubkey, + }); + return null; + } + return { event, isEncrypted: false }; + } +} diff --git a/src/transport/nostr-server/inbound-notification-dispatcher.ts b/src/transport/nostr-server/inbound-notification-dispatcher.ts index 7c0c3ce..6627b54 100644 --- a/src/transport/nostr-server/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-server/inbound-notification-dispatcher.ts @@ -18,10 +18,13 @@ import { type ClientSession } from './session-store.js'; import { sendAcceptFrame } from './oversized-server-handler.js'; import { injectClientPubkey, injectRequestEventId } from '../../core/utils/utils.js'; +/** + * Dependencies for the Server InboundNotificationDispatcher. + */ export interface InboundNotificationDispatcherDeps { openStreamReceiver: OpenStreamReceiver; oversizedReceiver: OversizedTransferReceiver; - openStreamWriters: Map; + openStreamFactory: { getWriter: (eventId: string) => OpenStreamWriter | undefined }; correlationStore: CorrelationStore; sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; handleIncomingRequest: ( @@ -39,6 +42,9 @@ export interface InboundNotificationDispatcherDeps { onerror?: (error: Error) => void; } +/** + * Intercepts incoming transport-level notifications (CEP-22, CEP-41) for the server. + */ export class InboundNotificationDispatcher { constructor(private deps: InboundNotificationDispatcherDeps) {} @@ -69,7 +75,7 @@ export class InboundNotificationDispatcher { if (frame?.frameType === 'abort') { const progressToken = String(inboundMessage.params?.progressToken ?? ''); const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); - const writer = eventId ? this.deps.openStreamWriters.get(eventId) : undefined; + const writer = eventId ? this.deps.openStreamFactory.getWriter(eventId) : undefined; if (writer) { void writer.abort(frame.reason).catch((err: unknown) => { @@ -89,7 +95,7 @@ export class InboundNotificationDispatcher { const progressToken = String(inboundMessage.params?.progressToken ?? ''); const nonce = 'nonce' in frame && typeof frame.nonce === 'string' ? frame.nonce : ''; const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); - const writer = eventId ? this.deps.openStreamWriters.get(eventId) : undefined; + const writer = eventId ? this.deps.openStreamFactory.getWriter(eventId) : undefined; if (writer) { void writer.pong(nonce).catch((err: unknown) => { diff --git a/src/transport/nostr-server/open-stream-factory.ts b/src/transport/nostr-server/open-stream-factory.ts index db7a2b0..0db2cc5 100644 --- a/src/transport/nostr-server/open-stream-factory.ts +++ b/src/transport/nostr-server/open-stream-factory.ts @@ -1,30 +1,132 @@ -import { type JSONRPCResponse, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; -import { OpenStreamWriter } from '../open-stream/index.js'; +import { OpenStreamWriter, OpenStreamReceiver, buildOpenStreamPingFrame, buildOpenStreamPongFrame, buildOpenStreamAbortFrame, type OpenStreamPolicy } from '../open-stream/index.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { type CorrelationStore } from './correlation-store.js'; +/** + * Dependencies for the ServerOpenStreamFactory. + */ export interface ServerOpenStreamFactoryDeps { openStreamEnabled: boolean; sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; handleResponse: (response: JSONRPCResponse) => Promise; + correlationStore: CorrelationStore; + policy?: OpenStreamPolicy; + logger: Logger; } +/** + * Manages the lifecycle of CEP-41 OpenStream instances for the server transport. + */ export class ServerOpenStreamFactory { private readonly writers = new Map(); private readonly pendingResponses = new Map(); + private readonly receiver: OpenStreamReceiver; - constructor(private deps: ServerOpenStreamFactoryDeps) {} + constructor(private deps: ServerOpenStreamFactoryDeps) { + this.receiver = new OpenStreamReceiver({ + maxConcurrentStreams: deps.policy?.maxConcurrentStreams, + maxBufferedChunksPerStream: deps.policy?.maxBufferedChunksPerStream, + maxBufferedBytesPerStream: deps.policy?.maxBufferedBytesPerStream, + idleTimeoutMs: deps.policy?.idleTimeoutMs, + probeTimeoutMs: deps.policy?.probeTimeoutMs, + closeGracePeriodMs: deps.policy?.closeGracePeriodMs, + getSessionOptions: (progressToken) => { + let progress = 0; + const getClientPubkey = () => { + const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); + if (!eventId) return undefined; + const route = this.deps.correlationStore.getEventRoute(eventId); + return route?.clientPubkey; + }; + + return { + sendPing: async (nonce: string): Promise => { + progress += 1; + const clientPubkey = getClientPubkey(); + if (!clientPubkey) return; + await this.deps.sendNotification(clientPubkey, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPingFrame({ + progressToken, + progress, + nonce, + }), + }); + }, + sendPong: async (nonce: string): Promise => { + progress += 1; + const clientPubkey = getClientPubkey(); + if (!clientPubkey) return; + await this.deps.sendNotification(clientPubkey, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPongFrame({ + progressToken, + progress, + nonce, + }), + }); + }, + sendAbort: async (reason?: string): Promise => { + progress += 1; + const clientPubkey = getClientPubkey(); + if (!clientPubkey) return; + await this.deps.sendNotification(clientPubkey, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamAbortFrame({ + progressToken, + progress, + reason, + }), + }); + }, + }; + }, + logger: deps.logger, + }); + } + + /** + * Gets the inbound OpenStreamReceiver instance used for CEP-41. + */ + public getReceiver(): OpenStreamReceiver { + return this.receiver; + } + + /** + * Gets an active OpenStreamWriter for a specific event ID. + */ public getWriter(eventId: string): OpenStreamWriter | undefined { return this.writers.get(eventId); } - public getWritersMap(): Map { - return this.writers; + /** + * Clears all active writers and pending responses. + */ + public clear(): void { + this.writers.clear(); + this.pendingResponses.clear(); } - public getPendingResponsesMap(): Map { - return this.pendingResponses; + /** + * Checks if an event has an active stream and defers the response if so. + * Returns true if deferred. + */ + public deferIfStreamActive(eventId: string, response: JSONRPCResponse): boolean { + const existingWriter = this.writers.get(eventId); + if (existingWriter && existingWriter.isActive) { + this.pendingResponses.set(eventId, response); + return true; + } + return false; } + /** + * Conditionally creates a new OpenStreamWriter if the client supports it. + */ public createWriterIfEnabled( eventId: string, clientPubkey: string, @@ -55,6 +157,9 @@ export class ServerOpenStreamFactory { this.writers.set(eventId, writer); } + /** + * Flushes a deferred response for a stream once it has closed or aborted. + */ public async flushPendingResponse(eventId: string): Promise { const pendingResponse = this.pendingResponses.get(eventId); this.pendingResponses.delete(eventId); diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index f4bcaec..eb93dbc 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -18,12 +18,14 @@ import { type OpenStreamWriter } from '../open-stream/index.js'; import { CTXVM_MESSAGES_KIND } from '../../core/constants.js'; import { sendOversizedServerResponse } from './oversized-server-handler.js'; +/** + * Dependencies for the OutboundResponseRouter. + */ export interface OutboundResponseRouterDeps { correlationStore: CorrelationStore; sessionStore: SessionStore; announcementManager: AnnouncementManager; - openStreamWriters: Map; - pendingOpenStreamResponses: Map; + openStreamFactory: { deferIfStreamActive: (eventId: string, response: JSONRPCResponse) => boolean }; oversizedConfig: { enabled: boolean; threshold: number; chunkSize: number }; applyListToolsResultTransformers: (result: ListToolsResult) => ListToolsResult; buildOutboundTags: (params: { baseTags: readonly string[][]; session: ClientSession }) => string[][]; @@ -42,9 +44,15 @@ export interface OutboundResponseRouterDeps { onerror?: (error: Error) => void; } +/** + * Routes outbound JSON-RPC responses back to the original client. + */ export class OutboundResponseRouter { constructor(private deps: OutboundResponseRouterDeps) {} + /** + * Routes a response, handling oversized transfer and stream deferral. + */ public async route( response: JSONRPCResponse | JSONRPCErrorResponse, ): Promise { @@ -62,9 +70,7 @@ export class OutboundResponseRouter { // Find the event route using O(1) lookup const nostrEventId = response.id as string; - const existingOpenStreamWriter = this.deps.openStreamWriters.get(nostrEventId); - if (existingOpenStreamWriter && existingOpenStreamWriter.isActive) { - this.deps.pendingOpenStreamResponses.set(nostrEventId, response); + if (this.deps.openStreamFactory.deferIfStreamActive(nostrEventId, response)) { return; } From 67c7d6930945f2511df7f6fed734f7c1e8a72e65 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Fri, 8 May 2026 22:10:58 +0530 Subject: [PATCH 09/17] fix: resolve build and lint errors from event pipeline extraction - Fixed generic type argument for LruCache in ClientEventPipeline and ServerEventPipeline - Removed unused imports (decryptMessage, verifyEvent, etc) from client and server transports - Fixed missing OpenStreamPolicy and JSONRPC imports in ServerOpenStreamFactory - Removed unused OpenStreamWriter from OutboundResponseRouter --- src/transport/nostr-client-transport.ts | 7 ++----- src/transport/nostr-client/event-pipeline.ts | 2 +- src/transport/nostr-server-transport.ts | 8 +------- src/transport/nostr-server/event-pipeline.ts | 2 +- src/transport/nostr-server/open-stream-factory.ts | 6 ++++-- src/transport/nostr-server/outbound-response-router.ts | 2 +- 6 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 9490e60..2fe676e 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -18,9 +18,6 @@ import { CTXVM_MESSAGES_KIND, DEFAULT_BOOTSTRAP_RELAY_URLS, DEFAULT_TIMEOUT_MS, - EPHEMERAL_GIFT_WRAP_KIND, - GIFT_WRAP_KIND, - decryptMessage, DEFAULT_LRU_SIZE, INITIALIZE_METHOD, NOSTR_TAGS, @@ -32,7 +29,7 @@ import { } from './base-nostr-transport.js'; import { getNostrEventTag } from '../core/utils/serializers.js'; import { NostrEvent } from 'nostr-tools'; -import { verifyEvent } from 'nostr-tools/pure'; + import { LogLevel } from '../core/utils/logger.js'; import { @@ -43,7 +40,7 @@ import { import { parseServerIdentity } from './nostr-client/server-identity.js'; import { resolveOperationalRelays } from './nostr-client/relay-resolution.js'; import { StatelessModeHandler } from './nostr-client/stateless-mode-handler.js'; -import { queryTags, withTimeout } from '../core/utils/utils.js'; +import { queryTags } from '../core/utils/utils.js'; import { OversizedTransferReceiver, type TransferPolicy, diff --git a/src/transport/nostr-client/event-pipeline.ts b/src/transport/nostr-client/event-pipeline.ts index 2b09ea7..a254798 100644 --- a/src/transport/nostr-client/event-pipeline.ts +++ b/src/transport/nostr-client/event-pipeline.ts @@ -8,7 +8,7 @@ import { withTimeout } from '../../core/utils/utils.js'; export interface ClientEventPipelineDeps { signer: NostrSigner; - seenEventIds: LruCache; + seenEventIds: LruCache; serverPubkey: string; giftWrapMode: GiftWrapMode; logger: Logger; diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 1d6f2a3..5adf930 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -21,12 +21,10 @@ import { GIFT_WRAP_KIND, NOSTR_TAGS, NOTIFICATIONS_INITIALIZED_METHOD, - decryptMessage, DEFAULT_LRU_SIZE, } from '../core/index.js'; -import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { GiftWrapMode } from '../core/interfaces.js'; import { NostrEvent } from 'nostr-tools'; -import { verifyEvent } from 'nostr-tools/pure'; import { LogLevel } from '../core/utils/logger.js'; import { injectClientPubkey, @@ -52,11 +50,7 @@ import { type TransferPolicy, } from './oversized-transfer/index.js'; import { - OpenStreamReceiver, OpenStreamWriter, - buildOpenStreamAbortFrame, - buildOpenStreamPingFrame, - buildOpenStreamPongFrame, } from './open-stream/index.js'; import { DEFAULT_CHUNK_SIZE, diff --git a/src/transport/nostr-server/event-pipeline.ts b/src/transport/nostr-server/event-pipeline.ts index 4b381c9..433463d 100644 --- a/src/transport/nostr-server/event-pipeline.ts +++ b/src/transport/nostr-server/event-pipeline.ts @@ -8,7 +8,7 @@ import { withTimeout } from '../../core/utils/utils.js'; export interface ServerEventPipelineDeps { signer: NostrSigner; - seenEventIds: LruCache; + seenEventIds: LruCache; encryptionMode: EncryptionMode; giftWrapMode: GiftWrapMode; logger: Logger; diff --git a/src/transport/nostr-server/open-stream-factory.ts b/src/transport/nostr-server/open-stream-factory.ts index 0db2cc5..d1d1309 100644 --- a/src/transport/nostr-server/open-stream-factory.ts +++ b/src/transport/nostr-server/open-stream-factory.ts @@ -1,6 +1,8 @@ -import { OpenStreamWriter, OpenStreamReceiver, buildOpenStreamPingFrame, buildOpenStreamPongFrame, buildOpenStreamAbortFrame, type OpenStreamPolicy } from '../open-stream/index.js'; +import { OpenStreamWriter, OpenStreamReceiver, buildOpenStreamPingFrame, buildOpenStreamPongFrame, buildOpenStreamAbortFrame } from '../open-stream/index.js'; +import { type OpenStreamRegistryOptions } from '../open-stream/registry.js'; import { type Logger } from '../../core/utils/logger.js'; import { type CorrelationStore } from './correlation-store.js'; +import { type JSONRPCMessage, type JSONRPCResponse } from '@modelcontextprotocol/sdk/types.js'; /** * Dependencies for the ServerOpenStreamFactory. @@ -10,7 +12,7 @@ export interface ServerOpenStreamFactoryDeps { sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; handleResponse: (response: JSONRPCResponse) => Promise; correlationStore: CorrelationStore; - policy?: OpenStreamPolicy; + policy?: Partial; logger: Logger; } diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index eb93dbc..faf0f86 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -14,7 +14,7 @@ import { type Logger } from '../../core/utils/logger.js'; import { type CorrelationStore } from './correlation-store.js'; import { type ClientSession, type SessionStore } from './session-store.js'; import { type AnnouncementManager } from './announcement-manager.js'; -import { type OpenStreamWriter } from '../open-stream/index.js'; + import { CTXVM_MESSAGES_KIND } from '../../core/constants.js'; import { sendOversizedServerResponse } from './oversized-server-handler.js'; From 42b4957e47bfe2048c1d8fbb1fb6d7e3694a46db Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Fri, 8 May 2026 22:42:29 +0530 Subject: [PATCH 10/17] refactor: address maintainer recommendations from transport extraction - Added JSDoc comments to ClientEventPipeline and ServerEventPipeline exports. - Extracted client open-stream session setup into ClientOpenStreamFactory. - Reduced nostr-client-transport.ts by ~120 LOC. - Kept authorizeAndProcessEvent inline in ServerTransport since extracting it would reduce clarity due to heavy coupling with transport state. --- src/transport/nostr-client-transport.ts | 147 ++--------------- src/transport/nostr-client/event-pipeline.ts | 3 + .../nostr-client/open-stream-factory.ts | 150 ++++++++++++++++++ src/transport/nostr-server/event-pipeline.ts | 3 + 4 files changed, 171 insertions(+), 132 deletions(-) create mode 100644 src/transport/nostr-client/open-stream-factory.ts diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 2fe676e..23a570a 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -46,25 +46,16 @@ import { type TransferPolicy, } from './oversized-transfer/index.js'; import { - OpenStreamReceiver, OpenStreamSession, - buildOpenStreamAbortFrame, - buildOpenStreamPingFrame, - buildOpenStreamPongFrame, } from './open-stream/index.js'; -import { - DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, - DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, - DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, - DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, - DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, -} from './open-stream/constants.js'; + import { parseDiscoveredPeerCapabilities, ClientCapabilityNegotiator, } from './capability-negotiator.js'; import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; import { ClientEventPipeline } from './nostr-client/event-pipeline.js'; +import { ClientOpenStreamFactory } from './nostr-client/open-stream-factory.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -187,14 +178,12 @@ export class NostrClientTransport private readonly oversizedChunkSize: number; private readonly oversizedAcceptTimeoutMs: number; private readonly openStreamEnabled: boolean; - private readonly openStreamPolicy: OpenStreamTransportPolicy | undefined; + + private readonly openStreamFactory: ClientOpenStreamFactory; /** Receives inbound oversized-transfer frames from the server (server→client responses). */ private readonly oversizedReceiver: OversizedTransferReceiver; - /** Receives inbound open-stream frames from the server (server→client notifications). */ - private readonly openStreamReceiver: OpenStreamReceiver; - /** * Deduplicate inbound events to avoid redundant work. * @@ -240,59 +229,12 @@ export class NostrClientTransport this.logger, ); this.openStreamEnabled = options.openStream?.enabled ?? false; - this.openStreamPolicy = options.openStream?.policy; - this.openStreamReceiver = new OpenStreamReceiver({ - maxConcurrentStreams: options.openStream?.policy?.maxConcurrentStreams, - maxBufferedChunksPerStream: - options.openStream?.policy?.maxBufferedChunksPerStream, - maxBufferedBytesPerStream: - options.openStream?.policy?.maxBufferedBytesPerStream, - idleTimeoutMs: options.openStream?.policy?.idleTimeoutMs, - probeTimeoutMs: options.openStream?.policy?.probeTimeoutMs, - closeGracePeriodMs: options.openStream?.policy?.closeGracePeriodMs, - getSessionOptions: (progressToken) => { - let progress = 0; - - return { - sendPing: async (nonce: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPingFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendPong: async (nonce: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPongFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendAbort: async (reason?: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamAbortFrame({ - progressToken, - progress, - reason, - }), - }); - }, - }; - }, + this.openStreamFactory = new ClientOpenStreamFactory({ + openStreamEnabled: this.openStreamEnabled, + policy: options.openStream?.policy, + send: this.send.bind(this), logger: this.logger, + onerror: (error) => this.onerror?.(error), }); this.capabilityNegotiator = new ClientCapabilityNegotiator({ @@ -304,7 +246,7 @@ export class NostrClientTransport }); this.inboundNotificationDispatcher = new ClientInboundNotificationDispatcher({ - openStreamReceiver: this.openStreamReceiver, + openStreamReceiver: this.openStreamFactory.getReceiver(), oversizedReceiver: this.oversizedReceiver, handleResponse: this.handleResponse.bind(this), handleNotification: this.handleNotification.bind(this), @@ -377,7 +319,7 @@ export class NostrClientTransport this.correlationStore.clear(); this.seenEventIds.clear(); this.oversizedReceiver.clear(); - this.openStreamReceiver.clear(); + this.openStreamFactory.getReceiver().clear(); this.onclose?.(); } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); @@ -584,7 +526,7 @@ export class NostrClientTransport public getOrCreateOpenStreamSession( progressToken: string, ): OpenStreamSession { - return this.openStreamReceiver.getOrCreateSession(progressToken); + return this.openStreamFactory.getOrCreateSession(progressToken); } /** @@ -594,66 +536,7 @@ export class NostrClientTransport public createOutboundOpenStreamSession( progressToken: string, ): OpenStreamSession { - const existing = this.openStreamReceiver.getSession(progressToken); - if (existing) { - return existing; - } - - let progress = 0; - return this.openStreamReceiver.createSession({ - progressToken, - maxBufferedChunks: - this.openStreamPolicy?.maxBufferedChunksPerStream ?? - DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, - maxBufferedBytes: - this.openStreamPolicy?.maxBufferedBytesPerStream ?? - DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, - idleTimeoutMs: - this.openStreamPolicy?.idleTimeoutMs ?? - DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, - probeTimeoutMs: - this.openStreamPolicy?.probeTimeoutMs ?? - DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, - closeGracePeriodMs: - this.openStreamPolicy?.closeGracePeriodMs ?? - DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, - sendPing: async (nonce: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPingFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendPong: async (nonce: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamPongFrame({ - progressToken, - progress, - nonce, - }), - }); - }, - sendAbort: async (reason?: string): Promise => { - progress += 1; - await this.send({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: buildOpenStreamAbortFrame({ - progressToken, - progress, - reason, - }), - }); - }, - }); + return this.openStreamFactory.createOutboundSession(progressToken); } /** @@ -662,7 +545,7 @@ export class NostrClientTransport public getOpenStreamSession( progressToken: string, ): OpenStreamSession | undefined { - return this.openStreamReceiver.getSession(progressToken); + return this.openStreamFactory.getSession(progressToken); } /** @@ -1088,7 +971,7 @@ export class NostrClientTransport serverResourceTemplatesListEvent: this.serverResourceTemplatesListEvent, serverPromptsListEvent: this.serverPromptsListEvent, oversizedReceiver: this.oversizedReceiver, - openStreamReceiver: this.openStreamReceiver, + openStreamReceiver: this.openStreamFactory.getReceiver(), }; } } diff --git a/src/transport/nostr-client/event-pipeline.ts b/src/transport/nostr-client/event-pipeline.ts index a254798..6c49c64 100644 --- a/src/transport/nostr-client/event-pipeline.ts +++ b/src/transport/nostr-client/event-pipeline.ts @@ -6,6 +6,7 @@ import { type Logger } from '../../core/utils/logger.js'; import { decryptMessage, DEFAULT_TIMEOUT_MS, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../../core/index.js'; import { withTimeout } from '../../core/utils/utils.js'; +/** Dependencies for the client-side event decryption and verification pipeline. */ export interface ClientEventPipelineDeps { signer: NostrSigner; seenEventIds: LruCache; @@ -15,10 +16,12 @@ export interface ClientEventPipelineDeps { onerror?: (error: Error) => void; } +/** Result of successfully unwrapping an inbound Nostr event on the client. */ export interface UnwrappedClientEvent { event: NostrEvent; } +/** Handles gift-wrap decryption, signature verification, and server-pubkey gating for the client transport. */ export class ClientEventPipeline { constructor(private deps: ClientEventPipelineDeps) {} diff --git a/src/transport/nostr-client/open-stream-factory.ts b/src/transport/nostr-client/open-stream-factory.ts new file mode 100644 index 0000000..d22b3ac --- /dev/null +++ b/src/transport/nostr-client/open-stream-factory.ts @@ -0,0 +1,150 @@ +import { + OpenStreamReceiver, + OpenStreamSession, + buildOpenStreamPingFrame, + buildOpenStreamPongFrame, + buildOpenStreamAbortFrame, +} from '../open-stream/index.js'; +import { + DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, + DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, + DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, + DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, + DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, +} from '../open-stream/constants.js'; +import type { OpenStreamTransportPolicy } from '../open-stream-policy.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { Logger } from '../../core/utils/logger.js'; + +/** Dependencies for the ClientOpenStreamFactory. */ +export interface ClientOpenStreamFactoryDeps { + openStreamEnabled: boolean; + policy?: OpenStreamTransportPolicy; + send: (message: JSONRPCMessage) => Promise; + logger: Logger; + onerror?: (error: Error) => void; +} + +/** + * Manages the lifecycle of CEP-41 OpenStream instances for the client transport. + * + * Owns the inbound OpenStreamReceiver and exposes helpers for creating + * outbound sessions with the correct ping/pong/abort wiring. + */ +export class ClientOpenStreamFactory { + private readonly receiver: OpenStreamReceiver; + private readonly policy: OpenStreamTransportPolicy | undefined; + private readonly send: (message: JSONRPCMessage) => Promise; + + constructor(deps: ClientOpenStreamFactoryDeps) { + this.policy = deps.policy; + this.send = deps.send; + + this.receiver = new OpenStreamReceiver({ + maxConcurrentStreams: deps.policy?.maxConcurrentStreams, + maxBufferedChunksPerStream: deps.policy?.maxBufferedChunksPerStream, + maxBufferedBytesPerStream: deps.policy?.maxBufferedBytesPerStream, + idleTimeoutMs: deps.policy?.idleTimeoutMs, + probeTimeoutMs: deps.policy?.probeTimeoutMs, + closeGracePeriodMs: deps.policy?.closeGracePeriodMs, + getSessionOptions: (progressToken) => { + let progress = 0; + return { + sendPing: async (nonce: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPingFrame({ progressToken, progress, nonce }), + }); + }, + sendPong: async (nonce: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPongFrame({ progressToken, progress, nonce }), + }); + }, + sendAbort: async (reason?: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamAbortFrame({ progressToken, progress, reason }), + }); + }, + }; + }, + logger: deps.logger, + }); + } + + /** Gets the inbound OpenStreamReceiver instance used for CEP-41. */ + public getReceiver(): OpenStreamReceiver { + return this.receiver; + } + + /** Returns an existing session for a progress token, or undefined. */ + public getSession(progressToken: string): OpenStreamSession | undefined { + return this.receiver.getSession(progressToken); + } + + /** Returns the session for a progress token, creating it lazily if needed. */ + public getOrCreateSession(progressToken: string): OpenStreamSession { + return this.receiver.getOrCreateSession(progressToken); + } + + /** + * Creates an outbound CEP-41 session whose local ping/pong/abort + * publishes the corresponding notification to the server. + */ + public createOutboundSession(progressToken: string): OpenStreamSession { + const existing = this.receiver.getSession(progressToken); + if (existing) { + return existing; + } + + let progress = 0; + return this.receiver.createSession({ + progressToken, + maxBufferedChunks: + this.policy?.maxBufferedChunksPerStream ?? + DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, + maxBufferedBytes: + this.policy?.maxBufferedBytesPerStream ?? + DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, + idleTimeoutMs: + this.policy?.idleTimeoutMs ?? DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, + probeTimeoutMs: + this.policy?.probeTimeoutMs ?? DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, + closeGracePeriodMs: + this.policy?.closeGracePeriodMs ?? + DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, + sendPing: async (nonce: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPingFrame({ progressToken, progress, nonce }), + }); + }, + sendPong: async (nonce: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamPongFrame({ progressToken, progress, nonce }), + }); + }, + sendAbort: async (reason?: string): Promise => { + progress += 1; + await this.send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: buildOpenStreamAbortFrame({ progressToken, progress, reason }), + }); + }, + }); + } +} diff --git a/src/transport/nostr-server/event-pipeline.ts b/src/transport/nostr-server/event-pipeline.ts index 433463d..2971ec0 100644 --- a/src/transport/nostr-server/event-pipeline.ts +++ b/src/transport/nostr-server/event-pipeline.ts @@ -6,6 +6,7 @@ import { type Logger } from '../../core/utils/logger.js'; import { decryptMessage, DEFAULT_TIMEOUT_MS, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../../core/index.js'; import { withTimeout } from '../../core/utils/utils.js'; +/** Dependencies for the server-side event decryption and verification pipeline. */ export interface ServerEventPipelineDeps { signer: NostrSigner; seenEventIds: LruCache; @@ -15,12 +16,14 @@ export interface ServerEventPipelineDeps { onerror?: (error: Error) => void; } +/** Result of successfully unwrapping an inbound Nostr event. */ export interface UnwrappedEvent { event: NostrEvent; isEncrypted: boolean; wrapKind?: number; } +/** Handles gift-wrap decryption, signature verification, and dedup for the server transport. */ export class ServerEventPipeline { constructor(private deps: ServerEventPipelineDeps) {} From ff6c74a64d4e828cc9cb85128720d5e9030c10b9 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 12 May 2026 20:43:27 +0530 Subject: [PATCH 11/17] refactor(transport): modularize client/server orchestration --- bun.lock | 4 +- src/transport/middleware.ts | 10 + src/transport/nostr-client-transport.ts | 451 +++--------------- .../nostr-client/inbound-coordinator.ts | 231 +++++++++ .../inbound-notification-dispatcher.ts | 3 +- src/transport/nostr-client/outbound-sender.ts | 159 ++++++ .../nostr-client/server-metadata-store.ts | 145 ++++++ src/transport/nostr-server-transport.ts | 407 +++------------- .../nostr-server/correlation-store.ts | 21 +- .../nostr-server/inbound-coordinator.ts | 293 ++++++++++++ .../inbound-notification-dispatcher.ts | 10 +- .../nostr-server/open-stream-factory.ts | 104 +++- .../outbound-notification-broadcaster.ts | 93 ++++ .../nostr-server/outbound-response-router.ts | 14 +- 14 files changed, 1224 insertions(+), 721 deletions(-) create mode 100644 src/transport/middleware.ts create mode 100644 src/transport/nostr-client/inbound-coordinator.ts create mode 100644 src/transport/nostr-client/outbound-sender.ts create mode 100644 src/transport/nostr-client/server-metadata-store.ts create mode 100644 src/transport/nostr-server/inbound-coordinator.ts create mode 100644 src/transport/nostr-server/outbound-notification-broadcaster.ts diff --git a/bun.lock b/bun.lock index d34fc48..179c9f2 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "pino": "^10.3.1", "rxjs": "^7.8.2", "ws": "^8.20.0", - "zod": "^4.3.6", + "zod": "^4.4.3", }, "devDependencies": { "@changesets/cli": "^2.31.0", @@ -23,7 +23,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "prettier": "^3.8.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.2", }, "peerDependencies": { "typescript": "^5.9.3", diff --git a/src/transport/middleware.ts b/src/transport/middleware.ts new file mode 100644 index 0000000..dc7c33f --- /dev/null +++ b/src/transport/middleware.ts @@ -0,0 +1,10 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Inbound middleware hook for server transports. + */ +export type InboundMiddlewareFn = ( + message: JSONRPCMessage, + ctx: { clientPubkey: string; clientPmis?: readonly string[] }, + forward: (message: JSONRPCMessage) => Promise, +) => Promise; diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 963deec..83eeef8 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -1,33 +1,23 @@ import { - InitializeResult, - InitializeResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, + type InitializeResult, NotificationSchema, type JSONRPCMessage, isJSONRPCRequest, isJSONRPCNotification, - isJSONRPCResultResponse, - isJSONRPCErrorResponse, type JSONRPCResponse, } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { - CTXVM_MESSAGES_KIND, DEFAULT_BOOTSTRAP_RELAY_URLS, DEFAULT_TIMEOUT_MS, DEFAULT_LRU_SIZE, INITIALIZE_METHOD, - NOSTR_TAGS, } from '../core/index.js'; import { LruCache } from '../core/utils/lru-cache.js'; import { BaseNostrTransport, BaseNostrTransportOptions, } from './base-nostr-transport.js'; -import { getNostrEventTag } from '../core/utils/serializers.js'; import { NostrEvent } from 'nostr-tools'; import { LogLevel } from '../core/utils/logger.js'; @@ -40,7 +30,6 @@ import { import { parseServerIdentity } from './nostr-client/server-identity.js'; import { resolveOperationalRelays } from './nostr-client/relay-resolution.js'; import { StatelessModeHandler } from './nostr-client/stateless-mode-handler.js'; -import { queryTags } from '../core/utils/utils.js'; import { OversizedTransferReceiver, type TransferPolicy, @@ -49,10 +38,10 @@ import { OpenStreamSession, } from './open-stream/index.js'; -import { - parseDiscoveredPeerCapabilities, - ClientCapabilityNegotiator, -} from './capability-negotiator.js'; +import { ClientCapabilityNegotiator } from './capability-negotiator.js'; +import { ClientInboundCoordinator } from './nostr-client/inbound-coordinator.js'; +import { ServerMetadataStore } from './nostr-client/server-metadata-store.js'; +import { ClientOutboundSender } from './nostr-client/outbound-sender.js'; import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; import { ClientEventPipeline } from './nostr-client/event-pipeline.js'; import { ClientOpenStreamFactory } from './nostr-client/open-stream-factory.js'; @@ -60,7 +49,6 @@ import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, } from './oversized-transfer/constants.js'; -import { sendOversizedClientRequest } from './nostr-client/oversized-client-sender.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; /** @@ -158,27 +146,14 @@ export class NostrClientTransport private readonly discoveryRelayUrls: readonly string[]; /** Optional non-authoritative operational relays used as a fast fallback. */ private readonly fallbackOperationalRelayUrls: readonly string[]; - /** The server's initialize event, if received */ - private serverInitializeEvent: NostrEvent | undefined; - - /** The latest server tools/list response event envelope, if received. */ - private serverToolsListEvent: NostrEvent | undefined; - /** The latest server prompts/list response event envelope, if received. */ - private serverPromptsListEvent: NostrEvent | undefined; - /** The latest server resources/list response event envelope, if received. */ - private serverResourcesListEvent: NostrEvent | undefined; - /** The latest server resources/templates/list response event envelope, if received. */ - private serverResourceTemplatesListEvent: NostrEvent | undefined; - - /** Whether the server has advertised CEP-22 oversized transfer support. */ - private serverSupportsOversizedTransfer: boolean = false; - - /** Whether the server has advertised CEP-41 open stream support. */ - private serverSupportsOpenStream: boolean = false; + /** Stores server discovery metadata learned from inbound events. */ + private readonly metadataStore: ServerMetadataStore; private readonly capabilityNegotiator: ClientCapabilityNegotiator; private readonly inboundNotificationDispatcher: ClientInboundNotificationDispatcher; private readonly eventPipeline: ClientEventPipeline; + private readonly inboundCoordinator: ClientInboundCoordinator; + private readonly outboundSender: ClientOutboundSender; // Oversized-transfer sender settings private readonly oversizedEnabled: boolean; @@ -226,6 +201,7 @@ export class NostrClientTransport }, }); this.statelessHandler = new StatelessModeHandler(); + this.metadataStore = new ServerMetadataStore(); const ot = options.oversizedTransfer; this.oversizedEnabled = ot?.enabled ?? true; @@ -270,6 +246,40 @@ export class NostrClientTransport logger: this.logger, onerror: (error) => this.onerror?.(error), }); + + this.inboundCoordinator = new ClientInboundCoordinator({ + capabilityNegotiator: this.capabilityNegotiator, + correlationStore: this.correlationStore, + notificationDispatcher: this.inboundNotificationDispatcher, + metadataStore: this.metadataStore, + unwrapEvent: this.eventPipeline.unwrap.bind(this.eventPipeline), + convertNostrEventToMcpMessage: + this.convertNostrEventToMcpMessage.bind(this), + handleResponse: this.handleResponse.bind(this), + handleNotification: this.handleNotification.bind(this), + logger: this.logger, + onerror: (error: Error) => this.onerror?.(error), + }); + + this.outboundSender = new ClientOutboundSender({ + serverPubkey: this.serverPubkey, + correlationStore: this.correlationStore, + capabilityNegotiator: this.capabilityNegotiator, + oversizedEnabled: this.oversizedEnabled, + oversizedThreshold: this.oversizedThreshold, + oversizedChunkSize: this.oversizedChunkSize, + oversizedAcceptTimeoutMs: this.oversizedAcceptTimeoutMs, + serverSupportsOversizedTransfer: () => + this.metadataStore.getServerSupportsOversizedTransfer(), + createRecipientTags: this.createRecipientTags.bind(this), + sendMcpMessage: this.sendMcpMessage.bind(this), + waitForAccept: this.oversizedReceiver.waitForAccept.bind( + this.oversizedReceiver, + ), + getOriginalRequestContext: this.getOriginalRequestContext.bind(this), + resolvePendingOpenStream: this.resolvePendingOutboundOpenStream.bind(this), + logger: this.logger, + }); } /** @@ -331,6 +341,7 @@ export class NostrClientTransport pending.reject(pendingOpenStreamError); } this.correlationStore.clear(); + this.metadataStore.clear(); this.seenEventIds.clear(); this.oversizedReceiver.clear(); this.openStreamFactory.getReceiver().clear(); @@ -360,7 +371,7 @@ export class NostrClientTransport return; } - await this.sendRequest(message); + await this.outboundSender.sendRequest(message); } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); this.logAndRethrowError('Error sending message', error, { @@ -377,135 +388,6 @@ export class NostrClientTransport } } - /** - * Sends a request and registers it for correlation tracking. - * @param message - The JSON-RPC message to send - * @returns The ID of the published Nostr event - */ - private async sendRequest(message: JSONRPCMessage): Promise { - const isRequest = isJSONRPCRequest(message); - - // --- CEP-22 Oversized Transfer (proactive path) --- - if (this.oversizedEnabled && isRequest) { - const progressToken = message.params?._meta?.progressToken; - if (progressToken !== undefined) { - const serialized = JSON.stringify(message); - const byteLength = new TextEncoder().encode(serialized).byteLength; - if (byteLength > this.oversizedThreshold) { - await this.sendOversizedRequest( - message, - serialized, - String(progressToken), - ); - return 'oversized-transfer'; - } - } - } - - const tags = this.capabilityNegotiator.buildOutboundTags({ - baseTags: this.createRecipientTags(this.serverPubkey), - includeDiscovery: isRequest, - }); - - const giftWrapKind = this.capabilityNegotiator.chooseOutboundGiftWrapKind(); - - const eventId = await this.sendMcpMessage( - message, - this.serverPubkey, - CTXVM_MESSAGES_KIND, - tags, - undefined, - (eventId) => { - const progressToken = isRequest - ? message.params?._meta?.progressToken - : undefined; - const originalRequestContext = isRequest - ? this.getOriginalRequestContext(message) - : undefined; - this.correlationStore.registerRequest(eventId, { - originalRequestId: isRequest ? message.id : null, - isInitialize: isRequest && message.method === INITIALIZE_METHOD, - progressToken: - progressToken !== undefined ? String(progressToken) : undefined, - originalRequestContext, - }); - - if ( - isRequest && - message.method === 'tools/call' && - progressToken !== undefined - ) { - const pending = this.pendingOutboundOpenStreamResolvers.shift(); - if (pending) { - const normalizedProgressToken = String(progressToken); - pending.resolve({ - progressToken: normalizedProgressToken, - stream: this.createOutboundOpenStreamSession( - normalizedProgressToken, - ), - }); - } - } - }, - giftWrapKind, - ); - - if (isRequest) { - this.capabilityNegotiator.markDiscoveryTagsSent(); - } - - return eventId; - } - - //Splits an oversized request into CEP-22 transfer frames and sends them sequentially. Waits for an `accept` frame from the server when the server's support is not yet known. - - private async sendOversizedRequest( - originalMessage: Extract< - JSONRPCMessage, - { id: string | number; method: string } - >, - serialized: string, - progressToken: string, - ): Promise { - const frameRecipientTags = this.createRecipientTags(this.serverPubkey); - const startFrameTags = this.capabilityNegotiator.buildOutboundTags({ - baseTags: frameRecipientTags, - includeDiscovery: true, - }); - const endFrameEventId = await sendOversizedClientRequest( - serialized, - progressToken, - { - chunkSizeBytes: this.oversizedChunkSize, - acceptTimeoutMs: this.oversizedAcceptTimeoutMs, - serverPubkey: this.serverPubkey, - serverSupportsOversizedTransfer: this.serverSupportsOversizedTransfer, - giftWrapKind: this.capabilityNegotiator.chooseOutboundGiftWrapKind(), - startFrameTags, - continuationFrameTags: frameRecipientTags, - }, - { - sendMcpMessage: this.sendMcpMessage.bind(this), - waitForAccept: this.oversizedReceiver.waitForAccept.bind( - this.oversizedReceiver, - ), - logger: this.logger, - }, - ); - - // Register the original request for correlating the final response. - if (endFrameEventId) { - this.correlationStore.registerRequest(endFrameEventId, { - originalRequestId: originalMessage.id, - isInitialize: originalMessage.method === INITIALIZE_METHOD, - progressToken, - originalRequestContext: this.getOriginalRequestContext(originalMessage), - }); - } - - this.capabilityNegotiator.markDiscoveryTagsSent(); - } - private getOriginalRequestContext( message: JSONRPCMessage, ): OriginalRequestContext | undefined { @@ -582,6 +464,18 @@ export class NostrClientTransport }); } + /** Resolves the next outbound open-stream placeholder with an active session. */ + private resolvePendingOutboundOpenStream(progressToken: string): void { + const pending = this.pendingOutboundOpenStreamResolvers.shift(); + if (!pending) { + return; + } + pending.resolve({ + progressToken, + stream: this.createOutboundOpenStreamSession(progressToken), + }); + } + /** * Returns the CEP-41 stream session for a progress token when it already exists. */ @@ -619,115 +513,7 @@ export class NostrClientTransport * @param event - The incoming Nostr event */ private async processIncomingEvent(event: NostrEvent): Promise { - try { - const unwrapped = await this.eventPipeline.unwrap(event); - if (!unwrapped) { - return; - } - const nostrEvent = unwrapped.event; - - this.learnServerDiscovery(nostrEvent); - - const eTag = getNostrEventTag(nostrEvent.tags, 'e'); - - if (!this.serverInitializeEvent && eTag) { - try { - const content = JSON.parse(nostrEvent.content); - const parse = InitializeResultSchema.safeParse(content.result); - if (parse.success) { - this.serverInitializeEvent = nostrEvent; - this.logger.info('Received server initialize event', { - eventId: nostrEvent.id, - }); - } - } catch { - this.logger.debug('Event is not a valid initialize response', { - eventId: nostrEvent.id, - }); - } - } - - const mcpMessage = this.convertNostrEventToMcpMessage(nostrEvent); - - if (!mcpMessage) { - this.logger.error( - 'Skipping invalid Nostr event with malformed JSON content', - { eventId: nostrEvent.id, pubkey: nostrEvent.pubkey }, - ); - return; - } - - // Message classification MUST be based on JSON-RPC type, not on the presence of an `e` tag. - // CEP-8 notifications are correlated (include `e`) but are still notifications. - if ( - isJSONRPCResultResponse(mcpMessage) || - isJSONRPCErrorResponse(mcpMessage) - ) { - if (!eTag) { - this.logger.warn( - 'Received JSON-RPC response without correlation `e` tag', - { - eventId: nostrEvent.id, - }, - ); - return; - } - - if (!this.correlationStore.hasPendingRequest(eTag)) { - this.logger.warn('Received response for unknown/expired request', { - eventId: nostrEvent.id, - eTag, - reason: - 'Request not found in pending set - may be duplicate or late response', - }); - return; - } - - // Capture outer Nostr event envelope for capability list JSON-RPC responses. - // This allows consumers to inspect Nostr tags (e.g. CEP-8 `cap` tags) - // that are not present in the JSON-RPC payload. - if (isJSONRPCResultResponse(mcpMessage)) { - const result = mcpMessage.result; - if (ListToolsResultSchema.safeParse(result).success) { - this.serverToolsListEvent = nostrEvent; - } else if (ListResourcesResultSchema.safeParse(result).success) { - this.serverResourcesListEvent = nostrEvent; - } else if ( - ListResourceTemplatesResultSchema.safeParse(result).success - ) { - this.serverResourceTemplatesListEvent = nostrEvent; - } else if (ListPromptsResultSchema.safeParse(result).success) { - this.serverPromptsListEvent = nostrEvent; - } - } - - this.handleResponse(eTag, mcpMessage); - return; - } - - if (isJSONRPCNotification(mcpMessage)) { - this.handleNotification(nostrEvent.id, eTag ?? undefined, mcpMessage); - return; - } - - this.logger.warn('Received unsupported JSON-RPC message type', { - eventId: nostrEvent.id, - hasETag: !!eTag, - }); - } catch (error) { - this.logger.error('Error handling incoming Nostr event', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - eventId: event.id, - pubkey: event.pubkey, - kind: event.kind, - }); - this.onerror?.( - error instanceof Error - ? error - : new Error('Failed to handle incoming Nostr event'), - ); - } + await this.inboundCoordinator.processIncomingEvent(event); } /** @@ -735,7 +521,7 @@ export class NostrClientTransport * @returns The server initialize event or undefined */ public getServerInitializeEvent(): NostrEvent | undefined { - return this.serverInitializeEvent; + return this.metadataStore.getServerInitializeEvent(); } /** @@ -743,19 +529,7 @@ export class NostrClientTransport * @returns The parsed initialize result or undefined when unavailable or invalid */ public getServerInitializeResult(): InitializeResult | undefined { - if (!this.serverInitializeEvent) { - return undefined; - } - - try { - const content = JSON.parse(this.serverInitializeEvent.content) as { - result?: unknown; - }; - const parse = InitializeResultSchema.safeParse(content.result); - return parse.success ? parse.data : undefined; - } catch { - return undefined; - } + return this.metadataStore.getServerInitializeResult(); } /** @@ -763,8 +537,7 @@ export class NostrClientTransport * @returns True when the initialize event contains the support_encryption tag */ public serverSupportsEncryption(): boolean { - return queryTags(this.serverInitializeEvent, NOSTR_TAGS.SUPPORT_ENCRYPTION) - .isFlag; + return this.metadataStore.serverSupportsEncryption(); } /** @@ -772,10 +545,7 @@ export class NostrClientTransport * @returns True when the initialize event contains the support_encryption_ephemeral tag */ public serverSupportsEphemeralEncryption(): boolean { - return queryTags( - this.serverInitializeEvent, - NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, - ).isFlag; + return this.metadataStore.serverSupportsEphemeralEncryption(); } /** @@ -783,10 +553,7 @@ export class NostrClientTransport * @returns The name tag value or undefined */ public getServerInitializeName(): string | undefined { - return getNostrEventTag( - this.serverInitializeEvent?.tags ?? [], - NOSTR_TAGS.NAME, - ); + return this.metadataStore.getServerInitializeName(); } /** @@ -794,10 +561,7 @@ export class NostrClientTransport * @returns The about tag value or undefined */ public getServerInitializeAbout(): string | undefined { - return getNostrEventTag( - this.serverInitializeEvent?.tags ?? [], - NOSTR_TAGS.ABOUT, - ); + return this.metadataStore.getServerInitializeAbout(); } /** @@ -805,10 +569,7 @@ export class NostrClientTransport * @returns The website tag value or undefined */ public getServerInitializeWebsite(): string | undefined { - return getNostrEventTag( - this.serverInitializeEvent?.tags ?? [], - NOSTR_TAGS.WEBSITE, - ); + return this.metadataStore.getServerInitializeWebsite(); } /** @@ -816,82 +577,27 @@ export class NostrClientTransport * @returns The picture tag value or undefined */ public getServerInitializePicture(): string | undefined { - return getNostrEventTag( - this.serverInitializeEvent?.tags ?? [], - NOSTR_TAGS.PICTURE, - ); + return this.metadataStore.getServerInitializePicture(); } /** Gets the server's most recently observed tools/list event envelope, if any. */ public getServerToolsListEvent(): NostrEvent | undefined { - return this.serverToolsListEvent; + return this.metadataStore.getServerToolsListEvent(); } /** Gets the server's most recently observed resources/list event envelope, if any. */ public getServerResourcesListEvent(): NostrEvent | undefined { - return this.serverResourcesListEvent; + return this.metadataStore.getServerResourcesListEvent(); } /** Gets the server's most recently observed resources/templates/list event envelope, if any. */ public getServerResourceTemplatesListEvent(): NostrEvent | undefined { - return this.serverResourceTemplatesListEvent; + return this.metadataStore.getServerResourceTemplatesListEvent(); } /** Gets the server's most recently observed prompts/list event envelope, if any. */ public getServerPromptsListEvent(): NostrEvent | undefined { - return this.serverPromptsListEvent; - } - - private learnServerDiscovery(event: NostrEvent): void { - if (!Array.isArray(event.tags)) { - return; - } - - const discovered = parseDiscoveredPeerCapabilities(event.tags); - if (discovered.discoveryTags.length === 0) { - return; - } - - this.capabilityNegotiator.learnServerCapabilities(discovered); - this.serverSupportsOversizedTransfer ||= - discovered.supportsOversizedTransfer; - this.serverSupportsOpenStream ||= discovered.supportsOpenStream; - - if (!this.serverInitializeEvent) { - this.serverInitializeEvent = event; - this.capabilityNegotiator.setServerInitializeEvent(event); - this.logger.info('Learned server discovery tags from inbound event', { - eventId: event.id, - }); - return; - } - - const currentHasInitializeResult = InitializeResultSchema.safeParse( - this.getInitializeResultCandidate(event), - ).success; - const existingHasInitializeResult = InitializeResultSchema.safeParse( - this.getInitializeResultCandidate(this.serverInitializeEvent), - ).success; - - if (!existingHasInitializeResult && currentHasInitializeResult) { - this.serverInitializeEvent = event; - this.capabilityNegotiator.setServerInitializeEvent(event); - this.logger.info( - 'Upgraded learned server discovery event to initialize response', - { - eventId: event.id, - }, - ); - } - } - - private getInitializeResultCandidate(event: NostrEvent): unknown { - try { - const content = JSON.parse(event.content) as { result?: unknown }; - return content.result; - } catch { - return undefined; - } + return this.metadataStore.getServerPromptsListEvent(); } private async resolveOperationalRelayHandler(): Promise { @@ -974,10 +680,6 @@ export class NostrClientTransport return; } - if (this.inboundNotificationDispatcher.tryIntercept(mcpMessage, eventId, correlatedEventId)) { - return; - } - this.onmessage?.(mcpMessage); this.onmessageWithContext?.(mcpMessage, { eventId, @@ -1008,11 +710,12 @@ export class NostrClientTransport discoveryRelayUrls: [...this.discoveryRelayUrls], fallbackOperationalRelayUrls: [...this.fallbackOperationalRelayUrls], relayUrls: this.relayHandler.getRelayUrls?.() ?? [], - serverInitializeEvent: this.serverInitializeEvent, - serverToolsListEvent: this.serverToolsListEvent, - serverResourcesListEvent: this.serverResourcesListEvent, - serverResourceTemplatesListEvent: this.serverResourceTemplatesListEvent, - serverPromptsListEvent: this.serverPromptsListEvent, + serverInitializeEvent: this.metadataStore.getServerInitializeEvent(), + serverToolsListEvent: this.metadataStore.getServerToolsListEvent(), + serverResourcesListEvent: this.metadataStore.getServerResourcesListEvent(), + serverResourceTemplatesListEvent: + this.metadataStore.getServerResourceTemplatesListEvent(), + serverPromptsListEvent: this.metadataStore.getServerPromptsListEvent(), oversizedReceiver: this.oversizedReceiver, openStreamReceiver: this.openStreamFactory.getReceiver(), }; diff --git a/src/transport/nostr-client/inbound-coordinator.ts b/src/transport/nostr-client/inbound-coordinator.ts new file mode 100644 index 0000000..54f7260 --- /dev/null +++ b/src/transport/nostr-client/inbound-coordinator.ts @@ -0,0 +1,231 @@ +import { + InitializeResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + type JSONRPCMessage, + isJSONRPCResultResponse, + isJSONRPCErrorResponse, + isJSONRPCNotification, +} from '@modelcontextprotocol/sdk/types.js'; +import { type NostrEvent } from 'nostr-tools'; +import { type Logger } from '../../core/utils/logger.js'; +import { getNostrEventTag } from '../../core/utils/serializers.js'; +import { + type ClientCapabilityNegotiator, + parseDiscoveredPeerCapabilities, +} from '../capability-negotiator.js'; +import { type ClientCorrelationStore } from './correlation-store.js'; +import { type UnwrappedClientEvent } from './event-pipeline.js'; +import { type ClientInboundNotificationDispatcher } from './inbound-notification-dispatcher.js'; +import { type ServerMetadataStore } from './server-metadata-store.js'; + +export interface ClientInboundCoordinatorDeps { + capabilityNegotiator: ClientCapabilityNegotiator; + correlationStore: ClientCorrelationStore; + notificationDispatcher: ClientInboundNotificationDispatcher; + metadataStore: ServerMetadataStore; + unwrapEvent: (event: NostrEvent) => Promise; + convertNostrEventToMcpMessage: (event: NostrEvent) => JSONRPCMessage | null; + handleResponse: (correlatedEventId: string, msg: JSONRPCMessage) => void; + handleNotification: ( + eventId: string, + correlatedEventId: string | undefined, + msg: JSONRPCMessage, + ) => void; + logger: Logger; + onerror?: (error: Error) => void; +} + +/** + * Owns the inbound protocol workflow for the client: discovery learning, + * initialize tracking, message classification, and response routing. + */ +export class ClientInboundCoordinator { + constructor(private deps: ClientInboundCoordinatorDeps) {} + + /** + * Processes an inbound Nostr event by unwrapping, validating, and routing it. + */ + public async processIncomingEvent(event: NostrEvent): Promise { + try { + const unwrapped = await this.deps.unwrapEvent(event); + if (!unwrapped) { + return; + } + const nostrEvent = unwrapped.event; + + this.learnServerDiscovery(nostrEvent); + + const eTag = getNostrEventTag(nostrEvent.tags, 'e'); + + if (!this.deps.metadataStore.getServerInitializeEvent() && eTag) { + this.trySetInitializeEventFromResponse(nostrEvent); + } + + const mcpMessage = this.deps.convertNostrEventToMcpMessage(nostrEvent); + + if (!mcpMessage) { + this.deps.logger.error( + 'Skipping invalid Nostr event with malformed JSON content', + { eventId: nostrEvent.id, pubkey: nostrEvent.pubkey }, + ); + return; + } + + // CEP-22/41 Interception + if (this.deps.notificationDispatcher.tryIntercept(mcpMessage, nostrEvent.id, eTag ?? undefined)) { + return; + } + + // Message classification MUST be based on JSON-RPC type, not on the presence of an `e` tag. + // CEP-8 notifications are correlated (include `e`) but are still notifications. + if ( + isJSONRPCResultResponse(mcpMessage) || + isJSONRPCErrorResponse(mcpMessage) + ) { + if (!eTag) { + this.deps.logger.warn( + 'Received JSON-RPC response without correlation `e` tag', + { + eventId: nostrEvent.id, + }, + ); + return; + } + + if (!this.deps.correlationStore.hasPendingRequest(eTag)) { + this.deps.logger.warn('Received response for unknown/expired request', { + eventId: nostrEvent.id, + eTag, + reason: + 'Request not found in pending set - may be duplicate or late response', + }); + return; + } + + // Capture outer Nostr event envelope for capability list JSON-RPC responses. + // This allows consumers to inspect Nostr tags (e.g. CEP-8 `cap` tags) + // that are not present in the JSON-RPC payload. + if (isJSONRPCResultResponse(mcpMessage)) { + const result = mcpMessage.result; + if (ListToolsResultSchema.safeParse(result).success) { + this.deps.metadataStore.updateListEnvelopeState('tools', nostrEvent); + } else if (ListResourcesResultSchema.safeParse(result).success) { + this.deps.metadataStore.updateListEnvelopeState('resources', nostrEvent); + } else if (ListResourceTemplatesResultSchema.safeParse(result).success) { + this.deps.metadataStore.updateListEnvelopeState('templates', nostrEvent); + } else if (ListPromptsResultSchema.safeParse(result).success) { + this.deps.metadataStore.updateListEnvelopeState('prompts', nostrEvent); + } + } + + this.deps.handleResponse(eTag, mcpMessage); + return; + } + + if (isJSONRPCNotification(mcpMessage)) { + this.deps.handleNotification(nostrEvent.id, eTag ?? undefined, mcpMessage); + return; + } + + this.deps.logger.warn('Received unsupported JSON-RPC message type', { + eventId: nostrEvent.id, + hasETag: !!eTag, + }); + } catch (error) { + this.deps.logger.error('Error handling incoming Nostr event', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + eventId: event.id, + pubkey: event.pubkey, + kind: event.kind, + }); + this.deps.onerror?.( + error instanceof Error + ? error + : new Error('Failed to handle incoming Nostr event'), + ); + } + } + + private learnServerDiscovery(event: NostrEvent): void { + if (!Array.isArray(event.tags)) { + return; + } + + const discovered = parseDiscoveredPeerCapabilities(event.tags); + if (discovered.discoveryTags.length === 0) { + return; + } + + this.deps.capabilityNegotiator.learnServerCapabilities(discovered); + this.deps.metadataStore.setSupportsOversizedTransfer( + discovered.supportsOversizedTransfer, + ); + this.deps.metadataStore.setSupportsOpenStream(discovered.supportsOpenStream); + + if (!this.deps.metadataStore.getServerInitializeEvent()) { + this.setInitializeEvent(event); + this.deps.logger.info('Learned server discovery tags from inbound event', { + eventId: event.id, + }); + return; + } + + const currentHasInitializeResult = InitializeResultSchema.safeParse( + this.getInitializeResultCandidate(event), + ).success; + const existingHasInitializeResult = InitializeResultSchema.safeParse( + this.getInitializeResultCandidate( + this.deps.metadataStore.getServerInitializeEvent(), + ), + ).success; + + if (!existingHasInitializeResult && currentHasInitializeResult) { + this.setInitializeEvent(event); + this.deps.logger.info( + 'Upgraded learned server discovery event to initialize response', + { + eventId: event.id, + }, + ); + } + } + + private trySetInitializeEventFromResponse(event: NostrEvent): void { + try { + const content = JSON.parse(event.content); + const parse = InitializeResultSchema.safeParse(content.result); + if (parse.success) { + this.setInitializeEvent(event); + this.deps.logger.info('Received server initialize event', { + eventId: event.id, + }); + } + } catch { + this.deps.logger.debug('Event is not a valid initialize response', { + eventId: event.id, + }); + } + } + + private setInitializeEvent(event: NostrEvent): void { + this.deps.metadataStore.setServerInitializeEvent(event); + this.deps.capabilityNegotiator.setServerInitializeEvent(event); + } + + private getInitializeResultCandidate(event: NostrEvent | undefined): unknown { + if (!event) { + return undefined; + } + + try { + const content = JSON.parse(event.content) as { result?: unknown }; + return content.result; + } catch { + return undefined; + } + } +} diff --git a/src/transport/nostr-client/inbound-notification-dispatcher.ts b/src/transport/nostr-client/inbound-notification-dispatcher.ts index de54e27..e294bd9 100644 --- a/src/transport/nostr-client/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-client/inbound-notification-dispatcher.ts @@ -49,7 +49,8 @@ export class ClientInboundNotificationDispatcher { }); this.deps.onerror?.(err instanceof Error ? err : new Error(String(err))); }); - return true; + // Allow progress notifications to fall through to the user's onmessage handler + return false; } if ( diff --git a/src/transport/nostr-client/outbound-sender.ts b/src/transport/nostr-client/outbound-sender.ts new file mode 100644 index 0000000..ed3a3b0 --- /dev/null +++ b/src/transport/nostr-client/outbound-sender.ts @@ -0,0 +1,159 @@ +import { + type JSONRPCMessage, + isJSONRPCRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import { CTXVM_MESSAGES_KIND, INITIALIZE_METHOD } from '../../core/index.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { + type ClientCorrelationStore, + type OriginalRequestContext, +} from './correlation-store.js'; +import { type ClientCapabilityNegotiator } from '../capability-negotiator.js'; +import { sendOversizedClientRequest } from './oversized-client-sender.js'; + +export interface ClientOutboundSenderDeps { + serverPubkey: string; + correlationStore: ClientCorrelationStore; + capabilityNegotiator: ClientCapabilityNegotiator; + oversizedEnabled: boolean; + oversizedThreshold: number; + oversizedChunkSize: number; + oversizedAcceptTimeoutMs: number; + serverSupportsOversizedTransfer: () => boolean; + createRecipientTags: (pubkey: string) => string[][]; + sendMcpMessage: ( + msg: JSONRPCMessage, + pubkey: string, + kind: number, + tags?: string[][], + isEncrypted?: boolean, + onEventPublished?: (id: string) => void, + wrapKind?: number, + ) => Promise; + waitForAccept: (token: string, timeoutMs: number) => Promise; + getOriginalRequestContext: (msg: JSONRPCMessage) => OriginalRequestContext | undefined; + resolvePendingOpenStream: (progressToken: string) => void; + logger: Logger; +} + +/** + * Encapsulates outbound client request routing, including CEP-22 oversized handling. + */ +export class ClientOutboundSender { + constructor(private deps: ClientOutboundSenderDeps) {} + + /** + * Sends an MCP message to the server, registering correlation when applicable. + */ + public async sendRequest(message: JSONRPCMessage): Promise { + const isRequest = isJSONRPCRequest(message); + + // --- CEP-22 Oversized Transfer (proactive path) --- + if (this.deps.oversizedEnabled && isRequest) { + const progressToken = message.params?._meta?.progressToken; + if (progressToken !== undefined) { + const serialized = JSON.stringify(message); + const byteLength = new TextEncoder().encode(serialized).byteLength; + if (byteLength > this.deps.oversizedThreshold) { + await this.sendOversizedRequest( + message, + serialized, + String(progressToken), + ); + return 'oversized-transfer'; + } + } + } + + const tags = this.deps.capabilityNegotiator.buildOutboundTags({ + baseTags: this.deps.createRecipientTags(this.deps.serverPubkey), + includeDiscovery: isRequest, + }); + + const giftWrapKind = this.deps.capabilityNegotiator.chooseOutboundGiftWrapKind(); + + const eventId = await this.deps.sendMcpMessage( + message, + this.deps.serverPubkey, + CTXVM_MESSAGES_KIND, + tags, + undefined, + (eventId) => { + const progressToken = isRequest + ? message.params?._meta?.progressToken + : undefined; + const originalRequestContext = isRequest + ? this.deps.getOriginalRequestContext(message) + : undefined; + this.deps.correlationStore.registerRequest(eventId, { + originalRequestId: isRequest ? message.id : null, + isInitialize: isRequest && message.method === INITIALIZE_METHOD, + progressToken: + progressToken !== undefined ? String(progressToken) : undefined, + originalRequestContext, + }); + + if ( + isRequest && + message.method === 'tools/call' && + progressToken !== undefined + ) { + this.deps.resolvePendingOpenStream(String(progressToken)); + } + }, + giftWrapKind, + ); + + if (isRequest) { + this.deps.capabilityNegotiator.markDiscoveryTagsSent(); + } + + return eventId; + } + + private async sendOversizedRequest( + originalMessage: Extract< + JSONRPCMessage, + { id: string | number; method: string } + >, + serialized: string, + progressToken: string, + ): Promise { + const frameRecipientTags = this.deps.createRecipientTags(this.deps.serverPubkey); + const startFrameTags = this.deps.capabilityNegotiator.buildOutboundTags({ + baseTags: frameRecipientTags, + includeDiscovery: true, + }); + const endFrameEventId = await sendOversizedClientRequest( + serialized, + progressToken, + { + chunkSizeBytes: this.deps.oversizedChunkSize, + acceptTimeoutMs: this.deps.oversizedAcceptTimeoutMs, + serverPubkey: this.deps.serverPubkey, + serverSupportsOversizedTransfer: + this.deps.serverSupportsOversizedTransfer(), + giftWrapKind: this.deps.capabilityNegotiator.chooseOutboundGiftWrapKind(), + startFrameTags, + continuationFrameTags: frameRecipientTags, + }, + { + sendMcpMessage: this.deps.sendMcpMessage, + waitForAccept: this.deps.waitForAccept, + logger: this.deps.logger, + }, + ); + + // Register the original request for correlating the final response. + if (endFrameEventId) { + this.deps.correlationStore.registerRequest(endFrameEventId, { + originalRequestId: originalMessage.id, + isInitialize: originalMessage.method === INITIALIZE_METHOD, + progressToken, + originalRequestContext: this.deps.getOriginalRequestContext(originalMessage), + }); + } + + this.deps.capabilityNegotiator.markDiscoveryTagsSent(); + } +} diff --git a/src/transport/nostr-client/server-metadata-store.ts b/src/transport/nostr-client/server-metadata-store.ts new file mode 100644 index 0000000..d7f3430 --- /dev/null +++ b/src/transport/nostr-client/server-metadata-store.ts @@ -0,0 +1,145 @@ +import { type InitializeResult, InitializeResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { type NostrEvent } from 'nostr-tools'; +import { NOSTR_TAGS } from '../../core/constants.js'; +import { getNostrEventTag } from '../../core/utils/serializers.js'; +import { queryTags } from '../../core/utils/utils.js'; + +export type ListEnvelopeType = 'tools' | 'resources' | 'templates' | 'prompts'; + +/** + * Stores server discovery metadata learned by the client transport. + */ +export class ServerMetadataStore { + private serverInitializeEvent: NostrEvent | undefined; + private serverToolsListEvent: NostrEvent | undefined; + private serverPromptsListEvent: NostrEvent | undefined; + private serverResourcesListEvent: NostrEvent | undefined; + private serverResourceTemplatesListEvent: NostrEvent | undefined; + private supportsOversizedTransfer = false; + private supportsOpenStream = false; + + public clear(): void { + this.serverInitializeEvent = undefined; + this.serverToolsListEvent = undefined; + this.serverPromptsListEvent = undefined; + this.serverResourcesListEvent = undefined; + this.serverResourceTemplatesListEvent = undefined; + this.supportsOversizedTransfer = false; + this.supportsOpenStream = false; + } + + public setServerInitializeEvent(event: NostrEvent): void { + this.serverInitializeEvent = event; + } + + public getServerInitializeEvent(): NostrEvent | undefined { + return this.serverInitializeEvent; + } + + public setSupportsOversizedTransfer(supported: boolean): void { + this.supportsOversizedTransfer ||= supported; + } + + public setSupportsOpenStream(supported: boolean): void { + this.supportsOpenStream ||= supported; + } + + public getServerSupportsOversizedTransfer(): boolean { + return this.supportsOversizedTransfer; + } + + public getServerSupportsOpenStream(): boolean { + return this.supportsOpenStream; + } + + public updateListEnvelopeState(type: ListEnvelopeType, event: NostrEvent): void { + switch (type) { + case 'tools': + this.serverToolsListEvent = event; + break; + case 'resources': + this.serverResourcesListEvent = event; + break; + case 'templates': + this.serverResourceTemplatesListEvent = event; + break; + case 'prompts': + this.serverPromptsListEvent = event; + break; + default: + break; + } + } + + public getServerToolsListEvent(): NostrEvent | undefined { + return this.serverToolsListEvent; + } + + public getServerResourcesListEvent(): NostrEvent | undefined { + return this.serverResourcesListEvent; + } + + public getServerResourceTemplatesListEvent(): NostrEvent | undefined { + return this.serverResourceTemplatesListEvent; + } + + public getServerPromptsListEvent(): NostrEvent | undefined { + return this.serverPromptsListEvent; + } + + public getServerInitializeResult(): InitializeResult | undefined { + if (!this.serverInitializeEvent) { + return undefined; + } + + try { + const content = JSON.parse(this.serverInitializeEvent.content) as { + result?: unknown; + }; + const parse = InitializeResultSchema.safeParse(content.result); + return parse.success ? parse.data : undefined; + } catch { + return undefined; + } + } + + public serverSupportsEncryption(): boolean { + return queryTags(this.serverInitializeEvent, NOSTR_TAGS.SUPPORT_ENCRYPTION) + .isFlag; + } + + public serverSupportsEphemeralEncryption(): boolean { + return queryTags( + this.serverInitializeEvent, + NOSTR_TAGS.SUPPORT_ENCRYPTION_EPHEMERAL, + ).isFlag; + } + + public getServerInitializeName(): string | undefined { + return getNostrEventTag( + this.serverInitializeEvent?.tags ?? [], + NOSTR_TAGS.NAME, + ); + } + + public getServerInitializeAbout(): string | undefined { + return getNostrEventTag( + this.serverInitializeEvent?.tags ?? [], + NOSTR_TAGS.ABOUT, + ); + } + + public getServerInitializeWebsite(): string | undefined { + return getNostrEventTag( + this.serverInitializeEvent?.tags ?? [], + NOSTR_TAGS.WEBSITE, + ); + } + + public getServerInitializePicture(): string | undefined { + return getNostrEventTag( + this.serverInitializeEvent?.tags ?? [], + NOSTR_TAGS.PICTURE, + ); + } +} diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 5adf930..527fc2e 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -1,9 +1,7 @@ import { type ListToolsResult, - isJSONRPCRequest, isJSONRPCNotification, type JSONRPCMessage, - type JSONRPCRequest, type JSONRPCResponse, isJSONRPCResultResponse, isJSONRPCErrorResponse, @@ -17,20 +15,12 @@ import { import { CTXVM_MESSAGES_KIND, DEFAULT_TIMEOUT_MS, - EPHEMERAL_GIFT_WRAP_KIND, - GIFT_WRAP_KIND, NOSTR_TAGS, - NOTIFICATIONS_INITIALIZED_METHOD, DEFAULT_LRU_SIZE, } from '../core/index.js'; -import { GiftWrapMode } from '../core/interfaces.js'; import { NostrEvent } from 'nostr-tools'; import { LogLevel } from '../core/utils/logger.js'; -import { - injectClientPubkey, - injectRequestEventId, - withTimeout, -} from '../core/utils/utils.js'; +import { withTimeout } from '../core/utils/utils.js'; import { CorrelationStore } from './nostr-server/correlation-store.js'; import { ClientSession, SessionStore } from './nostr-server/session-store.js'; import { LruCache } from '../core/utils/lru-cache.js'; @@ -49,23 +39,21 @@ import { OversizedTransferReceiver, type TransferPolicy, } from './oversized-transfer/index.js'; -import { - OpenStreamWriter, -} from './open-stream/index.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, } from './oversized-transfer/constants.js'; -import { - learnPeerCapabilities, - ServerCapabilityNegotiator, -} from './capability-negotiator.js'; +import { ServerCapabilityNegotiator } from './capability-negotiator.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; import { InboundNotificationDispatcher } from './nostr-server/inbound-notification-dispatcher.js'; import { OutboundResponseRouter } from './nostr-server/outbound-response-router.js'; +import { OutboundNotificationBroadcaster } from './nostr-server/outbound-notification-broadcaster.js'; import { ServerOpenStreamFactory } from './nostr-server/open-stream-factory.js'; import { ServerEventPipeline } from './nostr-server/event-pipeline.js'; +import { ServerInboundCoordinator } from './nostr-server/inbound-coordinator.js'; +import type { InboundMiddlewareFn } from './middleware.js'; +export type { InboundMiddlewareFn } from './middleware.js'; /** * Options for configuring the NostrServerTransport. */ @@ -165,11 +153,6 @@ export type ListToolsAnnouncementTagsProducer = ( result: ListToolsResult, ) => string[][]; -export type InboundMiddlewareFn = ( - message: JSONRPCMessage, - ctx: { clientPubkey: string; clientPmis?: readonly string[] }, - forward: (message: JSONRPCMessage) => Promise, -) => Promise; /** * A server-side transport layer for CTXVM that uses Nostr events for communication. @@ -225,8 +208,10 @@ export class NostrServerTransport private readonly capabilityNegotiator: ServerCapabilityNegotiator; private readonly openStreamFactory: ServerOpenStreamFactory; private readonly eventPipeline: ServerEventPipeline; + private readonly inboundCoordinator: ServerInboundCoordinator; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; private readonly outboundResponseRouter: OutboundResponseRouter; + private readonly outboundNotificationBroadcaster: OutboundNotificationBroadcaster; constructor(options: NostrServerTransportOptions) { super('nostr-server-transport', options); @@ -358,26 +343,63 @@ export class NostrServerTransport handleResponse: async (response) => { await this.outboundResponseRouter.route(response); }, + sessionStore: this.sessionStore, + onClientSessionEvicted: this.onClientSessionEvicted, correlationStore: this.correlationStore, policy: options.openStream?.policy, logger: this.logger, }); + this.inboundCoordinator = new ServerInboundCoordinator({ + sessionStore: this.sessionStore, + correlationStore: this.correlationStore, + authorizationPolicy: this.authorizationPolicy, + openStreamFactory: this.openStreamFactory, + inboundMiddlewares: this.inboundMiddlewares, + injectClientPubkey: this.injectClientPubkey, + shouldInjectRequestEventId: this.shouldInjectRequestEventId, + oversizedEnabled: this.oversizedEnabled, + openStreamEnabled: this.openStreamEnabled, + giftWrapMode: this.giftWrapMode, + sendMcpMessage: this.sendMcpMessage.bind(this), + createResponseTags: (clientPubkey, requestId) => + this.createResponseTags(clientPubkey, String(requestId)), + getOrCreateClientSession: this.getOrCreateClientSession.bind(this), + forwardMessage: async (msg: JSONRPCMessage, clientPubkey: string) => { + this.onmessage?.(msg); + this.onmessageWithContext?.(msg, { clientPubkey }); + return true; + }, + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); + this.inboundNotificationDispatcher = new InboundNotificationDispatcher({ openStreamReceiver: this.openStreamFactory.getReceiver(), oversizedReceiver: this.oversizedReceiver, openStreamFactory: this.openStreamFactory, correlationStore: this.correlationStore, sendNotification: this.sendNotification.bind(this), - handleIncomingRequest: this.handleIncomingRequest.bind(this), - handleIncomingNotification: this.handleIncomingNotification.bind(this), - cleanupDroppedRequest: this.cleanupDroppedRequest.bind(this), + handleIncomingRequest: this.inboundCoordinator.handleIncomingRequest.bind( + this.inboundCoordinator, + ), + handleIncomingNotification: + this.inboundCoordinator.handleIncomingNotification.bind( + this.inboundCoordinator, + ), + cleanupDroppedRequest: this.inboundCoordinator.cleanupDroppedRequest.bind( + this.inboundCoordinator, + ), shouldInjectRequestEventId: this.shouldInjectRequestEventId, injectClientPubkey: this.injectClientPubkey, logger: this.logger, onerror: (error) => this.onerror?.(error), }); + this.inboundCoordinator.setNotificationDispatcher(this.inboundNotificationDispatcher); + + + this.outboundResponseRouter = new OutboundResponseRouter({ correlationStore: this.correlationStore, sessionStore: this.sessionStore, @@ -397,6 +419,15 @@ export class NostrServerTransport onerror: (error) => this.onerror?.(error), }); + this.outboundNotificationBroadcaster = new OutboundNotificationBroadcaster({ + correlationStore: this.correlationStore, + sessionStore: this.sessionStore, + sendNotification: this.sendNotification.bind(this), + enqueueTask: this.taskQueue.add.bind(this.taskQueue), + logger: this.logger, + onerror: (error) => this.onerror?.(error), + }); + this.eventPipeline = new ServerEventPipeline({ signer: this.signer, seenEventIds: this.seenEventIds, @@ -619,69 +650,7 @@ export class NostrServerTransport } } - /** - * Handles incoming requests with correlation tracking. - * @param eventId The Nostr event ID. - * @param request The request message. - * @param clientPubkey The client's public key. - */ - private handleIncomingRequest( - event: NostrEvent, - eventId: string, - request: JSONRPCRequest, - clientPubkey: string, - wrapKind?: number, - ): void { - // Store the original request ID for later restoration - const originalRequestId = request.id; - // Use the unique Nostr event ID as the MCP request ID to avoid collisions - request.id = eventId; - - // Register the event route in the correlation store - const progressToken = request.params?._meta?.progressToken; - this.correlationStore.registerEventRoute( - eventId, - clientPubkey, - originalRequestId, - progressToken ? String(progressToken) : undefined, - wrapKind, - this.shouldInjectRequestEventId ? event : undefined, - ); - - this.openStreamFactory.createWriterIfEnabled( - eventId, - clientPubkey, - progressToken ? String(progressToken) : undefined, - ); - } - - /** - * Cleans up request correlation for a request that was dropped by middleware. - */ - private cleanupDroppedRequest(message: JSONRPCMessage): void { - if (!isJSONRPCRequest(message)) { - return; - } - this.correlationStore.popEventRoute(String(message.id)); - } - - /** - * Handles incoming notifications. - * @param clientPubkey The client's public key. - * @param notification The notification message. - */ - private handleIncomingNotification( - clientPubkey: string, - notification: JSONRPCMessage, - ): void { - if ( - isJSONRPCNotification(notification) && - notification.method === NOTIFICATIONS_INITIALIZED_METHOD - ) { - this.sessionStore.markInitialized(clientPubkey); - } - } /** * Handles response messages by finding the original request and routing back to client. @@ -715,66 +684,7 @@ export class NostrServerTransport private async handleNotification( notification: JSONRPCMessage, ): Promise { - try { - // Special handling for progress notifications - // TODO: Add handling for `notifications/resources/updated`, as they need to be associated with an id - if ( - isJSONRPCNotification(notification) && - notification.method === 'notifications/progress' && - notification.params?.progressToken - ) { - const token = String(notification.params.progressToken); - - // Use O(1) lookup for progress token routing - const nostrEventId = - this.correlationStore.getEventIdByProgressToken(token); - - if (nostrEventId) { - const route = this.correlationStore.getEventRoute(nostrEventId); - if (route) { - await this.sendNotification( - route.clientPubkey, - notification, - nostrEventId, - ); - return; - } - } - - const error = new Error(`No client found for progress token: ${token}`); - this.logger.error('Progress token not found', { token }); - this.onerror?.(error); - return; - } - - // Use TaskQueue for outbound notification broadcasting to prevent event loop blocking - for (const [ - clientPubkey, - session, - ] of this.sessionStore.getAllSessions()) { - if (session.isInitialized) { - this.taskQueue.add(async () => { - try { - await this.sendNotification(clientPubkey, notification); - } catch (error) { - this.logger.error('Error sending notification', { - error: error instanceof Error ? error.message : String(error), - clientPubkey, - method: isJSONRPCNotification(notification) - ? notification.method - : 'unknown', - }); - } - }); - } - } - } catch (error) { - this.logger.error('Error in handleNotification', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - this.onerror?.(error instanceof Error ? error : new Error(String(error))); - } + await this.outboundNotificationBroadcaster.broadcast(notification); } /** @@ -788,6 +698,10 @@ export class NostrServerTransport notification: JSONRPCMessage, correlatedEventId?: string, ): Promise { + if (this.openStreamFactory.isClientEvicted(clientPubkey)) { + throw new Error(`No active session found for client: ${clientPubkey}`); + } + const session = this.sessionStore.getSession(clientPubkey); if (!session) { throw new Error(`No active session found for client: ${clientPubkey}`); @@ -827,205 +741,28 @@ export class NostrServerTransport private async processIncomingEvent(event: NostrEvent): Promise { const unwrapped = await this.eventPipeline.unwrap(event); if (unwrapped) { - await this.authorizeAndProcessEvent( - unwrapped.event, - unwrapped.isEncrypted, - unwrapped.wrapKind, - ); - } - } - - /** - * Authorizes and processes an incoming Nostr event, handling message validation, - * client authorization, session management, and optional client public key injection. - * @param event The Nostr event to process. - * @param isEncrypted Whether the original event was encrypted. - */ - private async authorizeAndProcessEvent( - event: NostrEvent, - isEncrypted: boolean, - wrapKind?: number, - ): Promise { - try { - const mcpMessage = this.convertNostrEventToMcpMessage(event); - + const mcpMessage = this.convertNostrEventToMcpMessage(unwrapped.event); if (!mcpMessage) { this.logger.error( 'Skipping invalid Nostr event with malformed JSON content', { - eventId: event.id, - pubkey: event.pubkey, - content: event.content, + eventId: unwrapped.event.id, + pubkey: unwrapped.event.pubkey, + content: unwrapped.event.content, }, ); return; } - - const inboundMessage: JSONRPCMessage = mcpMessage; - - // Check authorization using the authorization policy - const authDecision = await this.authorizationPolicy.authorize( - event.pubkey, + await this.inboundCoordinator.authorizeAndProcessEvent( + unwrapped.event, + unwrapped.isEncrypted, mcpMessage, + unwrapped.wrapKind, ); - - if (!authDecision.allowed) { - this.logger.error( - `Unauthorized message from ${event.pubkey}, message: ${JSON.stringify(mcpMessage)}. Ignoring.`, - ); - - if ( - 'shouldReplyUnauthorized' in authDecision && - authDecision.shouldReplyUnauthorized && - isJSONRPCRequest(mcpMessage) - ) { - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: mcpMessage.id, - error: { - code: -32000, - message: 'Unauthorized', - }, - }; - - const tags = this.createResponseTags(event.pubkey, event.id); - this.sendMcpMessage( - errorResponse, - event.pubkey, - CTXVM_MESSAGES_KIND, - tags, - isEncrypted, - undefined, - isEncrypted - ? this.giftWrapMode === GiftWrapMode.EPHEMERAL - ? EPHEMERAL_GIFT_WRAP_KIND - : this.giftWrapMode === GiftWrapMode.PERSISTENT - ? GIFT_WRAP_KIND - : wrapKind - : undefined, - ).catch((err) => { - this.logger.error('Failed to send unauthorized response', { - error: err instanceof Error ? err.message : String(err), - pubkey: event.pubkey, - eventId: event.id, - }); - this.onerror?.( - new Error(`Failed to send unauthorized response: ${err}`), - ); - }); - } - return; - } - - const session = this.getOrCreateClientSession(event.pubkey, isEncrypted); - const hadLearnedOversizedSupport = session.supportsOversizedTransfer; - const discoveredCapabilities = learnPeerCapabilities(event.tags); - session.supportsEncryption ||= discoveredCapabilities.supportsEncryption; - session.supportsEphemeralEncryption ||= - discoveredCapabilities.supportsEphemeralEncryption; - session.supportsOversizedTransfer ||= - this.oversizedEnabled && - discoveredCapabilities.supportsOversizedTransfer; - session.supportsOpenStream ||= - this.openStreamEnabled && discoveredCapabilities.supportsOpenStream; - - const shouldSendAccept = !hadLearnedOversizedSupport; - - const forward = async (msg: JSONRPCMessage): Promise => { - this.onmessage?.(msg); - this.onmessageWithContext?.(msg, { - clientPubkey: event.pubkey, - }); - return true; - }; - - const clientPmis = event.tags - .filter((tag) => tag[0] === 'pmi' && typeof tag[1] === 'string') - .map((tag) => tag[1] as string); - const ctx = { - clientPubkey: event.pubkey, - clientPmis: clientPmis.length > 0 ? clientPmis : undefined, - }; - const middlewares = this.inboundMiddlewares; - - const dispatch = async ( - index: number, - msg: JSONRPCMessage, - ): Promise => { - const mw = middlewares[index]; - if (!mw) { - return await forward(msg); - } - let forwarded = false; - await mw(msg, ctx, async (nextMsg) => { - forwarded = await dispatch(index + 1, nextMsg); - }); - return forwarded; - }; - - if (isJSONRPCRequest(inboundMessage)) { - this.handleIncomingRequest( - event, - event.id, - inboundMessage, - event.pubkey, - wrapKind, - ); - - if (this.shouldInjectRequestEventId) { - injectRequestEventId(inboundMessage, event.id); - } - - if (this.injectClientPubkey) { - injectClientPubkey(inboundMessage, event.pubkey); - } - - const openStreamWriter = this.openStreamFactory.getWriter(event.id); - if (openStreamWriter) { - const params = inboundMessage.params ?? {}; - inboundMessage.params = params; - const meta = params._meta ?? {}; - params._meta = meta; - (meta as { stream?: OpenStreamWriter }).stream = openStreamWriter; - } - } else if (isJSONRPCNotification(inboundMessage)) { - this.handleIncomingNotification(event.pubkey, inboundMessage); - - const intercepted = this.inboundNotificationDispatcher.tryIntercept( - inboundMessage, - { event, session, shouldSendAccept, wrapKind }, - (msg) => dispatch(0, msg), - ); - if (intercepted) return; - } - - void dispatch(0, inboundMessage) - .then((forwarded) => { - if (!forwarded) { - this.cleanupDroppedRequest(inboundMessage); - } - }) - .catch((err: unknown) => { - this.logger.error('Error in inboundMiddleware chain', { - error: err instanceof Error ? err.message : String(err), - eventId: event.id, - pubkey: event.pubkey, - }); - this.onerror?.( - err instanceof Error ? err : new Error('inboundMiddleware failed'), - ); - }); - } catch (error) { - this.logger.error('Error in authorizeAndProcessEvent', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - eventId: event.id, - pubkey: event.pubkey, - }); - this.onerror?.(error instanceof Error ? error : new Error(String(error))); } } + /** * Test-only accessor for internal state. * @internal @@ -1036,6 +773,8 @@ export class NostrServerTransport correlationStore: this.correlationStore, oversizedReceiver: this.oversizedReceiver, openStreamReceiver: this.openStreamFactory.getReceiver(), + openStreamWriters: this.openStreamFactory.getWritersMap(), + pendingOpenStreamResponses: this.openStreamFactory.getPendingResponsesMap(), }; } } diff --git a/src/transport/nostr-server/correlation-store.ts b/src/transport/nostr-server/correlation-store.ts index 7d4d8ea..50692fd 100644 --- a/src/transport/nostr-server/correlation-store.ts +++ b/src/transport/nostr-server/correlation-store.ts @@ -202,17 +202,28 @@ export class CorrelationStore { /** * Gets the event ID for a given progress token. * - * @param clientPubkey The client's public key * @param progressToken The progress token + * @param clientPubkey Optional client pubkey for direct lookup * @returns The event ID, or undefined if not found */ getEventIdByProgressToken( - clientPubkey: string, progressToken: string, + clientPubkey?: string, ): string | undefined { - return this.progressTokenToEventId.get( - this.getProgressTokenKey(clientPubkey, progressToken), - ); + if (clientPubkey) { + return this.progressTokenToEventId.get( + this.getProgressTokenKey(clientPubkey, progressToken), + ); + } + + const suffix = `:${progressToken}`; + for (const [key, eventId] of this.progressTokenToEventId.entries()) { + if (key.endsWith(suffix)) { + return eventId; + } + } + + return undefined; } /** diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts new file mode 100644 index 0000000..58f4581 --- /dev/null +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -0,0 +1,293 @@ +import { + type JSONRPCMessage, + type JSONRPCRequest, + type JSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCNotification, +} from '@modelcontextprotocol/sdk/types.js'; +import { type NostrEvent } from 'nostr-tools'; +import { type Logger } from '../../core/utils/logger.js'; +import { type SessionStore, type ClientSession } from './session-store.js'; +import { type CorrelationStore } from './correlation-store.js'; +import { type AuthorizationPolicy } from './authorization-policy.js'; +import { type ServerOpenStreamFactory } from './open-stream-factory.js'; +import { type InboundNotificationDispatcher } from './inbound-notification-dispatcher.js'; +import { type InboundMiddlewareFn } from '../middleware.js'; +import { + injectClientPubkey, + injectRequestEventId, +} from '../../core/utils/utils.js'; +import { learnPeerCapabilities } from '../capability-negotiator.js'; +import { + CTXVM_MESSAGES_KIND, + EPHEMERAL_GIFT_WRAP_KIND, + GIFT_WRAP_KIND, + NOTIFICATIONS_INITIALIZED_METHOD, +} from '../../core/index.js'; +import { GiftWrapMode } from '../../core/interfaces.js'; +import { type OpenStreamWriter } from '../open-stream/index.js'; + +export interface ServerInboundCoordinatorDeps { + sessionStore: SessionStore; + correlationStore: CorrelationStore; + authorizationPolicy: AuthorizationPolicy; + openStreamFactory: ServerOpenStreamFactory; + inboundMiddlewares: InboundMiddlewareFn[]; + injectClientPubkey: boolean; + shouldInjectRequestEventId: boolean; + oversizedEnabled: boolean; + openStreamEnabled: boolean; + giftWrapMode: GiftWrapMode; + sendMcpMessage: ( + msg: JSONRPCMessage, + pubkey: string, + kind: number, + tags: string[][], + isEncrypted: boolean, + onEventPublished?: (id: string) => void, + wrapKind?: number, + ) => Promise; + createResponseTags: (clientPubkey: string, requestId: string) => string[][]; + getOrCreateClientSession: (clientPubkey: string, isEncrypted: boolean) => ClientSession; + forwardMessage: (msg: JSONRPCMessage, clientPubkey: string) => Promise; + logger: Logger; + onerror?: (error: Error) => void; +} + +/** + * Owns the inbound protocol workflow for the server: parsing, capability learning, + * authorization gating, request decoration, and middleware dispatch. + */ +export class ServerInboundCoordinator { + private inboundNotificationDispatcher?: InboundNotificationDispatcher; + + constructor(private deps: ServerInboundCoordinatorDeps) {} + + public setNotificationDispatcher(dispatcher: InboundNotificationDispatcher): void { + this.inboundNotificationDispatcher = dispatcher; + } + + /** + * Authorizes and processes an incoming Nostr event, handling message validation, + * client authorization, session management, and optional client public key injection. + */ + public async authorizeAndProcessEvent( + event: NostrEvent, + isEncrypted: boolean, + mcpMessage: JSONRPCMessage, + wrapKind?: number, + ): Promise { + try { + const inboundMessage: JSONRPCMessage = mcpMessage; + + const authDecision = await this.deps.authorizationPolicy.authorize( + event.pubkey, + mcpMessage, + ); + + if (!authDecision.allowed) { + this.deps.logger.error( + `Unauthorized message from ${event.pubkey}, message: ${JSON.stringify(mcpMessage)}. Ignoring.`, + ); + + if ( + 'shouldReplyUnauthorized' in authDecision && + authDecision.shouldReplyUnauthorized && + isJSONRPCRequest(mcpMessage) + ) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: mcpMessage.id, + error: { + code: -32000, + message: 'Unauthorized', + }, + }; + + const tags = this.deps.createResponseTags(event.pubkey, event.id); + this.deps + .sendMcpMessage( + errorResponse, + event.pubkey, + CTXVM_MESSAGES_KIND, + tags, + isEncrypted, + undefined, + isEncrypted + ? this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL + ? EPHEMERAL_GIFT_WRAP_KIND + : this.deps.giftWrapMode === GiftWrapMode.PERSISTENT + ? GIFT_WRAP_KIND + : wrapKind + : undefined, + ) + .catch((err) => { + this.deps.logger.error('Failed to send unauthorized response', { + error: err instanceof Error ? err.message : String(err), + pubkey: event.pubkey, + eventId: event.id, + }); + this.deps.onerror?.( + new Error(`Failed to send unauthorized response: ${err}`), + ); + }); + } + return; + } + + const session = this.deps.getOrCreateClientSession(event.pubkey, isEncrypted); + const hadLearnedOversizedSupport = session.supportsOversizedTransfer; + const discoveredCapabilities = learnPeerCapabilities(event.tags); + session.supportsEncryption ||= discoveredCapabilities.supportsEncryption; + session.supportsEphemeralEncryption ||= + discoveredCapabilities.supportsEphemeralEncryption; + session.supportsOversizedTransfer ||= + this.deps.oversizedEnabled && + discoveredCapabilities.supportsOversizedTransfer; + session.supportsOpenStream ||= + this.deps.openStreamEnabled && discoveredCapabilities.supportsOpenStream; + + const shouldSendAccept = !hadLearnedOversizedSupport; + + const clientPmis = event.tags + .filter((tag) => tag[0] === 'pmi' && typeof tag[1] === 'string') + .map((tag) => tag[1] as string); + const ctx = { + clientPubkey: event.pubkey, + clientPmis: clientPmis.length > 0 ? clientPmis : undefined, + }; + const middlewares = this.deps.inboundMiddlewares; + + const dispatch = async ( + index: number, + msg: JSONRPCMessage, + ): Promise => { + const mw = middlewares[index]; + if (!mw) { + return await this.deps.forwardMessage(msg, event.pubkey); + } + let forwarded = false; + await mw(msg, ctx, async (nextMsg) => { + forwarded = await dispatch(index + 1, nextMsg); + }); + return forwarded; + }; + + if (isJSONRPCRequest(inboundMessage)) { + this.handleIncomingRequest( + event, + event.id, + inboundMessage, + event.pubkey, + wrapKind, + ); + + if (this.deps.shouldInjectRequestEventId) { + injectRequestEventId(inboundMessage, event.id); + } + + if (this.deps.injectClientPubkey) { + injectClientPubkey(inboundMessage, event.pubkey); + } + + const openStreamWriter = this.deps.openStreamFactory.getWriter(event.id); + if (openStreamWriter) { + const params = inboundMessage.params ?? {}; + inboundMessage.params = params; + const meta = params._meta ?? {}; + params._meta = meta; + (meta as { stream?: OpenStreamWriter }).stream = openStreamWriter; + } + } else if (isJSONRPCNotification(inboundMessage)) { + this.handleIncomingNotification(event.pubkey, inboundMessage); + + const intercepted = this.inboundNotificationDispatcher?.tryIntercept( + inboundMessage, + { event, session, shouldSendAccept, wrapKind }, + (msg) => dispatch(0, msg), + ); + if (intercepted) return; + } + + void dispatch(0, inboundMessage) + .then((forwarded) => { + if (!forwarded) { + this.cleanupDroppedRequest(inboundMessage); + } + }) + .catch((err: unknown) => { + this.deps.logger.error('Error in inboundMiddleware chain', { + error: err instanceof Error ? err.message : String(err), + eventId: event.id, + pubkey: event.pubkey, + }); + this.deps.onerror?.( + err instanceof Error ? err : new Error('inboundMiddleware failed'), + ); + }); + } catch (error) { + this.deps.logger.error('Error in authorizeAndProcessEvent', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + eventId: event.id, + pubkey: event.pubkey, + }); + this.deps.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * Handles incoming requests with correlation tracking. + */ + public handleIncomingRequest( + event: NostrEvent, + eventId: string, + request: JSONRPCRequest, + clientPubkey: string, + wrapKind?: number, + ): void { + const originalRequestId = request.id; + request.id = eventId; + + const progressToken = request.params?._meta?.progressToken; + this.deps.correlationStore.registerEventRoute( + eventId, + clientPubkey, + originalRequestId, + progressToken ? String(progressToken) : undefined, + wrapKind, + this.deps.shouldInjectRequestEventId ? event : undefined, + ); + + this.deps.openStreamFactory.createWriterIfEnabled( + eventId, + clientPubkey, + progressToken ? String(progressToken) : undefined, + ); + } + + /** + * Cleans up request correlation for a request that was dropped by middleware. + */ + public cleanupDroppedRequest(message: JSONRPCMessage): void { + if (!isJSONRPCRequest(message)) { + return; + } + this.deps.correlationStore.popEventRoute(String(message.id)); + } + + /** + * Handles incoming notifications. + */ + public handleIncomingNotification( + clientPubkey: string, + notification: JSONRPCMessage, + ): void { + if ( + isJSONRPCNotification(notification) && + notification.method === NOTIFICATIONS_INITIALIZED_METHOD + ) { + this.deps.sessionStore.markInitialized(clientPubkey); + } + } +} diff --git a/src/transport/nostr-server/inbound-notification-dispatcher.ts b/src/transport/nostr-server/inbound-notification-dispatcher.ts index 6627b54..9467350 100644 --- a/src/transport/nostr-server/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-server/inbound-notification-dispatcher.ts @@ -74,7 +74,10 @@ export class InboundNotificationDispatcher { if (frame?.frameType === 'abort') { const progressToken = String(inboundMessage.params?.progressToken ?? ''); - const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); + const eventId = this.deps.correlationStore.getEventIdByProgressToken( + progressToken, + event.pubkey, + ); const writer = eventId ? this.deps.openStreamFactory.getWriter(eventId) : undefined; if (writer) { @@ -94,7 +97,10 @@ export class InboundNotificationDispatcher { if (frame?.frameType === 'ping') { const progressToken = String(inboundMessage.params?.progressToken ?? ''); const nonce = 'nonce' in frame && typeof frame.nonce === 'string' ? frame.nonce : ''; - const eventId = this.deps.correlationStore.getEventIdByProgressToken(progressToken); + const eventId = this.deps.correlationStore.getEventIdByProgressToken( + progressToken, + event.pubkey, + ); const writer = eventId ? this.deps.openStreamFactory.getWriter(eventId) : undefined; if (writer) { diff --git a/src/transport/nostr-server/open-stream-factory.ts b/src/transport/nostr-server/open-stream-factory.ts index d1d1309..3236f02 100644 --- a/src/transport/nostr-server/open-stream-factory.ts +++ b/src/transport/nostr-server/open-stream-factory.ts @@ -2,6 +2,7 @@ import { OpenStreamWriter, OpenStreamReceiver, buildOpenStreamPingFrame, buildOp import { type OpenStreamRegistryOptions } from '../open-stream/registry.js'; import { type Logger } from '../../core/utils/logger.js'; import { type CorrelationStore } from './correlation-store.js'; +import { type ClientSession, type SessionStore } from './session-store.js'; import { type JSONRPCMessage, type JSONRPCResponse } from '@modelcontextprotocol/sdk/types.js'; /** @@ -11,6 +12,12 @@ export interface ServerOpenStreamFactoryDeps { openStreamEnabled: boolean; sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; handleResponse: (response: JSONRPCResponse) => Promise; + sessionStore: SessionStore; + onClientSessionEvicted?: (ctx: { + clientPubkey: string; + session: ClientSession; + }) => void | Promise; + onAbort?: (clientPubkey: string, eventId: string, reason?: string) => Promise; correlationStore: CorrelationStore; policy?: Partial; logger: Logger; @@ -23,6 +30,11 @@ export class ServerOpenStreamFactory { private readonly writers = new Map(); private readonly pendingResponses = new Map(); private readonly receiver: OpenStreamReceiver; + private readonly pendingSessionEvictions = new Map< + string, + { clientPubkey: string; session: ClientSession } + >(); + private readonly evictedClientPubkeys = new Set(); constructor(private deps: ServerOpenStreamFactoryDeps) { this.receiver = new OpenStreamReceiver({ @@ -111,6 +123,29 @@ export class ServerOpenStreamFactory { public clear(): void { this.writers.clear(); this.pendingResponses.clear(); + this.pendingSessionEvictions.clear(); + this.evictedClientPubkeys.clear(); + } + + /** Returns true if the client is currently marked as evicted. */ + public isClientEvicted(clientPubkey: string): boolean { + return this.evictedClientPubkeys.has(clientPubkey); + } + + /** + * Takes and clears a pending eviction entry keyed by the request event id. + */ + public takePendingEviction( + eventId: string, + ): { clientPubkey: string; session: ClientSession } | undefined { + const pendingEviction = this.pendingSessionEvictions.get(eventId); + if (!pendingEviction) { + return undefined; + } + + this.pendingSessionEvictions.delete(eventId); + this.evictedClientPubkeys.delete(pendingEviction.clientPubkey); + return pendingEviction; } /** @@ -151,7 +186,11 @@ export class ServerOpenStreamFactory { onClose: async (): Promise => { await this.flushPendingResponse(eventId); }, - onAbort: async (): Promise => { + onAbort: async (reason?: string): Promise => { + if (reason === 'Probe timeout') { + await this.handleProbeTimeout(clientPubkey, eventId); + } + await this.deps.onAbort?.(clientPubkey, eventId, reason); await this.flushPendingResponse(eventId); }, }); @@ -173,4 +212,67 @@ export class ServerOpenStreamFactory { await this.deps.handleResponse(pendingResponse); } + + private async handleProbeTimeout( + clientPubkey: string, + eventId: string, + ): Promise { + const session = this.deps.sessionStore.getSession(clientPubkey); + if (session) { + this.pendingSessionEvictions.set(eventId, { + clientPubkey, + session: { ...session }, + }); + this.evictedClientPubkeys.add(clientPubkey); + } + + const removed = this.deps.sessionStore.removeSession(clientPubkey); + if (!removed && !session) { + return; + } + + this.deps.logger.info('Removed session after open-stream probe timeout', { + clientPubkey, + eventId, + }); + + const evictionSession = + session ?? { + isInitialized: false, + isEncrypted: false, + hasSentCommonTags: false, + supportsEncryption: false, + supportsEphemeralEncryption: false, + supportsOversizedTransfer: false, + supportsOpenStream: false, + }; + + await Promise.resolve( + this.deps.onClientSessionEvicted?.({ + clientPubkey, + session: evictionSession, + }), + ).catch((error) => { + this.deps.logger.error('Error in onClientSessionEvicted callback', { + clientPubkey, + error, + }); + }); + } + + /** + * Test-only accessor for writers map. + * @internal + */ + public getWritersMap(): Map { + return this.writers; + } + + /** + * Test-only accessor for pending responses map. + * @internal + */ + public getPendingResponsesMap(): Map { + return this.pendingResponses; + } } diff --git a/src/transport/nostr-server/outbound-notification-broadcaster.ts b/src/transport/nostr-server/outbound-notification-broadcaster.ts new file mode 100644 index 0000000..6667f3d --- /dev/null +++ b/src/transport/nostr-server/outbound-notification-broadcaster.ts @@ -0,0 +1,93 @@ +import { + type JSONRPCMessage, + isJSONRPCNotification, +} from '@modelcontextprotocol/sdk/types.js'; +import { type Logger } from '../../core/utils/logger.js'; +import { type CorrelationStore } from './correlation-store.js'; +import { type SessionStore } from './session-store.js'; + +export interface OutboundNotificationBroadcasterDeps { + correlationStore: CorrelationStore; + sessionStore: SessionStore; + sendNotification: ( + clientPubkey: string, + notification: JSONRPCMessage, + correlatedEventId?: string, + ) => Promise; + enqueueTask: (task: () => Promise) => void; + logger: Logger; + onerror?: (error: Error) => void; +} + +/** + * Routes server outbound notifications to a specific client or broadcasts to all. + */ +export class OutboundNotificationBroadcaster { + constructor(private deps: OutboundNotificationBroadcasterDeps) {} + + /** + * Broadcasts a notification or routes it based on correlation metadata. + */ + public async broadcast(notification: JSONRPCMessage): Promise { + try { + // Special handling for progress notifications + // TODO: Add handling for `notifications/resources/updated`, as they need to be associated with an id + if ( + isJSONRPCNotification(notification) && + notification.method === 'notifications/progress' && + notification.params?.progressToken + ) { + const token = String(notification.params.progressToken); + + // Use O(1) lookup for progress token routing + const nostrEventId = + this.deps.correlationStore.getEventIdByProgressToken(token); + + if (nostrEventId) { + const route = this.deps.correlationStore.getEventRoute(nostrEventId); + if (route) { + await this.deps.sendNotification( + route.clientPubkey, + notification, + nostrEventId, + ); + return; + } + } + + const error = new Error(`No client found for progress token: ${token}`); + this.deps.logger.error('Progress token not found', { token }); + this.deps.onerror?.(error); + return; + } + + // Use TaskQueue for outbound notification broadcasting to prevent event loop blocking + for (const [ + clientPubkey, + session, + ] of this.deps.sessionStore.getAllSessions()) { + if (session.isInitialized) { + this.deps.enqueueTask(async () => { + try { + await this.deps.sendNotification(clientPubkey, notification); + } catch (error) { + this.deps.logger.error('Error sending notification', { + error: error instanceof Error ? error.message : String(error), + clientPubkey, + method: isJSONRPCNotification(notification) + ? notification.method + : 'unknown', + }); + } + }); + } + } + } catch (error) { + this.deps.logger.error('Error in notification broadcaster', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + this.deps.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } +} diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index faf0f86..08ea2d4 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -25,7 +25,12 @@ export interface OutboundResponseRouterDeps { correlationStore: CorrelationStore; sessionStore: SessionStore; announcementManager: AnnouncementManager; - openStreamFactory: { deferIfStreamActive: (eventId: string, response: JSONRPCResponse) => boolean }; + openStreamFactory: { + deferIfStreamActive: (eventId: string, response: JSONRPCResponse) => boolean; + takePendingEviction: ( + eventId: string, + ) => { clientPubkey: string; session: ClientSession } | undefined; + }; oversizedConfig: { enabled: boolean; threshold: number; chunkSize: number }; applyListToolsResultTransformers: (result: ListToolsResult) => ListToolsResult; buildOutboundTags: (params: { baseTags: readonly string[][]; session: ClientSession }) => string[][]; @@ -83,7 +88,12 @@ export class OutboundResponseRouter { return; } - const session = this.deps.sessionStore.getSession(route.clientPubkey); + const pendingEviction = + this.deps.openStreamFactory.takePendingEviction(nostrEventId); + const session = + this.deps.sessionStore.getSession(route.clientPubkey) ?? + pendingEviction?.session; + if (!session) { this.deps.onerror?.( new Error(`No session found for client: ${route.clientPubkey}`), From f02e15931be057cecf41b9da9f8a927792419ee3 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 12 May 2026 20:44:23 +0530 Subject: [PATCH 12/17] test(transport): update correlation/open-stream expectations --- ...tr-server-transport.dedup-response.test.ts | 4 +- src/transport/nostr-server-transport.test.ts | 76 +++++++++++-------- .../nostr-server/correlation-store.test.ts | 20 +++-- 3 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/transport/nostr-server-transport.dedup-response.test.ts b/src/transport/nostr-server-transport.dedup-response.test.ts index 8b9f1bd..ea7b67a 100644 --- a/src/transport/nostr-server-transport.dedup-response.test.ts +++ b/src/transport/nostr-server-transport.dedup-response.test.ts @@ -131,8 +131,8 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { }); expect( state.correlationStore.getEventIdByProgressToken( - 'c'.repeat(64), 'token-1', + 'c'.repeat(64), ), ).toBe('event1'); @@ -148,8 +148,8 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { expect(state.correlationStore.getEventRoute('event1')).toBeUndefined(); expect( state.correlationStore.getEventIdByProgressToken( - 'c'.repeat(64), 'token-1', + 'c'.repeat(64), ), ).toBeUndefined(); }); diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index b3dd00d..674cdf1 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -40,6 +40,7 @@ import { isJSONRPCRequest, JSONRPCMessage, JSONRPCResponse, + JSONRPCErrorResponse, } from '@modelcontextprotocol/sdk/types.js'; import { withServerPayments } from '../payments/server-transport-payments.js'; @@ -1257,13 +1258,20 @@ describe.serial('NostrServerTransport', () => { await ( serverTransport as unknown as { - authorizeAndProcessEvent: ( - event: NostrEvent, - isEncrypted: boolean, - wrapKind?: number, - ) => Promise; + inboundCoordinator: { + authorizeAndProcessEvent: ( + event: NostrEvent, + isEncrypted: boolean, + mcpMessage: JSONRPCMessage, + wrapKind?: number, + ) => Promise; + } } - ).authorizeAndProcessEvent(requestEvent, false); + ).inboundCoordinator.authorizeAndProcessEvent( + requestEvent, + false, + JSON.parse(requestEvent.content) as JSONRPCMessage, + ); await sleep(100); expect(observedRequestEventId).toBeDefined(); @@ -1615,18 +1623,20 @@ describe.serial('NostrServerTransport', () => { ( serverTransport as unknown as { - handleIncomingRequest: ( - event: NostrEvent, - eventId: string, - request: { - id: string; - params?: { _meta?: { progressToken?: string } }; - }, - clientPubkey: string, - wrapKind?: number, - ) => void; + inboundCoordinator: { + handleIncomingRequest: ( + event: NostrEvent, + eventId: string, + request: { + id: string; + params?: { _meta?: { progressToken?: string } }; + }, + clientPubkey: string, + wrapKind?: number, + ) => void; + } } - ).handleIncomingRequest( + ).inboundCoordinator.handleIncomingRequest( { id: 'b'.repeat(64), pubkey: clientPublicKey, @@ -1687,26 +1697,30 @@ describe.serial('NostrServerTransport', () => { const handledResponses: JSONRPCResponse[] = []; ( serverTransport as unknown as { - handleResponse: (response: JSONRPCResponse) => Promise; + outboundResponseRouter: { + route: (response: JSONRPCResponse | JSONRPCErrorResponse) => Promise; + } } - ).handleResponse = async (response: JSONRPCResponse): Promise => { - handledResponses.push(response); + ).outboundResponseRouter.route = async (response: JSONRPCResponse | JSONRPCErrorResponse): Promise => { + handledResponses.push(response as JSONRPCResponse); }; ( serverTransport as unknown as { - handleIncomingRequest: ( - event: NostrEvent, - eventId: string, - request: { - id: string; - params?: { _meta?: { progressToken?: string } }; - }, - clientPubkey: string, - wrapKind?: number, - ) => void; + inboundCoordinator: { + handleIncomingRequest: ( + event: NostrEvent, + eventId: string, + request: { + id: string; + params?: { _meta?: { progressToken?: string } }; + }, + clientPubkey: string, + wrapKind?: number, + ) => void; + } } - ).handleIncomingRequest( + ).inboundCoordinator.handleIncomingRequest( { id: 'b'.repeat(64), pubkey: clientPublicKey, diff --git a/src/transport/nostr-server/correlation-store.test.ts b/src/transport/nostr-server/correlation-store.test.ts index 3a403f4..8087248 100644 --- a/src/transport/nostr-server/correlation-store.test.ts +++ b/src/transport/nostr-server/correlation-store.test.ts @@ -54,7 +54,7 @@ describe('CorrelationStore', () => { const store = new CorrelationStore(); store.registerEventRoute('event1', 'client1', 'req1', 'token1'); - expect(store.getEventIdByProgressToken('client1', 'token1')).toBe( + expect(store.getEventIdByProgressToken('token1', 'client1')).toBe( 'event1', ); expect(store.hasProgressToken('client1', 'token1')).toBe(true); @@ -65,10 +65,10 @@ describe('CorrelationStore', () => { store.registerEventRoute('event1', 'client1', 'req1', 'shared'); store.registerEventRoute('event2', 'client2', 'req2', 'shared'); - expect(store.getEventIdByProgressToken('client1', 'shared')).toBe( + expect(store.getEventIdByProgressToken('shared', 'client1')).toBe( 'event1', ); - expect(store.getEventIdByProgressToken('client2', 'shared')).toBe( + expect(store.getEventIdByProgressToken('shared', 'client2')).toBe( 'event2', ); }); @@ -136,9 +136,7 @@ describe('CorrelationStore', () => { describe('getEventIdByProgressToken', () => { it('returns undefined for unknown token', () => { const store = new CorrelationStore(); - expect( - store.getEventIdByProgressToken('client1', 'unknown'), - ).toBeUndefined(); + expect(store.getEventIdByProgressToken('unknown', 'client1')).toBeUndefined(); }); it('returns correct event id for token', () => { @@ -146,10 +144,10 @@ describe('CorrelationStore', () => { store.registerEventRoute('event1', 'client1', 'req1', 'token1'); store.registerEventRoute('event2', 'client2', 'req2', 'token2'); - expect(store.getEventIdByProgressToken('client1', 'token1')).toBe( + expect(store.getEventIdByProgressToken('token1', 'client1')).toBe( 'event1', ); - expect(store.getEventIdByProgressToken('client2', 'token2')).toBe( + expect(store.getEventIdByProgressToken('token2', 'client2')).toBe( 'event2', ); }); @@ -430,17 +428,17 @@ describe('CorrelationStore', () => { const store = new CorrelationStore(); store.registerEventRoute('event1', 'client1', 'req1', 'token1'); - expect(store.getEventIdByProgressToken('client1', 'token1')).toBe( + expect(store.getEventIdByProgressToken('token1', 'client1')).toBe( 'event1', ); store.registerEventRoute('event2', 'client1', 'req2', 'token1'); - expect(store.getEventIdByProgressToken('client1', 'token1')).toBe( + expect(store.getEventIdByProgressToken('token1', 'client1')).toBe( 'event2', ); expect( - store.getEventIdByProgressToken('client2', 'token1'), + store.getEventIdByProgressToken('token1', 'client2'), ).toBeUndefined(); }); From c6201661db415a8e8372b315d51836ad860a1378 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 12 May 2026 21:15:56 +0530 Subject: [PATCH 13/17] Fix TypeScript errors and visibility in NostrClientTransport for test compatibility --- src/transport/nostr-client-transport.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 4993eaf..317e1d7 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -701,14 +701,14 @@ export class NostrClientTransport } } - private buildOutboundClientTags(params: { + protected buildOutboundClientTags(params: { baseTags: readonly string[][]; - includeDiscovery?: boolean; + includeDiscovery: boolean; }): string[][] { return this.capabilityNegotiator.buildOutboundTags(params); } - private chooseOutboundGiftWrapKind(): number { + protected chooseOutboundGiftWrapKind(): number { return this.capabilityNegotiator.chooseOutboundGiftWrapKind(); } From 433697be2c58089bc47ba75e86ae5ec872266c4e Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Wed, 13 May 2026 19:52:29 +0530 Subject: [PATCH 14/17] docs: refine architecture guidelines in AGENTS.md --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 35b47cc..65a3f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,10 @@ - **Keep files concise**: Extract helpers instead of creating "V2" copies. - **Use existing patterns** for dependency injection via constructor options. -- **Aim to keep files under ~700 LOC**: This is a guideline only (not a hard guardrail). +- **Prioritize clear ownership boundaries**: The goal is to ensure each module answers a single architectural question. +- **Modularize around protocol or lifecycle concerns**: Modularization should follow logical sub-flows (e.g., event unwrapping, inbound coordination, outbound routing) rather than arbitrary code splitting. +- **Aim to keep files under ~700 LOC**: This is a heuristic guideline only, not a hard guardrail. - Split or refactor when it improves clarity or testability. -- Extract specialized concerns into dedicated modules (e.g., `StatelessModeHandler`, `CorrelationStore`). ## Environment From ae13fbd4ed1dca0ea96ce6a4ccbb984f2bd3a883 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Wed, 13 May 2026 20:26:45 +0530 Subject: [PATCH 15/17] fix(test): resolve race condition in open-stream response flushing --- src/transport/nostr-server-transport.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index 674cdf1..cd73182 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -1754,6 +1754,7 @@ describe.serial('NostrServerTransport', () => { expect(writer).toBeDefined(); await writer!.abort('Probe timeout'); + await sleep(50); expect(internalState.sessionStore.hasSession(clientPublicKey)).toBe(false); expect(handledResponses).toHaveLength(1); From 162d0b9d61e8389b094838d5df9e5c28bc67229b Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Wed, 13 May 2026 20:53:55 +0530 Subject: [PATCH 16/17] fix(transport): adapt open-stream tests to refactored architecture and resolve race condition --- src/transport/nostr-server-transport.test.ts | 50 ++++++++------------ src/transport/nostr-server-transport.ts | 2 + 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index 6a48445..0fd854e 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -1755,7 +1755,6 @@ describe.serial('NostrServerTransport', () => { expect(writer).toBeDefined(); await writer!.abort('Probe timeout'); - await sleep(50); expect(internalState.sessionStore.hasSession(clientPublicKey)).toBe(false); expect(handledResponses).toHaveLength(1); @@ -1787,20 +1786,30 @@ describe.serial('NostrServerTransport', () => { string, { abort: (reason?: string) => Promise } >; + openStreamFactory: { + deps: { + sendNotification: (clientPubkey: string, notification: JSONRPCMessage) => Promise; + handleResponse: (response: JSONRPCResponse) => Promise; + } + }; + inboundCoordinator: { + handleIncomingRequest: ( + event: NostrEvent, + eventId: string, + request: { + id: string; + params?: { _meta?: { progressToken?: string } }; + }, + clientPubkey: string, + wrapKind?: number, + ) => void; + }; }; internalState.sessionStore.getOrCreateSession(clientPublicKey, false); const events: string[] = []; - ( - serverTransport as unknown as { - sendNotification: ( - clientPubkey: string, - notification: JSONRPCMessage, - correlatedEventId?: string, - ) => Promise; - } - ).sendNotification = async ( + internalState.openStreamFactory.deps.sendNotification = async ( _clientPubkey: string, notification: JSONRPCMessage, ): Promise => { @@ -1813,28 +1822,11 @@ describe.serial('NostrServerTransport', () => { } }; - ( - serverTransport as unknown as { - handleResponse: (response: JSONRPCResponse) => Promise; - } - ).handleResponse = async (_response: JSONRPCResponse): Promise => { + internalState.openStreamFactory.deps.handleResponse = async (_response: JSONRPCResponse): Promise => { events.push('final-response'); }; - ( - serverTransport as unknown as { - handleIncomingRequest: ( - event: NostrEvent, - eventId: string, - request: { - id: string; - params?: { _meta?: { progressToken?: string } }; - }, - clientPubkey: string, - wrapKind?: number, - ) => void; - } - ).handleIncomingRequest( + internalState.inboundCoordinator.handleIncomingRequest( { id: 'b'.repeat(64), pubkey: clientPublicKey, diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 90386c7..c070ed8 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -777,6 +777,8 @@ export class NostrServerTransport openStreamReceiver: this.openStreamFactory.getReceiver(), openStreamWriters: this.openStreamFactory.getWritersMap(), pendingOpenStreamResponses: this.openStreamFactory.getPendingResponsesMap(), + openStreamFactory: this.openStreamFactory, + inboundCoordinator: this.inboundCoordinator, }; } } From 0c1bc349a5775cf6c85d0fc5850132a716e824ab Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Wed, 13 May 2026 21:03:57 +0530 Subject: [PATCH 17/17] fix(test): resolve typescript conversion error in server transport tests --- src/transport/nostr-server-transport.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index 0fd854e..dd30eff 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -1774,7 +1774,7 @@ describe.serial('NostrServerTransport', () => { openStream: { enabled: true }, }); - const internalState = serverTransport.getInternalStateForTesting() as { + const internalState = serverTransport.getInternalStateForTesting() as unknown as { sessionStore: { getOrCreateSession: ( clientPubkey: string,