mppx 0.15.0 → 0.17.0

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 (101) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/Challenge.d.ts +6 -0
  3. package/dist/Challenge.js +12 -0
  4. package/dist/Method.d.ts +2 -0
  5. package/dist/cli/cli.js +143 -131
  6. package/dist/cli/plugins/index.js +11 -11
  7. package/dist/cli/validate/payment.d.ts +12 -0
  8. package/dist/cli/validate/payment.js +137 -14
  9. package/dist/client/Mppx.d.ts +13 -3
  10. package/dist/client/Mppx.js +47 -22
  11. package/dist/client/internal/Fetch.d.ts +11 -0
  12. package/dist/client/internal/Fetch.js +46 -8
  13. package/dist/client/internal/ProofRealm.d.ts +21 -0
  14. package/dist/client/internal/ProofRealm.js +55 -0
  15. package/dist/client/internal/protocols/Mpp.js +4 -1
  16. package/dist/{attestation → extensions/identity}/Constants.d.ts +2 -2
  17. package/dist/{attestation → extensions/identity}/Constants.js +2 -2
  18. package/dist/extensions/identity/Types.d.ts +21 -0
  19. package/dist/extensions/identity/index.d.ts +7 -0
  20. package/dist/extensions/identity/index.js +6 -0
  21. package/dist/{attestation → extensions/identity}/internal/HttpMessageSignature.d.ts +3 -3
  22. package/dist/{attestation → extensions/identity}/internal/HttpMessageSignature.js +10 -10
  23. package/dist/extensions/identity/internal/SigningContext.d.ts +6 -0
  24. package/dist/{attestation → extensions/identity}/internal/SigningContext.js +3 -3
  25. package/dist/extensions/identity/internal/Verification.d.ts +9 -0
  26. package/dist/extensions/identity/internal/Verification.js +10 -0
  27. package/dist/{tap → extensions/identity/tap}/Constants.js +1 -1
  28. package/dist/extensions/identity/tap/client/index.d.ts +32 -0
  29. package/dist/{tap → extensions/identity/tap}/client/index.js +13 -9
  30. package/dist/extensions/identity/tap/server/index.d.ts +27 -0
  31. package/dist/{tap → extensions/identity/tap}/server/index.js +12 -4
  32. package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Constants.js +1 -1
  33. package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/client/index.d.ts +6 -5
  34. package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/client/index.js +5 -5
  35. package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/server/index.d.ts +6 -5
  36. package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/server/index.js +6 -4
  37. package/dist/identity/Client.d.ts +34 -0
  38. package/dist/identity/Client.js +133 -0
  39. package/dist/identity/Server.d.ts +59 -0
  40. package/dist/identity/Server.js +194 -0
  41. package/dist/identity/Types.d.ts +112 -0
  42. package/dist/identity/Types.js +2 -0
  43. package/dist/{attestation → identity}/index.d.ts +2 -2
  44. package/dist/{attestation → identity}/index.js +1 -2
  45. package/dist/identity/internal/SigningContext.d.ts +6 -0
  46. package/dist/identity/internal/SigningContext.js +11 -0
  47. package/dist/integrations/x402/Evm.d.ts +1 -0
  48. package/dist/internal/version.d.ts +2 -2
  49. package/dist/internal/version.js +1 -1
  50. package/dist/mcp/client/McpClient.d.ts +5 -0
  51. package/dist/mcp/client/McpClient.js +2 -0
  52. package/dist/proxy/Proxy.js +10 -5
  53. package/dist/proxy/Service.d.ts +42 -4
  54. package/dist/proxy/Service.js +21 -5
  55. package/dist/server/Mppx.d.ts +27 -8
  56. package/dist/server/Mppx.js +164 -54
  57. package/dist/server/internal/challengeMeta.d.ts +6 -0
  58. package/dist/server/internal/challengeMeta.js +17 -1
  59. package/dist/stripe/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/client/Charge.d.ts +11 -0
  61. package/dist/tempo/client/Charge.js +30 -1
  62. package/dist/tempo/internal/charge.js +2 -1
  63. package/dist/tempo/internal/defaults.d.ts +9 -0
  64. package/dist/tempo/internal/defaults.js +9 -0
  65. package/dist/tempo/internal/fee-payer.d.ts +0 -2
  66. package/dist/tempo/internal/fee-payer.js +2 -6
  67. package/dist/tempo/server/Charge.js +12 -1
  68. package/dist/tempo/server/Subscription.js +0 -1
  69. package/dist/tempo/server/internal/html.gen.js +1 -1
  70. package/dist/tempo/session/client/Runtime.d.ts +2 -0
  71. package/dist/tempo/session/client/Session.js +1 -1
  72. package/dist/tempo/session/client/SessionManager.d.ts +6 -0
  73. package/dist/tempo/session/client/SessionManager.js +14 -7
  74. package/dist/tempo/session/client/Transports.d.ts +12 -3
  75. package/dist/tempo/session/client/Transports.js +37 -11
  76. package/dist/tempo/session/precompile/Chain.js +0 -3
  77. package/dist/tempo/subscription/Store.js +10 -9
  78. package/package.json +15 -11
  79. package/dist/attestation/Client.d.ts +0 -17
  80. package/dist/attestation/Client.js +0 -28
  81. package/dist/attestation/Server.d.ts +0 -4
  82. package/dist/attestation/Server.js +0 -8
  83. package/dist/attestation/Types.d.ts +0 -76
  84. package/dist/attestation/internal/SigningContext.d.ts +0 -4
  85. package/dist/tap/client/index.d.ts +0 -26
  86. package/dist/tap/server/index.d.ts +0 -20
  87. /package/dist/{attestation → extensions/identity}/Types.js +0 -0
  88. /package/dist/{tap → extensions/identity/tap}/Constants.d.ts +0 -0
  89. /package/dist/{tap → extensions/identity/tap}/Types.d.ts +0 -0
  90. /package/dist/{tap → extensions/identity/tap}/Types.js +0 -0
  91. /package/dist/{tap → extensions/identity/tap}/index.d.ts +0 -0
  92. /package/dist/{tap → extensions/identity/tap}/index.js +0 -0
  93. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Constants.d.ts +0 -0
  94. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Types.d.ts +0 -0
  95. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Types.js +0 -0
  96. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/index.d.ts +0 -0
  97. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/index.js +0 -0
  98. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/JwkThumbprint.d.ts +0 -0
  99. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/JwkThumbprint.js +0 -0
  100. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/SignatureAgent.d.ts +0 -0
  101. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/SignatureAgent.js +0 -0
