From 070ce3a0fd665ff7ed2e696c31fb70eba9ff544e Mon Sep 17 00:00:00 2001 From: ContextVM Date: Wed, 19 Aug 2026 12:39:20 +0100 Subject: [PATCH 1/4] fix(transport): mirror request wrap kind in routeTargeted responses routeTargeted() chose the gift-wrap kind without consulting the wrap kind recorded for the client's request event, while route() mirrors it on both send paths. A targeted response (e.g. explicit-gating -32042/-32043 errors) could therefore answer an ephemeral-wrapped request (kind 21059) with a relay-stored gift wrap (kind 1059) in GiftWrapMode.OPTIONAL when the session lacks the ephemeral capability tag. Look up the recorded wrap kind via a non-destructive getEventRoute() and pass it as fallbackWrapKind, matching route()'s policy. Callers passing an unknown event ID degrade to the previous default. --- .../outbound-response-router.test.ts | 163 ++++++++++++++++++ .../nostr-server/outbound-response-router.ts | 8 + 2 files changed, 171 insertions(+) create mode 100644 src/transport/nostr-server/outbound-response-router.test.ts diff --git a/src/transport/nostr-server/outbound-response-router.test.ts b/src/transport/nostr-server/outbound-response-router.test.ts new file mode 100644 index 0000000..498dc13 --- /dev/null +++ b/src/transport/nostr-server/outbound-response-router.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'bun:test'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, +} from '@contextvm/mcp-sdk/types.js'; +import type { Logger } from '../../core/utils/logger.js'; +import { EPHEMERAL_GIFT_WRAP_KIND } from '../../core/constants.js'; +import { + OutboundResponseRouter, + type OutboundResponseRouterDeps, +} from './outbound-response-router.js'; +import { CorrelationStore } from './correlation-store.js'; +import type { ClientSession } from './session-store.js'; + +const testLogger: Logger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + withModule: () => testLogger, +}; + +const CLIENT_PUBKEY = 'a'.repeat(64); + +function createSession( + overrides: Partial = {}, +): ClientSession { + return { + isInitialized: true, + isEncrypted: true, + hasSentCommonTags: true, + supportsEncryption: true, + // Divergence precondition: no ephemeral capability learned from the client, + // so the fallback wrap kind is what chooseGiftWrapKind settles on. + supportsEphemeralEncryption: false, + supportsOversizedTransfer: false, + supportsOpenStream: false, + ...overrides, + }; +} + +interface CapturedDeps { + deps: OutboundResponseRouterDeps; + chooseCalls: Array<{ fallbackWrapKind?: number }>; + sentGiftWrapKinds: Array; +} + +function createRouterWithCapturedDeps( + correlationStore: CorrelationStore, + session: ClientSession, +): CapturedDeps { + const chooseCalls: CapturedDeps['chooseCalls'] = []; + const sentGiftWrapKinds: CapturedDeps['sentGiftWrapKinds'] = []; + + const deps = { + correlationStore, + sessionStore: { + getSession: (pubkey: string) => + pubkey === CLIENT_PUBKEY ? session : undefined, + }, + announcementManager: { getPricingTags: () => [] as string[][] }, + openStreamFactory: { + deferIfStreamActive: () => false, + takePendingEviction: () => undefined, + }, + oversizedConfig: { enabled: false, threshold: 0, chunkSize: 0 }, + applyListToolsResultTransformers: (result: unknown) => result, + buildOutboundTags: (params: { baseTags: readonly string[][] }) => [ + ...params.baseTags, + ], + createResponseTags: () => [] as string[][], + chooseGiftWrapKind: (params: { fallbackWrapKind?: number }) => { + chooseCalls.push({ fallbackWrapKind: params.fallbackWrapKind }); + return params.fallbackWrapKind; + }, + sendMcpMessage: async ( + _message: JSONRPCMessage, + _targetPubkey: string, + _kind: number, + _tags?: string[][], + _encrypt?: boolean, + _onCreateEvent?: (eventId: string) => void, + giftWrapKind?: number, + ) => { + sentGiftWrapKinds.push(giftWrapKind); + return 'inner-event-id'; + }, + measurePublishedMcpMessageSize: async () => 0, + resolveSafeOversizedChunkSize: async () => 0, + logger: testLogger, + } as unknown as OutboundResponseRouterDeps; + + return { deps, chooseCalls, sentGiftWrapKinds }; +} + +const gatingErrorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: 'original-request-id', + error: { code: -32042, message: 'Payment Required' }, +}; + +describe('OutboundResponseRouter.routeTargeted', () => { + test('mirrors the wrap kind recorded for the request event', async () => { + const correlationStore = new CorrelationStore({}); + correlationStore.registerEventRoute( + 'evt-ephemeral', + CLIENT_PUBKEY, + 'original-request-id', + undefined, + EPHEMERAL_GIFT_WRAP_KIND, + ); + const { deps, chooseCalls, sentGiftWrapKinds } = createRouterWithCapturedDeps( + correlationStore, + createSession(), + ); + + await new OutboundResponseRouter(deps).routeTargeted( + CLIENT_PUBKEY, + gatingErrorResponse, + 'evt-ephemeral', + ); + + expect(chooseCalls).toEqual([ + { fallbackWrapKind: EPHEMERAL_GIFT_WRAP_KIND }, + ]); + expect(sentGiftWrapKinds).toEqual([EPHEMERAL_GIFT_WRAP_KIND]); + // Non-destructive: the early rejection must not consume the route, which + // the normal response/cleanup lifecycle still needs. + expect(correlationStore.hasEventRoute('evt-ephemeral')).toBe(true); + }); + + test('sends without a wrap-kind hint when no route is recorded', async () => { + const { deps, chooseCalls, sentGiftWrapKinds } = createRouterWithCapturedDeps( + new CorrelationStore({}), + createSession(), + ); + + await new OutboundResponseRouter(deps).routeTargeted( + CLIENT_PUBKEY, + gatingErrorResponse, + 'evt-unknown', + ); + + expect(chooseCalls).toEqual([{ fallbackWrapKind: undefined }]); + expect(sentGiftWrapKinds).toEqual([undefined]); + }); + + test('does not send when the client has no active session', async () => { + const { deps, chooseCalls, sentGiftWrapKinds } = createRouterWithCapturedDeps( + new CorrelationStore({}), + createSession(), + ); + + await new OutboundResponseRouter(deps).routeTargeted( + 'b'.repeat(64), + gatingErrorResponse, + 'evt-any', + ); + + expect(chooseCalls).toHaveLength(0); + expect(sentGiftWrapKinds).toHaveLength(0); + }); +}); diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index 6d69917..cf4e57a 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -296,6 +296,10 @@ export class OutboundResponseRouter { * Routes a response back to a specifically targeted client and request event. * This bypasses the normal correlation lookup, which is useful when * middleware needs to reject a request early (e.g. for explicit gating). + * + * The gift-wrap kind mirrors the one recorded for the request event, matching + * the policy `route()` applies, so a targeted response never downgrades an + * ephemeral-wrapped request to a relay-stored wrap. */ public async routeTargeted( clientPubkey: string, @@ -320,6 +324,10 @@ export class OutboundResponseRouter { const giftWrapKind = this.deps.chooseGiftWrapKind({ session, + // Non-destructive read: the route must stay registered for the normal + // response/cleanup lifecycle that runs after this early rejection. + fallbackWrapKind: + this.deps.correlationStore.getEventRoute(requestEventId)?.wrapKind, }); await this.deps.sendMcpMessage( From e2bc1c4a906a126dd69875379901e4379f7f70d7 Mon Sep 17 00:00:00 2001 From: ContextVM Date: Wed, 19 Aug 2026 13:04:30 +0100 Subject: [PATCH 2/4] fix(transport): mirror request wrap kind on remaining server send paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendNotification() and the CEP-22 accept frame chose the gift-wrap kind without the request's wrap kind, so progress notifications and oversized accept frames answered an ephemeral-wrapped request (kind 21059) with a relay-stored gift wrap (kind 1059) in GiftWrapMode.OPTIONAL when the session lacks the ephemeral capability tag — the same divergence fixed for routeTargeted(). - sendNotification() mirrors via the correlated request's route and accepts an explicit wrapKindHint for callers without a route yet - the oversized accept frame threads the inbound request's wrap kind through that hint (no route exists at start-frame time) - route()'s send-failure re-register restores the full route, including the signed request event exposed via getNostrRequestEvent() Adds a path-agnostic invariant e2e: a raw ephemeral-wrapped client with no capability tags never receives a persistent (1059) wrap in OPTIONAL mode, across response, notification, and targeted forms. --- src/transport/nostr-server-transport.ts | 10 + .../inbound-notification-dispatcher.ts | 5 + .../outbound-response-router.test.ts | 45 +++++ .../nostr-server/outbound-response-router.ts | 1 + .../oversized-server-handler.test.ts | 34 ++++ .../nostr-server/oversized-server-handler.ts | 10 +- src/transport/wrap-kind-mirror.e2e.test.ts | 190 ++++++++++++++++++ 7 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/transport/wrap-kind-mirror.e2e.test.ts diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 8f9f048..a8b3e7c 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -755,12 +755,15 @@ export class NostrServerTransport * Sends a notification to a specific client by their public key. * @param clientPubkey The public key of the target client. * @param notification The notification message to send. + * @param correlatedEventId Optional request event ID to correlate the reply with; when present, the wrap kind mirrors the one recorded for that request. + * @param wrapKindHint Optional explicit wrap-kind fallback, used when no correlated route exists yet (e.g. CEP-22 accept frames, sent before the reassembled request registers a route). * @returns Promise that resolves when the notification is sent. */ public async sendNotification( clientPubkey: string, notification: JSONRPCMessage, correlatedEventId?: string, + wrapKindHint?: number, ): Promise { if (this.openStreamFactory.isClientEvicted(clientPubkey)) { throw new Error(`No active session found for client: ${clientPubkey}`); @@ -783,6 +786,13 @@ export class NostrServerTransport const giftWrapKind = this.capabilityNegotiator.chooseOutboundGiftWrapKind({ session, + // Mirror the request's wrap kind: an explicit hint wins (accept frames + // have no route yet); otherwise consult the correlated request's route. + fallbackWrapKind: + wrapKindHint ?? + (correlatedEventId + ? this.correlationStore.getEventRoute(correlatedEventId)?.wrapKind + : undefined), }); await this.sendMcpMessage( diff --git a/src/transport/nostr-server/inbound-notification-dispatcher.ts b/src/transport/nostr-server/inbound-notification-dispatcher.ts index f50db3a..c81582a 100644 --- a/src/transport/nostr-server/inbound-notification-dispatcher.ts +++ b/src/transport/nostr-server/inbound-notification-dispatcher.ts @@ -34,6 +34,8 @@ export interface InboundNotificationDispatcherDeps { sendNotification: ( clientPubkey: string, notification: JSONRPCMessage, + correlatedEventId?: string, + wrapKindHint?: number, ) => Promise; handleIncomingRequest: ( event: NostrEvent, @@ -206,6 +208,9 @@ export class InboundNotificationDispatcher { progressToken: String( inboundMessage.params?.progressToken ?? '', ), + // Mirror the oversized request's wrap kind onto the accept + // frame — no correlated route exists yet at start-frame time. + wrapKind, }, { sendNotification: this.deps.sendNotification, diff --git a/src/transport/nostr-server/outbound-response-router.test.ts b/src/transport/nostr-server/outbound-response-router.test.ts index 498dc13..1a27922 100644 --- a/src/transport/nostr-server/outbound-response-router.test.ts +++ b/src/transport/nostr-server/outbound-response-router.test.ts @@ -4,6 +4,7 @@ import type { JSONRPCMessage, } from '@contextvm/mcp-sdk/types.js'; import type { Logger } from '../../core/utils/logger.js'; +import type { NostrEvent } from 'nostr-tools'; import { EPHEMERAL_GIFT_WRAP_KIND } from '../../core/constants.js'; import { OutboundResponseRouter, @@ -48,6 +49,7 @@ interface CapturedDeps { function createRouterWithCapturedDeps( correlationStore: CorrelationStore, session: ClientSession, + options: { failSend?: boolean } = {}, ): CapturedDeps { const chooseCalls: CapturedDeps['chooseCalls'] = []; const sentGiftWrapKinds: CapturedDeps['sentGiftWrapKinds'] = []; @@ -82,6 +84,9 @@ function createRouterWithCapturedDeps( _onCreateEvent?: (eventId: string) => void, giftWrapKind?: number, ) => { + if (options.failSend) { + throw new Error('send failed'); + } sentGiftWrapKinds.push(giftWrapKind); return 'inner-event-id'; }, @@ -160,4 +165,44 @@ describe('OutboundResponseRouter.routeTargeted', () => { expect(chooseCalls).toHaveLength(0); expect(sentGiftWrapKinds).toHaveLength(0); }); + + test('re-registers the route with its request event when the send fails', async () => { + const requestEvent = { + id: 'evt-a3', + pubkey: CLIENT_PUBKEY, + sig: 'sig', + kind: 15, + tags: [], + content: '{}', + created_at: 0, + } as NostrEvent; + const correlationStore = new CorrelationStore({}); + correlationStore.registerEventRoute( + 'evt-a3', + CLIENT_PUBKEY, + 'original-request-id', + undefined, + EPHEMERAL_GIFT_WRAP_KIND, + requestEvent, + ); + const { deps } = createRouterWithCapturedDeps( + correlationStore, + createSession(), + { failSend: true }, + ); + + await expect( + new OutboundResponseRouter(deps).route({ + jsonrpc: '2.0', + id: 'evt-a3', + result: {}, + }), + ).rejects.toThrow('send failed'); + + // The retry path must restore the full route, including the signed + // request event exposed via getNostrRequestEvent(). + const restored = correlationStore.getEventRoute('evt-a3'); + expect(restored?.wrapKind).toBe(EPHEMERAL_GIFT_WRAP_KIND); + expect(correlationStore.getRequestEvent('evt-a3')).toBe(requestEvent); + }); }); diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index cf4e57a..130b8e5 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -287,6 +287,7 @@ export class OutboundResponseRouter { route.originalRequestId, route.progressToken, route.wrapKind, + route.requestEvent, ); throw error; } diff --git a/src/transport/nostr-server/oversized-server-handler.test.ts b/src/transport/nostr-server/oversized-server-handler.test.ts index 2d8778b..ce8af34 100644 --- a/src/transport/nostr-server/oversized-server-handler.test.ts +++ b/src/transport/nostr-server/oversized-server-handler.test.ts @@ -56,6 +56,40 @@ describe('oversized server handler', () => { }); }); + test('mirrors the oversized request wrap kind onto the accept frame', async () => { + const calls: Array<{ + clientPubkey: string; + correlatedEventId?: string; + wrapKindHint?: number; + }> = []; + + await sendAcceptFrame( + { + clientPubkey: 'c'.repeat(64), + progressToken: 'accept-token', + wrapKind: 21059, + }, + { + sendNotification: async ( + clientPubkey: string, + _notification: JSONRPCMessage, + correlatedEventId?: string, + wrapKindHint?: number, + ): Promise => { + calls.push({ clientPubkey, correlatedEventId, wrapKindHint }); + }, + }, + ); + + expect(calls).toEqual([ + { + clientPubkey: 'c'.repeat(64), + correlatedEventId: undefined, + wrapKindHint: 21059, + }, + ]); + }); + test('publishes start, chunk, and end frames using the correct tag sets', async () => { const publishedFrames: Array<{ frameType: string; diff --git a/src/transport/nostr-server/oversized-server-handler.ts b/src/transport/nostr-server/oversized-server-handler.ts index 7063655..6acff9b 100644 --- a/src/transport/nostr-server/oversized-server-handler.ts +++ b/src/transport/nostr-server/oversized-server-handler.ts @@ -31,6 +31,7 @@ export interface OversizedAcceptFrameDeps { clientPubkey: string, notification: JSONRPCMessage, correlatedEventId?: string, + wrapKindHint?: number, ) => Promise; } @@ -107,6 +108,8 @@ export async function sendOversizedServerResponse( export interface SendAcceptFrameOptions { clientPubkey: string; progressToken: string; + /** Wrap kind of the inbound oversized request, mirrored onto the accept frame. */ + wrapKind?: number; } /** @@ -133,5 +136,10 @@ export async function sendAcceptFrame( params: acceptParams, }; - await deps.sendNotification(options.clientPubkey, notification); + await deps.sendNotification( + options.clientPubkey, + notification, + undefined, + options.wrapKind, + ); } diff --git a/src/transport/wrap-kind-mirror.e2e.test.ts b/src/transport/wrap-kind-mirror.e2e.test.ts new file mode 100644 index 0000000..a138c94 --- /dev/null +++ b/src/transport/wrap-kind-mirror.e2e.test.ts @@ -0,0 +1,190 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from 'nostr-tools/pure'; +import { bytesToHex, hexToBytes } from 'nostr-tools/utils'; +import type { NostrEvent } from 'nostr-tools'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode } from '../core/interfaces.js'; +import { + CTXVM_MESSAGES_KIND, + EPHEMERAL_GIFT_WRAP_KIND, + GIFT_WRAP_KIND, +} from '../core/constants.js'; +import { encryptMessage } from '../core/encryption.js'; +import { waitFor } from '../core/utils/test.utils.js'; + +/** + * Invariant: for a session established via an ephemeral-wrapped request + * (kind 21059) whose client advertises no capability tags, the server must + * never publish a relay-stored gift wrap (kind 1059) back to that client — + * across every outbound form: normal responses (route), progress + * notifications (sendNotification with a correlated route), and targeted + * early-rejection responses (sendTargetedResponse). + * + * This pins the wrap-kind mirroring policy path-agnostically: a future send + * path that forgets to thread the request's wrap kind shows up here as a + * persistent 1059 event. + */ +describe.serial('server wrap-kind mirroring (OPTIONAL, divergence config)', () => { + let relayHub: MockRelayHub; + let server: McpServer; + let serverTransport: NostrServerTransport; + let serverPubkey: string; + let clientPubkey: string; + let clientSecretKey: Uint8Array; + let rawPublisher: ReturnType; + /** Wrap-kinded events the server published addressed to the raw client. */ + const serverWrapKinds: number[] = []; + let toolStarted: (() => void) | undefined; + let finishTool: (() => void) | undefined; + + /** Builds, signs, wraps (21059, no capability tags), and publishes a request. */ + const sendRawWrappedRequest = (request: object): NostrEvent => { + const inner = finalizeEvent( + { + kind: CTXVM_MESSAGES_KIND, + content: JSON.stringify(request), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }, + clientSecretKey, + ); + const wrap = encryptMessage( + JSON.stringify(inner), + serverPubkey, + EPHEMERAL_GIFT_WRAP_KIND, + ); + void rawPublisher.publish(wrap); + return inner; + }; + + beforeAll(async () => { + relayHub = new MockRelayHub(); + + const serverPrivateKey = bytesToHex(generateSecretKey()); + serverPubkey = getPublicKey(hexToBytes(serverPrivateKey)); + const clientPrivateKey = bytesToHex(generateSecretKey()); + clientSecretKey = hexToBytes(clientPrivateKey); + clientPubkey = getPublicKey(clientSecretKey); + + server = new McpServer({ name: 'MirrorServer', version: '1.0.0' }); + server.registerTool( + 'slow', + { title: 'slow', description: 'waits for the test' }, + async () => { + toolStarted?.(); + await new Promise((resolve) => { + finishTool = resolve; + }); + return { content: [{ type: 'text', text: 'ok' }] }; + }, + ); + + // giftWrapMode defaults to OPTIONAL — the divergence configuration. + serverTransport = new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: relayHub.createRelayHandler(), + encryptionMode: EncryptionMode.OPTIONAL, + serverInfo: {}, + }); + await server.connect(serverTransport); + + rawPublisher = relayHub.createRelayHandler(); + const collector = relayHub.createRelayHandler(); + await collector.subscribe( + [{ kinds: [GIFT_WRAP_KIND, EPHEMERAL_GIFT_WRAP_KIND] }], + (event) => { + if (event.tags.some((t) => t[0] === 'p' && t[1] === clientPubkey)) { + serverWrapKinds.push(event.kind); + } + }, + ); + + // MCP handshake from the raw client: initialize → response, then the + // initialized notification so tools/call is accepted. + sendRawWrappedRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'raw-client', version: '0.0.0' }, + }, + }); + await waitFor({ + produce: () => + serverWrapKinds.filter((k) => k === EPHEMERAL_GIFT_WRAP_KIND).length >= 1 + ? true + : undefined, + }); + sendRawWrappedRequest({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }); + }); + + afterAll(async () => { + await server.close(); + relayHub.clear(); + }); + + test('every server outbound form mirrors the ephemeral request wrap', async () => { + const toolStartedPromise = new Promise((resolve) => { + toolStarted = resolve; + }); + const baseline = serverWrapKinds.length; + + // In-flight request: its route stays registered while the tool runs. + const callInner = sendRawWrappedRequest({ + jsonrpc: '2.0', + id: 'call-1', + method: 'tools/call', + params: { name: 'slow', arguments: {} }, + }); + await toolStartedPromise; + + // Progress notification correlated to the live route (route lookup path). + await serverTransport.sendNotification( + clientPubkey, + { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'tok-1', progress: 1 }, + }, + callInner.id, + ); + + // Targeted early-rejection response (explicit-gating form). + await serverTransport.sendTargetedResponse( + clientPubkey, + { + jsonrpc: '2.0', + id: 'call-1', + error: { code: -32042, message: 'Payment Required' }, + }, + callInner.id, + ); + + // Normal response once the tool completes (route() path). + finishTool?.(); + await waitFor({ + produce: () => + serverWrapKinds.length - baseline >= 3 ? true : undefined, + }); + + // The invariant: nothing the server addressed to this client was + // relay-stored, and all reply forms arrived as ephemeral wraps. + expect(serverWrapKinds).not.toContain(GIFT_WRAP_KIND); + expect( + serverWrapKinds.every((k) => k === EPHEMERAL_GIFT_WRAP_KIND), + ).toBe(true); + expect(serverWrapKinds.length - baseline).toBeGreaterThanOrEqual(3); + }, 15000); +}); From ce9026d2fcbb9fe083dacc2be911079099f8c97f Mon Sep 17 00:00:00 2001 From: ContextVM Date: Wed, 19 Aug 2026 13:04:38 +0100 Subject: [PATCH 3/4] refactor(transport): single wrap-kind decision helpers + cleanups - extract mirrorRequestWrapKind() into capability-negotiator and replace the two hand-rolled ternaries in the inbound coordinator (identical semantics: policy pins win, otherwise mirror the request's kind) - add a debug tripwire in sendMcpMessage when an OPTIONAL-mode encrypted send carries no wrap-kind hint, so future unmirrored send paths are grep-visible in debug logs - LruCache: evict correctly when the eldest key is falsy ('') - delete unused isHex64() --- src/core/utils/lru-cache.ts | 2 +- src/core/utils/utils.ts | 9 ---- src/transport/base-nostr-transport.ts | 13 ++++++ src/transport/capability-negotiator.test.ts | 42 ++++++++++++++++++- src/transport/capability-negotiator.ts | 22 ++++++++++ .../nostr-server/inbound-coordinator.ts | 28 +++++-------- 6 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/core/utils/lru-cache.ts b/src/core/utils/lru-cache.ts index 262ae13..1288fff 100644 --- a/src/core/utils/lru-cache.ts +++ b/src/core/utils/lru-cache.ts @@ -29,7 +29,7 @@ export class LruCache { this.cache.delete(key); } else if (this.cache.size >= this.capacity) { const firstKey = this.cache.keys().next().value; - if (firstKey) { + if (firstKey !== undefined) { const evictedValue = this.cache.get(firstKey); this.cache.delete(firstKey); if (evictedValue !== undefined && this.onEvict) { diff --git a/src/core/utils/utils.ts b/src/core/utils/utils.ts index a4877d3..559818e 100644 --- a/src/core/utils/utils.ts +++ b/src/core/utils/utils.ts @@ -102,15 +102,6 @@ export function withTimeout( }); } -/** - * Validates a string as a 64-character hex string. - * @param value - The string to validate - * @returns Whether the string is a valid hex string - */ -export function isHex64(value: string | undefined): value is string { - return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value); -} - /** * Transforms Date.now() to seconds. * @returns The current time in seconds diff --git a/src/transport/base-nostr-transport.ts b/src/transport/base-nostr-transport.ts index 9e335b4..ae39481 100644 --- a/src/transport/base-nostr-transport.ts +++ b/src/transport/base-nostr-transport.ts @@ -483,6 +483,19 @@ export abstract class BaseNostrTransport { if (shouldEncrypt) { // Optional transports may decide gift wrap kind upstream. // Default remains persistent kind (1059) for backwards compatibility. + if ( + giftWrapKind === undefined && + this.giftWrapMode === GiftWrapMode.OPTIONAL + ) { + // Drift tripwire: every OPTIONAL-mode send path should pass a + // wrap-kind hint (or accept the session/route-based default + // deliberately). An unexpected miss here silently persists an + // ephemeral exchange — grep for this message in debug logs. + this.logger.debug( + 'Encrypted send without wrap-kind hint in OPTIONAL mode; defaulting to persistent gift wrap', + { kind, recipient: recipientPublicKey }, + ); + } const encryptedEvent = this.buildPublishedEventFromSignedEvent( event, recipientPublicKey, diff --git a/src/transport/capability-negotiator.test.ts b/src/transport/capability-negotiator.test.ts index 1f9cf0c..0ed5c01 100644 --- a/src/transport/capability-negotiator.test.ts +++ b/src/transport/capability-negotiator.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from 'bun:test'; -import { ClientCapabilityNegotiator } from './capability-negotiator.js'; +import { + ClientCapabilityNegotiator, + mirrorRequestWrapKind, +} from './capability-negotiator.js'; import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { + EPHEMERAL_GIFT_WRAP_KIND, + GIFT_WRAP_KIND, +} from '../core/constants.js'; describe('ClientCapabilityNegotiator', () => { test('should not consume payment_interaction tag during measurement calls', () => { @@ -129,3 +136,36 @@ describe('ClientCapabilityNegotiator', () => { ).toBe(true); }); }); + +describe('mirrorRequestWrapKind', () => { + test('returns undefined for unencrypted replies regardless of policy', () => { + expect( + mirrorRequestWrapKind(false, GiftWrapMode.OPTIONAL, GIFT_WRAP_KIND), + ).toBeUndefined(); + expect( + mirrorRequestWrapKind(false, GiftWrapMode.EPHEMERAL, GIFT_WRAP_KIND), + ).toBeUndefined(); + }); + + test('pins the wrap kind under EPHEMERAL and PERSISTENT policies', () => { + expect( + mirrorRequestWrapKind(true, GiftWrapMode.EPHEMERAL, GIFT_WRAP_KIND), + ).toBe(EPHEMERAL_GIFT_WRAP_KIND); + expect( + mirrorRequestWrapKind(true, GiftWrapMode.PERSISTENT, EPHEMERAL_GIFT_WRAP_KIND), + ).toBe(GIFT_WRAP_KIND); + }); + + test('mirrors the request wrap kind under OPTIONAL policy', () => { + expect( + mirrorRequestWrapKind( + true, + GiftWrapMode.OPTIONAL, + EPHEMERAL_GIFT_WRAP_KIND, + ), + ).toBe(EPHEMERAL_GIFT_WRAP_KIND); + expect( + mirrorRequestWrapKind(true, GiftWrapMode.OPTIONAL, undefined), + ).toBeUndefined(); + }); +}); diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts index 894f9ac..c3b8c3f 100644 --- a/src/transport/capability-negotiator.ts +++ b/src/transport/capability-negotiator.ts @@ -105,6 +105,28 @@ export function learnPeerCapabilities( }; } +/** + * Resolves the wrap kind for a direct reply by mirroring the request's wrap. + * + * Unlike the capability-aware ladders in the negotiator classes, this is a + * pure mirror: server policy pins (EPHEMERAL/PERSISTENT) win, otherwise the + * kind the request arrived in is echoed. Used on pre-session early-rejection + * paths (unauthorized, unsupported payment_interaction) where no session + * capability state exists yet. + */ +export function mirrorRequestWrapKind( + isEncrypted: boolean, + giftWrapMode: GiftWrapMode, + wrapKind?: number, +): number | undefined { + if (!isEncrypted) return undefined; + if (giftWrapMode === GiftWrapMode.EPHEMERAL) { + return EPHEMERAL_GIFT_WRAP_KIND; + } + if (giftWrapMode === GiftWrapMode.PERSISTENT) return GIFT_WRAP_KIND; + return wrapKind; +} + /** * Manages capability discovery and negotiation for the server transport. */ diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts index 12d31ca..2eefe25 100644 --- a/src/transport/nostr-server/inbound-coordinator.ts +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -18,11 +18,9 @@ import { injectClientPubkey, injectRequestEventId, } from '../../core/utils/utils.js'; -import { learnPeerCapabilities } from '../capability-negotiator.js'; +import { learnPeerCapabilities, mirrorRequestWrapKind } from '../capability-negotiator.js'; import { CTXVM_MESSAGES_KIND, - EPHEMERAL_GIFT_WRAP_KIND, - GIFT_WRAP_KIND, INITIALIZE_METHOD, NOTIFICATIONS_INITIALIZED_METHOD, } from '../../core/index.js'; @@ -135,13 +133,11 @@ export class ServerInboundCoordinator { tags, isEncrypted, undefined, - isEncrypted - ? this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL - ? EPHEMERAL_GIFT_WRAP_KIND - : this.deps.giftWrapMode === GiftWrapMode.PERSISTENT - ? GIFT_WRAP_KIND - : wrapKind - : undefined, + mirrorRequestWrapKind( + isEncrypted, + this.deps.giftWrapMode, + wrapKind, + ), ) .catch((err) => { this.deps.logger.error('Failed to send unauthorized response', { @@ -246,13 +242,11 @@ export class ServerInboundCoordinator { tags, isEncrypted, undefined, - isEncrypted - ? this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL - ? EPHEMERAL_GIFT_WRAP_KIND - : this.deps.giftWrapMode === GiftWrapMode.PERSISTENT - ? GIFT_WRAP_KIND - : wrapKind - : undefined, + mirrorRequestWrapKind( + isEncrypted, + this.deps.giftWrapMode, + wrapKind, + ), ) .catch((err) => { this.deps.logger.error( From 64a27dceccff340feb242fa1d1a0b01452e8891b Mon Sep 17 00:00:00 2001 From: ContextVM Date: Wed, 19 Aug 2026 13:07:22 +0100 Subject: [PATCH 4/4] chore: add changeset for wrap-kind mirroring fixes --- .changeset/wrap-kind-mirroring.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/wrap-kind-mirroring.md diff --git a/.changeset/wrap-kind-mirroring.md b/.changeset/wrap-kind-mirroring.md new file mode 100644 index 0000000..551080d --- /dev/null +++ b/.changeset/wrap-kind-mirroring.md @@ -0,0 +1,9 @@ +--- +'@contextvm/sdk': patch +--- + +Mirror the request's gift-wrap kind on all server send paths in `GiftWrapMode.OPTIONAL`. + +Previously only `route()` mirrored the wrap kind recorded for the client's request; `routeTargeted()`, `sendNotification()` (progress notifications), and the CEP-22 oversized accept frame all defaulted to the persistent gift wrap (kind 1059). A client that sent its request as an ephemeral gift wrap (kind 21059) without advertising the `support_encryption_ephemeral` capability tag could therefore get relay-stored replies — including the `-32042`/`-32043` explicit-gating errors that are frequently the first message a stateless client receives. Content was already NIP-44 encrypted either way; the divergence only affected relay persistence and metadata exposure. + +All server outbound forms now mirror: targeted and correlated-notification paths look up the wrap kind from the recorded request route, and the accept frame threads the inbound wrap kind directly (no route exists at start-frame time). Also: `route()`'s send-failure retry now restores the full route including the signed request event, duplicated wrap-kind ternaries in the inbound coordinator were extracted into one `mirrorRequestWrapKind()` helper, and a debug-level tripwire logs hint-less encrypted sends in OPTIONAL mode so future unmirrored paths are grep-visible. No behavior change for sessions with the ephemeral capability tag, pinned `EPHEMERAL`/`PERSISTENT` policies, or unencrypted transports.