mppx 0.11.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +22 -1
  3. package/dist/Method.d.ts +7 -1
  4. package/dist/Method.js +7 -2
  5. package/dist/Receipt.d.ts +2 -0
  6. package/dist/Receipt.js +2 -0
  7. package/dist/cli/agent.d.ts +3 -0
  8. package/dist/cli/agent.js +20 -0
  9. package/dist/cli/cli.d.ts +9 -1
  10. package/dist/cli/cli.js +134 -136
  11. package/dist/cli/plugins/index.js +12 -14
  12. package/dist/cli/plugins/tempo.js +9 -2
  13. package/dist/cli/utils.d.ts +11 -1
  14. package/dist/cli/utils.js +15 -2
  15. package/dist/cli/validate/challenge.d.ts +2 -0
  16. package/dist/cli/validate/challenge.js +19 -4
  17. package/dist/cli/validate/discovery.d.ts +1 -0
  18. package/dist/cli/validate/discovery.js +10 -1
  19. package/dist/cli/validate/payment.js +63 -24
  20. package/dist/client/Methods.d.ts +0 -2
  21. package/dist/client/Methods.js +0 -2
  22. package/dist/client/Mppx.js +1 -0
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/index.js +1 -1
  25. package/dist/client/internal/Fetch.d.ts +2 -0
  26. package/dist/client/internal/Fetch.js +8 -2
  27. package/dist/client/internal/MethodChallenge.d.ts +1 -0
  28. package/dist/evm/Types.js +1 -1
  29. package/dist/evm/server/Charge.js +5 -0
  30. package/dist/evm/server/Methods.d.ts +164 -3
  31. package/dist/evm/server/Methods.js +50 -2
  32. package/dist/integrations/x402/Evm.js +4 -3
  33. package/dist/internal/HeaderCodec.d.ts +2 -2
  34. package/dist/internal/version.d.ts +2 -2
  35. package/dist/internal/version.js +1 -1
  36. package/dist/mcp/client/McpClient.js +4 -7
  37. package/dist/middlewares/elysia.d.ts +11 -8
  38. package/dist/middlewares/elysia.js +67 -14
  39. package/dist/middlewares/express.js +13 -6
  40. package/dist/middlewares/hono.js +4 -4
  41. package/dist/middlewares/nextjs.js +9 -5
  42. package/dist/proxy/internal/Headers.js +65 -0
  43. package/dist/proxy/services/stripe.js +1 -0
  44. package/dist/server/Mppx.d.ts +18 -2
  45. package/dist/server/Mppx.js +204 -92
  46. package/dist/server/NodeListener.js +71 -6
  47. package/dist/server/Request.d.ts +11 -1
  48. package/dist/server/Request.js +132 -13
  49. package/dist/server/Transport.d.ts +14 -1
  50. package/dist/server/Transport.js +1 -0
  51. package/dist/server/internal/html/compose.main.gen.d.ts +1 -1
  52. package/dist/server/internal/html/serviceWorker.gen.d.ts +1 -1
  53. package/dist/stripe/server/Methods.d.ts +10 -3
  54. package/dist/stripe/server/Methods.js +19 -6
  55. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  56. package/dist/stripe/server/internal/html.gen.js +1 -1
  57. package/dist/tempo/Methods.d.ts +1 -0
  58. package/dist/tempo/Methods.js +1 -0
  59. package/dist/tempo/client/Charge.js +17 -13
  60. package/dist/tempo/client/Methods.d.ts +1 -5
  61. package/dist/tempo/client/Methods.js +10 -5
  62. package/dist/tempo/client/ResolveAccount.d.ts +3 -0
  63. package/dist/tempo/client/index.d.ts +0 -3
  64. package/dist/tempo/client/index.js +0 -2
  65. package/dist/tempo/internal/currencies.d.ts +19 -0
  66. package/dist/tempo/internal/currencies.js +41 -0
  67. package/dist/tempo/internal/defaults.d.ts +5 -3
  68. package/dist/tempo/internal/defaults.js +6 -2
  69. package/dist/tempo/internal/fee-payer.d.ts +11 -7
  70. package/dist/tempo/internal/fee-payer.js +30 -11
  71. package/dist/tempo/internal/machine-token-charge.d.ts +23 -0
  72. package/dist/tempo/internal/machine-token-charge.js +2 -1
  73. package/dist/tempo/legacy/index.d.ts +1 -3
  74. package/dist/tempo/legacy/index.js +1 -3
  75. package/dist/tempo/server/Charge.d.ts +12 -1
  76. package/dist/tempo/server/Charge.js +109 -78
  77. package/dist/tempo/server/Methods.d.ts +5048 -40
  78. package/dist/tempo/server/Methods.js +104 -13
  79. package/dist/tempo/server/Subscription.d.ts +1 -1
  80. package/dist/tempo/server/Subscription.js +31 -4
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.js +1 -1
  83. package/dist/tempo/server/internal/transport.js +28 -11
  84. package/dist/tempo/session/client/ChannelOps.js +5 -0
  85. package/dist/tempo/session/client/Session.d.ts +1 -0
  86. package/dist/tempo/session/client/Session.js +7 -4
  87. package/dist/tempo/session/client/SessionManager.js +3 -0
  88. package/dist/tempo/session/client/Transports.d.ts +8 -1
  89. package/dist/tempo/session/client/Transports.js +12 -3
  90. package/dist/tempo/session/precompile/Chain.js +16 -1
  91. package/dist/tempo/session/precompile/Protocol.d.ts +2 -0
  92. package/dist/tempo/session/precompile/Voucher.d.ts +16 -0
  93. package/dist/tempo/session/precompile/Voucher.js +48 -0
  94. package/dist/tempo/session/server/ChannelStore.d.ts +22 -2
  95. package/dist/tempo/session/server/ChannelStore.js +37 -7
  96. package/dist/tempo/session/server/CredentialVerification.d.ts +6 -1
  97. package/dist/tempo/session/server/CredentialVerification.js +56 -19
  98. package/dist/tempo/session/server/MeteredStream.d.ts +3 -2
  99. package/dist/tempo/session/server/MeteredStream.js +85 -29
  100. package/dist/tempo/session/server/RequestState.d.ts +6 -0
  101. package/dist/tempo/session/server/RequestState.js +11 -4
  102. package/dist/tempo/session/server/Session.d.ts +2 -1
  103. package/dist/tempo/session/server/Session.js +10 -2
  104. package/dist/tempo/session/server/Settlement.d.ts +23 -0
  105. package/dist/tempo/session/server/Settlement.js +105 -25
  106. package/dist/tempo/session/server/Sse.d.ts +3 -1
  107. package/dist/tempo/session/server/Sse.js +105 -32
  108. package/dist/tempo/session/server/Transports.d.ts +24 -19
  109. package/dist/tempo/session/server/Transports.js +169 -58
  110. package/dist/tempo/session/server/Ws.d.ts +5 -1
  111. package/dist/tempo/session/server/Ws.js +75 -20
  112. package/dist/tempo/subscription/KeyAuthorization.d.ts +70 -8
  113. package/dist/tempo/subscription/Store.d.ts +2 -0
  114. package/dist/tempo/subscription/Store.js +3 -0
  115. package/dist/validation/core.js +2 -1
  116. package/dist/x402/server/EvmCharge.d.ts +3 -1
  117. package/dist/x402/server/EvmCharge.js +14 -1
  118. package/package.json +8 -8
  119. package/dist/tempo/legacy/client/ChannelOps.d.ts +0 -69
  120. package/dist/tempo/legacy/client/ChannelOps.js +0 -168
  121. package/dist/tempo/legacy/client/Session.d.ts +0 -185
  122. package/dist/tempo/legacy/client/Session.js +0 -279
  123. package/dist/tempo/legacy/client/SessionManager.d.ts +0 -100
  124. package/dist/tempo/legacy/client/SessionManager.js +0 -706
  125. package/dist/tempo/legacy/client/index.d.ts +0 -7
  126. package/dist/tempo/legacy/client/index.js +0 -5
