mppx 0.6.18 → 0.6.20

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 (155) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/middlewares/elysia.d.ts.map +1 -1
  19. package/dist/middlewares/elysia.js +14 -0
  20. package/dist/middlewares/elysia.js.map +1 -1
  21. package/dist/middlewares/express.d.ts.map +1 -1
  22. package/dist/middlewares/express.js +1 -2
  23. package/dist/middlewares/express.js.map +1 -1
  24. package/dist/middlewares/hono.d.ts.map +1 -1
  25. package/dist/middlewares/hono.js +14 -0
  26. package/dist/middlewares/hono.js.map +1 -1
  27. package/dist/middlewares/nextjs.d.ts.map +1 -1
  28. package/dist/middlewares/nextjs.js +14 -0
  29. package/dist/middlewares/nextjs.js.map +1 -1
  30. package/dist/proxy/Proxy.d.ts.map +1 -1
  31. package/dist/proxy/Proxy.js +2 -2
  32. package/dist/proxy/Proxy.js.map +1 -1
  33. package/dist/proxy/Service.d.ts.map +1 -1
  34. package/dist/proxy/Service.js +1 -1
  35. package/dist/proxy/Service.js.map +1 -1
  36. package/dist/server/Mppx.d.ts +15 -3
  37. package/dist/server/Mppx.d.ts.map +1 -1
  38. package/dist/server/Mppx.js +190 -40
  39. package/dist/server/Mppx.js.map +1 -1
  40. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  41. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.js +1 -1
  43. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  44. package/dist/tempo/Methods.d.ts +96 -0
  45. package/dist/tempo/Methods.d.ts.map +1 -1
  46. package/dist/tempo/Methods.js +97 -0
  47. package/dist/tempo/Methods.js.map +1 -1
  48. package/dist/tempo/client/Methods.d.ts +3 -0
  49. package/dist/tempo/client/Methods.d.ts.map +1 -1
  50. package/dist/tempo/client/Methods.js +3 -0
  51. package/dist/tempo/client/Methods.js.map +1 -1
  52. package/dist/tempo/client/Subscription.d.ts +114 -0
  53. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  54. package/dist/tempo/client/Subscription.js +100 -0
  55. package/dist/tempo/client/Subscription.js.map +1 -0
  56. package/dist/tempo/client/index.d.ts +1 -0
  57. package/dist/tempo/client/index.d.ts.map +1 -1
  58. package/dist/tempo/client/index.js +1 -0
  59. package/dist/tempo/client/index.js.map +1 -1
  60. package/dist/tempo/index.d.ts +1 -0
  61. package/dist/tempo/index.d.ts.map +1 -1
  62. package/dist/tempo/index.js +1 -0
  63. package/dist/tempo/index.js.map +1 -1
  64. package/dist/tempo/server/Charge.js +2 -2
  65. package/dist/tempo/server/Charge.js.map +1 -1
  66. package/dist/tempo/server/Methods.d.ts +5 -0
  67. package/dist/tempo/server/Methods.d.ts.map +1 -1
  68. package/dist/tempo/server/Methods.js +5 -0
  69. package/dist/tempo/server/Methods.js.map +1 -1
  70. package/dist/tempo/server/Subscription.d.ts +221 -0
  71. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  72. package/dist/tempo/server/Subscription.js +637 -0
  73. package/dist/tempo/server/Subscription.js.map +1 -0
  74. package/dist/tempo/server/index.d.ts +1 -0
  75. package/dist/tempo/server/index.d.ts.map +1 -1
  76. package/dist/tempo/server/index.js +1 -0
  77. package/dist/tempo/server/index.js.map +1 -1
  78. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  79. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  80. package/dist/tempo/server/internal/html.gen.js +1 -1
  81. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  82. package/dist/tempo/session/Chain.d.ts.map +1 -1
  83. package/dist/tempo/session/Chain.js +3 -4
  84. package/dist/tempo/session/Chain.js.map +1 -1
  85. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  86. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  87. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  88. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  89. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  90. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  91. package/dist/tempo/subscription/Receipt.js +16 -0
  92. package/dist/tempo/subscription/Receipt.js.map +1 -0
  93. package/dist/tempo/subscription/Store.d.ts +99 -0
  94. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  95. package/dist/tempo/subscription/Store.js +292 -0
  96. package/dist/tempo/subscription/Store.js.map +1 -0
  97. package/dist/tempo/subscription/Types.d.ts +65 -0
  98. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  99. package/dist/tempo/subscription/Types.js +2 -0
  100. package/dist/tempo/subscription/Types.js.map +1 -0
  101. package/dist/tempo/subscription/index.d.ts +6 -0
  102. package/dist/tempo/subscription/index.d.ts.map +1 -0
  103. package/dist/tempo/subscription/index.js +4 -0
  104. package/dist/tempo/subscription/index.js.map +1 -0
  105. package/dist/zod.d.ts +7 -0
  106. package/dist/zod.d.ts.map +1 -1
  107. package/dist/zod.js +18 -0
  108. package/dist/zod.js.map +1 -1
  109. package/package.json +3 -3
  110. package/src/Challenge.test.ts +13 -0
  111. package/src/Challenge.ts +3 -3
  112. package/src/Method.ts +46 -1
  113. package/src/Receipt.ts +2 -0
  114. package/src/client/Methods.ts +1 -0
  115. package/src/middlewares/elysia.test.ts +31 -1
  116. package/src/middlewares/elysia.ts +13 -0
  117. package/src/middlewares/express.ts +1 -5
  118. package/src/middlewares/hono.test.ts +30 -1
  119. package/src/middlewares/hono.ts +13 -0
  120. package/src/middlewares/nextjs.test.ts +28 -1
  121. package/src/middlewares/nextjs.ts +13 -0
  122. package/src/proxy/Proxy.ts +2 -5
  123. package/src/proxy/Service.test.ts +34 -0
  124. package/src/proxy/Service.ts +7 -0
  125. package/src/server/Mppx.authorize.test.ts +210 -0
  126. package/src/server/Mppx.test-d.ts +23 -1
  127. package/src/server/Mppx.test.ts +73 -3
  128. package/src/server/Mppx.ts +291 -58
  129. package/src/stripe/server/internal/html/package.json +1 -1
  130. package/src/stripe/server/internal/html.gen.ts +1 -1
  131. package/src/tempo/Methods.test.ts +131 -0
  132. package/src/tempo/Methods.ts +136 -0
  133. package/src/tempo/Subscription.integration.test.ts +591 -0
  134. package/src/tempo/client/Methods.ts +3 -0
  135. package/src/tempo/client/Subscription.test.ts +131 -0
  136. package/src/tempo/client/Subscription.ts +155 -0
  137. package/src/tempo/client/index.ts +1 -0
  138. package/src/tempo/index.ts +1 -0
  139. package/src/tempo/server/Charge.ts +2 -2
  140. package/src/tempo/server/Methods.ts +5 -0
  141. package/src/tempo/server/Subscription.test.ts +1410 -0
  142. package/src/tempo/server/Subscription.ts +1014 -0
  143. package/src/tempo/server/index.ts +1 -0
  144. package/src/tempo/server/internal/html/package.json +1 -1
  145. package/src/tempo/server/internal/html.gen.ts +1 -1
  146. package/src/tempo/session/Chain.ts +3 -5
  147. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  148. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  149. package/src/tempo/subscription/Receipt.ts +28 -0
  150. package/src/tempo/subscription/Store.test.ts +554 -0
  151. package/src/tempo/subscription/Store.ts +431 -0
  152. package/src/tempo/subscription/Types.ts +68 -0
  153. package/src/tempo/subscription/index.ts +23 -0
  154. package/src/zod.test.ts +23 -1
  155. package/src/zod.ts +24 -0