@@ -1,10 +1,10 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import * as AttestationServer from '../attestation/Server.js';
3
2
  import * as Challenge from '../Challenge.js';
4
3
  import * as Constants from '../Constants.js';
5
4
  import * as Credential from '../Credential.js';
6
5
  import * as Errors from '../Errors.js';
7
6
  import * as Expires from '../Expires.js';
7
+ import * as IdentityServer from '../identity/Server.js';
8
8
  import * as AcceptPayment from '../internal/AcceptPayment.js';
9
9
  import * as Env from '../internal/env.js';
10
10
  import { warnOnce } from '../internal/warnOnce.js';
@@ -59,17 +59,19 @@ export const reservedMppxKeys = new Set(reservedMppxKeyValues);
59
59
  * ```
60
60
  */
61
61
  export function create(config) {
62
- const { attestation, requiresAuth, realm = Env.get('realm'), selectOffers, secretKey = Env.get('secretKey'), transport = Transport.http({ requiresAuth }), } = config;
62
+ const { requiresAuth, realm = Env.get('realm'), selectOffers, secretKey = Env.get('secretKey'), transport = Transport.http({ requiresAuth }), } = config;
63
63
  const credentialHeader = requiresAuth ? Constants.Headers.paymentAuthorization : undefined;
64
+ if ('attestation' in config)
65
+ throw new TypeError('Mppx server `attestation` was replaced by per-route `identity`: pass `identity: [verifier]` to each route.');
66
+ // Identity is configured per route; a top-level option would otherwise be ignored (fail open).
67
+ if ('identity' in config)
68
+ throw new TypeError('Mppx server identity is configured per route: pass `identity: [verifier]` to each route.');
64
69
  if (!secretKey) {
65
70
  throw new Error('Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().');
66
71
  }
67
72
  SecretKey.assert(secretKey);
68
73
  const methods = config.methods.flat();
69
74
  const serverEvents = createServerEventDispatcher();
70
- const verifyAttestation = attestation
71
- ? createAttestationGuard(attestation)
72
- : undefined;
73
75
  const handlers = {};
74
76
  const methodHandlers = new Map();
75
77
  const intentCount = {};
@@ -87,7 +89,6 @@ export function create(config) {
87
89
  assertNoReservedMppxKeys(methods);
88
90
  for (const mi of methods) {
89
91
  const fn = createMethodFn({
90
- ...(verifyAttestation && { attest: verifyAttestation }),
91
92
  authorize: mi.authorize,
92
93
  credentialHeader,
93
94
  defaults: mi.defaults,
@@ -436,8 +437,19 @@ export function create(config) {
436
437
  }
437
438
  // biome-ignore lint/correctness/noUnusedVariables: _
438
439
  function createMethodFn(parameters) {
439
- const { attest, authorize, credentialHeader, defaults, events, method, preflight, realm, respond, secretKey, broadcast, stableBinding, transport, validate, verify, } = parameters;
440
- return (options) => {
440
+ const { authorize, credentialHeader, defaults, events, method, preflight, realm, respond, secretKey, broadcast, stableBinding, transport, validate, verify, } = parameters;
441
+ return (routeOptions) => {
442
+ const { identity = [], ...options } = routeOptions;
443
+ const requestVerifiers = identity.filter((verifier) => IdentityServer.stageOf(verifier) === 'request');
444
+ const paymentVerifiers = identity.filter((verifier) => IdentityServer.stageOf(verifier) === 'payment');
445
+ // Preflight hooks (e.g. session bootstrap) issue and settle their own challenges, which
446
+ // payment-stage verifiers cannot see; reject the combination instead of skipping identity.
447
+ if (paymentVerifiers.length > 0 && preflight)
448
+ throw new TypeError(`Payment-stage identity is not supported on ${method.name}/${method.intent} routes with a preflight hook.`);
449
+ // The HTML checkout cannot answer identity challenges, and it pays before resubmitting, so
450
+ // a browser would pay and then be rejected.
451
+ if (paymentVerifiers.length > 0 && method.html)
452
+ throw new TypeError(`Payment-stage identity is not supported on ${method.name}/${method.intent} routes with the HTML checkout.`);
441
453
  const { description, meta, scope, ...rest } = options;
442
454
  const staticMeta = Scope.merge({ meta, scope });
443
455
  const internal = {
@@ -455,20 +467,26 @@ function createMethodFn(parameters) {
455
467
  ]),
456
468
  ],
457
469
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
470
+ _paymentIdentity: paymentVerifiers.length > 0 ? paymentVerifiers : undefined,
458
471
  _stableBinding: stableBinding,
459
472
  _transport: transport,
460
473
  };
461
474
  Object.defineProperty(internal, '_method', { value: method });
462
475
  const handler = async (input) => {
463
- if (attest && input instanceof globalThis.Request) {
464
- const response = await attest(input);
465
- if (response)
466
- return {
467
- status: 200,
468
- withReceipt() {
469
- return response;
470
- },
471
- };
476
+ const identityValues = new Map();
477
+ if (identity.length > 0) {
478
+ if (!(input instanceof globalThis.Request))
479
+ throw new TypeError('Route identity requires the HTTP transport.');
480
+ const outcome = await IdentityServer.verify(input, requestVerifiers);
481
+ if (outcome.status !== 'valid') {
482
+ // Returned on the non-200 branch so callers never run paid work: like an early
483
+ // rejection, `challenge` holds the identity response (401/403) to send as is.
484
+ const rejection = IdentityServer.toResponse(outcome);
485
+ identityRejections.add(rejection);
486
+ return { challenge: rejection, status: 402 };
487
+ }
488
+ for (const [verifier, value] of outcome.values)
489
+ identityValues.set(verifier, value);
472
490
  }
473
491
  if (method.html && isServiceWorkerRequest(input))
474
492
  return {
@@ -482,6 +500,12 @@ function createMethodFn(parameters) {
482
500
  const effectiveMeta = scope === undefined && input instanceof globalThis.Request
483
501
  ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
484
502
  : staticMeta;
503
+ // Every offer of one 402 shares the identity nonce (composed routes pass each offer the
504
+ // same request), so a payment-stage proof is bound to the 402, whichever offer is paid.
505
+ const identityNonce = paymentVerifiers.length > 0 && input instanceof globalThis.Request
506
+ ? IdentityServer.nonceFor(input, ChallengeMeta.createNonce)
507
+ : undefined;
508
+ const challengeMeta = ChallengeMeta.withIdentity(effectiveMeta, identityNonce);
485
509
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
486
510
  let [credential, credentialError] = (() => {
487
511
  try {
@@ -507,6 +531,7 @@ function createMethodFn(parameters) {
507
531
  return { challenge: response, status: 402 };
508
532
  return {
509
533
  status: 200,
534
+ identity: IdentityServer.results(identityValues),
510
535
  withReceipt() {
511
536
  return response;
512
537
  },
@@ -514,12 +539,31 @@ function createMethodFn(parameters) {
514
539
  }
515
540
  }
516
541
  const emitChallenge = async (parameters) => {
517
- const response = await transport.respondChallenge({
542
+ let response = await transport.respondChallenge({
518
543
  challenge: parameters.challenge,
519
544
  input,
520
545
  ...(parameters.error && { error: parameters.error }),
521
546
  ...(parameters.html && { html: parameters.html }),
522
547
  });
548
+ // Payment-stage verifiers add their own challenge headers to the 402.
549
+ if (identityNonce !== undefined &&
550
+ input instanceof globalThis.Request &&
551
+ response instanceof globalThis.Response) {
552
+ const identityHeaders = await IdentityServer.challenge(input, paymentVerifiers, identityNonce);
553
+ if ([...identityHeaders].length > 0) {
554
+ const paymentChallenge = response.headers.get(paymentAuthChallengeHeader);
555
+ const headers = new Headers(response.headers);
556
+ for (const [name, value] of identityHeaders)
557
+ headers.append(name, value);
558
+ response = new globalThis.Response(response.body, {
559
+ headers,
560
+ status: response.status,
561
+ statusText: response.statusText,
562
+ });
563
+ identityChallenges.set(response, identityHeaders);
564
+ paymentChallenges.set(response, paymentChallenge);
565
+ }
566
+ }
523
567
  if (isIssuedChallengeResponse(response))
524
568
  await events.emit('challenge.created', createChallengeContext({
525
569
  capturedRequest,
@@ -552,7 +596,7 @@ function createMethodFn(parameters) {
552
596
  description,
553
597
  expires,
554
598
  header: credentialHeader,
555
- meta: effectiveMeta,
599
+ meta: challengeMeta,
556
600
  method,
557
601
  realm,
558
602
  request: parameters.request,
@@ -567,7 +611,7 @@ function createMethodFn(parameters) {
567
611
  description,
568
612
  expires,
569
613
  header: credentialHeader,
570
- meta: effectiveMeta,
614
+ meta: challengeMeta,
571
615
  method,
572
616
  realm,
573
617
  routeRequest: rest,
@@ -637,6 +681,7 @@ function createMethodFn(parameters) {
637
681
  const result = {
638
682
  status: 200,
639
683
  challenge,
684
+ identity: IdentityServer.results(identityValues),
640
685
  withReceipt(response) {
641
686
  if (managementResponse) {
642
687
  return transport.respondReceipt({
@@ -666,7 +711,17 @@ function createMethodFn(parameters) {
666
711
  };
667
712
  // No credential provided—issue challenge
668
713
  if (!credential) {
669
- if (authorize && input instanceof globalThis.Request) {
714
+ // Authorization without a payment credential still requires payment-stage identity;
715
+ // a verifier that needs the credential reports missing, so the route issues its 402.
716
+ const authorizeIdentity = authorize && input instanceof globalThis.Request && paymentVerifiers.length > 0
717
+ ? await IdentityServer.verify(input, paymentVerifiers, { challenge })
718
+ : undefined;
719
+ if (authorize &&
720
+ input instanceof globalThis.Request &&
721
+ (!authorizeIdentity || authorizeIdentity.status === 'valid')) {
722
+ if (authorizeIdentity?.status === 'valid')
723
+ for (const [verifier, value] of authorizeIdentity.values)
724
+ identityValues.set(verifier, value);
670
725
  try {
671
726
  const authorized = await authorize({
672
727
  challenge,
@@ -839,6 +894,34 @@ function createMethodFn(parameters) {
839
894
  credential: parsedCredential,
840
895
  request: parsedRequest,
841
896
  });
897
+ // Payment-stage identity is checked against the answered challenge before settlement.
898
+ if (paymentVerifiers.length > 0) {
899
+ const outcome = await IdentityServer.verify(input, paymentVerifiers, {
900
+ challenge: credential.challenge,
901
+ credential: parsedCredential,
902
+ nonce: ChallengeMeta.identity(credential.challenge.meta),
903
+ });
904
+ if (outcome.status !== 'valid') {
905
+ const error = new Errors.VerificationFailedError({ reason: outcome.reason });
906
+ await emitPaymentFailed({
907
+ challenge,
908
+ credential: parsedCredential,
909
+ error,
910
+ request: parsedRequest,
911
+ retryChallenge: challenge,
912
+ submittedChallenge: credential.challenge,
913
+ });
914
+ // The client may already have pushed this payment, so a fresh 402 would invite
915
+ // it to pay again. Reject without a challenge, like a replayed credential.
916
+ // Returned on the non-200 branch so callers never run paid work: like an early
917
+ // rejection, `challenge` holds the identity response (401/403) to send as is.
918
+ const rejection = IdentityServer.toResponse(outcome);
919
+ identityRejections.add(rejection);
920
+ return { challenge: rejection, status: 402 };
921
+ }
922
+ for (const [verifier, value] of outcome.values)
923
+ identityValues.set(verifier, value);
924
+ }
842
925
  // User-provided verification (e.g., check signature, submit tx, verify payment).
843
926
  // If verification fails, re-issue the challenge so the client can retry.
844
927
  let receiptData;
@@ -922,7 +1005,7 @@ function createMethodFn(parameters) {
922
1005
  function createChallengeFn(parameters) {
923
1006
  const { credentialHeader, defaults, method, realm, secretKey } = parameters;
924
1007
  return async (options) => {
925
- const { description, meta, scope, ...rest } = options;
1008
+ const { description, identity: _, meta, scope, ...rest } = options;
926
1009
  const effectiveMeta = Scope.merge({ meta, scope });
927
1010
  const expires = 'expires' in options
928
1011
  ? normalizeExpires(options.expires)
@@ -941,33 +1024,6 @@ function createChallengeFn(parameters) {
941
1024
  }).then((resolved) => resolved.challenge);
942
1025
  };
943
1026
  }
944
- function createAttestationGuard(verifiers) {
945
- const entries = Object.entries(verifiers);
946
- if (entries.length === 0)
947
- throw new TypeError('Mppx server attestation must configure at least one verifier.');
948
- const cache = new WeakMap();
949
- return (request) => {
950
- const cached = cache.get(request);
951
- if (cached)
952
- return cached;
953
- const verification = AttestationServer.verify(request, verifiers).then((outcomes) => {
954
- for (const [name, outcome] of Object.entries(outcomes))
955
- if (outcome.status === 'invalid')
956
- return new globalThis.Response(`Request attestation "${name}" is invalid: ${outcome.reason}`, { status: 401 });
957
- for (const [name, outcome] of Object.entries(outcomes)) {
958
- if (outcome.status === 'verified')
959
- continue;
960
- const reason = outcome.status === 'unverified'
961
- ? `Request attestation "${name}" could not be verified: ${outcome.reason}`
962
- : `Request attestation "${name}" is required.`;
963
- return new globalThis.Response(reason, { status: 403 });
964
- }
965
- return undefined;
966
- });
967
- cache.set(request, verification);
968
- return verification;
969
- };
970
- }
971
1027
  /** Dispatches instance events and scopes success hooks to the supplied configured method. */
972
1028
  function createServerEventDispatcher() {
973
1029
  const handlers = {
@@ -1295,6 +1351,7 @@ async function resolveRouteChallenge(parameters) {
1295
1351
  ? (await parameters.request({
1296
1352
  capturedRequest: parameters.capturedRequest,
1297
1353
  credential: parameters.credential,
1354
+ expires: parameters.credential?.challenge.expires ?? parameters.expires,
1298
1355
  request: merged,
1299
1356
  }))
1300
1357
  : merged;
@@ -1531,11 +1588,34 @@ function withParsedCredentialPayload(credential, payload) {
1531
1588
  };
1532
1589
  }
1533
1590
  const paymentAuthChallengeHeader = Constants.Headers.wwwAuthenticate;
1591
+ /** Payment-stage identity headers added to issued 402 responses, keyed by response. @internal */
1592
+ const identityChallenges = new WeakMap();
1593
+ /** Identity rejections (401/403) returned on a route's non-success branch. @internal */
1594
+ const identityRejections = new WeakSet();
1595
+ /**
1596
+ * A 402's Payment `WWW-Authenticate` value without its identity challenge, keyed by response.
1597
+ * Composers merge these and append the shared identity challenge once. @internal
1598
+ */
1599
+ const paymentChallenges = new WeakMap();
1600
+ /** Records a merged 402's identity challenge so an outer composer can forward it. @internal */
1601
+ function withIdentityChallenge(response, headers, paymentChallenge) {
1602
+ if (headers) {
1603
+ identityChallenges.set(response, headers);
1604
+ paymentChallenges.set(response, paymentChallenge);
1605
+ }
1606
+ return response;
1607
+ }
1534
1608
  const challengeHeaderMerges = [
1535
1609
  {
1536
1610
  name: paymentAuthChallengeHeader,
1537
1611
  values: (context) => context.challengeEntries
1538
- .map((entry) => entry.result.challenge.headers.get(paymentAuthChallengeHeader))
1612
+ .map((entry) => {
1613
+ const response = entry.result.challenge;
1614
+ // Identity challenges are appended once by the composer, not once per offer.
1615
+ return paymentChallenges.has(response)
1616
+ ? paymentChallenges.get(response)
1617
+ : response.headers.get(paymentAuthChallengeHeader);
1618
+ })
1539
1619
  .filter((value) => value !== null),
1540
1620
  merge: (values) => values,
1541
1621
  },
@@ -1576,6 +1656,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1576
1656
  });
1577
1657
  if (handlers.length === 0)
1578
1658
  throw new Error('compose() requires at least one handler');
1659
+ // A 402 carries one identity challenge, shared by its offers through the identity nonce, so
1660
+ // every offer that requires payment-stage identity must require the same verifiers.
1661
+ const identityOffers = handlers
1662
+ .flatMap(getConfiguredOffers)
1663
+ .flatMap((offer) => (offer._paymentIdentity ? [offer._paymentIdentity] : []));
1664
+ if (identityOffers.some((verifiers) => !sameVerifiers(verifiers, identityOffers[0])))
1665
+ throw new TypeError('Offers in a composed route must require the same payment-stage identity verifiers.');
1579
1666
  const offerSelector = selectOffers;
1580
1667
  const offerEntries = handlers.map((handler) => {
1581
1668
  const internal = handler._internal;
@@ -1684,21 +1771,30 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1684
1771
  return transportCredentialHandler(input);
1685
1772
  // No credential — evaluate handlers sequentially so authorize()/renewal hooks
1686
1773
  // can safely claim the request without racing each other.
1774
+ // An identity rejection from one offer does not hide the others: it is returned only
1775
+ // when no offer issued a payment challenge.
1687
1776
  const results = [];
1688
1777
  for (const handler of selectedHandlers) {
1689
1778
  const result = transportResults.get(handler) ?? (await handler(input));
1690
1779
  if (result.status === 200)
1691
1780
  return result;
1692
- if (result.challenge.status !== 402)
1781
+ const rejected = identityRejections.has(result.challenge);
1782
+ if (result.challenge.status !== 402 && !rejected)
1693
1783
  return result;
1694
1784
  results.push(result);
1695
1785
  }
1786
+ const offers = results.filter((result) => result.status === 402 && !identityRejections.has(result.challenge));
1787
+ if (offers.length === 0 && results[0])
1788
+ return results[0];
1696
1789
  const challengeEntries = (() => {
1697
1790
  const entries = [];
1698
1791
  for (let i = 0; i < selectedHandlers.length; i++) {
1699
1792
  const result = results[i];
1700
1793
  if (result?.status !== 402)
1701
1794
  continue;
1795
+ // Identity rejections may carry non-Payment challenges (e.g. Bearer); they are not offers.
1796
+ if (identityRejections.has(result.challenge))
1797
+ continue;
1702
1798
  const response = result.challenge;
1703
1799
  const wwwAuth = response.headers.get(paymentAuthChallengeHeader);
1704
1800
  if (!wwwAuth)
@@ -1723,7 +1819,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1723
1819
  return entries;
1724
1820
  }
1725
1821
  })();
1726
- const challengeResponses = results.flatMap((result) => result.status === 402 ? [result.challenge] : []);
1822
+ const challengeResponses = offers.map((result) => result.challenge);
1727
1823
  const unnegotiatedX402Responses = input.headers.has(Constants.Headers.acceptPayment) || challengeEntries.length === 0
1728
1824
  ? []
1729
1825
  : challengeResponses.filter((response) => response.headers.has(x402_Types.paymentRequiredHeader) &&
@@ -1734,6 +1830,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1734
1830
  ...unnegotiatedX402Responses,
1735
1831
  ]
1736
1832
  : challengeResponses;
1833
+ // Offers share one identity challenge (enforced when composing); forward it only if an
1834
+ // offer that carries it is still advertised after negotiation.
1835
+ const identityHeaders = negotiatedChallengeResponses
1836
+ .map((response) => identityChallenges.get(response))
1837
+ .find((headers) => headers !== undefined);
1737
1838
  // Merge challenge headers from the negotiated 402 responses.
1738
1839
  const mergedHeaders = new Headers();
1739
1840
  mergedHeaders.set('Cache-Control', 'no-store');
@@ -1746,6 +1847,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1746
1847
  mergedHeaders.append(header.name, value);
1747
1848
  }
1748
1849
  }
1850
+ // The merged offer values above exclude identity challenges; append the shared one once.
1851
+ const paymentChallenge = mergedHeaders.get(paymentAuthChallengeHeader);
1852
+ if (identityHeaders)
1853
+ for (const [name, value] of identityHeaders)
1854
+ mergedHeaders.append(name, value);
1749
1855
  // Collect html-enabled handlers and their challenges
1750
1856
  const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
1751
1857
  const wantsHtml = input.headers.get('Accept')?.includes('text/html');
@@ -1782,7 +1888,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1782
1888
  });
1783
1889
  return {
1784
1890
  status: 402,
1785
- challenge: new Response(body, { status: 402, headers: mergedHeaders }),
1891
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
1786
1892
  };
1787
1893
  }
1788
1894
  // Non-HTML fallback: prefer the first Payment-auth body, otherwise use
@@ -1802,7 +1908,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1802
1908
  }
1803
1909
  return {
1804
1910
  status: 402,
1805
- challenge: new Response(body, { status: 402, headers: mergedHeaders }),
1911
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
1806
1912
  };
1807
1913
  };
1808
1914
  const offers = handlers.flatMap(getConfiguredOffers);
@@ -1811,6 +1917,10 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1811
1917
  return composed;
1812
1918
  }
1813
1919
  /** Returns the flattened configured offers accepted by a handler. */
1920
+ /** Whether two verifier lists hold the same verifiers in the same order. */
1921
+ function sameVerifiers(a, b) {
1922
+ return a.length === b.length && a.every((verifier, i) => verifier === b[i]);
1923
+ }
1814
1924
  function getConfiguredOffers(handler) {
1815
1925
  const internal = handler._internal;
1816
1926
  if (!internal)
@@ -1,5 +1,11 @@
1
1
  /** Adds fresh issuance randomness while preserving application and route metadata. */
2
2
  export declare function withNonce(meta: Record<string, string> | undefined): Record<string, string>;
3
+ /** Returns fresh randomness for an identity nonce. */
4
+ export declare function createNonce(): string;
5
+ /** Adds an identity nonce to challenge metadata. */
6
+ export declare function withIdentity(meta: Record<string, string> | undefined, nonce: string | undefined): Record<string, string> | undefined;
7
+ /** Reads the identity nonce from challenge metadata. */
8
+ export declare function identity(meta: Record<string, string> | undefined): string | undefined;
3
9
  /** Removes issuance-only metadata when comparing stable route requirements. */
4
10
  export declare function routeMeta(meta: Record<string, string> | undefined): Record<string, string> | undefined;
5
11
  //# sourceMappingURL=challengeMeta.d.ts.map
@@ -6,9 +6,25 @@ export function withNonce(meta) {
6
6
  // Ox uses runtime-specific secure randomness, including Node without global Web Crypto.
7
7
  return { ...meta, [nonceKey]: Secp256k1.randomPrivateKey() };
8
8
  }
9
+ /** Reserved metadata key for the HMAC-bound identity nonce shared by every offer in one 402. */
10
+ const identityKey = '_mppx_identity';
11
+ /** Returns fresh randomness for an identity nonce. */
12
+ export function createNonce() {
13
+ return Secp256k1.randomPrivateKey();
14
+ }
15
+ /** Adds an identity nonce to challenge metadata. */
16
+ export function withIdentity(meta, nonce) {
17
+ if (nonce === undefined)
18
+ return meta;
19
+ return { ...meta, [identityKey]: nonce };
20
+ }
21
+ /** Reads the identity nonce from challenge metadata. */
22
+ export function identity(meta) {
23
+ return meta?.[identityKey];
24
+ }
9
25
  /** Removes issuance-only metadata when comparing stable route requirements. */
10
26
  export function routeMeta(meta) {
11
- const { [nonceKey]: _, ...rest } = meta ?? {};
27
+ const { [nonceKey]: _, [identityKey]: __, ...rest } = meta ?? {};
12
28
  return Object.keys(rest).length ? rest : undefined;
13
29
  }
14
30
  //# sourceMappingURL=challengeMeta.js.map