mppx 0.14.1 → 0.16.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 (105) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/dist/Expires.js +1 -1
  4. package/dist/Store.d.ts +2 -2
  5. package/dist/Store.js +13 -3
  6. package/dist/cli/cli.js +137 -131
  7. package/dist/cli/plugins/index.js +11 -11
  8. package/dist/cli/validate/payment.d.ts +12 -0
  9. package/dist/cli/validate/payment.js +113 -14
  10. package/dist/client/Mppx.d.ts +13 -3
  11. package/dist/client/Mppx.js +47 -22
  12. package/dist/client/internal/Fetch.d.ts +11 -0
  13. package/dist/client/internal/Fetch.js +46 -8
  14. package/dist/client/internal/ProofRealm.d.ts +21 -0
  15. package/dist/client/internal/ProofRealm.js +55 -0
  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 +12 -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 +46 -0
  40. package/dist/identity/Server.js +145 -0
  41. package/dist/identity/Types.d.ts +90 -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 +2 -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/middlewares/express.js +18 -6
  53. package/dist/proxy/Proxy.js +10 -5
  54. package/dist/proxy/Service.d.ts +42 -4
  55. package/dist/proxy/Service.js +21 -5
  56. package/dist/server/Mppx.d.ts +32 -8
  57. package/dist/server/Mppx.js +144 -54
  58. package/dist/stripe/server/internal/html.gen.js +1 -1
  59. package/dist/tempo/Methods.d.ts +5 -0
  60. package/dist/tempo/Methods.js +1 -0
  61. package/dist/tempo/client/Subscription.d.ts +5 -0
  62. package/dist/tempo/internal/charge.js +2 -1
  63. package/dist/tempo/internal/fee-payer.d.ts +0 -2
  64. package/dist/tempo/internal/fee-payer.js +0 -4
  65. package/dist/tempo/server/Charge.js +4 -0
  66. package/dist/tempo/server/Methods.d.ts +26 -0
  67. package/dist/tempo/server/Subscription.d.ts +5 -0
  68. package/dist/tempo/server/Subscription.js +8 -5
  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/session/server/Ws.d.ts +5 -2
  78. package/dist/tempo/session/server/Ws.js +11 -2
  79. package/dist/tempo/subscription/Store.d.ts +7 -1
  80. package/dist/tempo/subscription/Store.js +66 -13
  81. package/dist/tempo/subscription/Types.d.ts +2 -0
  82. package/package.json +15 -11
  83. package/dist/attestation/Client.d.ts +0 -17
  84. package/dist/attestation/Client.js +0 -28
  85. package/dist/attestation/Server.d.ts +0 -4
  86. package/dist/attestation/Server.js +0 -8
  87. package/dist/attestation/Types.d.ts +0 -76
  88. package/dist/attestation/internal/SigningContext.d.ts +0 -4
  89. package/dist/tap/client/index.d.ts +0 -26
  90. package/dist/tap/server/index.d.ts +0 -20
  91. /package/dist/{attestation → extensions/identity}/Types.js +0 -0
  92. /package/dist/{tap → extensions/identity/tap}/Constants.d.ts +0 -0
  93. /package/dist/{tap → extensions/identity/tap}/Types.d.ts +0 -0
  94. /package/dist/{tap → extensions/identity/tap}/Types.js +0 -0
  95. /package/dist/{tap → extensions/identity/tap}/index.d.ts +0 -0
  96. /package/dist/{tap → extensions/identity/tap}/index.js +0 -0
  97. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Constants.d.ts +0 -0
  98. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Types.d.ts +0 -0
  99. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/Types.js +0 -0
  100. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/index.d.ts +0 -0
  101. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/index.js +0 -0
  102. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/JwkThumbprint.d.ts +0 -0
  103. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/JwkThumbprint.js +0 -0
  104. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/SignatureAgent.d.ts +0 -0
  105. /package/dist/{web-bot-auth → extensions/identity/web-bot-auth}/internal/SignatureAgent.js +0 -0
@@ -86,16 +86,32 @@ export function toLlmsTxt(services, options) {
86
86
  }
87
87
  /** Extracts per-endpoint options from an endpoint definition. */
