Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/wrap-kind-mirroring.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/core/utils/lru-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class LruCache<T> {
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) {
Expand Down
9 changes: 0 additions & 9 deletions src/core/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,6 @@ export function withTimeout<T>(
});
}

/**
* 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
Expand Down
13 changes: 13 additions & 0 deletions src/transport/base-nostr-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 41 additions & 1 deletion src/transport/capability-negotiator.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
22 changes: 22 additions & 0 deletions src/transport/capability-negotiator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
10 changes: 10 additions & 0 deletions src/transport/nostr-server-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (this.openStreamFactory.isClientEvicted(clientPubkey)) {
throw new Error(`No active session found for client: ${clientPubkey}`);
Expand All @@ -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(
Expand Down
28 changes: 11 additions & 17 deletions src/transport/nostr-server/inbound-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/transport/nostr-server/inbound-notification-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface InboundNotificationDispatcherDeps {
sendNotification: (
clientPubkey: string,
notification: JSONRPCMessage,
correlatedEventId?: string,
wrapKindHint?: number,
) => Promise<void>;
handleIncomingRequest: (
event: NostrEvent,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading