diff --git a/bun.lock b/bun.lock index 179c9f2..75425e7 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@noble/hashes": "^2.2.0", "applesauce-relay": "^5.2.0", "canonicalize": "^2.1.0", + "json-canonicalize": "^2.0.0", "nostr-tools": "~2.18.2", "pino": "^10.3.1", "rxjs": "^7.8.2", @@ -495,6 +496,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-canonicalize": ["json-canonicalize@2.0.0", "", {}, "sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], diff --git a/src/core/constants.ts b/src/core/constants.ts index 4a4ea17..f0633cc 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -121,6 +121,9 @@ export const NOSTR_TAGS = { * Support CEP-41 open-ended stream transfer via notifications/progress framing. */ SUPPORT_OPEN_STREAM: 'support_open_stream', + + /** CEP-8 payment interaction negotiation tag. */ + PAYMENT_INTERACTION: 'payment_interaction', } as const; export const DEFAULT_LRU_SIZE = 5000; diff --git a/src/payments/authorization-store.test.ts b/src/payments/authorization-store.test.ts new file mode 100644 index 0000000..d5aa150 --- /dev/null +++ b/src/payments/authorization-store.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'bun:test'; +import { AuthorizationStore } from './authorization-store.js'; +import type { CanonicalInvocationIdentity } from './types.js'; + +describe('AuthorizationStore', () => { + const identity: CanonicalInvocationIdentity = { + clientPubkey: 'client-1', + invocationHash: 'hash-1', + }; + + test('grant and claim a single authorization', () => { + const store = new AuthorizationStore(); + + expect(store.claim(identity)).toBe(false); + + store.grant(identity, 10000); + + expect(store.claim(identity)).toBe(true); + expect(store.claim(identity)).toBe(false); + }); + + test('grant multiple executions', () => { + const store = new AuthorizationStore(); + + store.grant(identity, 10000, 2); + + expect(store.claim(identity)).toBe(true); + expect(store.claim(identity)).toBe(true); + expect(store.claim(identity)).toBe(false); + }); + + test('claim fails after TTL expires', async () => { + const store = new AuthorizationStore(); + + store.grant(identity, 50); + + await new Promise((resolve) => setTimeout(resolve, 75)); + + expect(store.claim(identity)).toBe(false); + }); + + test('trySetPending prevents concurrent duplicates', () => { + const store = new AuthorizationStore(); + + // First call transitions to pending -> true + expect(store.trySetPending(identity, 10000)).toBe(true); + + // Second call is blocked -> false + expect(store.trySetPending(identity, 10000)).toBe(false); + + // hasPending should reflect the state + expect(store.hasPending(identity)).toBe(true); + }); + + test('trySetPending allows setting again after clearPending', () => { + const store = new AuthorizationStore(); + + expect(store.trySetPending(identity, 10000)).toBe(true); + expect(store.trySetPending(identity, 10000)).toBe(false); + + store.clearPending(identity); + + expect(store.trySetPending(identity, 10000)).toBe(true); + }); + + test('trySetPending allows setting again after pending state expires', async () => { + const store = new AuthorizationStore(); + + expect(store.trySetPending(identity, 50)).toBe(true); + expect(store.trySetPending(identity, 50)).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 75)); + + expect(store.trySetPending(identity, 50)).toBe(true); + }); + + test('grant clears pending state', () => { + const store = new AuthorizationStore(); + + store.trySetPending(identity, 10000); + expect(store.hasPending(identity)).toBe(true); + + store.grant(identity, 10000); + + expect(store.hasPending(identity)).toBe(false); + expect(store.claim(identity)).toBe(true); + }); + + test('LRU eviction works when maxEntries is exceeded', () => { + const store = new AuthorizationStore({ maxEntries: 2 }); + + const id1 = { clientPubkey: 'client', invocationHash: 'h1' }; + const id2 = { clientPubkey: 'client', invocationHash: 'h2' }; + const id3 = { clientPubkey: 'client', invocationHash: 'h3' }; + + store.grant(id1, 10000); + store.grant(id2, 10000); + store.grant(id3, 10000); // This should evict id1 + + expect(store.claim(id1)).toBe(false); + expect(store.claim(id2)).toBe(true); + expect(store.claim(id3)).toBe(true); + }); + + test('pending LRU eviction works when maxEntries is exceeded', () => { + const store = new AuthorizationStore({ maxEntries: 2 }); + + const id1 = { clientPubkey: 'client', invocationHash: 'p1' }; + const id2 = { clientPubkey: 'client', invocationHash: 'p2' }; + const id3 = { clientPubkey: 'client', invocationHash: 'p3' }; + + store.trySetPending(id1, 10000); + store.trySetPending(id2, 10000); + store.trySetPending(id3, 10000); // This should evict id1 + + expect(store.hasPending(id1)).toBe(false); + expect(store.hasPending(id2)).toBe(true); + expect(store.hasPending(id3)).toBe(true); + }); + + test('updatePendingTtl and getPendingRemainingMs behave correctly', async () => { + const store = new AuthorizationStore(); + + // (1) verify getPendingRemainingMs right after trySetPending + expect(store.trySetPending(identity, 100)).toBe(true); + const remainingAfterSet = store.getPendingRemainingMs(identity); + expect(remainingAfterSet).toBeGreaterThan(0); + expect(remainingAfterSet).toBeLessThanOrEqual(100); + + // (2) verify updatePendingTtl extends the pending TTL + store.updatePendingTtl(identity, 500); + const remainingAfterUpdate = store.getPendingRemainingMs(identity); + expect(remainingAfterUpdate).toBeGreaterThan(100); + expect(remainingAfterUpdate).toBeLessThanOrEqual(500); + + // (3) verify getPendingRemainingMs returns 0 after waiting past TTL + await new Promise((resolve) => setTimeout(resolve, 550)); + expect(store.getPendingRemainingMs(identity)).toBe(0); + + // (4) verify updatePendingTtl is a no-op when there is no active pending entry + store.updatePendingTtl(identity, 1000); + expect(store.getPendingRemainingMs(identity)).toBe(0); + + // And after clearPending + store.trySetPending(identity, 1000); + store.clearPending(identity); + store.updatePendingTtl(identity, 1000); + expect(store.getPendingRemainingMs(identity)).toBe(0); + }); +}); diff --git a/src/payments/authorization-store.ts b/src/payments/authorization-store.ts new file mode 100644 index 0000000..f739e6e --- /dev/null +++ b/src/payments/authorization-store.ts @@ -0,0 +1,180 @@ +import type { CanonicalInvocationIdentity } from './types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; +import { createLogger } from '../core/utils/logger.js'; + +export interface PaidAuthorization { + /** Composite key: `${clientPubkey}:${invocationHash}` */ + key: string; + expiresAtMs: number; + /** Number of remaining executions (usually 1). */ + remaining: number; +} + +/** + * A bounded, TTL-aware store for explicit gating authorizations. + * It manages both the pending state (waiting for payment verification) + * and the granted state (paid and ready to consume). + * + * NOTE: The atomicity provided by `trySetPending` relies on in-memory maps, + * meaning it is strictly single-process. For multi-process horizontal scaling, + * implementers should use a distributed lock (e.g. Redis Redlock) keyed by + * the canonical invocation identity to prevent duplicate payments. + */ +export class AuthorizationStore { + private readonly authorizations: LruCache; + private readonly pending: LruCache; // Map of key -> expiresAtMs + private readonly logger = createLogger('authorization-store'); + + constructor(opts?: { maxEntries?: number }) { + const maxEntries = opts?.maxEntries ?? 5000; + this.authorizations = new LruCache(maxEntries); + this.pending = new LruCache(maxEntries); + } + + private getKey(identity: CanonicalInvocationIdentity): string { + return `${identity.clientPubkey}:${identity.invocationHash}`; + } + + /** + * Records a paid authorization. + */ + public grant( + identity: CanonicalInvocationIdentity, + ttlMs: number, + count: number = 1, + ): void { + if (count <= 0) { + throw new RangeError('Authorization count must be greater than 0'); + } + + const key = this.getKey(identity); + const expiresAtMs = Date.now() + ttlMs; + + this.authorizations.set(key, { + key, + expiresAtMs, + remaining: count, + }); + + // Once granted, it's no longer pending + this.pending.delete(key); + + this.logger.debug('authorization granted', { + key, + ttlMs, + count, + }); + } + + /** + * Atomically claims one execution authorization. + * Returns true if claimed, false if none available. + */ + public claim(identity: CanonicalInvocationIdentity): boolean { + const key = this.getKey(identity); + const auth = this.authorizations.get(key); + + if (!auth) { + return false; + } + + if (Date.now() > auth.expiresAtMs) { + this.authorizations.delete(key); + return false; + } + + if (auth.remaining > 0) { + auth.remaining -= 1; + if (auth.remaining === 0) { + this.authorizations.delete(key); + } else { + // Explicitly delete and set to guarantee LRU position is refreshed + this.authorizations.delete(key); + this.authorizations.set(key, auth); + } + this.logger.debug('authorization claimed', { key, remaining: auth.remaining }); + return true; + } + + return false; + } + + /** + * Atomically checks whether a payment is already pending for this identity + * and, if not, marks it as pending. Returns `true` if this call transitioned + * the identity to pending (caller should emit -32042). Returns `false` if + * already pending (caller should emit -32043). + * + * This atomic check-and-set prevents concurrent requests from both receiving + * -32042 and triggering duplicate payment flows. + * NOTE: This is single-process only. Distributed setups must use an external lock. + */ + public trySetPending(identity: CanonicalInvocationIdentity, ttlMs: number): boolean { + const key = this.getKey(identity); + const now = Date.now(); + + const existingExpiry = this.pending.get(key); + if (existingExpiry !== undefined) { + if (now > existingExpiry) { + // Expired pending state, we can overwrite it + this.pending.delete(key); + } else { + // Already pending and active + return false; + } + } + + this.pending.set(key, now + ttlMs); + this.logger.debug('authorization marked pending', { key, ttlMs }); + return true; + } + + /** Checks if a payment is pending (not yet authorized). */ + public hasPending(identity: CanonicalInvocationIdentity): boolean { + const key = this.getKey(identity); + const expiry = this.pending.get(key); + + if (expiry === undefined) { + return false; + } + + if (Date.now() > expiry) { + this.pending.delete(key); + return false; + } + + return true; + } + + /** + * Updates the TTL of an already pending authorization. No-op if not pending. + * + * @param identity The canonical invocation identity. + * @param ttlMs The new TTL in milliseconds to apply from now. + * @returns void + */ + public updatePendingTtl(identity: CanonicalInvocationIdentity, ttlMs: number): void { + const key = this.getKey(identity); + const existingExpiry = this.pending.get(key); + if (existingExpiry !== undefined && Date.now() <= existingExpiry) { + this.pending.set(key, Date.now() + ttlMs); + this.logger.debug('authorization pending TTL updated', { key, ttlMs }); + } + } + + /** Gets the remaining TTL in milliseconds for a pending authorization, or 0 if not pending. */ + public getPendingRemainingMs(identity: CanonicalInvocationIdentity): number { + const key = this.getKey(identity); + const expiry = this.pending.get(key); + if (expiry === undefined) return 0; + const remaining = expiry - Date.now(); + return remaining > 0 ? remaining : 0; + } + + /** Clears pending state (e.g. on verification failure or expiry). */ + public clearPending(identity: CanonicalInvocationIdentity): void { + const key = this.getKey(identity); + this.pending.delete(key); + this.logger.debug('authorization pending state cleared', { key }); + } +} diff --git a/src/payments/canonical-identity.test.ts b/src/payments/canonical-identity.test.ts new file mode 100644 index 0000000..b2fb7b2 --- /dev/null +++ b/src/payments/canonical-identity.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test'; +import { + computeCanonicalInvocationHash, + computeCanonicalInvocationIdentity, +} from './canonical-identity.js'; + +describe('Canonical Invocation Identity', () => { + describe('computeCanonicalInvocationHash', () => { + test('is deterministic regardless of object key order', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + a: 1, + b: 2, + name: 'test', + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + name: 'test', + b: 2, + a: 1, + }); + + expect(hash1).toBe(hash2); + // Ensure we're getting a hex string + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + test('handles empty params', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', undefined); + const hash2 = computeCanonicalInvocationHash('tools/call', null); + + expect(hash1).not.toBe(hash2); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + test('handles nested objects deterministically', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + nested: { z: 1, y: 2, x: 3 }, + arr: [1, 2, 3], + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + arr: [1, 2, 3], + nested: { x: 3, z: 1, y: 2 }, + }); + + expect(hash1).toBe(hash2); + }); + + test('handles unicode correctly', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + text: 'Hello 🌍', + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + text: 'Hello 🌍', + }); + + expect(hash1).toBe(hash2); + }); + + test('differs for different methods', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { a: 1 }); + const hash2 = computeCanonicalInvocationHash('prompts/get', { a: 1 }); + + expect(hash1).not.toBe(hash2); + }); + + test('differs for different param values', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { a: 1 }); + const hash2 = computeCanonicalInvocationHash('tools/call', { a: 2 }); + + expect(hash1).not.toBe(hash2); + }); + }); + + describe('computeCanonicalInvocationIdentity', () => { + test('combines pubkey and hash correctly', () => { + const pubkey = 'test-client-pubkey'; + const method = 'tools/call'; + const params = { name: 'test' }; + + const identity = computeCanonicalInvocationIdentity(pubkey, method, params); + + expect(identity.clientPubkey).toBe(pubkey); + expect(identity.invocationHash).toBe(computeCanonicalInvocationHash(method, params)); + }); + }); +}); diff --git a/src/payments/canonical-identity.ts b/src/payments/canonical-identity.ts new file mode 100644 index 0000000..40fb7cd --- /dev/null +++ b/src/payments/canonical-identity.ts @@ -0,0 +1,48 @@ +import canonicalizePackage from 'canonicalize'; +type CanonicalizeFn = (input: unknown) => string | undefined; +const canonicalize = canonicalizePackage as unknown as CanonicalizeFn; +import { createHash } from 'crypto'; +import type { CanonicalInvocationIdentity } from './types.js'; + +/** + * Computes a deterministic SHA-256 hash of an invocation's method and parameters. + * Uses RFC 8785 JSON Canonicalization Scheme (JCS) to ensure structurally + * identical JSON objects produce the same hash regardless of key ordering. + * + * @param method - The JSON-RPC method (e.g. 'tools/call') + * @param params - The JSON-RPC parameters + * @returns A hex-encoded SHA-256 hash string + */ +export function computeCanonicalInvocationHash( + method: string, + params: unknown, +): string { + const payload = { method, params }; + const canonicalString = canonicalize(payload); + if (canonicalString === undefined) { + throw new Error('Failed to canonicalize invocation payload'); + } + + return createHash('sha256') + .update(canonicalString) + .digest('hex'); +} + +/** + * Computes the canonical invocation identity for explicit-gating authorization matching. + * + * @param clientPubkey - The client's public key + * @param method - The JSON-RPC method + * @param params - The JSON-RPC parameters + * @returns The computed identity + */ +export function computeCanonicalInvocationIdentity( + clientPubkey: string, + method: string, + params: unknown, +): CanonicalInvocationIdentity { + return { + clientPubkey, + invocationHash: computeCanonicalInvocationHash(method, params), + }; +} diff --git a/src/payments/client-payments.test.ts b/src/payments/client-payments.test.ts index d3cab64..98126b6 100644 --- a/src/payments/client-payments.test.ts +++ b/src/payments/client-payments.test.ts @@ -593,4 +593,175 @@ describe('withClientPayments()', () => { await paid.close(); }); + + test('handles explicit gating -32042 error and retries request', async () => { + const transport = createMockNostrTransport(); + let sentMessage: JSONRPCMessage | undefined; + transport.send = async (msg) => { + sentMessage = msg; + }; + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-3', { + originalRequestId: 77, + isInitialize: false, + rawRequest: { jsonrpc: '2.0', id: 77, method: 'tools/call', params: { name: 'test' } }, + originalRequestContext: { method: 'tools/call' } + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ paid: true }), + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ jsonrpc: '2.0', id: 77, method: 'tools/call', params: { name: 'test' } }); + sentMessage = undefined; // Reset mock state so we can observe the retry + + // Deliver -32042 Payment Required error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 77, + error: { + code: -32042, + message: 'Payment Required', + data: { + payment_options: [{ amount: 10, pmi: 'fake', pay_req: 'pr1' }] + } + } + }, + { eventId: 'evt4', correlatedEventId: 'req-event-id-3' }, + ); + + // Wait for async processing + await new Promise((r) => setTimeout(r, 0)); + + // Error should not be delivered to caller + expect(observed).toHaveLength(0); + + // Original request should be retried + expect(sentMessage).toEqual({ jsonrpc: '2.0', id: 77, method: 'tools/call', params: { name: 'test' } }); + + await paid.close(); + }); + + test('propagates -32042 error if onPaymentRequired returns paid: false', async () => { + const transport = createMockNostrTransport(); + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-4', { + originalRequestId: 88, + isInitialize: false, + rawRequest: { jsonrpc: '2.0', id: 88, method: 'tools/call', params: { name: 'test' } }, + originalRequestContext: { method: 'tools/call' } + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ paid: false, reason: 'user_cancelled' }), + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ jsonrpc: '2.0', id: 88, method: 'tools/call', params: { name: 'test' } }); + + // Deliver -32042 Payment Required error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 88, + error: { + code: -32042, + message: 'Payment Required', + data: { + payment_options: [{ amount: 10, pmi: 'fake', pay_req: 'pr2' }] + } + } + }, + { eventId: 'evt5', correlatedEventId: 'req-event-id-4' }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + // Error should be delivered to caller with reason + expect(observed).toHaveLength(1); + const errResp = observed[0] as JSONRPCMessage; + expect(errResp.id).toBe(88); + expect('error' in errResp && errResp.error?.code).toBe(-32042); + expect('error' in errResp && (errResp.error?.data as { reason?: string })?.reason).toBe('user_cancelled'); + + await paid.close(); + }); + + test('handles explicit gating -32043 Payment Pending error and retries after backoff', async () => { + const transport = createMockNostrTransport(); + let sentMessage: JSONRPCMessage | undefined; + transport.send = async (msg) => { + sentMessage = msg; + }; + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-5', { + originalRequestId: 99, + isInitialize: false, + rawRequest: { jsonrpc: '2.0', id: 99, method: 'tools/call', params: { name: 'test_pending' } }, + originalRequestContext: { method: 'tools/call' } + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ jsonrpc: '2.0', id: 99, method: 'tools/call', params: { name: 'test_pending' } }); + sentMessage = undefined; // Reset mock state so we can observe the retry + + // Deliver -32043 Payment Pending error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 99, + error: { + code: -32043, + message: 'Payment Pending', + data: { + instructions: 'Wait and retry.', + retry_after: 0.05 // 50ms for test + } + } + }, + { eventId: 'evt6', correlatedEventId: 'req-event-id-5' }, + ); + + // Initial check: Should intercept error and wait + await new Promise((r) => setTimeout(r, 10)); + expect(observed).toHaveLength(0); + expect(sentMessage).toBeUndefined(); + + // Wait for retry_after timer to fire + await new Promise((r) => setTimeout(r, 60)); + + // Error should not be delivered to caller + expect(observed).toHaveLength(0); + + // Original request should be retried + expect(sentMessage).toEqual({ jsonrpc: '2.0', id: 99, method: 'tools/call', params: { name: 'test_pending' } }); + + await paid.close(); + }); }); diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 4d53105..007692c 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -5,6 +5,7 @@ import { isJSONRPCErrorResponse, JSONRPCNotification, type JSONRPCMessage, + type JSONRPCRequest, } from '@modelcontextprotocol/sdk/types.js'; import { NostrClientTransport } from '../transport/nostr-client-transport.js'; import { @@ -21,6 +22,8 @@ import { PAYMENT_ACCEPTED_METHOD, PAYMENT_REJECTED_METHOD, PAYMENT_REQUIRED_METHOD, + PAYMENT_REQUIRED_ERROR_CODE, + PAYMENT_PENDING_ERROR_CODE, } from './constants.js'; export interface ClientPaymentsOptions { @@ -56,6 +59,32 @@ export interface ClientPaymentsOptions { req: PaymentHandlerRequest, originalRequestContext?: OriginalRequestContext, ) => boolean | Promise; + + /** Requested payment interaction mode. @default 'transparent' */ + paymentInteraction?: import('./types.js').PaymentInteractionMode; + + /** + * Handler for explicit-gating -32042 errors. + * Called when a priced invocation returns Payment Required. + * The handler should pay one option and signal completion. + * + * **Error handling contract**: + * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the + * original request with the same `method` and `params`. + * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes + * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. + * Use `reason: 'user_cancelled'` for user-initiated cancellations. + * - If the promise **rejects**, the wrapper MUST NOT silently fall back. + * It synthesizes a JSON-RPC error with code `-32042` and + * `data: { reason: error.message, type: 'payment_handler_error' }`. + * - Transient payment-provider failures should reject with an Error whose + * `message` contains the provider error details. + */ + onPaymentRequired?: (params: { + options: import('./types.js').PaymentOption[]; + instructions?: string; + originalRequest: import('@modelcontextprotocol/sdk/types.js').JSONRPCRequest; + }) => Promise<{ paid: boolean; reason?: string }>; } type ProgressToken = string; @@ -82,6 +111,31 @@ function supportsOnmessageWithContext( ); } +function isExplicitPaymentRequiredError( + msg: JSONRPCMessage, +): msg is import('@modelcontextprotocol/sdk/types.js').JSONRPCErrorResponse { + return ( + isJSONRPCErrorResponse(msg) && + msg.error.code === PAYMENT_REQUIRED_ERROR_CODE && + typeof msg.error.data === 'object' && + msg.error.data !== null && + Array.isArray((msg.error.data as { payment_options?: unknown }).payment_options) && + ((msg.error.data as { payment_options: unknown[] }).payment_options).length > 0 + ); +} + +function isExplicitPaymentPendingError( + msg: JSONRPCMessage, +): msg is import('@modelcontextprotocol/sdk/types.js').JSONRPCErrorResponse { + return ( + isJSONRPCErrorResponse(msg) && + msg.error.code === PAYMENT_PENDING_ERROR_CODE && + typeof msg.error.data === 'object' && + msg.error.data !== null && + typeof (msg.error.data as { retry_after?: unknown }).retry_after === 'number' + ); +} + function isPaymentRequiredNotification( msg: JSONRPCMessage, ): msg is PaymentRequiredNotification { @@ -162,12 +216,23 @@ export function withClientPayments( maybeStopScheduler(); }; + const pendingTimers = new Set>(); + const retryCounts = new Map(); + const rawRequestCache = new Map(); + const MAX_RETRIES = 5; + const stopAllSyntheticProgress = (): void => { syntheticProgress.clear(); if (syntheticProgressScheduler) { clearInterval(syntheticProgressScheduler); syntheticProgressScheduler = undefined; } + for (const timer of pendingTimers) { + clearTimeout(timer); + } + pendingTimers.clear(); + retryCounts.clear(); + rawRequestCache.clear(); }; // Ensure CEP-8 discovery/negotiation: when using Nostr transports, always advertise @@ -177,6 +242,12 @@ export function withClientPayments( logger.debug('advertised client PMIs', { pmis: options.handlers.map((h) => h.pmi), }); + if (options.paymentInteraction === 'explicit_gating') { + transport.setPaymentInteraction('explicit_gating'); + logger.debug('advertised requested payment interaction mode', { + mode: 'explicit_gating', + }); + } } const handlersByPmi = new Map( @@ -205,6 +276,240 @@ export function withClientPayments( message: JSONRPCMessage, requestEventId: string, ): Promise { + if (isExplicitPaymentRequiredError(message)) { + // Explicit gating lifecycle (-32042 Payment Required) + const data = message.error.data as import('./types.js').PaymentRequiredErrorData; + + for (const option of data.payment_options) { + const handler = handlersByPmi.get(option.pmi); + if (!handler && !options.onPaymentRequired) continue; + + const isNostrTransport = transport instanceof NostrClientTransport; + const pending = isNostrTransport + ? transport.getPendingRequestForEventId(requestEventId) + : undefined; + + if (isNostrTransport && !pending) { + logger.warn('dropping uncorrelated explicit payment error', { + requestEventId, + pmi: option.pmi, + }); + onmessage?.(message); + return; + } + + const originalContext = pending?.originalRequestContext + ? { + method: pending.originalRequestContext.method, + capability: pending.originalRequestContext.capability, + id: pending.originalRequestId, + } + : undefined; + + const request: PaymentHandlerRequest = { + amount: option.amount, + pay_req: option.pay_req, + pmi: option.pmi, + description: option.description, + ttl: option.ttl, + _meta: option._meta, + requestEventId, + }; + + const allow = options.paymentPolicy + ? await options.paymentPolicy(request, originalContext) + : true; + + if (!allow) { + logger.debug('payment_required rejected by policy', { + requestEventId, + pmi: option.pmi, + }); + continue; // Try next option if rejected by policy + } + + const canHandle = handler?.canHandle + ? await handler.canHandle(request) + : true; + + if (!canHandle) { + logger.debug('payment_required cannot be handled by handler', { + requestEventId, + pmi: option.pmi, + }); + continue; // Try next option if handler can't handle + } + + logger.info('executing payment handler for explicit gating', { + requestEventId, + pmi: option.pmi, + amount: option.amount, + }); + + try { + // In explicit gating, we do NOT call handler.handle(request) directly. + // Instead, we delegate entirely to options.onPaymentRequired. + + // In explicit gating, the client MUST retry the exact same request + // to trigger authorization consumption and get the result. + // Since we intercepted the error, we need the original request. + // For NostrClientTransport, we don't have the original raw request cached perfectly, + // but we can reconstruct it or we should just let the error propagate + // and let the caller handle retry. + + if (!options.onPaymentRequired) { + // We have a payment required error but the transport level onPaymentRequired handler + // wasn't configured. The client didn't supply an explicit gating handler. + // We'll let the error propagate. + onmessage?.(message); + return; + } + + const requestId = message.id; + const rawRequest = requestId != null ? rawRequestCache.get(requestId) : undefined; + if (!rawRequest) { + logger.warn('missing raw original request, cannot retry explicit payment', { requestEventId }); + onmessage?.(message); + return; + } + + const result = await options.onPaymentRequired({ + options: data.payment_options, + instructions: data.instructions, + originalRequest: rawRequest, + }); + + if (result.paid) { + // Only if they successfully paid via onPaymentRequired do we proceed to retry + logger.info('explicit payment satisfied, retrying original request', { + requestEventId, + method: rawRequest.method, + }); + + // Re-send the exact request, updating the ID if necessary (or letting MCP SDK handle it) + // But actually we are the transport, we can just resend the raw request through the transport. + // Wait, we need to create a new ID so the proxy can track it properly. + // Oh right, we can't easily resend and magically stitch it back to the original Promise in the MCP Client. + // Actually, if we just send() it, the original promise in the MCP Client is already waiting + // for the response with the *original* ID. + // Wait, no, the server sent us an error response with the *original* ID. + // The MCP Client will resolve that promise with an Error. + // So we MUST NOT deliver the error response to `onmessage` if we want to intercept and retry. + // We intercepted the error! We haven't called `onmessage` yet. + // So if we just resend the raw request to the server, with a new requestEventId, + // we will need to map the NEW response back to the OLD request ID. + + // This requires transport level support. + // The plan says: "When onPaymentRequired returns { paid: true }, the wrapper re-sends the original JSONRPCRequest with the same method and params (new id is fine per spec). This is transparent to the upstream MCP Client." + // Wait, if it has a new id, how does the upstream MCP Client know it's the response? + // Actually, we must use the original ID when communicating with the upstream client. + // But when we send it to the server, we just pass the original request exactly as it was. + // We don't change the ID. The `NostrClientTransport` wraps the `id` inside a new `requestId`. + + await transport.send(rawRequest); + return; // WE SUCCESSFULLY RETRIED! Do not deliver the error to `onmessage`. + } else { + // User cancelled or returned paid=false + logger.debug('onPaymentRequired returned paid=false', { requestEventId, reason: result.reason }); + const errorMsg: import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage = { + jsonrpc: '2.0', + id: message.id, + error: { + code: PAYMENT_REQUIRED_ERROR_CODE, + message: 'Payment Required', + data: { reason: result.reason || 'user_cancelled' } + } + }; + onmessage?.(errorMsg); + return; + } + } catch (err) { + logger.error('payment handler failed', { + requestEventId, + pmi: option.pmi, + error: err instanceof Error ? err.message : String(err), + }); + // Spec: onPaymentRequired rejection MUST cause the original JSON-RPC request to fail. + const errorMsg: import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage = { + jsonrpc: '2.0', + id: message.id, + error: { + code: PAYMENT_REQUIRED_ERROR_CODE, + message: 'Payment Required', + data: { reason: err instanceof Error ? err.message : String(err), type: 'payment_handler_error' } + } + }; + onmessage?.(errorMsg); + return; + } + + // We handled (or attempted) the payment. Stop evaluating other options. + // If we failed, we break and the -32042 will be emitted to `onmessage`. + // Actually we already returned if we handled it. + } + + // If we got here, we either: + // 1. Paid successfully but we need to signal the caller to retry (if we don't retry ourselves) + // 2. Failed to pay (policy, unhandled, or error) + // In both cases, for now we will just emit the -32042 error to `onmessage` and let + // the caller retry. To implement transparent retry at the transport level, we'd need + // to cache every outbound request, which is expensive. + onmessage?.(message); + return; + } + + if (isExplicitPaymentPendingError(message)) { + const data = message.error.data as import('./types.js').PaymentPendingErrorData; + const retryAfterSeconds = data.retry_after; + + const isNostrTransport = transport instanceof NostrClientTransport; + const pending = isNostrTransport + ? transport.getPendingRequestForEventId(requestEventId) + : undefined; + + if (!isNostrTransport || !pending) { + logger.warn('dropping uncorrelated explicit payment pending error', { + requestEventId, + }); + onmessage?.(message); + return; + } + + const requestId = message.id; + const rawRequest = requestId != null ? rawRequestCache.get(requestId) : undefined; + if (!rawRequest) { + logger.warn('missing raw original request, cannot retry explicit payment pending', { requestEventId }); + onmessage?.(message); + return; + } + + const requestIdKey = message.id as string | number; + const retries = retryCounts.get(requestIdKey) ?? 0; + if (retries >= MAX_RETRIES) { + logger.error('max explicit payment retries exceeded', { requestEventId, id: requestIdKey, maxRetries: MAX_RETRIES }); + onmessage?.(message); + return; + } + + retryCounts.set(requestIdKey, retries + 1); + + logger.info('payment pending, retrying after backoff', { + requestEventId, + retryAfterSeconds, + retryCount: retries + 1, + }); + + const timer = setTimeout(() => { + pendingTimers.delete(timer); + transport.send(rawRequest).catch(err => { + logger.error('failed to retry pending request', { requestEventId, error: err instanceof Error ? err.message : String(err) }); + }); + }, (retryAfterSeconds ?? 1) * 1000); + pendingTimers.add(timer); + + return; // Intercept the error so the client waits + } + if (!isPaymentRequiredNotification(message)) { return; } @@ -430,6 +735,15 @@ export function withClientPayments( isJSONRPCErrorResponse(message) ) { stopSyntheticProgress(String(message.id)); + if (!isExplicitPaymentRequiredError(message) && !isExplicitPaymentPendingError(message)) { + const reqId = message.id as string | number; + rawRequestCache.delete(reqId); + retryCounts.delete(reqId); + } + } + + if (hasContextPath) { + return; } // Best-effort: execute handler asynchronously, but never block delivery. @@ -440,6 +754,12 @@ export function withClientPayments( }, ); + // If it's an explicit gating error, we intercept it here because + // maybeHandlePaymentRequired takes responsibility for re-emitting it if unhandled. + if (isExplicitPaymentRequiredError(message) || isExplicitPaymentPendingError(message)) { + return; + } + onmessage?.(message); }; @@ -491,6 +811,12 @@ export function withClientPayments( }, ); + // If it's an explicit gating error, we intercept it here because + // maybeHandlePaymentRequired takes responsibility for re-emitting it if unhandled. + if (isExplicitPaymentRequiredError(message) || isExplicitPaymentPendingError(message)) { + return; + } + // Forward exactly once (see duplicate-delivery guard in `transport.onmessage`). onmessage?.(message); }; @@ -505,6 +831,9 @@ export function withClientPayments( }, async send(message: JSONRPCMessage): Promise { + if ('method' in message && 'id' in message && message.id != null) { + rawRequestCache.set(message.id, message as JSONRPCRequest); + } await transport.send(message); }, diff --git a/src/payments/constants.ts b/src/payments/constants.ts index bd54f87..63b7add 100644 --- a/src/payments/constants.ts +++ b/src/payments/constants.ts @@ -22,3 +22,18 @@ export const PAYMENT_ACCEPTED_METHOD = 'notifications/payment_accepted'; /** CEP-8 notification method: server rejected payment (or refused to proceed). */ export const PAYMENT_REJECTED_METHOD = 'notifications/payment_rejected'; + +/** CEP-8 explicit-gating JSON-RPC error: payment required. */ +export const PAYMENT_REQUIRED_ERROR_CODE = -32042; + +/** CEP-8 explicit-gating JSON-RPC error: payment pending. */ +export const PAYMENT_PENDING_ERROR_CODE = -32043; + +/** + * CEP-8 unsupported payment_interaction negotiation error. + * + * Uses -32602 (Invalid params) as mandated by CEP-8 spec: the `payment_interaction` + * tag value is treated as an invalid parameter when the server does not support it. + * This is an intentional reuse of the standard JSON-RPC code, not a CEP-specific code. + */ +export const UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE = -32602; diff --git a/src/payments/server-explicit-gating.test.ts b/src/payments/server-explicit-gating.test.ts new file mode 100644 index 0000000..e26b0e1 --- /dev/null +++ b/src/payments/server-explicit-gating.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONRPCErrorResponse, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; +import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; +import { AuthorizationStore } from './authorization-store.js'; +import { PAYMENT_PENDING_ERROR_CODE, PAYMENT_REQUIRED_ERROR_CODE } from './constants.js'; + +describe('Explicit Gating Middleware', () => { + const processor = { + pmi: 'fake', + async createPaymentRequired(params: { + amount: number; + description?: string; + requestEventId: string; + clientPubkey: string; + }) { + return { + amount: params.amount, + pay_req: 'pay_req', + description: params.description, + pmi: 'fake', + ttl: 300, + _meta: { test: true }, + }; + }, + async verifyPayment() { + return { _meta: { ok: true } }; + }, + }; + + const pricedCapabilities = [ + { + method: 'tools/call', + name: 'add', + amount: 10, + currencyUnit: 'test', + description: 'listed', + }, + ] as const; + + const ctx: { clientPubkey: string; clientPmis?: readonly string[] } = { + clientPubkey: 'test-client', + }; + + const message: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event-id', + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 2 } }, + }; + + test('emits -32042 Payment Required on first request', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (pubkey, response) => { + sentResponses.push(response); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(false); + expect(sentResponses.length).toBe(1); + + const response = sentResponses[0]; + expect(response.error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + const data = response.error.data as { payment_options: { amount: number; pay_req: string }[] }; + expect(data.payment_options.length).toBe(1); + expect(data.payment_options[0].amount).toBe(10); + expect(data.payment_options[0].pay_req).toBe('pay_req'); + }); + + test('emits -32043 Payment Pending if already pending', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (pubkey, response) => { + sentResponses.push(response); + }, + }); + + await mw(message, ctx, async () => {}); + await mw(message, ctx, async () => {}); // Second call should be pending + + expect(sentResponses.length).toBe(2); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(sentResponses[1].error.code).toBe(PAYMENT_PENDING_ERROR_CODE); + }); + + test('forwards request if authorization is granted', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (pubkey, response) => { + sentResponses.push(response); + }, + }); + + // We fake the authorization grant + // The canonical identity depends on the method and params + // JCS of { method: "tools/call", params: { name: "add", arguments: { a: 1, b: 2 } } } + // We can just use the utility to compute it + const { computeCanonicalInvocationIdentity } = await import('./canonical-identity.js'); + const identity = computeCanonicalInvocationIdentity(ctx.clientPubkey, message.method, message.params); + store.grant(identity, 10000); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(sentResponses.length).toBe(0); + expect(forwarded).toBe(true); + + // Auth should be consumed, second call should trigger payment required + let forwarded2 = false; + await mw(message, ctx, async () => { + forwarded2 = true; + }); + + expect(forwarded2).toBe(false); + expect(sentResponses.length).toBe(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + }); + + test('forwards request directly if resolvePrice waives payment', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + resolvePrice: async () => ({ waive: true }), + }, + authorizationStore: store, + sendResponse: async (pubkey, response) => { + sentResponses.push(response); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(sentResponses.length).toBe(0); + expect(forwarded).toBe(true); + }); + + test('rejects request immediately if resolvePrice rejects', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + resolvePrice: async () => ({ reject: true, message: 'Rate limited' }), + }, + authorizationStore: store, + sendResponse: async (pubkey, response) => { + sentResponses.push(response as JSONRPCErrorResponse); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(false); + expect(sentResponses.length).toBe(1); + expect(sentResponses[0].error.code).toBe(-32000); + expect(sentResponses[0].error.message).toBe('Rate limited'); + }); +}); diff --git a/src/payments/server-explicit-gating.ts b/src/payments/server-explicit-gating.ts new file mode 100644 index 0000000..abfe896 --- /dev/null +++ b/src/payments/server-explicit-gating.ts @@ -0,0 +1,262 @@ +import type { JSONRPCErrorResponse } from '@modelcontextprotocol/sdk/types.js'; +import type { ServerMiddlewareFn } from './types.js'; +import { isJsonRpcRequest } from './types.js'; +import type { ServerPaymentsOptions } from './server-payments.js'; +import type { AuthorizationStore } from './authorization-store.js'; +import { computeCanonicalInvocationIdentity } from './canonical-identity.js'; +import { + getVerificationTimeoutMs, + matchPricedCapability, + isResolvePriceRejection, + isResolvePriceWaiver, +} from './server-payments-utils.js'; +import { createLogger } from '../core/utils/logger.js'; +import { withTimeout } from '../core/utils/utils.js'; +import { + PAYMENT_PENDING_ERROR_CODE, + PAYMENT_REQUIRED_ERROR_CODE, +} from './constants.js'; + +export interface ExplicitGatingMiddlewareParams { + options: ServerPaymentsOptions; + authorizationStore: AuthorizationStore; + sendResponse: ( + clientPubkey: string, + response: JSONRPCErrorResponse, + requestEventId: string, + ) => Promise; +} + +export function createExplicitGatingMiddleware( + params: ExplicitGatingMiddlewareParams, +): ServerMiddlewareFn { + const { options, authorizationStore, sendResponse } = params; + const logger = createLogger('server-explicit-gating'); + + const processorsByPmi = new Map( + options.processors.map((p) => [p.pmi, p] as const), + ); + + return async (message, ctx, forward) => { + // Only gate requests. + if (!isJsonRpcRequest(message)) { + await forward(message); + return; + } + + const priced = matchPricedCapability(message, options.pricedCapabilities); + if (!priced) { + await forward(message); + return; + } + + const requestEventId = String(message.id); + const identity = computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ); + + // 1. Try to claim an existing authorization + if (authorizationStore.claim(identity)) { + logger.debug('authorization claimed, forwarding request', { + requestEventId, + method: message.method, + }); + await forward(message); + return; + } + + const paymentTtlMs = options.paymentTtlMs ?? 300_000; + + // 2. Try to set pending state atomically + // We use a safe default TTL here, but will override it below if the payment option has a specific TTL + if (!authorizationStore.trySetPending(identity, paymentTtlMs)) { + logger.debug('payment already pending, returning -32043', { + requestEventId, + }); + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: message.id, + error: { + code: PAYMENT_PENDING_ERROR_CODE, + message: 'Payment Pending', + data: { + instructions: 'A payment is already pending for this invocation. Wait and retry.', + // Suggest a short polling interval (e.g. 2 seconds) rather than the full TTL + retry_after: Math.min(2, Math.ceil(authorizationStore.getPendingRemainingMs(identity) / 1000)) || 2, + }, + }, + }; + await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + return; + } + + // 3. Resolve price and initiate new payment + try { + const clientPmis = ctx.clientPmis; + const chosenPmi = clientPmis + ? clientPmis.find((pmi) => processorsByPmi.has(pmi)) + : undefined; + + const chosenProcessor = chosenPmi + ? processorsByPmi.get(chosenPmi) + : options.processors[0]; + + if (!chosenProcessor) { + throw new Error('No payment processors configured'); + } + + const processor = chosenProcessor; + + const quote = options.resolvePrice + ? await options.resolvePrice({ + capability: priced, + request: message, + clientPubkey: ctx.clientPubkey, + requestEventId, + }) + : { amount: priced.amount, description: priced.description }; + + if (isResolvePriceRejection(quote)) { + logger.info('payment rejected', { + requestEventId, + pmi: processor.pmi, + amount: priced.amount, + reason: quote.message, + }); + + authorizationStore.clearPending(identity); + + // Spec: When a capability is rejected by policy, return a standard error. + // We'll use -32000 (Internal error or application-defined error) since CEP-8 doesn't specify a special rejection code. + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: message.id, + error: { + code: -32000, + message: quote.message || 'Payment rejected by policy', + }, + }; + await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + return; + } + + if (isResolvePriceWaiver(quote)) { + logger.debug('payment waived, forwarding priced request', { + requestEventId, + method: message.method, + }); + + authorizationStore.clearPending(identity); + await forward(message); + return; + } + + const resolvedQuote = quote; + const paymentRequired = await processor.createPaymentRequired({ + amount: resolvedQuote.amount, + description: resolvedQuote.description, + requestEventId, + clientPubkey: ctx.clientPubkey, + }); + + const mergedMeta = + resolvedQuote.meta === undefined && + paymentRequired._meta === undefined + ? undefined + : { + ...(paymentRequired._meta ?? {}), + ...(resolvedQuote.meta ?? {}), + }; + + // Ensure pending TTL matches the payment request TTL + const verifyTimeoutMs = getVerificationTimeoutMs({ + ttlSeconds: paymentRequired.ttl, + }); + const effectiveTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); + + // Update pending with the precise TTL + authorizationStore.updatePendingTtl(identity, effectiveTimeoutMs); + + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: message.id, + error: { + code: PAYMENT_REQUIRED_ERROR_CODE, + message: 'Payment Required', + data: { + payment_options: [ + { + amount: paymentRequired.amount, + pmi: paymentRequired.pmi, + pay_req: paymentRequired.pay_req, + description: paymentRequired.description, + ttl: paymentRequired.ttl, + _meta: mergedMeta, + }, + ], + }, + }, + }; + + logger.info('payment required error sent', { + requestEventId, + pmi: paymentRequired.pmi, + amount: paymentRequired.amount, + ttl: paymentRequired.ttl, + }); + + await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + + // Start async verification + // Do not await this, we must let the middleware chain return the error response. + (async () => { + const controller = new AbortController(); + try { + logger.debug('verifying explicit payment', { + requestEventId, + pmi: paymentRequired.pmi, + timeoutMs: effectiveTimeoutMs, + }); + + await withTimeout( + processor.verifyPayment({ + pay_req: paymentRequired.pay_req, + requestEventId, + clientPubkey: ctx.clientPubkey, + abortSignal: controller.signal, + }), + effectiveTimeoutMs, + 'verifyPayment timed out', + ); + + logger.info('explicit payment accepted, granting authorization', { + requestEventId, + pmi: paymentRequired.pmi, + amount: paymentRequired.amount, + }); + + authorizationStore.grant(identity, effectiveTimeoutMs); + } catch (err) { + logger.info('explicit payment verification failed or timed out', { + requestEventId, + error: err instanceof Error ? err.message : String(err), + }); + authorizationStore.clearPending(identity); + } finally { + controller.abort(); + } + })().catch((err) => { + logger.error('unhandled exception in async payment verification', { + requestEventId, + pmi: paymentRequired.pmi, + error: err instanceof Error ? err.message : String(err), + }); + }); + } catch (err) { + authorizationStore.clearPending(identity); + throw err; + } + }; +} diff --git a/src/payments/server-payments-utils.ts b/src/payments/server-payments-utils.ts new file mode 100644 index 0000000..2928ea4 --- /dev/null +++ b/src/payments/server-payments-utils.ts @@ -0,0 +1,69 @@ +import type { JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { + PricedCapability, + ResolvePriceRejection, + ResolvePriceWaiver, + ResolvePriceResult, +} from './types.js'; + +export function getVerificationTimeoutMs(params: { + ttlSeconds: number | undefined; +}): number { + // CEP-8 TTL is in seconds. If TTL is absent, default is 5 minutes. + const ttlSeconds = params.ttlSeconds; + if (ttlSeconds === undefined) { + return 5 * 60 * 1000; + } + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + return 5 * 60 * 1000; + } + return Math.floor(ttlSeconds * 1000); +} + +export function matchPricedCapability( + message: JSONRPCRequest, + priced: readonly PricedCapability[], +): PricedCapability | undefined { + const capabilityName = getCapabilityNameForPricing(message); + + return priced.find((p) => { + if (p.method !== message.method) return false; + if (p.name === undefined) return true; + return p.name === capabilityName; + }); +} + +export function getCapabilityNameForPricing( + message: JSONRPCRequest, +): string | undefined { + const params = message.params as Record | undefined; + + switch (message.method) { + case 'tools/call': { + const name = params?.name; + return typeof name === 'string' ? name : undefined; + } + case 'prompts/get': { + const name = params?.name; + return typeof name === 'string' ? name : undefined; + } + case 'resources/read': { + const uri = params?.uri; + return typeof uri === 'string' ? uri : undefined; + } + default: + return undefined; + } +} + +export function isResolvePriceRejection( + quote: ResolvePriceResult, +): quote is ResolvePriceRejection { + return 'reject' in quote && quote.reject; +} + +export function isResolvePriceWaiver( + quote: ResolvePriceResult, +): quote is ResolvePriceWaiver { + return 'waive' in quote && quote.waive; +} diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 878ec74..83f8e17 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -1,17 +1,14 @@ -import { type JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; -import { +import { isJsonRpcRequest } from './types.js'; +import type { CorrelatedNotificationSender, PaymentAcceptedNotification, PaymentProcessor, PaymentRejectedNotification, PaymentRequiredNotification, PricedCapability, - ResolvePriceRejection, - ResolvePriceWaiver, - ResolvePriceResult, ResolvePriceFn, ServerMiddlewareFn, - isJsonRpcRequest, + PaymentInteractionMode, } from './types.js'; import { LruCache } from '../core/utils/lru-cache.js'; import { withTimeout } from '../core/utils/utils.js'; @@ -22,6 +19,13 @@ import { PAYMENT_REJECTED_METHOD, PAYMENT_REQUIRED_METHOD, } from './constants.js'; +import { + getVerificationTimeoutMs, + matchPricedCapability, + isResolvePriceRejection, + isResolvePriceWaiver, +} from './server-payments-utils.js'; + export interface ServerPaymentsOptions { processors: readonly PaymentProcessor[]; @@ -47,6 +51,9 @@ export interface ServerPaymentsOptions { * @default 1000 */ maxPendingPayments?: number; + + /** Effective payment interaction mode for this server instance. @default 'transparent' */ + paymentInteraction?: PaymentInteractionMode; } function purgeExpiredPending(params: { @@ -71,55 +78,7 @@ type PendingPaymentState = { inFlight: Promise; }; -function getVerificationTimeoutMs(params: { - ttlSeconds: number | undefined; -}): number { - // CEP-8 TTL is in seconds. If TTL is absent, default is 5 minutes. - const ttlSeconds = params.ttlSeconds; - if (ttlSeconds === undefined) { - return 5 * 60 * 1000; - } - if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { - return 5 * 60 * 1000; - } - return Math.floor(ttlSeconds * 1000); -} - -function matchPricedCapability( - message: JSONRPCRequest, - priced: readonly PricedCapability[], -): PricedCapability | undefined { - const capabilityName = getCapabilityNameForPricing(message); - - return priced.find((p) => { - if (p.method !== message.method) return false; - if (p.name === undefined) return true; - return p.name === capabilityName; - }); -} -function getCapabilityNameForPricing( - message: JSONRPCRequest, -): string | undefined { - const params = message.params as Record | undefined; - - switch (message.method) { - case 'tools/call': { - const name = params?.name; - return typeof name === 'string' ? name : undefined; - } - case 'prompts/get': { - const name = params?.name; - return typeof name === 'string' ? name : undefined; - } - case 'resources/read': { - const uri = params?.uri; - return typeof uri === 'string' ? uri : undefined; - } - default: - return undefined; - } -} function createPaymentRequiredNotification(params: { amount: number; @@ -160,17 +119,7 @@ function createPaymentRejectedNotification(params: { }; } -function isResolvePriceRejection( - quote: ResolvePriceResult, -): quote is ResolvePriceRejection { - return 'reject' in quote && quote.reject; -} -function isResolvePriceWaiver( - quote: ResolvePriceResult, -): quote is ResolvePriceWaiver { - return 'waive' in quote && quote.waive; -} /** * Creates a server-side middleware that gates priced requests until payment is verified. diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index 66ae160..ca0b1c1 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -3,6 +3,8 @@ import type { ServerPaymentsOptions } from './server-payments.js'; import { createCapTagsFromPricedCapabilities } from './cap-tags.js'; import { createPmiTagsFromProcessors } from './pmi-tags.js'; import { createServerPaymentsMiddleware } from './server-payments.js'; +import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; +import { AuthorizationStore } from './authorization-store.js'; /** * Attaches CEP-8 payments gating to a NostrServerTransport. @@ -12,15 +14,35 @@ export function withServerPayments( options: ServerPaymentsOptions, ): NostrServerTransport { // CEP-8 discovery tags: advertise supported PMIs + reference pricing on announcement/list events. - transport.setAnnouncementExtraTags( - createPmiTagsFromProcessors(options.processors), - ); + const extraTags: string[][] = createPmiTagsFromProcessors(options.processors); + + if (options.paymentInteraction === 'explicit_gating') { + extraTags.push(['payment_interaction', 'explicit_gating']); + } + + transport.setAnnouncementExtraTags(extraTags); transport.setAnnouncementPricingTags( createCapTagsFromPricedCapabilities(options.pricedCapabilities), ); - transport.addInboundMiddleware( - createServerPaymentsMiddleware({ sender: transport, options }), - ); + // Expose the configured payment interaction mode to the transport coordinator. + transport.setSupportedPaymentInteraction(options.paymentInteraction); + + if (options.paymentInteraction === 'explicit_gating') { + const authorizationStore = new AuthorizationStore({}); + transport.addInboundMiddleware( + createExplicitGatingMiddleware({ + options, + authorizationStore, + sendResponse: async (clientPubkey, response, requestEventId) => { + await transport.sendTargetedResponse(clientPubkey, response, requestEventId); + }, + }), + ); + } else { + transport.addInboundMiddleware( + createServerPaymentsMiddleware({ sender: transport, options }), + ); + } return transport; } diff --git a/src/payments/types.ts b/src/payments/types.ts index be25a12..2728ba0 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -62,6 +62,48 @@ export type PaymentRequiredNotification = JSONRPCNotification & { }; }; +/** CEP-8 payment interaction modes. */ +export type PaymentInteractionMode = 'transparent' | 'explicit_gating'; + +/** A single payment option inside a -32042 error.data.payment_options entry. */ +export interface PaymentOption { + amount: number; + pmi: string; + pay_req: string; + description?: string; + ttl?: number; + _meta?: Record; +} + +/** Shape of error.data for -32042 Payment Required. */ +export interface PaymentRequiredErrorData { + instructions?: string; + payment_options: PaymentOption[]; +} + +/** Shape of error.data for -32043 Payment Pending. */ +export interface PaymentPendingErrorData { + instructions?: string; + retry_after?: number; +} + +/** Nostr `payment_interaction` tag as defined by CEP-8. */ +export type PaymentInteractionTag = ['payment_interaction', PaymentInteractionMode]; + +/** + * Canonical invocation identity for explicit-gating authorization matching. + * + * `invocationHash` is SHA-256 over JCS({method, params}). This means `params` MUST be + * deterministic — no timestamps, UUIDs, or ephemeral IDs that change across retries. + * Clients MUST preserve the exact original `params` object when retrying after payment + * so the retry computes the same `invocationHash` and matches the paid authorization. + */ +export interface CanonicalInvocationIdentity { + clientPubkey: string; + /** Hex-encoded SHA-256 of JCS({method, params}). */ + invocationHash: string; +} + /** A CEP-8 payment-accepted notification (JSON-RPC notification). */ export type PaymentAcceptedNotification = JSONRPCNotification & { method: 'notifications/payment_accepted'; diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts index cae603b..ad118e9 100644 --- a/src/transport/capability-negotiator.ts +++ b/src/transport/capability-negotiator.ts @@ -180,6 +180,7 @@ export class ServerCapabilityNegotiator { export class ClientCapabilityNegotiator { private hasSentDiscoveryTags = false; private clientPmis?: readonly string[]; + private paymentInteraction?: import('../payments/types.js').PaymentInteractionMode; private serverSupportsEphemeralGiftWraps = false; private _serverInitializeEvent?: NostrEvent; @@ -204,6 +205,13 @@ export class ClientCapabilityNegotiator { this.clientPmis = pmis; } + /** + * Sets the requested payment interaction mode for negotiation. + */ + public setPaymentInteraction(mode: import('../payments/types.js').PaymentInteractionMode): void { + this.paymentInteraction = mode; + } + /** * Updates server capability flags from discovered peer tags. * Called by the transport when it learns new capabilities from inbound events. @@ -253,6 +261,9 @@ export class ClientCapabilityNegotiator { if (this.clientPmis) { tags.push(...this.clientPmis.map((pmi) => ['pmi', pmi])); } + if (this.paymentInteraction && this.paymentInteraction !== 'transparent') { + tags.push(['payment_interaction', this.paymentInteraction]); + } return tags; } diff --git a/src/transport/middleware.ts b/src/transport/middleware.ts index dc7c33f..293a0f8 100644 --- a/src/transport/middleware.ts +++ b/src/transport/middleware.ts @@ -1,10 +1,22 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { PaymentInteractionMode } from '../payments/types.js'; + /** * Inbound middleware hook for server transports. + * + * @note Context relationship: `InboundMiddlewareFn`'s `ctx` is the authoritative source + * of per-request context, populated by the inbound coordinator from the session and + * inbound event tags. `ServerPaymentsContext` (used by `ServerMiddlewareFn`) is a subset + * of this context — it reads the same `paymentInteraction` field. The inbound coordinator + * constructs both from the same session state, so they stay synchronized automatically. */ export type InboundMiddlewareFn = ( message: JSONRPCMessage, - ctx: { clientPubkey: string; clientPmis?: readonly string[] }, + ctx: { + clientPubkey: string; + clientPmis?: readonly string[]; + paymentInteraction?: PaymentInteractionMode; + }, forward: (message: JSONRPCMessage) => Promise, ) => Promise; diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index da9c8b8..6dbdb32 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -584,6 +584,20 @@ export class NostrClientTransport return this.metadataStore.getServerInitializePicture(); } + /** + * Sets the requested payment interaction mode for negotiation. + */ + public setPaymentInteraction(mode: import('../payments/types.js').PaymentInteractionMode): void { + this.capabilityNegotiator.setPaymentInteraction(mode); + } + + /** + * Gets the effective payment interaction mode disclosed by the server. + */ + public getEffectivePaymentInteraction(): import('../payments/types.js').PaymentInteractionMode | undefined { + return this.metadataStore.getEffectivePaymentInteraction(); + } + /** Gets the server's most recently observed tools/list event envelope, if any. */ public getServerToolsListEvent(): NostrEvent | undefined { return this.metadataStore.getServerToolsListEvent(); @@ -628,6 +642,7 @@ export class NostrClientTransport private handleResponse( correlatedEventId: string, mcpMessage: JSONRPCMessage, + eventId?: string, ): void { try { const resolved = this.correlationStore.resolveResponse( @@ -637,6 +652,10 @@ export class NostrClientTransport if (resolved) { this.onmessage?.(mcpMessage); + this.onmessageWithContext?.(mcpMessage, { + eventId: eventId ?? correlatedEventId, + correlatedEventId, + }); } else { this.logger.warn('Response for unknown request', { eventId: correlatedEventId, diff --git a/src/transport/nostr-client/correlation-store.ts b/src/transport/nostr-client/correlation-store.ts index 2f93dc7..3313a07 100644 --- a/src/transport/nostr-client/correlation-store.ts +++ b/src/transport/nostr-client/correlation-store.ts @@ -15,6 +15,8 @@ export interface PendingRequest { progressToken?: string; /** Minimal context about the original request (safe to store; no arguments). */ originalRequestContext?: OriginalRequestContext; + /** The full raw original JSON-RPC request for explicit gating retries. */ + rawRequest?: import('@modelcontextprotocol/sdk/types.js').JSONRPCRequest; } /** @@ -74,6 +76,13 @@ export class ClientCorrelationStore { return this.pendingRequests.get(eventId); } + /** + * Gets the raw original JSON-RPC request for explicit gating retries. + */ + getRawRequest(eventId: string): import('@modelcontextprotocol/sdk/types.js').JSONRPCRequest | undefined { + return this.pendingRequests.get(eventId)?.rawRequest; + } + /** * Resolves a response by finding and removing the corresponding request. * Restores the original request ID in the response before resolving. diff --git a/src/transport/nostr-client/inbound-coordinator.ts b/src/transport/nostr-client/inbound-coordinator.ts index b476c74..4af7239 100644 --- a/src/transport/nostr-client/inbound-coordinator.ts +++ b/src/transport/nostr-client/inbound-coordinator.ts @@ -28,7 +28,7 @@ export interface ClientInboundCoordinatorDeps { metadataStore: ServerMetadataStore; unwrapEvent: (event: NostrEvent) => Promise; convertNostrEventToMcpMessage: (event: NostrEvent) => JSONRPCMessage | null; - handleResponse: (correlatedEventId: string, msg: JSONRPCMessage) => void; + handleResponse: (correlatedEventId: string, msg: JSONRPCMessage, eventId?: string) => void; handleNotification: ( eventId: string, correlatedEventId: string | undefined, @@ -144,7 +144,7 @@ export class ClientInboundCoordinator { } } - this.deps.handleResponse(eTag, mcpMessage); + this.deps.handleResponse(eTag, mcpMessage, nostrEvent.id); return; } @@ -206,6 +206,18 @@ export class ClientInboundCoordinator { return; } + const paymentInteractionTag = event.tags.find( + (tag) => tag[0] === 'payment_interaction' && typeof tag[1] === 'string' + ); + if (paymentInteractionTag) { + const mode = paymentInteractionTag[1]; + if (mode === 'transparent' || mode === 'explicit_gating') { + this.deps.metadataStore.setEffectivePaymentInteraction( + mode as import('../../payments/types.js').PaymentInteractionMode + ); + } + } + const currentHasInitializeResult = InitializeResultSchema.safeParse( this.getInitializeResultCandidate(event), ).success; diff --git a/src/transport/nostr-client/outbound-sender.ts b/src/transport/nostr-client/outbound-sender.ts index 661fbef..67e9c07 100644 --- a/src/transport/nostr-client/outbound-sender.ts +++ b/src/transport/nostr-client/outbound-sender.ts @@ -129,6 +129,7 @@ export class ClientOutboundSender { progressToken: progressToken !== undefined ? String(progressToken) : undefined, originalRequestContext, + rawRequest: isRequest ? (message as import('@modelcontextprotocol/sdk/types.js').JSONRPCRequest) : undefined, }); if ( @@ -204,6 +205,7 @@ export class ClientOutboundSender { progressToken, originalRequestContext: this.deps.getOriginalRequestContext(originalMessage), + rawRequest: originalMessage as import('@modelcontextprotocol/sdk/types.js').JSONRPCRequest, }); } diff --git a/src/transport/nostr-client/server-metadata-store.ts b/src/transport/nostr-client/server-metadata-store.ts index ebe3b77..3c895e0 100644 --- a/src/transport/nostr-client/server-metadata-store.ts +++ b/src/transport/nostr-client/server-metadata-store.ts @@ -20,6 +20,15 @@ export class ServerMetadataStore { private serverResourceTemplatesListEvent: NostrEvent | undefined; private supportsOversizedTransfer = false; private supportsOpenStream = false; + private effectivePaymentInteraction?: import('../../payments/types.js').PaymentInteractionMode; + + public setEffectivePaymentInteraction(mode: import('../../payments/types.js').PaymentInteractionMode): void { + this.effectivePaymentInteraction = mode; + } + + public getEffectivePaymentInteraction(): import('../../payments/types.js').PaymentInteractionMode | undefined { + return this.effectivePaymentInteraction; + } public clear(): void { this.serverInitializeEvent = undefined; @@ -29,6 +38,7 @@ export class ServerMetadataStore { this.serverResourceTemplatesListEvent = undefined; this.supportsOversizedTransfer = false; this.supportsOpenStream = false; + this.effectivePaymentInteraction = undefined; } public setServerInitializeEvent(event: NostrEvent): void { diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 3123205..44ff670 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -487,6 +487,13 @@ export class NostrServerTransport this.listToolsResultTransformers.push(transformer); } + /** + * Sets the supported payment interaction mode for this server. + */ + public setSupportedPaymentInteraction(mode: import('../payments/types.js').PaymentInteractionMode | undefined): void { + this.inboundCoordinator.setSupportedPaymentInteraction(mode); + } + /** * Adds a provider for extra tags on public tools/list announcement events. */ @@ -686,6 +693,23 @@ export class NostrServerTransport await this.outboundResponseRouter.route(response); } + /** + * Sends a targeted response explicitly bypassing the correlation store lookup. + * Useful for middleware that needs to proactively reject requests without + * letting them reach the MCP application. + * + * @param clientPubkey The target client's public key. + * @param response The JSON-RPC response or error to send. + * @param requestEventId The original Nostr event ID of the request being responded to. + */ + public async sendTargetedResponse( + clientPubkey: string, + response: JSONRPCResponse | JSONRPCErrorResponse, + requestEventId: string, + ): Promise { + await this.outboundResponseRouter.routeTargeted(clientPubkey, response, requestEventId); + } + /** * Handles notification messages with routing. * @param notification The JSON-RPC notification to send. diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts index b08f053..b14f6fb 100644 --- a/src/transport/nostr-server/inbound-coordinator.ts +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -26,6 +26,7 @@ import { } from '../../core/index.js'; import { GiftWrapMode } from '../../core/interfaces.js'; import { type OpenStreamWriter } from '../open-stream/index.js'; +import { UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE } from '../../payments/constants.js'; export interface ServerInboundCoordinatorDeps { sessionStore: SessionStore; @@ -38,6 +39,7 @@ export interface ServerInboundCoordinatorDeps { oversizedEnabled: boolean; openStreamEnabled: boolean; giftWrapMode: GiftWrapMode; + supportedPaymentInteraction?: import('../../payments/types.js').PaymentInteractionMode; sendMcpMessage: ( msg: JSONRPCMessage, pubkey: string, @@ -75,6 +77,10 @@ export class ServerInboundCoordinator { this.inboundNotificationDispatcher = dispatcher; } + public setSupportedPaymentInteraction(mode: import('../../payments/types.js').PaymentInteractionMode | undefined): void { + this.deps.supportedPaymentInteraction = mode; + } + /** * Authorizes and processes an incoming Nostr event, handling message validation, * client authorization, session management, and optional client public key injection. @@ -164,9 +170,68 @@ export class ServerInboundCoordinator { const clientPmis = event.tags .filter((tag) => tag[0] === 'pmi' && typeof tag[1] === 'string') .map((tag) => tag[1] as string); + + const serverSupportsExplicitGating = + this.deps.supportedPaymentInteraction === 'explicit_gating'; + + const paymentInteractionTag = event.tags.find( + (tag) => tag[0] === 'payment_interaction' && typeof tag[1] === 'string' + ); + + if (paymentInteractionTag && !session.requestedPaymentInteraction) { + const mode = paymentInteractionTag[1]; + if (mode === 'transparent' || mode === 'explicit_gating') { + session.requestedPaymentInteraction = mode as import('../../payments/types.js').PaymentInteractionMode; + + if (mode === 'explicit_gating' && !serverSupportsExplicitGating) { + if (isJSONRPCRequest(inboundMessage)) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: inboundMessage.id, + error: { + code: UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE, + message: 'Unsupported payment_interaction mode: explicit_gating', + }, + }; + 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 negotiation error response', { + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + } + + session.effectivePaymentInteraction = serverSupportsExplicitGating + ? session.requestedPaymentInteraction + : 'transparent'; + } else { + session.requestedPaymentInteraction = 'transparent'; + session.effectivePaymentInteraction = 'transparent'; + } + } + const ctx = { clientPubkey: event.pubkey, clientPmis: clientPmis.length > 0 ? clientPmis : undefined, + paymentInteraction: session.effectivePaymentInteraction ?? 'transparent', }; const middlewares = this.deps.inboundMiddlewares; diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index f525926..8b6a6b5 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -220,6 +220,17 @@ export class OutboundResponseRouter { baseTags: this.deps.createResponseTags(route.clientPubkey, nostrEventId), session, }); + + // CEP-8: Disclose effective mode on first response if client requested a non-default mode + if ( + session.requestedPaymentInteraction && + session.requestedPaymentInteraction !== 'transparent' && + !session.hasDisclosedPaymentInteraction && + session.effectivePaymentInteraction + ) { + tags.push(['payment_interaction', session.effectivePaymentInteraction]); + session.hasDisclosedPaymentInteraction = true; + } const giftWrapKind = this.deps.chooseGiftWrapKind({ session, @@ -260,4 +271,54 @@ export class OutboundResponseRouter { throw error; } } + + /** + * 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). + */ + public async routeTargeted( + clientPubkey: string, + response: JSONRPCResponse | JSONRPCErrorResponse, + requestEventId: string, + ): Promise { + const session = this.deps.sessionStore.getSession(clientPubkey); + if (!session) { + this.deps.logger.warn( + 'Cannot route targeted response: no active session found', + { clientPubkey, requestEventId }, + ); + return; + } + + const tags = this.deps.buildOutboundTags({ + baseTags: this.deps.createResponseTags(clientPubkey, requestEventId), + session, + }); + + // CEP-8: Disclose effective mode on first response if client requested a non-default mode + if ( + session.requestedPaymentInteraction && + session.requestedPaymentInteraction !== 'transparent' && + !session.hasDisclosedPaymentInteraction && + session.effectivePaymentInteraction + ) { + tags.push(['payment_interaction', session.effectivePaymentInteraction]); + session.hasDisclosedPaymentInteraction = true; + } + + const giftWrapKind = this.deps.chooseGiftWrapKind({ + session, + }); + + await this.deps.sendMcpMessage( + response, + clientPubkey, + CTXVM_MESSAGES_KIND, + tags, + session.isEncrypted, + undefined, + giftWrapKind, + ); + } } diff --git a/src/transport/nostr-server/session-store.ts b/src/transport/nostr-server/session-store.ts index e911c1b..881f308 100644 --- a/src/transport/nostr-server/session-store.ts +++ b/src/transport/nostr-server/session-store.ts @@ -26,6 +26,12 @@ export interface ClientSession { supportsOversizedTransfer: boolean; /** Whether the client has advertised CEP-41 open stream support. */ supportsOpenStream: boolean; + /** Client-requested payment interaction mode (from first message). */ + requestedPaymentInteraction?: import('../../payments/types.js').PaymentInteractionMode; + /** Effective payment interaction mode for this session. */ + effectivePaymentInteraction?: import('../../payments/types.js').PaymentInteractionMode; + /** Whether the effective mode has been disclosed on the first response. */ + hasDisclosedPaymentInteraction?: boolean; } /** diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 0ab3532..db7dfc2 100644 --- a/src/transport/payments-flow.test.ts +++ b/src/transport/payments-flow.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, + spyOn, } from 'bun:test'; import { sleep } from 'bun'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -1009,4 +1010,95 @@ describe.serial('payments fake flow (transport-level)', () => { await client.close(); await mcpServer.close(); }, 20000); + + test('explicit gating: gates tools/call via -32042 error and auto-retries', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ name: 'explicit-server', version: '1.0.0' }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor({ verifyDelayMs: 20 }); + const createSpy = spyOn(processor, 'createPaymentRequired'); + const verifySpy = spyOn(processor, 'verifyPayment'); + const pricedCapabilities = [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + description: 'explicit test payment', + }, + ] as const; + + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + paymentInteraction: 'explicit_gating', + }, + ); + + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + + // Track if onPaymentRequired was called + let explicitPaymentHandled = false; + + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + explicitPaymentHandled = true; + return { paid: true }; + } + }); + + const client = new Client({ name: 'explicit-client', version: '1.0.0' }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 10, b: 20 }, + }); + + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '30' }); + + expect(explicitPaymentHandled).toBe(true); + expect(toolCallCount).toBe(1); + expect(createSpy).toHaveBeenCalled(); + expect(verifySpy).toHaveBeenCalled(); + + await client.close(); + await mcpServer.close(); + }, 20000); });