88
88
  export function getOptions(endpoint) {
89
- if (typeof endpoint === 'object' && endpoint !== null && 'options' in endpoint)
89
+ if (typeof endpoint === 'object' && endpoint !== null)
90
90
  return endpoint.options;
91
91
  return undefined;
92
92
  }
93
+ /**
94
+ * Returns the payment handler of an endpoint, or `undefined` for a route declared free (`true` or
95
+ * `{ free: true }`). Throws for anything else, such as `{ pay: undefined }`, so a missing handler
96
+ * fails closed instead of proxying for free with the service's credentials.
97
+ */
98
+ export function getHandler(endpoint) {
99
+ if (typeof endpoint === 'function')
100
+ return endpoint;
101
+ if (endpoint === true)
102
+ return undefined;
103
+ if (typeof endpoint === 'object' && endpoint !== null) {
104
+ if (typeof endpoint.pay === 'function')
105
+ return endpoint.pay;
106
+ if (endpoint.free === true && endpoint.pay === undefined)
107
+ return undefined;
108
+ }
109
+ throw new Error('Invalid proxy endpoint: expected a payment handler, `true` or `{ free: true }`.');
110
+ }
93
111
  /** Derives discovery payment metadata from an explicitly priced endpoint handler. */
94
112
  export function paymentOf(endpoint) {
95
- if (endpoint === true)
96
- return null;
97
- const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay;
98
- if (!('_internal' in handler))
113
+ const handler = getHandler(endpoint);
114
+ if (!handler || !('_internal' in handler))
99
115
  return null;
100
116
  const metadata = handler._internal;
101
117
  if (!metadata)
@@ -1,8 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type * as Attestation from '../attestation/Types.js';
3
2
  import * as Challenge from '../Challenge.js';
4
3
  import * as Credential from '../Credential.js';
5
4
  import * as Errors from '../Errors.js';
5
+ import type * as Identity from '../identity/Types.js';
6
6
  import type { DeepReadonly, MaybePromise } from '../internal/types.js';
7
7
  import * as Method from '../Method.js';
8
8
  import type * as Receipt from '../Receipt.js';
@@ -212,6 +212,7 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
212
212
  * can be parsed; strings that cannot be deserialized have no challenge
213
213
  * context to report.
214
214
  *
215
+ * Requires a configured realm or trusted route context.
215
216
  * @deprecated Use `broadcastCredential()` instead. This legacy name maps
216
217
  * to the same mutating operation and does not provide validation-only
217
218
  * semantics.
@@ -226,6 +227,7 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
226
227
  verifyCredential(credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined): Promise<Receipt.Receipt>;
227
228
  /**
228
229
  * Validate a credential without consuming or broadcasting it.
230
+ * Requires a configured/explicit realm or a trusted captured request URL.
229
231
  *
230
232
  * This is an advisory pre-check. Methods that support it must not write
231
233
  * replay state, sign fee-payer transactions, broadcast, or otherwise
@@ -234,6 +236,7 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
234
236
  validateCredential(credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined): Promise<Method.Validation>;
235
237
  /**
236
238
  * Re-validates and broadcasts a credential, returning a receipt.
239
+ * Requires a configured/explicit realm or a trusted captured request URL.
237
240
  *
238
241
  * `verifyCredential()` is retained as a backwards-compatible alias for
239
242
  * this mutating path.
@@ -274,8 +277,25 @@ type IntentHandlers<methods extends readonly Method.AnyServer[], transport exten
274
277
  intent: intent;
275
278
  }>, transport>;
276
279
  };
280
+ /**
281
+ * Route identity option. HTTP routes accept identity verifiers, all of which must be valid;
282
+ * use `Identity.anyOf` to accept one of several protocols. Other transports reject it.
283
+ */
284
+ type RouteIdentity<transport extends Transport.AnyTransport> = Transport.InputOf<transport> extends Request ? Transport.ChallengeOutputOf<transport> extends Response ? {
285
+ identity?: readonly Identity.Verifier[] | undefined;
286
+ } : {
287
+ identity?: never;
288
+ } : {
289
+ identity?: never;
290
+ };
277
291
  /** Handler for an intent with multiple methods - takes the intersection of all methods' options. */
278
- type MultiMethodFn<methods extends Method.AnyServer, transport extends Transport.AnyTransport> = (options: UnionToIntersection<MultiMethodFnDistribute<methods>>) => (input: Transport.InputOf<transport>) => Promise<MethodFn.Response<transport>>;
292
+ type MultiMethodFn<methods extends Method.AnyServer, transport extends Transport.AnyTransport> = (options: MultiMethodOptions<methods> & RouteIdentity<transport>) => (input: Transport.InputOf<transport>) => Promise<MethodFn.Response<transport>>;
293
+ /**
294
+ * Intersected options of every method for an intent. When a method's options can't be inferred,
295
+ * the intersection is `unknown`; fall back to any object (interfaces included) so `RouteIdentity`
296
+ * doesn't turn it into an object that rejects every option but `identity`.
297
+ */
298
+ type MultiMethodOptions<methods extends Method.AnyServer> = UnionToIntersection<MultiMethodFnDistribute<methods>> extends infer options ? unknown extends options ? Record<string, any> : options : never;
279
299
  type MultiMethodFnDistribute<M> = M extends Method.Server<infer method, infer defaults, any, any, any> ? MethodFn.Options<method, NonNullable<defaults>> : never;
280
300
  type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
281
301
  /** Nested handlers: `mppx.tempo.charge(...)`, grouped by method name then intent. */
@@ -331,8 +351,6 @@ type ChallengeFn<method extends Method.Method, defaults extends Record<string, u
331
351
  export declare function create<const methods extends Methods, const transport extends Transport.AnyTransport = Transport.Http>(config: create.Config<methods, transport>): Mppx<methods, transport>;
332
352
  export declare namespace create {
333
353
  type Config<methods extends Methods = Methods, transport extends Transport.AnyTransport = Transport.Http> = {
334
- /** Request-attestation verifiers required before handling an HTTP payment request. */
335
- attestation?: transport extends Transport.Http ? Attestation.VerifierMap | undefined : never;
336
354
  /** Array of configured methods. @example [tempo()] */
337
355
  methods: methods;
338
356
  /** Uses `Payment-Authorization` for Payment credentials so `Authorization` remains available for application authentication. */
@@ -354,7 +372,7 @@ export declare class MissingReceiptResponseError extends Error {
354
372
  }
355
373
  /** Returns true when an error is the typed `withReceipt()` no-response sentinel. */
356
374
  export declare function isMissingReceiptResponseError(error: unknown): error is MissingReceiptResponseError;
357
- export type MethodFn<method extends Method.Method, transport extends Transport.AnyTransport, defaults extends Record<string, unknown>> = (options: MethodFn.Options<method, defaults>) => (input: Transport.InputOf<transport>) => Promise<MethodFn.Response<transport>>;
375
+ export type MethodFn<method extends Method.Method, transport extends Transport.AnyTransport, defaults extends Record<string, unknown>> = (options: MethodFn.Options<method, defaults> & RouteIdentity<transport>) => (input: Transport.InputOf<transport>) => Promise<MethodFn.Response<transport>>;
358
376
  /** @internal */
359
377
  export type AnyMethodFn = (options: any) => (input: any) => Promise<any>;
360
378
  /** @internal */
@@ -374,6 +392,10 @@ declare namespace MethodFn {
374
392
  status: 402;
375
393
  } | {
376
394
  status: 200;
395
+ /** Canonical challenge resolved from the selected route's terms, when available. */
396
+ challenge?: Challenge.Challenge | undefined;
397
+ /** Verified identity values for the route's verifiers. */
398
+ identity?: Identity.Results | undefined;
377
399
  withReceipt: Transport.WithReceipt<transport>;
378
400
  };
379
401
  }
@@ -390,6 +412,8 @@ type ConfiguredHandler<transport extends Transport.AnyTransport = Transport.Http
390
412
  scope?: string | undefined;
391
413
  _canonicalRequest: Record<string, unknown>;
392
414
  _stableBinding?: Method.StableBindingFn<Method.Method> | undefined;
415
+ /** Whether the offer requires payment-stage identity. */
416
+ _paymentIdentity?: boolean | undefined;
393
417
  _transport: Transport.AnyTransport;
394
418
  };
395
419
  };