@@ -19,6 +19,11 @@ import * as SecretKey from './internal/SecretKey.js';
19
19
  import * as NodeListener from './NodeListener.js';
20
20
  import * as Request from './Request.js';
21
21
  import * as Transport from './Transport.js';
22
+ const streamingReceiptWrappers = new WeakSet();
23
+ /** Returns whether a successful payment result accepts streaming receipt responses. @internal */
24
+ export function supportsStreamingReceipts(result) {
25
+ return streamingReceiptWrappers.has(result.withReceipt);
26
+ }
22
27
  const reservedMppxKeyValues = [
23
28
  'challenge',
24
29
  'compose',
@@ -64,22 +69,18 @@ export function create(config) {
64
69
  ? createAttestationGuard(attestation)
65
70
  : undefined;
66
71
  const handlers = {};
72
+ const methodHandlers = new Map();
67
73
  const intentCount = {};
74
+ function composeConfigured(configured) {
75
+ if (transport.name === 'mcp' || transport.name === 'mcp-sdk')
76
+ return composeMcpHandlers(configured, transport);
77
+ return composeHandlers(configured, undefined, selectOffers);
78
+ }
79
+ function createGroupedHandler(group) {
80
+ return (options) => composeConfigured(group.map((method) => methodHandlers.get(method)(options)));
81
+ }
68
82
  for (const mi of methods) {
69
83
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1;
70
- if (mi.onPaymentSuccess) {
71
- serverEvents.on('payment.success', (async (ctx) => {
72
- if (ctx.method.name === mi.name && ctx.method.intent === mi.intent) {
73
- await mi.onPaymentSuccess({
74
- challenge: ctx.challenge,
75
- input: ctx.input,
76
- receipt: ctx.receipt,
77
- request: ctx.request,
78
- ...(ctx.requestInput !== undefined && { requestInput: ctx.requestInput }),
79
- });
80
- }
81
- }));
82
- }
83
84
  }
84
85
  assertNoReservedMppxKeys(methods);
85
86
  for (const mi of methods) {
@@ -105,6 +106,8 @@ export function create(config) {
105
106
  const aliasKey = mi.alias ? `${mi.name}/${mi.alias}` : undefined;
106
107
  if (mi.extensions)
107
108
  Object.assign(fn, mi.extensions);
109
+ Object.assign(fn, { _method: mi });
110
+ methodHandlers.set(mi, fn);
108
111
  if (!aliasKey || !handlers[wireKey])
109
112
  handlers[wireKey] = fn;
110
113
  if (aliasKey)
@@ -122,23 +125,25 @@ export function create(config) {
122
125
  }
123
126
  else {
124
127
  const intentMethods = methods.filter((m) => m.intent === mi.intent);
125
- handlers[mi.intent] = (options) => {
126
- const configured = intentMethods.map((m) => {
127
- const key = `${m.name}/${m.intent}`;
128
- const handlerFn = handlers[key];
129
- return handlerFn(options);
130
- });
131
- return composeHandlers(configured, undefined, selectOffers);
132
- };
128
+ handlers[mi.intent] = createGroupedHandler(intentMethods);
133
129
  }
134
130
  }
131
+ // Preserve distinct configured offers sharing a wire method, including nested handlers.
132
+ for (const key of new Set(methods.filter((m) => !m.alias).map((m) => `${m.name}/${m.intent}`))) {
133
+ const matching = methods.filter((m) => !m.alias && `${m.name}/${m.intent}` === key);
134
+ if (matching.length < 2)
135
+ continue;
136
+ const composed = createGroupedHandler(matching);
137
+ Object.assign(composed, matching[0].extensions);
138
+ handlers[key] = composed;
139
+ }
135
140
  // Build nested handlers: mppx.tempo.charge(...)
136
141
  for (const mi of methods) {
137
142
  if (!handlers[mi.name])
138
143
  handlers[mi.name] = {};
139
144
  const key = mi.alias ? `${mi.name}/${mi.alias}` : `${mi.name}/${mi.intent}`;
140
145
  const fn = handlers[key];
141
- fn._method = mi;
146
+ fn._method ??= mi;
142
147
  handlers[mi.name][mi.alias ?? mi.intent] = fn;
143
148
  }
144
149
  // Build challenge generators: mppx.challenge.tempo.charge(...)
@@ -146,6 +151,8 @@ export function create(config) {
146
151
  for (const mi of methods) {
147
152
  if (!challengeHandlers[mi.name])
148
153
  challengeHandlers[mi.name] = {};
154
+ if (challengeHandlers[mi.name][mi.alias ?? mi.intent])
155
+ continue;
149
156
  challengeHandlers[mi.name][mi.alias ?? mi.intent] = createChallengeFn({
150
157
  credentialHeader,
151
158
  defaults: mi.defaults,
@@ -160,7 +167,7 @@ export function create(config) {
160
167
  const emitFailures = parameters.emitFailures === true;
161
168
  const { method: credMethod, intent: credIntent } = credential.challenge;
162
169
  const methodCandidates = methods.filter((m) => m.name === credMethod && m.intent === credIntent);
163
- const mi = methodCandidates[0];
170
+ let mi = methodCandidates[0];
164
171
  const eventMethod = mi ?? { intent: credIntent, name: credMethod };
165
172
  const emitStandalonePaymentFailed = async (failure) => {
166
173
  if (!emitFailures)
@@ -191,11 +198,6 @@ export function create(config) {
191
198
  reason: `no registered method for ${credMethod}/${credIntent}`,
192
199
  }));
193
200
  }
194
- if (parameters.requireValidate && !mi.validate)
195
- await fail(new Errors.VerificationFailedError({
196
- details: { intent: credIntent, method: credMethod },
197
- reason: `${credMethod}/${credIntent} does not support non-mutating credential validation`,
198
- }));
199
201
  if (!Challenge.verify(credential.challenge, { secretKey: secretKey })) {
200
202
  await fail(new Errors.InvalidChallengeError({
201
203
  id: credential.challenge.id,
@@ -210,6 +212,24 @@ export function create(config) {
210
212
  await fail(e);
211
213
  throw e;
212
214
  }
215
+ if (methodCandidates.length > 1) {
216
+ const matches = [
217
+ ...new Set(methodCandidates.filter((candidate) => matchesStandaloneMethod(candidate, credential.challenge.request, options?.request))),
218
+ ];
219
+ if (matches.length !== 1)
220
+ await fail(new Errors.InvalidChallengeError({
221
+ id: credential.challenge.id,
222
+ reason: matches.length === 0
223
+ ? 'no configured method matches the challenge request'
224
+ : 'multiple configured methods match the challenge request; use a configured route handler',
225
+ }));
226
+ mi = matches[0];
227
+ }
228
+ if (parameters.requireValidate && !mi.validate)
229
+ await fail(new Errors.VerificationFailedError({
230
+ details: { intent: credIntent, method: credMethod },
231
+ reason: `${credMethod}/${credIntent} does not support non-mutating credential validation`,
232
+ }));
213
233
  let parsedCredential;
214
234
  try {
215
235
  parsedCredential = withParsedCredentialPayload(credential, mi.schema.credential.payload.parse(credential.payload));
@@ -291,6 +311,7 @@ export function create(config) {
291
311
  return prepared.method.validate({
292
312
  credential: prepared.parsedCredential,
293
313
  envelope: prepared.envelope,
314
+ operation: 'validate',
294
315
  request: prepared.request,
295
316
  });
296
317
  }
@@ -312,7 +333,12 @@ export function create(config) {
312
333
  let receipt;
313
334
  try {
314
335
  if (mi.broadcast && mi.validate)
315
- await mi.validate({ credential: parsedCredential, envelope, request });
336
+ await mi.validate({
337
+ credential: parsedCredential,
338
+ envelope,
339
+ operation: 'broadcast',
340
+ request,
341
+ });
316
342
  const broadcast = mi.broadcast ?? mi.verify;
317
343
  receipt = await broadcast({ credential: parsedCredential, envelope, request });
318
344
  }
@@ -336,7 +362,7 @@ export function create(config) {
336
362
  receipt,
337
363
  request: parsedRequest,
338
364
  ...(requestInput !== undefined && { requestInput }),
339
- }));
365
+ }), mi);
340
366
  return receipt;
341
367
  }
342
368
  const verifyCredentialFn = broadcastCredentialFn;
@@ -347,18 +373,27 @@ export function create(config) {
347
373
  if (entries.length === 0)
348
374
  throw new Error('compose() requires at least one entry');
349
375
  const configured = entries.map(([methodOrKey, options]) => {
376
+ const method = typeof methodOrKey === 'string'
377
+ ? undefined
378
+ : typeof methodOrKey === 'function'
379
+ ? methodOrKey._method
380
+ : methodOrKey;
350
381
  const key = typeof methodOrKey === 'string'
351
382
  ? methodOrKey
352
- : typeof methodOrKey === 'function' && '_method' in methodOrKey
353
- ? `${methodOrKey._method.name}/${methodOrKey._method.alias ?? methodOrKey._method.intent}`
354
- : `${methodOrKey.name}/${methodOrKey.alias ?? methodOrKey.intent}`;
355
- const handlerFn = handlers[key];
383
+ : `${method.name}/${method.alias ?? method.intent}`;
384
+ const handlerFn = typeof methodOrKey === 'function' && handlers[key] === methodOrKey
385
+ ? methodOrKey
386
+ : method
387
+ ? methodHandlers.get(method)
388
+ : handlers[key];
356
389
  if (!handlerFn)
357
390
  throw new Error(`No handler for "${key}". Is this method in your methods array?`);
358
- const method = handlerFn._method;
359
- if (isMcp && method?.transport && method.transport.name !== transport.name)
391
+ const configuredMethod = handlerFn._method;
392
+ if (isMcp &&
393
+ configuredMethod?.transport &&
394
+ configuredMethod.transport.name !== transport.name)
360
395
  throw new Error('MCP compose() requires methods using the configured MCP transport');
361
- if (isMcp && method?.canOffer)
396
+ if (isMcp && configuredMethod?.canOffer)
362
397
  throw new Error('MCP compose() does not support HTTP canOffer hooks');
363
398
  return handlerFn(options);
364
399
  });
@@ -405,8 +440,15 @@ function createMethodFn(parameters) {
405
440
  name: method.name,
406
441
  intent: method.intent,
407
442
  html: method.html,
443
+ credentialHeaders: [
444
+ ...new Set([
445
+ credentialHeader ?? Constants.Headers.authorization,
446
+ ...(transport.credentialHeaders ?? []),
447
+ ]),
448
+ ],
408
449
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
409
450
  _stableBinding: stableBinding,
451
+ _transport: transport,
410
452
  };
411
453
  Object.defineProperty(internal, '_method', { value: method });
412
454
  const handler = async (input) => {
@@ -584,7 +626,7 @@ function createMethodFn(parameters) {
584
626
  }
585
627
  const success = (receiptData, options = {}) => {
586
628
  const { challengeId = challenge.id, credentialForReceipt = { challenge, payload: {} }, envelopeForReceipt, managementResponse, } = options;
587
- return {
629
+ const result = {
588
630
  status: 200,
589
631
  withReceipt(response) {
590
632
  if (managementResponse) {
@@ -609,6 +651,9 @@ function createMethodFn(parameters) {
609
651
  });
610
652
  },
611
653
  };
654
+ if (transport.supportsStreamingReceipts)
655
+ streamingReceiptWrappers.add(result.withReceipt);
656
+ return result;
612
657
  };
613
658
  // No credential provided—issue challenge
614
659
  if (!credential) {
@@ -620,15 +665,6 @@ function createMethodFn(parameters) {
620
665
  request: challenge.request,
621
666
  });
622
667
  if (authorized) {
623
- await events.emit('payment.success', createPaymentSuccessContext({
624
- capturedRequest,
625
- challenge,
626
- input,
627
- method,
628
- receipt: authorized.receipt,
629
- request: parsedRequest,
630
- requestInput: request,
631
- }));
632
668
  return success(authorized.receipt, {
633
669
  managementResponse: authorized.response,
634
670
  });
@@ -799,7 +835,12 @@ function createMethodFn(parameters) {
799
835
  let receiptData;
800
836
  try {
801
837
  if (broadcast && validate)
802
- await validate({ credential: parsedCredential, envelope, request });
838
+ await validate({
839
+ credential: parsedCredential,
840
+ envelope,
841
+ operation: 'broadcast',
842
+ request,
843
+ });
803
844
  const broadcastCredential = broadcast ?? verify;
804
845
  receiptData = await broadcastCredential({
805
846
  credential: parsedCredential,
@@ -851,7 +892,7 @@ function createMethodFn(parameters) {
851
892
  receipt: receiptData,
852
893
  request: parsedRequest,
853
894
  requestInput: request,
854
- }));
895
+ }), method);
855
896
  return success(receiptData, {
856
897
  challengeId: credential.challenge.id,
857
898
  credentialForReceipt: parsedCredential,
@@ -916,6 +957,7 @@ function createAttestationGuard(verifiers) {
916
957
  return verification;
917
958
  };
918
959
  }
960
+ /** Dispatches instance events and scopes success hooks to the supplied configured method. */
919
961
  function createServerEventDispatcher() {
920
962
  const handlers = {
921
963
  '*': new Set(),
@@ -936,7 +978,22 @@ function createServerEventDispatcher() {
936
978
  }
937
979
  };
938
980
  return {
939
- async emit(name, context) {
981
+ async emit(name, context, method) {
982
+ if (name === 'payment.success' && method?.onPaymentSuccess) {
983
+ const success = context;
984
+ try {
985
+ await method.onPaymentSuccess({
986
+ challenge: success.challenge,
987
+ input: success.input,
988
+ receipt: success.receipt,
989
+ request: success.request,
990
+ ...(success.requestInput !== undefined && { requestInput: success.requestInput }),
991
+ });
992
+ }
993
+ catch {
994
+ // Per-method hooks are isolated just like instance event handlers.
995
+ }
996
+ }
940
997
  await emitServerEventHandlers(handlers[name], context);
941
998
  await emitServerEventHandlers(handlers['*'], toServerEventEnvelope(name, context));
942
999
  },
@@ -1328,6 +1385,30 @@ function getPinnedChallengeMismatch(expectedChallenge, actualChallenge) {
1328
1385
  return 'opaque';
1329
1386
  return getPinnedRequestBindingMismatch(expectedChallenge.request, actualChallenge.request);
1330
1387
  }
1388
+ /** Matches standalone credentials against the configured portion of a method's stable binding. */
1389
+ function matchesStandaloneMethod(method, request, routeRequest) {
1390
+ try {
1391
+ const configured = { ...method.defaults, ...routeRequest };
1392
+ const parsed = method.schema.request.safeParse(configured);
1393
+ // Standalone callers may omit route-only inputs such as amount. In that
1394
+ // case, constrain selection by the defaults we have rather than parsing
1395
+ // the echoed wire request as input and applying its transforms twice.
1396
+ const expectedRequest = parsed.success ? parsed.data : configured;
1397
+ const binding = (value) => {
1398
+ if (method.stableBinding)
1399
+ return getStableBinding(value, method.stableBinding);
1400
+ const { coreBinding, methodBinding } = getPinnedRequestBinding(value);
1401
+ return { ...coreBinding, ...methodBinding };
1402
+ };
1403
+ const expected = binding(expectedRequest);
1404
+ const actual = binding(request);
1405
+ return Object.entries(expected).every(([key, value]) => value === undefined ||
1406
+ isDeepStrictEqual(normalizeComparable(value), normalizeComparable(actual[key])));
1407
+ }
1408
+ catch {
1409
+ return false;
1410
+ }
1411
+ }
1331
1412
  function getPinnedRequestBindingMismatch(expectedRequest, actualRequest) {
1332
1413
  const expected = getPinnedRequestBinding(expectedRequest);
1333
1414
  const actual = getPinnedRequestBinding(actualRequest);
@@ -1457,6 +1538,11 @@ export function compose(...args) {
1457
1538
  return composeHandlers(handlers, composeOptions);
1458
1539
  }
1459
1540
  function composeHandlers(handlers, composeOptions, selectOffers) {
1541
+ // Flatten only shared policies; a static wrapper must retain a nested server's selector.
1542
+ handlers = handlers.flatMap((handler) => {
1543
+ const internal = handler._internal;
1544
+ return internal?.selectOffers === selectOffers ? (internal?.handlers ?? [handler]) : [handler];
1545
+ });
1460
1546
  if (handlers.length === 0)
1461
1547
  throw new Error('compose() requires at least one handler');
1462
1548
  const offerSelector = selectOffers;
@@ -1486,7 +1572,10 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1486
1572
  // Try to extract a Payment credential to decide whether to dispatch or challenge.
1487
1573
  // Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.)
1488
1574
  // should fall through to the merged-402 path so all offers are presented.
1489
- const paymentHeader = Array.from(input.headers.values())
1575
+ const credentialHeaders = new Set(handlers.flatMap((handler) => getConfiguredOffers(handler).flatMap((internal) => internal.credentialHeaders ?? [])));
1576
+ const paymentHeader = Array.from(credentialHeaders)
1577
+ .map((header) => input.headers.get(header))
1578
+ .filter((value) => value !== null)
1490
1579
  .map((value) => Credential.extractPaymentScheme(value))
1491
1580
  .find((value) => value !== null);
1492
1581
  if (paymentHeader) {
@@ -1525,6 +1614,24 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1525
1614
  // Unrecognized or malformed credentials still use the currently eligible
1526
1615
  // offer set before the first handler issues its rejection challenge.
1527
1616
  }
1617
+ const transportResults = new Map();
1618
+ for (const handler of handlers) {
1619
+ for (const internal of getConfiguredOffers(handler)) {
1620
+ const matchCredential = internal._transport.matchCredential;
1621
+ if (!matchCredential)
1622
+ continue;
1623
+ try {
1624
+ if (await matchCredential({ input, request: internal._canonicalRequest })) {
1625
+ const result = await handler(input);
1626
+ transportResults.set(handler, result);
1627
+ if (result.status === 200)
1628
+ return result;
1629
+ break;
1630
+ }
1631
+ }
1632
+ catch { }
1633
+ }
1634
+ }
1528
1635
  const selectedHandlers = hasOfferPolicy
1529
1636
  ? await selectOfferHandlers({
1530
1637
  entries: offerEntries,
@@ -1532,13 +1639,23 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1532
1639
  selectOffers: offerSelector,
1533
1640
  })
1534
1641
  : handlers;
1642
+ const transportCredentialHandler = selectedHandlers.find((handler) => getConfiguredOffers(handler).some((internal) => internal.credentialHeaders?.some((header) => {
1643
+ const value = input.headers.get(header);
1644
+ return (header.toLowerCase() !== Constants.Headers.authorization.toLowerCase() &&
1645
+ value !== null &&
1646
+ Credential.extractPaymentScheme(value) === null);
1647
+ })));
1648
+ if (transportCredentialHandler)
1649
+ return transportCredentialHandler(input);
1535
1650
  // No credential — evaluate handlers sequentially so authorize()/renewal hooks
1536
1651
  // can safely claim the request without racing each other.
1537
1652
  const results = [];
1538
1653
  for (const handler of selectedHandlers) {
1539
- const result = await handler(input);
1654
+ const result = transportResults.get(handler) ?? (await handler(input));
1540
1655
  if (result.status === 200)
1541
1656
  return result;
1657
+ if (result.challenge.status !== 402)
1658
+ return result;
1542
1659
  results.push(result);
1543
1660
  }
1544
1661
  const challengeEntries = (() => {
@@ -1655,7 +1772,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1655
1772
  };
1656
1773
  const offers = handlers.flatMap(getConfiguredOffers);
1657
1774
  if (offers.length > 0)
1658
- composed._internal = { offers };
1775
+ composed._internal = { offers, handlers: handlers, selectOffers };
1659
1776
  return composed;
1660
1777
  }
1661
1778
  /** Returns the flattened configured offers accepted by a handler. */
@@ -1669,7 +1786,7 @@ function getConfiguredOffers(handler) {
1669
1786
  }
1670
1787
  /** Dispatches one MCP credential or gathers unsigned offers in their configured order. */
1671
1788
  function composeMcpHandlers(handlers, transport) {
1672
- return async (input) => {
1789
+ const composed = async (input) => {
1673
1790
  let credential;
1674
1791
  try {
1675
1792
  const parsed = transport.getCredential(input);
@@ -1680,13 +1797,9 @@ function composeMcpHandlers(handlers, transport) {
1680
1797
  return handlers[0](input);
1681
1798
  }
1682
1799
  if (credential) {
1683
- const candidates = handlers.filter((handler) => {
1684
- const internal = handler._internal;
1685
- return (internal.name === credential.challenge.method &&
1686
- internal.intent === credential.challenge.intent);
1687
- });
1688
- const match = candidates.find((handler) => {
1689
- const internal = handler._internal;
1800
+ const candidates = handlers.filter((handler) => getConfiguredOffers(handler).some((internal) => internal.name === credential.challenge.method &&
1801
+ internal.intent === credential.challenge.intent));
1802
+ const match = candidates.find((handler) => getConfiguredOffers(handler).some((internal) => {
1690
1803
  try {
1691
1804
  const mismatch = internal._stableBinding
1692
1805
  ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credential.challenge.request, internal._stableBinding))
@@ -1696,7 +1809,7 @@ function composeMcpHandlers(handlers, transport) {
1696
1809
  catch {
1697
1810
  return false;
1698
1811
  }
1699
- });
1812
+ }));
1700
1813
  // Verification and settlement belong to exactly one handler, including failures.
1701
1814
  return (match ?? candidates[0] ?? handlers[0])(input);
1702
1815
  }
@@ -1738,6 +1851,10 @@ function composeMcpHandlers(handlers, transport) {
1738
1851
  },
1739
1852
  };
1740
1853
  };
1854
+ composed._internal = {
1855
+ offers: handlers.flatMap((handler) => getConfiguredOffers(handler)),
1856
+ };
1857
+ return composed;
1741
1858
  }
1742
1859
  function isComposedHandlerMetadata(internal) {
1743
1860
  return 'offers' in internal && !('_canonicalRequest' in internal);
@@ -1849,52 +1966,47 @@ function mergeX402PaymentRequiredHeaders(values) {
1849
1966
  }),
1850
1967
  ];
1851
1968
  }
1852
- /**
1853
- * Wraps a payment handler to create a Node.js HTTP listener.
1854
- *
1855
- * On 402: writes the challenge response and ends the connection.
1856
- * On 200: sets the Payment-Receipt header; caller should write response body.
1857
- *
1858
- * @example
1859
- * ```ts
1860
- * import * as http from 'node:http'
1861
- * import { Mppx } from 'mppx/server'
1862
- *
1863
- * const payment = Mppx.create({ ... })
1864
- *
1865
- * http.createServer(async (req, res) => {
1866
- * const result = await Mppx.toNodeListener(
1867
- * payment.charge({
1868
- * amount: '1', currency: '...', recipient: '0x...',
1869
- * }),
1870
- * )(req, res)
1871
- * if (result.status === 402) return
1872
- * res.end('OK')
1873
- * })
1874
- * ```
1875
- */
1876
- export function toNodeListener(handler) {
1969
+ export function toNodeListener(handler, options) {
1877
1970
  return async (req, res) => {
1878
- const result = await handler(Request.fromNodeListener(req, res));
1971
+ let result;
1972
+ try {
1973
+ const request = Request.fromNodeListener(req, res, options);
1974
+ await Request.waitForBody(request);
1975
+ result = await handler(request);
1976
+ }
1977
+ catch (error) {
1978
+ if (!(error instanceof Request.RequestBodyTooLargeError))
1979
+ throw error;
1980
+ const response = new globalThis.Response('Payload Too Large', {
1981
+ status: 413,
1982
+ headers: { 'Content-Type': 'text/plain' },
1983
+ });
1984
+ await NodeListener.sendResponse(res, response);
1985
+ return { challenge: response, status: 402 };
1986
+ }
1879
1987
  if (result.status === 402) {
1880
1988
  await NodeListener.sendResponse(res, result.challenge);
1881
1989
  }
1882
1990
  else {
1883
- const managementResponse = getManagementResponse(result);
1991
+ const managementResponse = await getManagementResponse(result);
1884
1992
  if (managementResponse) {
1885
1993
  await NodeListener.sendResponse(res, managementResponse);
1886
1994
  return { challenge: managementResponse, status: 402 };
1887
1995
  }
1888
- const wrapped = result.withReceipt(new globalThis.Response());
1996
+ const wrapped = await result.withReceipt(new globalThis.Response(null, { status: 204 }));
1997
+ if (wrapped.status === 402) {
1998
+ await NodeListener.sendResponse(res, wrapped);
1999
+ return { challenge: wrapped, status: 402 };
2000
+ }
1889
2001
  for (const [name, value] of wrapped.headers)
1890
2002
  res.setHeader(name, value);
1891
2003
  }
1892
2004
  return result;
1893
2005
  };
1894
2006
  }
1895
- function getManagementResponse(result) {
2007
+ async function getManagementResponse(result) {
1896
2008
  try {
1897
- return result.withReceipt();
2009
+ return await result.withReceipt();
1898
2010
  }
1899
2011
  catch (error) {
1900
2012
  if (isMissingReceiptResponseError(error)) {
@@ -4,6 +4,10 @@
4
4
  * Useful when bridging Fetch API handlers with Node.js HTTP servers.
5
5
  */
6
6
  export async function sendResponse(res, response) {
7
+ if (isResponseClosed(res)) {
8
+ await response.body?.cancel('response connection closed').catch(() => { });
9
+ return;
10
+ }
7
11
  const headers = {};
8
12
  for (const [key, value] of response.headers) {
9
13
  if (key in headers) {
@@ -23,21 +27,82 @@ export async function sendResponse(res, response) {
23
27
  res.writeHead(response.status, headers);
24
28
  if (response.body != null && res.req?.method !== 'HEAD') {
25
29
  const reader = response.body.getReader();
30
+ let connectionClosed = false;
31
+ const onClose = () => {
32
+ connectionClosed = true;
33
+ void reader.cancel('response connection closed').catch(() => { });
34
+ };
35
+ res.once('close', onClose);
36
+ if (isResponseClosed(res))
37
+ onClose();
26
38
  try {
39
+ if (res.destroyed || res.writableEnded) {
40
+ await reader.cancel('response connection closed');
41
+ return;
42
+ }
27
43
  while (true) {
28
44
  const { done, value } = await reader.read();
29
- if (done)
45
+ if (done || connectionClosed)
30
46
  break;
31
- if (res.write(value) === false)
32
- await new Promise((resolve) => {
33
- res.once('drain', resolve);
34
- });
47
+ if (res.write(value) === false) {
48
+ try {
49
+ if (!(await waitForDrain(res))) {
50
+ await reader.cancel('response connection closed');
51
+ return;
52
+ }
53
+ }
54
+ catch (error) {
55
+ await reader.cancel(error).catch(() => { });
56
+ throw error;
57
+ }
58
+ }
35
59
  }
36
60
  }
37
61
  finally {
62
+ res.removeListener('close', onClose);
38
63
  reader.releaseLock();
39
64
  }
40
65
  }
41
- res.end();
66
+ if (!isResponseClosed(res))
67
+ res.end();
68
+ }
69
+ /** Returns whether the HTTP/1 response or underlying HTTP/2 stream has closed. */
70
+ function isResponseClosed(res) {
71
+ const stream = 'stream' in res ? res.stream : undefined;
72
+ return Boolean(res.destroyed || res.writableEnded || stream?.destroyed || stream?.closed);
73
+ }
74
+ /**
75
+ * Waits for write backpressure to clear.
76
+ *
77
+ * Returns `true` on drain, `false` when the response closes, and rejects on a
78
+ * write-side error. Event listeners are always removed before settlement.
79
+ */
80
+ function waitForDrain(res) {
81
+ if (isResponseClosed(res))
82
+ return Promise.resolve(false);
83
+ return new Promise((resolve, reject) => {
84
+ const cleanup = () => {
85
+ res.removeListener('drain', onDrain);
86
+ res.removeListener('close', onClose);
87
+ res.removeListener('error', onError);
88
+ };
89
+ const onDrain = () => {
90
+ cleanup();
91
+ resolve(true);
92
+ };
93
+ const onClose = () => {
94
+ cleanup();
95
+ resolve(false);
96
+ };
97
+ const onError = (error) => {
98
+ cleanup();
99
+ reject(error);
100
+ };
101
+ res.once('drain', onDrain);
102
+ res.once('close', onClose);
103
+ res.once('error', onError);
104
+ if (isResponseClosed(res))
105
+ onClose();
106
+ });
42
107
  }
43
108
  //# sourceMappingURL=NodeListener.js.map
@@ -2,9 +2,17 @@ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http
2
2
  export type FetchHandler = (request: Request) => Promise<Response> | Response;
3
3
  export type RequestListenerOptions = {
4
4
  host?: string | undefined;
5
+ /** Maximum accepted request body size in bytes. @default 10485760 (10 MiB) */
6
+ maxBodySize?: number | undefined;
5
7
  onError?: ((error: unknown) => void | Response | Promise<void | Response>) | undefined;
6
8
  protocol?: string | undefined;
7
9
  };
10
+ /** Options used only while converting a Node request to a Fetch request. */
11
+ export type NodeConversionOptions = Omit<RequestListenerOptions, 'onError'>;
12
+ /** Raised when a Node request body exceeds the configured byte limit. */
13
+ export declare class RequestBodyTooLargeError extends Error {
14
+ readonly name = "RequestBodyTooLargeError";
15
+ }
8
16
  /**
9
17
  * Converts a Fetch API handler into a Node.js HTTP request listener.
10
18
  *
@@ -20,5 +28,7 @@ export declare function toNodeListener(handler: FetchHandler, options?: RequestL
20
28
  * @param res - The Node.js ServerResponse (used for abort signal lifecycle).
21
29
  * @returns A Fetch API Request.
22
30
  */
23
- export declare function fromNodeListener(req: IncomingMessage, res: ServerResponse, options?: RequestListenerOptions | undefined): Request;
31
+ export declare function fromNodeListener(req: IncomingMessage, res: ServerResponse, options?: NodeConversionOptions | undefined): Request;
32
+ /** Waits for a Node-backed request body to finish and enforces its size limit. */
33
+ export declare function waitForBody(request: Request): Promise<void>;
24
34
  //# sourceMappingURL=Request.d.ts.map