@@ -10,7 +10,7 @@ import * as Env from '../internal/env.js'
10
10
  import type * as Method from '../Method.js'
11
11
  import * as PaymentRequest from '../PaymentRequest.js'
12
12
  import type * as Receipt from '../Receipt.js'
13
- import type * as z from '../zod.js'
13
+ import * as z from '../zod.js'
14
14
  import * as Html from './internal/html/config.js'
15
15
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
16
16
  import * as Scope from './internal/scope.js'
@@ -53,6 +53,8 @@ export type Mppx<
53
53
  * server methods passed to `Mppx.create()`, looked up by `name`+`intent`.
54
54
  *
55
55
  * Only available on HTTP transports.
56
+ * No-credential authorize hooks run in entry order; the first 200 response
57
+ * wins, and earlier hooks may have already run side effects.
56
58
  *
57
59
  * @example
58
60
  * ```ts
@@ -105,6 +107,9 @@ export type Mppx<
105
107
  * HMAC-check, match to a registered method, validate payload schema,
106
108
  * check expiry, and call the method's verify function.
107
109
  *
110
+ * Method verification can settle payments and persist state. For example,
111
+ * subscription credentials may activate or renew a subscription.
112
+ *
108
113
  * @example
109
114
  * ```ts
110
115
  * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