@@ -404,19 +428,19 @@ type ComposedHandler<transport extends Transport.AnyTransport = Transport.Http>
404
428
  type ComposeEntry<methods extends readonly Method.AnyServer[]> = {
405
429
  [i in keyof methods]: readonly [
406
430
  methods[i],
407
- MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>>
431
+ MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>> & RouteIdentity<Transport.Http>
408
432
  ];
409
433
  }[number] | {
410
434
  [i in keyof methods]: readonly [
411
435
  `${methods[i]['name']}/${methods[i]['intent']}`,
412
- MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>>
436
+ MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>> & RouteIdentity<Transport.Http>
413
437
  ];
414
438
  }[number] | {
415
439
  [i in keyof methods]: readonly [
416
440
  MethodFn<methods[i], any, any> & {
417
441
  _method: methods[i];
418
442
  },
419
- MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>>
443
+ MethodFn.Options<methods[i], NonNullable<methods[i]['defaults']>> & RouteIdentity<Transport.Http>
420
444
  ];
421
445
  }[number];
422
446
  export declare function compose(...args: readonly unknown[]): ComposedHandler;
@@ -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,
@@ -250,9 +251,13 @@ export function create(config) {
250
251
  options?.meta !== undefined ||
251
252
  options?.realm !== undefined ||
252
253
  options?.request !== undefined;
253
- const expectedRealm = options?.realm ??
254
- realm ??
255
- (options?.capturedRequest === undefined ? credential.challenge.realm : undefined);
254
+ const expectedRealm = options?.realm ?? realm;
255
+ if ((expectedRealm === undefined && options?.capturedRequest === undefined) ||
256
+ (expectedRealm !== undefined && credential.challenge.realm !== expectedRealm))
257
+ await fail(new Errors.InvalidChallengeError({
258
+ id: credential.challenge.id,
259
+ reason: 'standalone verification requires a matching configured realm or trusted route context',
260
+ }), { credential: parsedCredential });
256
261
  let parsedRequest = credential.challenge.request;
257
262
  let request;
258
263
  try {
@@ -432,8 +437,19 @@ export function create(config) {
432
437
  }
433
438
  // biome-ignore lint/correctness/noUnusedVariables: _
434
439
  function createMethodFn(parameters) {
435
- const { attest, authorize, credentialHeader, defaults, events, method, preflight, realm, respond, secretKey, broadcast, stableBinding, transport, validate, verify, } = parameters;
436
- 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.`);
437
453
  const { description, meta, scope, ...rest } = options;
438
454
  const staticMeta = Scope.merge({ meta, scope });
439
455
  const internal = {
@@ -451,20 +467,26 @@ function createMethodFn(parameters) {
451
467
  ]),
452
468
  ],
453
469
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
470
+ _paymentIdentity: paymentVerifiers.length > 0,
454
471
  _stableBinding: stableBinding,
455
472
  _transport: transport,
456
473
  };
457
474
  Object.defineProperty(internal, '_method', { value: method });
458
475
  const handler = async (input) => {
459
- if (attest && input instanceof globalThis.Request) {
460
- const response = await attest(input);
461
- if (response)
462
- return {
463
- status: 200,
464
- withReceipt() {
465
- return response;
466
- },
467
- };
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);
468
490
  }
469
491
  if (method.html && isServiceWorkerRequest(input))
470
492
  return {
@@ -503,6 +525,7 @@ function createMethodFn(parameters) {
503
525
  return { challenge: response, status: 402 };
504
526
  return {
505
527
  status: 200,
528
+ identity: IdentityServer.results(identityValues),
506
529
  withReceipt() {
507
530
  return response;
508
531
  },
@@ -510,12 +533,35 @@ function createMethodFn(parameters) {
510
533
  }
511
534
  }
512
535
  const emitChallenge = async (parameters) => {
513
- const response = await transport.respondChallenge({
536
+ let response = await transport.respondChallenge({
514
537
  challenge: parameters.challenge,
515
538
  input,
516
539
  ...(parameters.error && { error: parameters.error }),
517
540
  ...(parameters.html && { html: parameters.html }),
518
541
  });
542
+ // Payment-stage verifiers add their own challenge headers to the 402.
543
+ if (paymentVerifiers.length > 0 &&
544
+ input instanceof globalThis.Request &&
545
+ response instanceof globalThis.Response) {
546
+ const outcome = await IdentityServer.verify(input, paymentVerifiers, {
547
+ challenge: parameters.challenge,
548
+ });
549
+ const identityHeaders = new Headers();
550
+ if (outcome.status !== 'valid')
551
+ for (const [name, value] of outcome.headers)
552
+ identityHeaders.append(name, value);
553
+ if ([...identityHeaders].length > 0) {
554
+ const headers = new Headers(response.headers);
555
+ for (const [name, value] of identityHeaders)
556
+ headers.append(name, value);
557
+ response = new globalThis.Response(response.body, {
558
+ headers,
559
+ status: response.status,
560
+ statusText: response.statusText,
561
+ });
562
+ identityChallenges.set(response, identityHeaders);
563
+ }
564
+ }
519
565
  if (isIssuedChallengeResponse(response))
520
566
  await events.emit('challenge.created', createChallengeContext({
521
567
  capturedRequest,
@@ -632,6 +678,8 @@ function createMethodFn(parameters) {
632
678
  const { challengeId = challenge.id, credentialForReceipt = { challenge, payload: {} }, envelopeForReceipt, managementResponse, } = options;
633
679
  const result = {
634
680
  status: 200,
681
+ challenge,
682
+ identity: IdentityServer.results(identityValues),
635
683
  withReceipt(response) {
636
684
  if (managementResponse) {
637
685
  return transport.respondReceipt({
@@ -661,7 +709,17 @@ function createMethodFn(parameters) {
661
709
  };
662
710
  // No credential provided—issue challenge
663
711
  if (!credential) {
664
- if (authorize && input instanceof globalThis.Request) {
712
+ // Authorization without a payment credential still requires payment-stage identity;
713
+ // a verifier that needs the credential reports missing, so the route issues its 402.
714
+ const authorizeIdentity = authorize && input instanceof globalThis.Request && paymentVerifiers.length > 0
715
+ ? await IdentityServer.verify(input, paymentVerifiers, { challenge })
716
+ : undefined;
717
+ if (authorize &&
718
+ input instanceof globalThis.Request &&
719
+ (!authorizeIdentity || authorizeIdentity.status === 'valid')) {
720
+ if (authorizeIdentity?.status === 'valid')
721
+ for (const [verifier, value] of authorizeIdentity.values)
722
+ identityValues.set(verifier, value);
665
723
  try {
666
724
  const authorized = await authorize({
667
725
  challenge,
@@ -834,6 +892,33 @@ function createMethodFn(parameters) {
834
892
  credential: parsedCredential,
835
893
  request: parsedRequest,
836
894
  });
895
+ // Payment-stage identity is checked against the answered challenge before settlement.
896
+ if (paymentVerifiers.length > 0) {
897
+ const outcome = await IdentityServer.verify(input, paymentVerifiers, {
898
+ challenge: credential.challenge,
899
+ credential: parsedCredential,
900
+ });
901
+ if (outcome.status !== 'valid') {
902
+ const error = new Errors.VerificationFailedError({ reason: outcome.reason });
903
+ await emitPaymentFailed({
904
+ challenge,
905
+ credential: parsedCredential,
906
+ error,
907
+ request: parsedRequest,
908
+ retryChallenge: challenge,
909
+ submittedChallenge: credential.challenge,
910
+ });
911
+ // The client may already have pushed this payment, so a fresh 402 would invite
912
+ // it to pay again. Reject without a challenge, like a replayed credential.
913
+ // Returned on the non-200 branch so callers never run paid work: like an early
914
+ // rejection, `challenge` holds the identity response (401/403) to send as is.
915
+ const rejection = IdentityServer.toResponse(outcome);
916
+ identityRejections.add(rejection);
917
+ return { challenge: rejection, status: 402 };
918
+ }
919
+ for (const [verifier, value] of outcome.values)
920
+ identityValues.set(verifier, value);
921
+ }
837
922
  // User-provided verification (e.g., check signature, submit tx, verify payment).
838
923
  // If verification fails, re-issue the challenge so the client can retry.
839
924
  let receiptData;
@@ -917,7 +1002,7 @@ function createMethodFn(parameters) {
917
1002
  function createChallengeFn(parameters) {
918
1003
  const { credentialHeader, defaults, method, realm, secretKey } = parameters;
919
1004
  return async (options) => {
920
- const { description, meta, scope, ...rest } = options;
1005
+ const { description, identity: _, meta, scope, ...rest } = options;
921
1006
  const effectiveMeta = Scope.merge({ meta, scope });
922
1007
  const expires = 'expires' in options
923
1008
  ? normalizeExpires(options.expires)
@@ -936,33 +1021,6 @@ function createChallengeFn(parameters) {
936
1021
  }).then((resolved) => resolved.challenge);
937
1022
  };
938
1023
  }
939
- function createAttestationGuard(verifiers) {
940
- const entries = Object.entries(verifiers);
941
- if (entries.length === 0)
942
- throw new TypeError('Mppx server attestation must configure at least one verifier.');
943
- const cache = new WeakMap();
944
- return (request) => {
945
- const cached = cache.get(request);
946
- if (cached)
947
- return cached;
948
- const verification = AttestationServer.verify(request, verifiers).then((outcomes) => {
949
- for (const [name, outcome] of Object.entries(outcomes))
950
- if (outcome.status === 'invalid')
951
- return new globalThis.Response(`Request attestation "${name}" is invalid: ${outcome.reason}`, { status: 401 });
952
- for (const [name, outcome] of Object.entries(outcomes)) {
953
- if (outcome.status === 'verified')
954
- continue;
955
- const reason = outcome.status === 'unverified'
956
- ? `Request attestation "${name}" could not be verified: ${outcome.reason}`
957
- : `Request attestation "${name}" is required.`;
958
- return new globalThis.Response(reason, { status: 403 });
959
- }
960
- return undefined;
961
- });
962
- cache.set(request, verification);
963
- return verification;
964
- };
965
- }
966
1024
  /** Dispatches instance events and scopes success hooks to the supplied configured method. */
967
1025
  function createServerEventDispatcher() {
968
1026
  const handlers = {
@@ -1526,6 +1584,16 @@ function withParsedCredentialPayload(credential, payload) {
1526
1584
  };
1527
1585
  }
1528
1586
  const paymentAuthChallengeHeader = Constants.Headers.wwwAuthenticate;
1587
+ /** Payment-stage identity headers added to issued 402 responses, keyed by response. @internal */
1588
+ const identityChallenges = new WeakMap();
1589
+ /** Identity rejections (401/403) returned on a route's non-success branch. @internal */
1590
+ const identityRejections = new WeakSet();
1591
+ /** Records a merged 402's identity challenge so an outer composer can forward it. @internal */
1592
+ function withIdentityChallenge(response, headers) {
1593
+ if (headers)
1594
+ identityChallenges.set(response, headers);
1595
+ return response;
1596
+ }
1529
1597
  const challengeHeaderMerges = [
1530
1598
  {
1531
1599
  name: paymentAuthChallengeHeader,
@@ -1571,6 +1639,9 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1571
1639
  });
1572
1640
  if (handlers.length === 0)
1573
1641
  throw new Error('compose() requires at least one handler');
1642
+ // A 402 carries one identity challenge, bound to one payment challenge.
1643
+ if (handlers.flatMap(getConfiguredOffers).filter((offer) => offer._paymentIdentity).length > 1)
1644
+ throw new TypeError('A composed route can include at most one offer with payment-stage identity.');
1574
1645
  const offerSelector = selectOffers;
1575
1646
  const offerEntries = handlers.map((handler) => {
1576
1647
  const internal = handler._internal;
@@ -1679,21 +1750,30 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1679
1750
  return transportCredentialHandler(input);
1680
1751
  // No credential — evaluate handlers sequentially so authorize()/renewal hooks
1681
1752
  // can safely claim the request without racing each other.
1753
+ // An identity rejection from one offer does not hide the others: it is returned only
1754
+ // when no offer issued a payment challenge.
1682
1755
  const results = [];
1683
1756
  for (const handler of selectedHandlers) {
1684
1757
  const result = transportResults.get(handler) ?? (await handler(input));
1685
1758
  if (result.status === 200)
1686
1759
  return result;
1687
- if (result.challenge.status !== 402)
1760
+ const rejected = identityRejections.has(result.challenge);
1761
+ if (result.challenge.status !== 402 && !rejected)
1688
1762
  return result;
1689
1763
  results.push(result);
1690
1764
  }
1765
+ const offers = results.filter((result) => result.status === 402 && !identityRejections.has(result.challenge));
1766
+ if (offers.length === 0 && results[0])
1767
+ return results[0];
1691
1768
  const challengeEntries = (() => {
1692
1769
  const entries = [];
1693
1770
  for (let i = 0; i < selectedHandlers.length; i++) {
1694
1771
  const result = results[i];
1695
1772
  if (result?.status !== 402)
1696
1773
  continue;
1774
+ // Identity rejections may carry non-Payment challenges (e.g. Bearer); they are not offers.
1775
+ if (identityRejections.has(result.challenge))
1776
+ continue;
1697
1777
  const response = result.challenge;
1698
1778
  const wwwAuth = response.headers.get(paymentAuthChallengeHeader);
1699
1779
  if (!wwwAuth)
@@ -1718,7 +1798,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1718
1798
  return entries;
1719
1799
  }
1720
1800
  })();
1721
- const challengeResponses = results.flatMap((result) => result.status === 402 ? [result.challenge] : []);
1801
+ const challengeResponses = offers.map((result) => result.challenge);
1722
1802
  const unnegotiatedX402Responses = input.headers.has(Constants.Headers.acceptPayment) || challengeEntries.length === 0
1723
1803
  ? []
1724
1804
  : challengeResponses.filter((response) => response.headers.has(x402_Types.paymentRequiredHeader) &&
@@ -1729,6 +1809,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1729
1809
  ...unnegotiatedX402Responses,
1730
1810
  ]
1731
1811
  : challengeResponses;
1812
+ // At most one offer carries an identity challenge (enforced when composing); forward it
1813
+ // only if its payment challenge is still advertised after negotiation.
1814
+ const identityHeaders = negotiatedChallengeResponses
1815
+ .map((response) => identityChallenges.get(response))
1816
+ .find((headers) => headers !== undefined);
1732
1817
  // Merge challenge headers from the negotiated 402 responses.
1733
1818
  const mergedHeaders = new Headers();
1734
1819
  mergedHeaders.set('Cache-Control', 'no-store');
@@ -1741,6 +1826,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1741
1826
  mergedHeaders.append(header.name, value);
1742
1827
  }
1743
1828
  }
1829
+ // Identity WWW-Authenticate values already arrive in the merged offer value above.
1830
+ if (identityHeaders)
1831
+ for (const [name, value] of identityHeaders)
1832
+ if (name !== paymentAuthChallengeHeader.toLowerCase())
1833
+ mergedHeaders.append(name, value);
1744
1834
  // Collect html-enabled handlers and their challenges
1745
1835
  const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
1746
1836
  const wantsHtml = input.headers.get('Accept')?.includes('text/html');
@@ -1777,7 +1867,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1777
1867
  });
1778
1868
  return {
1779
1869
  status: 402,
1780
- challenge: new Response(body, { status: 402, headers: mergedHeaders }),
1870
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
1781
1871
  };
1782
1872
  }
1783
1873
  // Non-HTML fallback: prefer the first Payment-auth body, otherwise use
@@ -1797,7 +1887,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1797
1887
  }
1798
1888
  return {
1799
1889
  status: 402,
1800
- challenge: new Response(body, { status: 402, headers: mergedHeaders }),
1890
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
1801
1891
  };
1802
1892
  };
1803
1893
  const offers = handlers.flatMap(getConfiguredOffers);