@@ -237,12 +242,14 @@ export function create<
237
242
  for (const mi of methods) {
238
243
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
239
244
  handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
245
+ authorize: mi.authorize as never,
240
246
  defaults: mi.defaults,
241
247
  method: mi,
242
248
  realm,
243
249
  request: mi.request as never,
244
250
  respond: mi.respond as never,
245
251
  secretKey,
252
+ stableBinding: mi.stableBinding as never,
246
253
  transport: (mi.transport ?? transport) as never,
247
254
  verify: mi.verify as never,
248
255
  })
@@ -339,7 +346,11 @@ export function create<
339
346
  routeRequest: options?.request ?? {},
340
347
  secretKey: secretKey!,
341
348
  }).then((resolved) => {
342
- const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge)
349
+ const mismatch = getChallengeBindingMismatch(
350
+ resolved.challenge,
351
+ credential.challenge,
352
+ mi.stableBinding as never,
353
+ )
343
354
  if (mismatch)
344
355
  throw new Errors.InvalidChallengeError({
345
356
  id: credential.challenge.id,
@@ -421,7 +432,17 @@ function createMethodFn<
421
432
  ): createMethodFn.ReturnType<method, transport, defaults>
422
433
  // biome-ignore lint/correctness/noUnusedVariables: _
423
434
  function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
424
- const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
435
+ const {
436
+ authorize,
437
+ defaults,
438
+ method,
439
+ realm,
440
+ respond,
441
+ secretKey,
442
+ stableBinding,
443
+ transport,
444
+ verify,
445
+ } = parameters
425
446
 
426
447
  return (options) => {
427
448
  const { description, meta, scope, ...rest } = options
@@ -430,7 +451,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
430
451
  return Object.assign(
431
452
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
432
453
  const expires =
433
- 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
454
+ 'expires' in options
455
+ ? normalizeExpires(options.expires as z.DatetimeInput | undefined)
456
+ : Expires.minutes(5)
434
457
  const capturedRequest = await captureRequest(transport, input)
435
458
  const effectiveMeta =
436
459
  scope === undefined && input instanceof globalThis.Request
@@ -446,7 +469,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
446
469
  return [null, e as Error] as const
447
470
  }
448
471
  })()
449
- const { challenge, request } = await resolveRouteChallenge({
472
+ const routeChallenge = await resolveRouteChallenge({
450
473
  capturedRequest,
451
474
  credential,
452
475
  defaults,
@@ -458,7 +481,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
458
481
  request: parameters.request,
459
482
  routeRequest: rest,
460
483
  secretKey,
484
+ }).catch(async (e) => {
485
+ if (!(e instanceof Errors.PaymentError)) throw e
486
+ const challenge = createFallbackChallenge({
487
+ capturedRequest,
488
+ defaults: defaults ?? {},
489
+ description,
490
+ expires,
491
+ meta: effectiveMeta,
492
+ method,
493
+ realm,
494
+ routeRequest: rest,
495
+ secretKey,
496
+ })
497
+ const response = await transport.respondChallenge({
498
+ challenge,
499
+ input,
500
+ error: e,
501
+ html: method.html,
502
+ })
503
+ return { response }
461
504
  })
505
+ if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 }
506
+ const { challenge, request } = routeChallenge
462
507
 
463
508
  // Credential was provided but malformed
464
509
  if (credentialError) {
@@ -472,8 +517,77 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
472
517
  return { challenge: response, status: 402 }
473
518
  }
474
519
 
520
+ const success = (
521
+ receiptData: Receipt.Receipt,
522
+ options: {
523
+ challengeId?: string | undefined
524
+ credentialForReceipt?: Credential.Credential | undefined
525
+ envelopeForReceipt?: Method.VerifiedChallengeEnvelope | undefined
526
+ managementResponse?: globalThis.Response | undefined
527
+ } = {},
528
+ ): MethodFn.Response => {
529
+ const {
530
+ challengeId = challenge.id,
531
+ credentialForReceipt = { challenge, payload: {} } as Credential.Credential,
532
+ envelopeForReceipt,
533
+ managementResponse,
534
+ } = options
535
+
536
+ return {
537
+ status: 200,
538
+ withReceipt<response>(response?: response) {
539
+ if (managementResponse) {
540
+ return transport.respondReceipt({
541
+ challengeId,
542
+ credential: credentialForReceipt,
543
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
544
+ input,
545
+ receipt: receiptData,
546
+ response: managementResponse as never,
547
+ }) as response
548
+ }
549
+ if (!response) throw new MissingReceiptResponseError()
550
+ return transport.respondReceipt({
551
+ challengeId,
552
+ credential: credentialForReceipt,
553
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
554
+ input,
555
+ receipt: receiptData,
556
+ response: response as never,
557
+ }) as response
558
+ },
559
+ }
560
+ }
561
+
475
562
  // No credential provided—issue challenge
476
563
  if (!credential) {
564
+ if (authorize && input instanceof globalThis.Request) {
565
+ try {
566
+ const authorized = await authorize({
567
+ challenge,
568
+ input,
569
+ request: challenge.request,
570
+ } as never)
571
+ if (authorized) {
572
+ return success(authorized.receipt, {
573
+ managementResponse: authorized.response,
574
+ })
575
+ }
576
+ } catch (e) {
577
+ if (!(e instanceof Errors.PaymentError))
578
+ console.error('mppx: internal authorization error', e)
579
+ const error =
580
+ e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
581
+ const response = await transport.respondChallenge({
582
+ challenge,
583
+ input,
584
+ error,
585
+ html: method.html,
586
+ })
587
+ return { challenge: response, status: 402 }
588
+ }
589
+ }
590
+
477
591
  const response = await transport.respondChallenge({
478
592
  challenge,
479
593
  input,
@@ -530,7 +644,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
530
644
  // `expires` still is not pinned here because its default is generated
531
645
  // per invocation, and `digest` is already bound by the echoed HMAC.
532
646
  {
533
- const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge)
647
+ const mismatch = getChallengeBindingMismatch(
648
+ challenge,
649
+ credential.challenge,
650
+ stableBinding as never,
651
+ )
534
652
  if (mismatch) {
535
653
  const response = await transport.respondChallenge({
536
654
  challenge,
@@ -601,30 +719,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
601
719
  ? await respond({ credential, envelope, input, receipt: receiptData, request } as never)
602
720
  : undefined
603
721
 
604
- return {
605
- status: 200,
606
- withReceipt<response>(response?: response) {
607
- if (managementResponse) {
608
- return transport.respondReceipt({
609
- challengeId: credential.challenge.id,
610
- credential,
611
- envelope,
612
- input,
613
- receipt: receiptData,
614
- response: managementResponse as never,
615
- }) as response
616
- }
617
- if (!response) throw new Error('withReceipt() requires a response argument')
618
- return transport.respondReceipt({
619
- challengeId: credential.challenge.id,
620
- credential,
621
- envelope,
622
- input,
623
- receipt: receiptData,
624
- response: response as never,
625
- }) as response
626
- },
627
- }
722
+ return success(receiptData, {
723
+ challengeId: credential.challenge.id,
724
+ credentialForReceipt: credential,
725
+ envelopeForReceipt: envelope,
726
+ managementResponse,
727
+ })
628
728
  },
629
729
  {
630
730
  _internal: {
@@ -635,6 +735,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
635
735
  name: method.name,
636
736
  intent: method.intent,
637
737
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
738
+ _stableBinding: stableBinding as never,
638
739
  },
639
740
  },
640
741
  )
@@ -658,14 +759,16 @@ function createChallengeFn(parameters: {
658
759
  return async (options) => {
659
760
  const { description, meta, scope, ...rest } = options as {
660
761
  description?: string
661
- expires?: string
762
+ expires?: z.DatetimeInput
662
763
  meta?: Record<string, string>
663
764
  scope?: string
664
765
  [key: string]: unknown
665
766
  }
666
767
  const effectiveMeta = Scope.merge({ meta, scope })
667
768
  const expires =
668
- 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
769
+ 'expires' in options
770
+ ? normalizeExpires(options.expires as z.DatetimeInput | undefined)
771
+ : Expires.minutes(5)
669
772
 
670
773
  return resolveRouteChallenge({
671
774
  defaults,
@@ -694,12 +797,14 @@ declare namespace createMethodFn {
694
797
  transport extends Transport.AnyTransport = Transport.Http,
695
798
  defaults extends Record<string, unknown> = Record<string, unknown>,
696
799
  > = {
800
+ authorize?: Method.AuthorizeFn<method>
697
801
  defaults?: defaults
698
802
  method: method
699
803
  realm: string | undefined
700
804
  request?: Method.RequestFn<method>
701
805
  respond?: Method.RespondFn<method>
702
806
  secretKey: string
807
+ stableBinding?: Method.StableBindingFn<method>
703
808
  transport: transport
704
809
  verify: Method.VerifyFn<method>
705
810
  }
@@ -715,6 +820,34 @@ const defaultRealm = 'MPP Payment'
715
820
  const Warnings = {
716
821
  realmFallback: 'realm-fallback',
717
822
  } as const
823
+ const missingReceiptResponseErrorName = 'MissingReceiptResponseError'
824
+ const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument'
825
+
826
+ /** Error thrown when `withReceipt()` needs a response but none was provided. */
827
+ export class MissingReceiptResponseError extends Error {
828
+ override name = missingReceiptResponseErrorName
829
+
830
+ constructor() {
831
+ super(missingReceiptResponseErrorMessage)
832
+ }
833
+ }
834
+
835
+ /** Returns true when an error is the typed `withReceipt()` no-response sentinel. */
836
+ export function isMissingReceiptResponseError(
837
+ error: unknown,
838
+ ): error is MissingReceiptResponseError {
839
+ if (error instanceof MissingReceiptResponseError) return true
840
+ if (!error || typeof error !== 'object') return false
841
+ const value = error as { message?: unknown; name?: unknown }
842
+ return (
843
+ value.name === missingReceiptResponseErrorName &&
844
+ value.message === missingReceiptResponseErrorMessage
845
+ )
846
+ }
847
+
848
+ function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined {
849
+ return expires === undefined ? undefined : z.toDatetimeString(expires)
850
+ }
718
851
 
719
852
  const _warned = new Set<string>()
720
853
  function warnOnce(key: string, message: string) {
@@ -785,6 +918,31 @@ async function resolveRouteChallenge(parameters: {
785
918
  }
786
919
  }
787
920
 
921
+ function createFallbackChallenge(parameters: {
922
+ capturedRequest?: Method.CapturedRequest | undefined
923
+ defaults: Record<string, unknown>
924
+ description?: string | undefined
925
+ expires?: string | undefined
926
+ meta?: Record<string, string> | undefined
927
+ method: Method.Method
928
+ realm?: string | undefined
929
+ routeRequest: Record<string, unknown>
930
+ secretKey: string
931
+ }) {
932
+ return Challenge.fromMethod(parameters.method, {
933
+ description: parameters.description,
934
+ expires: parameters.expires,
935
+ meta: parameters.meta,
936
+ realm:
937
+ parameters.realm ??
938
+ (parameters.capturedRequest
939
+ ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
940
+ : defaultRealm),
941
+ request: { ...parameters.defaults, ...parameters.routeRequest } as never,
942
+ secretKey: parameters.secretKey,
943
+ })
944
+ }
945
+
788
946
  /**
789
947
  * Captures the transport request into a frozen snapshot at the start of the
790
948
  * verification flow. This snapshot is threaded through request() → verify() →
@@ -831,6 +989,26 @@ type CoreBindingField = (typeof coreBindingFields)[number]
831
989
  type MethodBindingField = (typeof methodBindingFields)[number]
832
990
  type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
833
991
  type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField
992
+ type StableBinding = Record<string, unknown>
993
+
994
+ function getChallengeBindingMismatch(
995
+ expectedChallenge: Challenge.Challenge,
996
+ actualChallenge: Challenge.Challenge,
997
+ stableBinding?: Method.StableBindingFn<Method.Method> | undefined,
998
+ ): string | undefined {
999
+ if (!stableBinding) return getPinnedChallengeMismatch(expectedChallenge, actualChallenge)
1000
+
1001
+ for (const field of ['method', 'intent', 'realm'] as const) {
1002
+ if (actualChallenge[field] !== expectedChallenge[field]) return field
1003
+ }
1004
+
1005
+ if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque'
1006
+
1007
+ return getRequestBindingMismatch(
1008
+ getStableBinding(expectedChallenge.request as Record<string, unknown>, stableBinding),
1009
+ getStableBinding(actualChallenge.request as Record<string, unknown>, stableBinding),
1010
+ )
1011
+ }
834
1012
 
835
1013
  /**
836
1014
  * Compares only the fields that MUST be stable across request-hook transforms.
@@ -911,6 +1089,44 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
911
1089
  }
912
1090
  }
913
1091
 
1092
+ function getRequestBindingMismatch(
1093
+ expected: StableBinding,
1094
+ actual: StableBinding,
1095
+ ): string | undefined {
1096
+ const fields = [
1097
+ ...Object.keys(expected),
1098
+ ...Object.keys(actual).filter((key) => !(key in expected)),
1099
+ ]
1100
+
1101
+ return fields.find(
1102
+ (field) =>
1103
+ !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])),
1104
+ )
1105
+ }
1106
+
1107
+ function getStableBinding(
1108
+ request: Record<string, unknown>,
1109
+ stableBinding: Method.StableBindingFn<Method.Method>,
1110
+ ): StableBinding {
1111
+ return stableBinding(request as never)
1112
+ }
1113
+
1114
+ /** Top-level economic fields that should never drift after challenge issuance. */
1115
+ type CoreBinding = {
1116
+ [field in CoreBindingField]?: string
1117
+ }
1118
+
1119
+ /** Method-specific fields that are pinned by the fallback binding check. */
1120
+ type MethodBinding = {
1121
+ [field in MethodBindingField]?: unknown
1122
+ }
1123
+
1124
+ /** Normalized request subset used when a method does not provide a custom stable binding. */
1125
+ type PinnedRequestBinding = {
1126
+ coreBinding: CoreBinding
1127
+ methodBinding: MethodBinding
1128
+ }
1129
+
914
1130
  function normalizeScalar(value: unknown): string | undefined {
915
1131
  return value === undefined ? undefined : String(value)
916
1132
  }
@@ -957,20 +1173,6 @@ function hydrateCredentialMeta<payload>(
957
1173
  },
958
1174
  }
959
1175
  }
960
-
961
- type CoreBinding = {
962
- [field in CoreBindingField]?: string
963
- }
964
-
965
- type MethodBinding = {
966
- [field in MethodBindingField]?: unknown
967
- }
968
-
969
- type PinnedRequestBinding = {
970
- coreBinding: CoreBinding
971
- methodBinding: MethodBinding
972
- }
973
-
974
1176
  export type MethodFn<
975
1177
  method extends Method.Method,
976
1178
  transport extends Transport.AnyTransport,
@@ -991,8 +1193,8 @@ declare namespace MethodFn {
991
1193
  > = {
992
1194
  /** Optional human-readable description of the payment. */
993
1195
  description?: string | undefined
994
- /** Optional challenge expiration timestamp (ISO 8601). */
995
- expires?: string | undefined
1196
+ /** Optional challenge expiration timestamp (ISO 8601) or Date. */
1197
+ expires?: z.DatetimeInput | undefined
996
1198
  /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
997
1199
  meta?: Record<string, string> | undefined
998
1200
  /** Optional route/resource scope bound via reserved challenge metadata. */
@@ -1019,6 +1221,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
1019
1221
  meta?: Record<string, string> | undefined
1020
1222
  scope?: string | undefined
1021
1223
  _canonicalRequest: Record<string, unknown>
1224
+ _stableBinding?: Method.StableBindingFn<Method.Method> | undefined
1022
1225
  }
1023
1226
  }
1024
1227
 
@@ -1139,15 +1342,20 @@ export function compose(
1139
1342
  // transformed fields (e.g. amount with decimals) match correctly.
1140
1343
  // Also checks inside methodDetails for fields moved there by transforms.
1141
1344
  const candidates = handlers.filter((h) => {
1142
- const internal = (h as ConfiguredHandler)._internal
1143
- if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
1345
+ try {
1346
+ const internal = (h as ConfiguredHandler)._internal
1347
+ if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
1348
+ return false
1349
+ const mismatch = internal._stableBinding
1350
+ ? getRequestBindingMismatch(
1351
+ getStableBinding(internal._canonicalRequest, internal._stableBinding),
1352
+ getStableBinding(credReq, internal._stableBinding),
1353
+ )
1354
+ : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq)
1355
+ return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta)
1356
+ } catch {
1144
1357
  return false
1145
- const canonical = internal._canonicalRequest
1146
- if (!canonical) return true
1147
- return (
1148
- !getPinnedRequestBindingMismatch(canonical, credReq) &&
1149
- opaqueValuesMatch(internal.meta, credential.challenge.meta)
1150
- )
1358
+ }
1151
1359
  })
1152
1360
 
1153
1361
  const match =
@@ -1164,8 +1372,14 @@ export function compose(
1164
1372
  return handlers[0]!(input)
1165
1373
  }
1166
1374
 
1167
- // No credential — call all handlers and merge 402 challenges.
1168
- const results = await Promise.all(handlers.map((h) => h(input)))
1375
+ // No credential — evaluate handlers sequentially so authorize()/renewal hooks
1376
+ // can safely claim the request without racing each other.
1377
+ const results: MethodFn.Response<Transport.Http>[] = []
1378
+ for (const handler of handlers) {
1379
+ const result = await handler(input)
1380
+ if (result.status === 200) return result
1381
+ results.push(result)
1382
+ }
1169
1383
 
1170
1384
  const challengeEntries = (() => {
1171
1385
  const entries: {
@@ -1316,6 +1530,12 @@ export function toNodeListener(
1316
1530
  if (result.status === 402) {
1317
1531
  await NodeListener.sendResponse(res, result.challenge as globalThis.Response)
1318
1532
  } else {
1533
+ const managementResponse = getManagementResponse(result)
1534
+ if (managementResponse) {
1535
+ await NodeListener.sendResponse(res, managementResponse)
1536
+ return { challenge: managementResponse, status: 402 }
1537
+ }
1538
+
1319
1539
  const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response
1320
1540
  res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!)
1321
1541
  }
@@ -1324,6 +1544,19 @@ export function toNodeListener(
1324
1544
  }
1325
1545
  }
1326
1546
 
1547
+ function getManagementResponse(
1548
+ result: Extract<MethodFn.Response<Transport.Http>, { status: 200 }>,
1549
+ ): globalThis.Response | null {
1550
+ try {
1551
+ return (result.withReceipt as () => globalThis.Response)()
1552
+ } catch (error) {
1553
+ if (isMissingReceiptResponseError(error)) {
1554
+ return null
1555
+ }
1556
+ throw error
1557
+ }
1558
+ }
1559
+
1327
1560
  /**
1328
1561
  * Flattens a methods config tuple, preserving positional types.
1329
1562
  * @internal
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@stripe/stripe-js": "9.3.1",
6
+ "@stripe/stripe-js": "9.4.0",
7
7
  "mppx": "workspace:*"
8
8
  }
9
9
  }