mppx 0.6.19 → 0.6.21

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 (168) hide show
  1. package/CHANGELOG.md +14 -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/client/Mppx.d.ts +12 -1
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +127 -10
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/internal/Fetch.d.ts +69 -1
  23. package/dist/client/internal/Fetch.d.ts.map +1 -1
  24. package/dist/client/internal/Fetch.js +250 -20
  25. package/dist/client/internal/Fetch.js.map +1 -1
  26. package/dist/middlewares/elysia.d.ts.map +1 -1
  27. package/dist/middlewares/elysia.js +14 -0
  28. package/dist/middlewares/elysia.js.map +1 -1
  29. package/dist/middlewares/express.d.ts.map +1 -1
  30. package/dist/middlewares/express.js +1 -2
  31. package/dist/middlewares/express.js.map +1 -1
  32. package/dist/middlewares/hono.d.ts.map +1 -1
  33. package/dist/middlewares/hono.js +14 -0
  34. package/dist/middlewares/hono.js.map +1 -1
  35. package/dist/middlewares/internal/mppx.d.ts +1 -1
  36. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  37. package/dist/middlewares/internal/mppx.js +2 -1
  38. package/dist/middlewares/internal/mppx.js.map +1 -1
  39. package/dist/middlewares/nextjs.d.ts.map +1 -1
  40. package/dist/middlewares/nextjs.js +14 -0
  41. package/dist/middlewares/nextjs.js.map +1 -1
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +2 -2
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts.map +1 -1
  46. package/dist/proxy/Service.js +1 -1
  47. package/dist/proxy/Service.js.map +1 -1
  48. package/dist/server/Mppx.d.ts +96 -5
  49. package/dist/server/Mppx.d.ts.map +1 -1
  50. package/dist/server/Mppx.js +739 -115
  51. package/dist/server/Mppx.js.map +1 -1
  52. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  53. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.js +1 -1
  55. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  56. package/dist/tempo/Methods.d.ts +96 -0
  57. package/dist/tempo/Methods.d.ts.map +1 -1
  58. package/dist/tempo/Methods.js +97 -0
  59. package/dist/tempo/Methods.js.map +1 -1
  60. package/dist/tempo/client/Methods.d.ts +3 -0
  61. package/dist/tempo/client/Methods.d.ts.map +1 -1
  62. package/dist/tempo/client/Methods.js +3 -0
  63. package/dist/tempo/client/Methods.js.map +1 -1
  64. package/dist/tempo/client/Subscription.d.ts +114 -0
  65. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  66. package/dist/tempo/client/Subscription.js +100 -0
  67. package/dist/tempo/client/Subscription.js.map +1 -0
  68. package/dist/tempo/client/index.d.ts +1 -0
  69. package/dist/tempo/client/index.d.ts.map +1 -1
  70. package/dist/tempo/client/index.js +1 -0
  71. package/dist/tempo/client/index.js.map +1 -1
  72. package/dist/tempo/index.d.ts +1 -0
  73. package/dist/tempo/index.d.ts.map +1 -1
  74. package/dist/tempo/index.js +1 -0
  75. package/dist/tempo/index.js.map +1 -1
  76. package/dist/tempo/server/Methods.d.ts +5 -0
  77. package/dist/tempo/server/Methods.d.ts.map +1 -1
  78. package/dist/tempo/server/Methods.js +5 -0
  79. package/dist/tempo/server/Methods.js.map +1 -1
  80. package/dist/tempo/server/Subscription.d.ts +221 -0
  81. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  82. package/dist/tempo/server/Subscription.js +637 -0
  83. package/dist/tempo/server/Subscription.js.map +1 -0
  84. package/dist/tempo/server/index.d.ts +1 -0
  85. package/dist/tempo/server/index.d.ts.map +1 -1
  86. package/dist/tempo/server/index.js +1 -0
  87. package/dist/tempo/server/index.js.map +1 -1
  88. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  89. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  90. package/dist/tempo/server/internal/html.gen.js +1 -1
  91. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  92. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  93. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  94. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  95. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  96. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  97. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  98. package/dist/tempo/subscription/Receipt.js +16 -0
  99. package/dist/tempo/subscription/Receipt.js.map +1 -0
  100. package/dist/tempo/subscription/Store.d.ts +99 -0
  101. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  102. package/dist/tempo/subscription/Store.js +292 -0
  103. package/dist/tempo/subscription/Store.js.map +1 -0
  104. package/dist/tempo/subscription/Types.d.ts +65 -0
  105. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  106. package/dist/tempo/subscription/Types.js +2 -0
  107. package/dist/tempo/subscription/Types.js.map +1 -0
  108. package/dist/tempo/subscription/index.d.ts +6 -0
  109. package/dist/tempo/subscription/index.d.ts.map +1 -0
  110. package/dist/tempo/subscription/index.js +4 -0
  111. package/dist/tempo/subscription/index.js.map +1 -0
  112. package/dist/zod.d.ts +7 -0
  113. package/dist/zod.d.ts.map +1 -1
  114. package/dist/zod.js +18 -0
  115. package/dist/zod.js.map +1 -1
  116. package/package.json +3 -3
  117. package/src/Challenge.test.ts +13 -0
  118. package/src/Challenge.ts +3 -3
  119. package/src/Method.ts +46 -1
  120. package/src/Receipt.ts +2 -0
  121. package/src/client/Methods.ts +1 -0
  122. package/src/client/Mppx.test-d.ts +55 -0
  123. package/src/client/Mppx.test.ts +181 -0
  124. package/src/client/Mppx.ts +248 -16
  125. package/src/client/internal/Fetch.test-d.ts +31 -0
  126. package/src/client/internal/Fetch.test.ts +261 -0
  127. package/src/client/internal/Fetch.ts +467 -24
  128. package/src/middlewares/elysia.test.ts +31 -1
  129. package/src/middlewares/elysia.ts +13 -0
  130. package/src/middlewares/express.ts +1 -5
  131. package/src/middlewares/hono.test.ts +30 -1
  132. package/src/middlewares/hono.ts +13 -0
  133. package/src/middlewares/internal/mppx.ts +5 -6
  134. package/src/middlewares/nextjs.test.ts +28 -1
  135. package/src/middlewares/nextjs.ts +13 -0
  136. package/src/proxy/Proxy.test.ts +69 -0
  137. package/src/proxy/Proxy.ts +2 -5
  138. package/src/proxy/Service.test.ts +34 -0
  139. package/src/proxy/Service.ts +7 -0
  140. package/src/server/Mppx.authorize.test.ts +210 -0
  141. package/src/server/Mppx.test-d.ts +73 -1
  142. package/src/server/Mppx.test.ts +965 -3
  143. package/src/server/Mppx.ts +1138 -140
  144. package/src/stripe/server/internal/html/package.json +1 -1
  145. package/src/stripe/server/internal/html.gen.ts +1 -1
  146. package/src/tempo/Methods.test.ts +131 -0
  147. package/src/tempo/Methods.ts +136 -0
  148. package/src/tempo/Subscription.integration.test.ts +591 -0
  149. package/src/tempo/client/Methods.ts +3 -0
  150. package/src/tempo/client/Subscription.test.ts +131 -0
  151. package/src/tempo/client/Subscription.ts +155 -0
  152. package/src/tempo/client/index.ts +1 -0
  153. package/src/tempo/index.ts +1 -0
  154. package/src/tempo/server/Methods.ts +5 -0
  155. package/src/tempo/server/Subscription.test.ts +1410 -0
  156. package/src/tempo/server/Subscription.ts +1014 -0
  157. package/src/tempo/server/index.ts +1 -0
  158. package/src/tempo/server/internal/html/package.json +1 -1
  159. package/src/tempo/server/internal/html.gen.ts +1 -1
  160. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  161. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  162. package/src/tempo/subscription/Receipt.ts +28 -0
  163. package/src/tempo/subscription/Store.test.ts +554 -0
  164. package/src/tempo/subscription/Store.ts +431 -0
  165. package/src/tempo/subscription/Types.ts +68 -0
  166. package/src/tempo/subscription/index.ts +23 -0
  167. package/src/zod.test.ts +23 -1
  168. package/src/zod.ts +24 -0
@@ -7,10 +7,11 @@ import * as Errors from '../Errors.js'
7
7
  import * as Expires from '../Expires.js'
8
8
  import * as AcceptPayment from '../internal/AcceptPayment.js'
9
9
  import * as Env from '../internal/env.js'
10
+ import type { MaybePromise } from '../internal/types.js'
10
11
  import type * as Method from '../Method.js'
11
12
  import * as PaymentRequest from '../PaymentRequest.js'
12
13
  import type * as Receipt from '../Receipt.js'
13
- import type * as z from '../zod.js'
14
+ import * as z from '../zod.js'
14
15
  import * as Html from './internal/html/config.js'
15
16
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
16
17
  import * as Scope from './internal/scope.js'
@@ -20,6 +21,125 @@ import * as Transport from './Transport.js'
20
21
 
21
22
  export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[]
22
23
 
24
+ export type ServerEventMap<
25
+ methods extends readonly Method.Method[] = readonly Method.Method[],
26
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
27
+ > = {
28
+ 'challenge.created': ChallengeContext<methods[number], transport>
29
+ 'payment.failed': PaymentFailedContext<methods[number], transport>
30
+ 'payment.success': PaymentSuccessContext<methods[number], transport>
31
+ }
32
+
33
+ export type ServerEventName<
34
+ methods extends readonly Method.Method[] = readonly Method.Method[],
35
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
36
+ > = keyof ServerEventMap<methods, transport> | '*'
37
+
38
+ export type ServerEventEnvelope<
39
+ methods extends readonly Method.Method[] = readonly Method.Method[],
40
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
41
+ > = {
42
+ [name in keyof ServerEventMap<methods, transport>]: Readonly<{
43
+ name: name
44
+ payload: ServerEventMap<methods, transport>[name]
45
+ }>
46
+ }[keyof ServerEventMap<methods, transport>]
47
+
48
+ export type ServerEventPayload<
49
+ methods extends readonly Method.Method[] = readonly Method.Method[],
50
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
51
+ name extends ServerEventName<methods, transport> = ServerEventName<methods, transport>,
52
+ > = name extends '*'
53
+ ? ServerEventEnvelope<methods, transport>
54
+ : name extends keyof ServerEventMap<methods, transport>
55
+ ? ServerEventMap<methods, transport>[name]
56
+ : never
57
+
58
+ /**
59
+ * Server event handler.
60
+ *
61
+ * Handlers are awaited inline and sequentially on the payment request path.
62
+ * Errors are swallowed so observers cannot change payment control flow, but
63
+ * slow handlers still delay the response.
64
+ */
65
+ export type ServerEventHandler<
66
+ methods extends readonly Method.Method[] = readonly Method.Method[],
67
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
68
+ name extends ServerEventName<methods, transport> = ServerEventName<methods, transport>,
69
+ > = (context: ServerEventPayload<methods, transport, name>) => MaybePromise<void>
70
+
71
+ /** Removes a registered server event handler. */
72
+ export type Unsubscribe = () => void
73
+
74
+ /** Inert method descriptor exposed to server event handlers. */
75
+ export type ServerMethodDescriptor<
76
+ method extends Pick<Method.Method, 'intent' | 'name'> = Method.Method,
77
+ > = Readonly<{
78
+ intent: method['intent']
79
+ name: method['name']
80
+ }>
81
+
82
+ /** Context passed to `onChallengeCreated`. */
83
+ export type ChallengeContext<
84
+ method extends Method.Method = Method.Method,
85
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
86
+ > = Readonly<{
87
+ capturedRequest?: Method.CapturedRequest | undefined
88
+ challenge: Challenge.Challenge
89
+ credential?: Credential.Credential | null | undefined
90
+ error?: Errors.PaymentError | undefined
91
+ input?: Transport.InputOf<transport> | undefined
92
+ method: ServerMethodDescriptor<method>
93
+ request: z.output<method['schema']['request']>
94
+ }>
95
+
96
+ /** Context passed to `onPaymentFailed`. */
97
+ export type PaymentFailedContext<
98
+ method extends Method.Method = Method.Method,
99
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
100
+ > = Readonly<{
101
+ capturedRequest?: Method.CapturedRequest | undefined
102
+ challenge: Challenge.Challenge
103
+ credential: Credential.Credential | null
104
+ error: Errors.PaymentError
105
+ input?: Transport.InputOf<transport> | undefined
106
+ method: ServerMethodDescriptor<method>
107
+ request: z.output<method['schema']['request']>
108
+ retryChallenge?: Challenge.Challenge | undefined
109
+ submittedChallenge?: Challenge.Challenge | undefined
110
+ }>
111
+
112
+ /** Context passed to `onPaymentSuccess`. */
113
+ export type PaymentSuccessContext<
114
+ method extends Method.Method = Method.Method,
115
+ transport extends Transport.AnyTransport = Transport.AnyTransport,
116
+ > = Readonly<{
117
+ capturedRequest?: Method.CapturedRequest | undefined
118
+ challenge: Challenge.Challenge<
119
+ z.output<method['schema']['request']>,
120
+ method['intent'],
121
+ method['name']
122
+ >
123
+ credential?:
124
+ | Credential.Credential<
125
+ z.output<method['schema']['credential']['payload']>,
126
+ Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
127
+ >
128
+ | undefined
129
+ envelope?:
130
+ | Method.VerifiedChallengeEnvelope<
131
+ z.output<method['schema']['request']>,
132
+ z.output<method['schema']['credential']['payload']>,
133
+ method['intent'],
134
+ method['name']
135
+ >
136
+ | undefined
137
+ input?: Transport.InputOf<transport> | undefined
138
+ method: ServerMethodDescriptor<method>
139
+ receipt: Receipt.Receipt
140
+ request: z.output<method['schema']['request']>
141
+ }>
142
+
23
143
  /** Options for standalone credential verification. */
24
144
  export type VerifyCredentialOptions = {
25
145
  capturedRequest?: Method.CapturedRequest | undefined
@@ -43,6 +163,23 @@ export type Mppx<
43
163
  realm: string
44
164
  /** The transport used. */
45
165
  transport: transport
166
+ /** Register a server event handler by canonical event name. */
167
+ on<name extends ServerEventName<FlattenMethods<methods>, transport>>(
168
+ name: name,
169
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, name>,
170
+ ): Unsubscribe
171
+ /** Register a handler for issued payment challenges. */
172
+ onChallengeCreated(
173
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'challenge.created'>,
174
+ ): Unsubscribe
175
+ /** Register a handler for failed submitted payment credentials. */
176
+ onPaymentFailed(
177
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.failed'>,
178
+ ): Unsubscribe
179
+ /** Register a handler for successful verified payments. */
180
+ onPaymentSuccess(
181
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.success'>,
182
+ ): Unsubscribe
46
183
  } & (transport extends Transport.Http
47
184
  ? {
48
185
  /**
@@ -53,6 +190,8 @@ export type Mppx<
53
190
  * server methods passed to `Mppx.create()`, looked up by `name`+`intent`.
54
191
  *
55
192
  * Only available on HTTP transports.
193
+ * No-credential authorize hooks run in entry order; the first 200 response
194
+ * wins, and earlier hooks may have already run side effects.
56
195
  *
57
196
  * @example
58
197
  * ```ts
@@ -105,6 +244,12 @@ export type Mppx<
105
244
  * HMAC-check, match to a registered method, validate payload schema,
106
245
  * check expiry, and call the method's verify function.
107
246
  *
247
+ * Method verification can settle payments and persist state. For example,
248
+ * subscription credentials may activate or renew a subscription. Failed
249
+ * standalone verification emits `payment.failed` once a credential challenge
250
+ * can be parsed; strings that cannot be deserialized have no challenge
251
+ * context to report.
252
+ *
108
253
  * @example
109
254
  * ```ts
110
255
  * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
@@ -132,6 +277,25 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
132
277
  ? defaultTransport
133
278
  : TransportOverrideOf<mi>
134
279
 
280
+ const reservedMppxKeyValues = [
281
+ 'challenge',
282
+ 'compose',
283
+ 'methods',
284
+ 'on',
285
+ 'onChallengeCreated',
286
+ 'onPaymentFailed',
287
+ 'onPaymentSuccess',
288
+ 'realm',
289
+ 'transport',
290
+ 'verifyCredential',
291
+ ] as const
292
+
293
+ /** Public instance keys that payment method names and shorthand intents cannot shadow. */
294
+ export type ReservedKey = (typeof reservedMppxKeyValues)[number]
295
+
296
+ /** Public instance keys that payment method names and shorthand intents cannot shadow. */
297
+ export const reservedMppxKeys: ReadonlySet<ReservedKey> = new Set(reservedMppxKeyValues)
298
+
135
299
  /** True when exactly one method has the given intent (no name collision). */
136
300
  type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> =
137
301
  Extract<methods[number], { intent: intent }> extends infer M
@@ -148,7 +312,9 @@ type UniqueIntentHandlers<
148
312
  transport extends Transport.AnyTransport,
149
313
  > = {
150
314
  [method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
151
- ? method_name
315
+ ? method_name extends ReservedKey
316
+ ? never
317
+ : method_name
152
318
  : never]: MethodFn<
153
319
  Extract<methods[number], { intent: method_name }>,
154
320
  EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
@@ -161,7 +327,7 @@ type NestedHandlers<
161
327
  methods extends readonly Method.AnyServer[],
162
328
  transport extends Transport.AnyTransport,
163
329
  > = {
164
- [name in methods[number]['name']]: {
330
+ [name in methods[number]['name'] as name extends ReservedKey ? never : name]: {
165
331
  [mi in Extract<methods[number], { name: name }> as mi['intent']]: MethodFn<
166
332
  mi,
167
333
  EffectiveTransportOf<mi, transport>,
@@ -230,19 +396,27 @@ export function create<
230
396
  }
231
397
 
232
398
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
399
+ const serverEvents = createServerEventDispatcher<FlattenMethods<methods>, transport>()
233
400
 
234
401
  const handlers: Record<string, unknown> = {}
235
402
  const intentCount: Record<string, number> = {}
236
403
 
237
404
  for (const mi of methods) {
238
405
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
406
+ }
407
+ assertNoReservedMppxKeys(methods as readonly Method.AnyServer[])
408
+
409
+ for (const mi of methods) {
239
410
  handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
411
+ authorize: mi.authorize as never,
240
412
  defaults: mi.defaults,
241
413
  method: mi,
242
414
  realm,
415
+ events: serverEvents as never,
243
416
  request: mi.request as never,
244
417
  respond: mi.respond as never,
245
418
  secretKey,
419
+ stableBinding: mi.stableBinding as never,
246
420
  transport: (mi.transport ?? transport) as never,
247
421
  verify: mi.verify as never,
248
422
  })
@@ -283,37 +457,114 @@ export function create<
283
457
  typeof input === 'string' ? Credential.deserialize(input) : input,
284
458
  )
285
459
 
286
- // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
287
- if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
288
- throw new Errors.InvalidChallengeError({
289
- id: credential.challenge.id,
290
- reason: 'challenge was not issued by this server',
291
- })
292
-
293
- // Expiry check
294
- Expires.assert(credential.challenge.expires, credential.challenge.id)
295
-
296
460
  // Find matching method by name + intent
297
461
  const { method: credMethod, intent: credIntent } = credential.challenge
298
462
  const mi = (methods as readonly Method.AnyServer[]).find(
299
463
  (m) => m.name === credMethod && m.intent === credIntent,
300
464
  )
301
- if (!mi)
302
- throw new Errors.InvalidChallengeError({
465
+ const eventMethod =
466
+ mi ?? ({ intent: credIntent, name: credMethod } satisfies ServerMethodDescriptor)
467
+
468
+ const emitStandalonePaymentFailed = async (parameters: {
469
+ challenge: Challenge.Challenge
470
+ credential: Credential.Credential | null
471
+ error: Errors.PaymentError
472
+ request: Record<string, unknown>
473
+ submittedChallenge?: Challenge.Challenge | undefined
474
+ }) => {
475
+ await serverEvents.emit(
476
+ 'payment.failed',
477
+ createPaymentFailedContext({
478
+ capturedRequest: options?.capturedRequest,
479
+ challenge: parameters.challenge,
480
+ credential: parameters.credential,
481
+ error: parameters.error,
482
+ method: eventMethod,
483
+ request: parameters.request,
484
+ submittedChallenge: parameters.submittedChallenge,
485
+ }) as never,
486
+ )
487
+ }
488
+
489
+ if (!mi) {
490
+ const error = new Errors.InvalidChallengeError({
303
491
  id: credential.challenge.id,
304
492
  reason: `no registered method for ${credMethod}/${credIntent}`,
305
493
  })
494
+ await emitStandalonePaymentFailed({
495
+ challenge: credential.challenge,
496
+ credential,
497
+ error,
498
+ request: credential.challenge.request as Record<string, unknown>,
499
+ submittedChallenge: credential.challenge,
500
+ })
501
+ throw error
502
+ }
503
+
504
+ // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
505
+ if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) {
506
+ const error = new Errors.InvalidChallengeError({
507
+ id: credential.challenge.id,
508
+ reason: 'challenge was not issued by this server',
509
+ })
510
+ await emitStandalonePaymentFailed({
511
+ challenge: credential.challenge,
512
+ credential,
513
+ error,
514
+ request: credential.challenge.request as Record<string, unknown>,
515
+ submittedChallenge: credential.challenge,
516
+ })
517
+ throw error
518
+ }
519
+
520
+ // Expiry check
521
+ try {
522
+ Expires.assert(credential.challenge.expires, credential.challenge.id)
523
+ } catch (e) {
524
+ if (e instanceof Errors.PaymentError)
525
+ await emitStandalonePaymentFailed({
526
+ challenge: credential.challenge,
527
+ credential,
528
+ error: e,
529
+ request: credential.challenge.request as Record<string, unknown>,
530
+ submittedChallenge: credential.challenge,
531
+ })
532
+ throw e
533
+ }
306
534
 
307
535
  // Validate payload against method schema
308
- mi.schema.credential.payload.parse(credential.payload)
536
+ let parsedCredential: Credential.Credential
537
+ try {
538
+ parsedCredential = withParsedCredentialPayload(
539
+ credential,
540
+ mi.schema.credential.payload.parse(credential.payload),
541
+ )
542
+ } catch (e) {
543
+ await emitStandalonePaymentFailed({
544
+ challenge: credential.challenge,
545
+ credential,
546
+ error: new Errors.InvalidPayloadError(),
547
+ request: credential.challenge.request as Record<string, unknown>,
548
+ submittedChallenge: credential.challenge,
549
+ })
550
+ throw e
551
+ }
309
552
 
310
553
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
311
554
 
312
555
  if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
313
- throw new Errors.InvalidChallengeError({
556
+ const error = new Errors.InvalidChallengeError({
314
557
  id: credential.challenge.id,
315
558
  reason: "credential scope does not match this route's requirements",
316
559
  })
560
+ await emitStandalonePaymentFailed({
561
+ challenge: credential.challenge,
562
+ credential: parsedCredential,
563
+ error,
564
+ request: credential.challenge.request as Record<string, unknown>,
565
+ submittedChallenge: credential.challenge,
566
+ })
567
+ throw error
317
568
  }
318
569
 
319
570
  const shouldValidateRoute =
@@ -326,40 +577,87 @@ export function create<
326
577
  realm ??
327
578
  (options?.capturedRequest === undefined ? credential.challenge.realm : undefined)
328
579
 
329
- const request = shouldValidateRoute
330
- ? await resolveRouteChallenge({
331
- capturedRequest: options?.capturedRequest,
332
- credential,
333
- defaults: mi.defaults,
334
- expires: credential.challenge.expires,
335
- meta: expectedMeta,
336
- method: mi,
337
- realm: expectedRealm,
338
- request: mi.request as never,
339
- routeRequest: options?.request ?? {},
340
- secretKey: secretKey!,
341
- }).then((resolved) => {
342
- const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge)
343
- if (mismatch)
344
- throw new Errors.InvalidChallengeError({
345
- id: credential.challenge.id,
346
- reason: `credential ${mismatch} does not match this route's requirements`,
347
- })
580
+ let parsedRequest = credential.challenge.request as Record<string, unknown>
581
+ let request: z.input<typeof mi.schema.request>
582
+ try {
583
+ request = shouldValidateRoute
584
+ ? await resolveRouteChallenge({
585
+ capturedRequest: options?.capturedRequest,
586
+ credential: parsedCredential,
587
+ defaults: mi.defaults,
588
+ expires: credential.challenge.expires,
589
+ meta: expectedMeta,
590
+ method: mi,
591
+ realm: expectedRealm,
592
+ request: mi.request as never,
593
+ routeRequest: options?.request ?? {},
594
+ secretKey: secretKey!,
595
+ }).then((resolved) => {
596
+ const mismatch = getChallengeBindingMismatch(
597
+ resolved.challenge,
598
+ credential.challenge,
599
+ mi.stableBinding as never,
600
+ )
601
+ if (mismatch)
602
+ throw new Errors.InvalidChallengeError({
603
+ id: credential.challenge.id,
604
+ reason: `credential ${mismatch} does not match this route's requirements`,
605
+ })
348
606
 
349
- return resolved.request as z.input<typeof mi.schema.request>
607
+ parsedRequest = resolved.parsedRequest
608
+ return resolved.request as z.input<typeof mi.schema.request>
609
+ })
610
+ : (credential.challenge.request as z.input<typeof mi.schema.request>)
611
+ } catch (e) {
612
+ if (e instanceof Errors.PaymentError)
613
+ await emitStandalonePaymentFailed({
614
+ challenge: credential.challenge,
615
+ credential: parsedCredential,
616
+ error: e,
617
+ request: credential.challenge.request as Record<string, unknown>,
618
+ submittedChallenge: credential.challenge,
350
619
  })
351
- : (credential.challenge.request as z.input<typeof mi.schema.request>)
620
+ throw e
621
+ }
352
622
 
353
623
  const envelope = options?.capturedRequest
354
624
  ? ({
355
625
  capturedRequest: options.capturedRequest,
356
626
  challenge: credential.challenge,
357
- credential,
358
- request,
627
+ credential: parsedCredential,
628
+ request: parsedRequest,
359
629
  } as Method.VerifiedChallengeEnvelope)
360
630
  : undefined
361
631
 
362
- return mi.verify({ credential, envelope, request } as never)
632
+ let receipt: Receipt.Receipt
633
+ try {
634
+ receipt = await mi.verify({ credential: parsedCredential, envelope, request } as never)
635
+ } catch (e) {
636
+ const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
637
+ await emitStandalonePaymentFailed({
638
+ challenge: credential.challenge,
639
+ credential: parsedCredential,
640
+ error,
641
+ request: parsedRequest,
642
+ submittedChallenge: credential.challenge,
643
+ })
644
+ throw e
645
+ }
646
+
647
+ await serverEvents.emit(
648
+ 'payment.success',
649
+ createPaymentSuccessContext({
650
+ capturedRequest: options?.capturedRequest,
651
+ challenge: credential.challenge,
652
+ credential: parsedCredential,
653
+ envelope,
654
+ method: mi,
655
+ receipt,
656
+ request: parsedRequest,
657
+ }) as never,
658
+ )
659
+
660
+ return receipt
363
661
  }
364
662
 
365
663
  function composeFn(
@@ -385,10 +683,32 @@ export function create<
385
683
  return compose(...(configured as ConfiguredHandler[]))
386
684
  }
387
685
 
686
+ function onChallengeCreated(
687
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'challenge.created'>,
688
+ ) {
689
+ return serverEvents.on('challenge.created', handler)
690
+ }
691
+
692
+ function onPaymentFailed(
693
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.failed'>,
694
+ ) {
695
+ return serverEvents.on('payment.failed', handler)
696
+ }
697
+
698
+ function onPaymentSuccess(
699
+ handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.success'>,
700
+ ) {
701
+ return serverEvents.on('payment.success', handler)
702
+ }
703
+
388
704
  return {
389
705
  methods,
390
706
  challenge: challengeHandlers,
391
707
  compose: composeFn,
708
+ on: serverEvents.on,
709
+ onChallengeCreated,
710
+ onPaymentFailed,
711
+ onPaymentSuccess,
392
712
  realm: realm as string | undefined,
393
713
  transport,
394
714
  verifyCredential: verifyCredentialFn,
@@ -421,7 +741,18 @@ function createMethodFn<
421
741
  ): createMethodFn.ReturnType<method, transport, defaults>
422
742
  // biome-ignore lint/correctness/noUnusedVariables: _
423
743
  function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
424
- const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
744
+ const {
745
+ authorize,
746
+ defaults,
747
+ events,
748
+ method,
749
+ realm,
750
+ respond,
751
+ secretKey,
752
+ stableBinding,
753
+ transport,
754
+ verify,
755
+ } = parameters
425
756
 
426
757
  return (options) => {
427
758
  const { description, meta, scope, ...rest } = options
@@ -429,8 +760,16 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
429
760
 
430
761
  return Object.assign(
431
762
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
763
+ if (method.html && isServiceWorkerRequest(input))
764
+ return {
765
+ status: 402,
766
+ challenge: createServiceWorkerResponse(),
767
+ } as MethodFn.Response<Transport.Http>
768
+
432
769
  const expires =
433
- 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
770
+ 'expires' in options
771
+ ? normalizeExpires(options.expires as z.DatetimeInput | undefined)
772
+ : Expires.minutes(5)
434
773
  const capturedRequest = await captureRequest(transport, input)
435
774
  const effectiveMeta =
436
775
  scope === undefined && input instanceof globalThis.Request
@@ -446,7 +785,61 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
446
785
  return [null, e as Error] as const
447
786
  }
448
787
  })()
449
- const { challenge, request } = await resolveRouteChallenge({
788
+
789
+ const emitChallenge = async (parameters: {
790
+ challenge: Challenge.Challenge
791
+ credential?: Credential.Credential | null | undefined
792
+ error?: Errors.PaymentError | undefined
793
+ html?: Method.Method['html'] | undefined
794
+ request: Record<string, unknown>
795
+ }) => {
796
+ const response = await transport.respondChallenge({
797
+ challenge: parameters.challenge,
798
+ input,
799
+ ...(parameters.error && { error: parameters.error }),
800
+ ...(parameters.html && { html: parameters.html }),
801
+ })
802
+ if (isIssuedChallengeResponse(response))
803
+ await events.emit(
804
+ 'challenge.created',
805
+ createChallengeContext({
806
+ capturedRequest,
807
+ challenge: parameters.challenge,
808
+ credential: parameters.credential,
809
+ error: parameters.error,
810
+ input,
811
+ method,
812
+ request: parameters.request,
813
+ }) as never,
814
+ )
815
+ return response
816
+ }
817
+
818
+ const emitPaymentFailed = async (parameters: {
819
+ challenge: Challenge.Challenge
820
+ credential: Credential.Credential | null
821
+ error: Errors.PaymentError
822
+ request: Record<string, unknown>
823
+ retryChallenge?: Challenge.Challenge | undefined
824
+ submittedChallenge?: Challenge.Challenge | undefined
825
+ }) => {
826
+ await events.emit(
827
+ 'payment.failed',
828
+ createPaymentFailedContext({
829
+ capturedRequest,
830
+ challenge: parameters.challenge,
831
+ credential: parameters.credential,
832
+ error: parameters.error,
833
+ input,
834
+ method,
835
+ request: parameters.request,
836
+ retryChallenge: parameters.retryChallenge,
837
+ submittedChallenge: parameters.submittedChallenge,
838
+ }) as never,
839
+ )
840
+ }
841
+
842
+ const routeChallenge = await resolveRouteChallenge({
450
843
  capturedRequest,
451
844
  credential,
452
845
  defaults,
@@ -458,26 +851,156 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
458
851
  request: parameters.request,
459
852
  routeRequest: rest,
460
853
  secretKey,
854
+ }).catch(async (e) => {
855
+ if (!(e instanceof Errors.PaymentError)) throw e
856
+ const challenge = createFallbackChallenge({
857
+ capturedRequest,
858
+ defaults: defaults ?? {},
859
+ description,
860
+ expires,
861
+ meta: effectiveMeta,
862
+ method,
863
+ realm,
864
+ routeRequest: rest,
865
+ secretKey,
866
+ })
867
+ if (credential)
868
+ await emitPaymentFailed({
869
+ challenge,
870
+ credential,
871
+ error: e,
872
+ request: challenge.request,
873
+ retryChallenge: challenge,
874
+ submittedChallenge: credential.challenge,
875
+ })
876
+ const response = await emitChallenge({
877
+ challenge,
878
+ credential,
879
+ request: challenge.request,
880
+ error: e,
881
+ html: method.html,
882
+ })
883
+ return { response }
461
884
  })
885
+ if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 }
886
+ const { challenge, parsedRequest, request } = routeChallenge
462
887
 
463
888
  // Credential was provided but malformed
464
889
  if (credentialError) {
465
890
  const reason = getSafeCredentialReason(credentialError)
466
- const response = await transport.respondChallenge({
891
+ const error = new Errors.MalformedCredentialError(reason ? { reason } : {})
892
+ await emitPaymentFailed({
467
893
  challenge,
468
- input,
469
- error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
894
+ credential: null,
895
+ error,
896
+ request: parsedRequest,
897
+ retryChallenge: challenge,
898
+ })
899
+ const response = await emitChallenge({
900
+ challenge,
901
+ credential: null,
902
+ request: parsedRequest,
903
+ error,
470
904
  html: method.html,
471
905
  })
472
906
  return { challenge: response, status: 402 }
473
907
  }
474
908
 
909
+ const success = (
910
+ receiptData: Receipt.Receipt,
911
+ options: {
912
+ challengeId?: string | undefined
913
+ credentialForReceipt?: Credential.Credential | undefined
914
+ envelopeForReceipt?: Method.VerifiedChallengeEnvelope | undefined
915
+ managementResponse?: globalThis.Response | undefined
916
+ } = {},
917
+ ): MethodFn.Response => {
918
+ const {
919
+ challengeId = challenge.id,
920
+ credentialForReceipt = { challenge, payload: {} } as Credential.Credential,
921
+ envelopeForReceipt,
922
+ managementResponse,
923
+ } = options
924
+
925
+ return {
926
+ status: 200,
927
+ withReceipt<response>(response?: response) {
928
+ if (managementResponse) {
929
+ return transport.respondReceipt({
930
+ challengeId,
931
+ credential: credentialForReceipt,
932
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
933
+ input,
934
+ receipt: receiptData,
935
+ response: managementResponse as never,
936
+ }) as response
937
+ }
938
+ if (!response) throw new MissingReceiptResponseError()
939
+ return transport.respondReceipt({
940
+ challengeId,
941
+ credential: credentialForReceipt,
942
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
943
+ input,
944
+ receipt: receiptData,
945
+ response: response as never,
946
+ }) as response
947
+ },
948
+ }
949
+ }
950
+
475
951
  // No credential provided—issue challenge
476
952
  if (!credential) {
477
- const response = await transport.respondChallenge({
953
+ if (authorize && input instanceof globalThis.Request) {
954
+ try {
955
+ const authorized = await authorize({
956
+ challenge,
957
+ input,
958
+ request: challenge.request,
959
+ } as never)
960
+ if (authorized) {
961
+ await events.emit(
962
+ 'payment.success',
963
+ createPaymentSuccessContext({
964
+ capturedRequest,
965
+ challenge,
966
+ input,
967
+ method,
968
+ receipt: authorized.receipt,
969
+ request: parsedRequest,
970
+ }) as never,
971
+ )
972
+ return success(authorized.receipt, {
973
+ managementResponse: authorized.response,
974
+ })
975
+ }
976
+ } catch (e) {
977
+ if (!(e instanceof Errors.PaymentError))
978
+ console.error('mppx: internal authorization error', e)
979
+ const error =
980
+ e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
981
+ await emitPaymentFailed({
982
+ challenge,
983
+ credential: null,
984
+ error,
985
+ request: parsedRequest,
986
+ retryChallenge: challenge,
987
+ })
988
+ const response = await emitChallenge({
989
+ challenge,
990
+ request: parsedRequest,
991
+ error,
992
+ html: method.html,
993
+ })
994
+ return { challenge: response, status: 402 }
995
+ }
996
+ }
997
+
998
+ const error = new Errors.PaymentRequiredError({ description })
999
+ const response = await emitChallenge({
478
1000
  challenge,
479
- input,
480
- error: new Errors.PaymentRequiredError({ description }),
1001
+ credential: null,
1002
+ request: parsedRequest,
1003
+ error,
481
1004
  html: method.html,
482
1005
  })
483
1006
  return { challenge: response, status: 402 }
@@ -495,13 +1018,23 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
495
1018
  // (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
496
1019
  // No database lookup is needed; the HMAC is stateless verification.
497
1020
  if (!Challenge.verify(credential.challenge, { secretKey })) {
498
- const response = await transport.respondChallenge({
1021
+ const error = new Errors.InvalidChallengeError({
1022
+ id: credential.challenge.id,
1023
+ reason: 'challenge was not issued by this server',
1024
+ })
1025
+ await emitPaymentFailed({
499
1026
  challenge,
500
- input,
501
- error: new Errors.InvalidChallengeError({
502
- id: credential.challenge.id,
503
- reason: 'challenge was not issued by this server',
504
- }),
1027
+ credential,
1028
+ error,
1029
+ request: parsedRequest,
1030
+ retryChallenge: challenge,
1031
+ submittedChallenge: credential.challenge,
1032
+ })
1033
+ const response = await emitChallenge({
1034
+ challenge,
1035
+ credential,
1036
+ request: parsedRequest,
1037
+ error,
505
1038
  html: method.html,
506
1039
  })
507
1040
  return { challenge: response, status: 402 }
@@ -530,15 +1063,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
530
1063
  // `expires` still is not pinned here because its default is generated
531
1064
  // per invocation, and `digest` is already bound by the echoed HMAC.
532
1065
  {
533
- const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge)
1066
+ const mismatch = getChallengeBindingMismatch(
1067
+ challenge,
1068
+ credential.challenge,
1069
+ stableBinding as never,
1070
+ )
534
1071
  if (mismatch) {
535
- const response = await transport.respondChallenge({
1072
+ const error = new Errors.InvalidChallengeError({
1073
+ id: credential.challenge.id,
1074
+ reason: `credential ${mismatch} does not match this route's requirements`,
1075
+ })
1076
+ await emitPaymentFailed({
536
1077
  challenge,
537
- input,
538
- error: new Errors.InvalidChallengeError({
539
- id: credential.challenge.id,
540
- reason: `credential ${mismatch} does not match this route's requirements`,
541
- }),
1078
+ credential,
1079
+ error,
1080
+ request: parsedRequest,
1081
+ retryChallenge: challenge,
1082
+ submittedChallenge: credential.challenge,
1083
+ })
1084
+ const response = await emitChallenge({
1085
+ challenge,
1086
+ credential,
1087
+ request: parsedRequest,
1088
+ error,
542
1089
  html: method.html,
543
1090
  })
544
1091
  return { challenge: response, status: 402 }
@@ -549,21 +1096,44 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
549
1096
  try {
550
1097
  Expires.assert(credential.challenge.expires, credential.challenge.id)
551
1098
  } catch (error) {
552
- const response = await transport.respondChallenge({
1099
+ await emitPaymentFailed({
553
1100
  challenge,
554
- input,
1101
+ credential,
1102
+ error: error as Errors.PaymentError,
1103
+ request: parsedRequest,
1104
+ retryChallenge: challenge,
1105
+ submittedChallenge: credential.challenge,
1106
+ })
1107
+ const response = await emitChallenge({
1108
+ challenge,
1109
+ credential,
1110
+ request: parsedRequest,
555
1111
  error: error as Errors.PaymentError,
556
1112
  })
557
1113
  return { challenge: response, status: 402 }
558
1114
  }
559
1115
  // Validate payload structure against method schema
1116
+ let parsedCredential: Credential.Credential
560
1117
  try {
561
- method.schema.credential.payload.parse(credential.payload)
1118
+ parsedCredential = withParsedCredentialPayload(
1119
+ credential,
1120
+ method.schema.credential.payload.parse(credential.payload),
1121
+ )
562
1122
  } catch {
563
- const response = await transport.respondChallenge({
1123
+ const error = new Errors.InvalidPayloadError()
1124
+ await emitPaymentFailed({
564
1125
  challenge,
565
- input,
566
- error: new Errors.InvalidPayloadError(),
1126
+ credential,
1127
+ error,
1128
+ request: parsedRequest,
1129
+ retryChallenge: challenge,
1130
+ submittedChallenge: credential.challenge,
1131
+ })
1132
+ const response = await emitChallenge({
1133
+ challenge,
1134
+ credential,
1135
+ request: parsedRequest,
1136
+ error,
567
1137
  })
568
1138
  return { challenge: response, status: 402 }
569
1139
  }
@@ -571,22 +1141,31 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
571
1141
  const envelope: Method.VerifiedChallengeEnvelope = Object.freeze({
572
1142
  capturedRequest,
573
1143
  challenge: credential.challenge,
574
- credential,
575
- request,
1144
+ credential: parsedCredential,
1145
+ request: parsedRequest,
576
1146
  })
577
1147
 
578
1148
  // User-provided verification (e.g., check signature, submit tx, verify payment).
579
1149
  // If verification fails, re-issue the challenge so the client can retry.
580
1150
  let receiptData: Receipt.Receipt
581
1151
  try {
582
- receiptData = await verify({ credential, envelope, request } as never)
1152
+ receiptData = await verify({ credential: parsedCredential, envelope, request } as never)
583
1153
  } catch (e) {
584
1154
  if (!(e instanceof Errors.PaymentError))
585
1155
  console.error('mppx: internal verification error', e)
586
1156
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
587
- const response = await transport.respondChallenge({
1157
+ await emitPaymentFailed({
588
1158
  challenge,
589
- input,
1159
+ credential: parsedCredential,
1160
+ error,
1161
+ request: parsedRequest,
1162
+ retryChallenge: challenge,
1163
+ submittedChallenge: credential.challenge,
1164
+ })
1165
+ const response = await emitChallenge({
1166
+ challenge,
1167
+ credential: parsedCredential,
1168
+ request: parsedRequest,
590
1169
  error,
591
1170
  })
592
1171
  return { challenge: response, status: 402 }
@@ -598,33 +1177,35 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
598
1177
  // return the management response directly. If undefined, `withReceipt()`
599
1178
  // expects the caller to pass the user handler's response instead.
600
1179
  const managementResponse = respond
601
- ? await respond({ credential, envelope, input, receipt: receiptData, request } as never)
602
- : undefined
603
-
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,
1180
+ ? await respond({
1181
+ credential: parsedCredential,
621
1182
  envelope,
622
1183
  input,
623
1184
  receipt: receiptData,
624
- response: response as never,
625
- }) as response
626
- },
627
- }
1185
+ request,
1186
+ } as never)
1187
+ : undefined
1188
+
1189
+ await events.emit(
1190
+ 'payment.success',
1191
+ createPaymentSuccessContext({
1192
+ capturedRequest,
1193
+ challenge: credential.challenge,
1194
+ credential: parsedCredential,
1195
+ envelope,
1196
+ input,
1197
+ method,
1198
+ receipt: receiptData,
1199
+ request: parsedRequest,
1200
+ }) as never,
1201
+ )
1202
+
1203
+ return success(receiptData, {
1204
+ challengeId: credential.challenge.id,
1205
+ credentialForReceipt: parsedCredential,
1206
+ envelopeForReceipt: envelope,
1207
+ managementResponse,
1208
+ })
628
1209
  },
629
1210
  {
630
1211
  _internal: {
@@ -635,6 +1216,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
635
1216
  name: method.name,
636
1217
  intent: method.intent,
637
1218
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
1219
+ _stableBinding: stableBinding as never,
638
1220
  },
639
1221
  },
640
1222
  )
@@ -658,14 +1240,16 @@ function createChallengeFn(parameters: {
658
1240
  return async (options) => {
659
1241
  const { description, meta, scope, ...rest } = options as {
660
1242
  description?: string
661
- expires?: string
1243
+ expires?: z.DatetimeInput
662
1244
  meta?: Record<string, string>
663
1245
  scope?: string
664
1246
  [key: string]: unknown
665
1247
  }
666
1248
  const effectiveMeta = Scope.merge({ meta, scope })
667
1249
  const expires =
668
- 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
1250
+ 'expires' in options
1251
+ ? normalizeExpires(options.expires as z.DatetimeInput | undefined)
1252
+ : Expires.minutes(5)
669
1253
 
670
1254
  return resolveRouteChallenge({
671
1255
  defaults,
@@ -681,25 +1265,21 @@ function createChallengeFn(parameters: {
681
1265
  }
682
1266
  }
683
1267
 
684
- function getSafeCredentialReason(error: unknown): string | undefined {
685
- if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
686
- if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
687
- if (error instanceof Credential.MissingPaymentSchemeError) return error.message
688
- return undefined
689
- }
690
-
691
1268
  declare namespace createMethodFn {
692
1269
  type Parameters<
693
1270
  method extends Method.Method = Method.Method,
694
1271
  transport extends Transport.AnyTransport = Transport.Http,
695
1272
  defaults extends Record<string, unknown> = Record<string, unknown>,
696
1273
  > = {
1274
+ authorize?: Method.AuthorizeFn<method>
697
1275
  defaults?: defaults
698
1276
  method: method
1277
+ events: ServerEventDispatcher<readonly [method], transport>
699
1278
  realm: string | undefined
700
1279
  request?: Method.RequestFn<method>
701
1280
  respond?: Method.RespondFn<method>
702
1281
  secretKey: string
1282
+ stableBinding?: Method.StableBindingFn<method>
703
1283
  transport: transport
704
1284
  verify: Method.VerifyFn<method>
705
1285
  }
@@ -711,10 +1291,314 @@ declare namespace createMethodFn {
711
1291
  > = MethodFn<method, transport, defaults>
712
1292
  }
713
1293
 
1294
+ type ServerEventDispatcher<
1295
+ methods extends readonly Method.Method[],
1296
+ transport extends Transport.AnyTransport,
1297
+ > = {
1298
+ emit<name extends keyof ServerEventMap<methods, transport>>(
1299
+ name: name,
1300
+ context: ServerEventMap<methods, transport>[name],
1301
+ ): Promise<void>
1302
+ on<name extends ServerEventName<methods, transport>>(
1303
+ name: name,
1304
+ handler: ServerEventHandler<methods, transport, name>,
1305
+ ): Unsubscribe
1306
+ }
1307
+
1308
+ function createServerEventDispatcher<
1309
+ methods extends readonly Method.Method[],
1310
+ transport extends Transport.AnyTransport,
1311
+ >(): ServerEventDispatcher<methods, transport> {
1312
+ const handlers = {
1313
+ '*': new Set<ServerEventHandler<methods, transport, '*'>>(),
1314
+ 'challenge.created': new Set<ServerEventHandler<methods, transport, 'challenge.created'>>(),
1315
+ 'payment.failed': new Set<ServerEventHandler<methods, transport, 'payment.failed'>>(),
1316
+ 'payment.success': new Set<ServerEventHandler<methods, transport, 'payment.success'>>(),
1317
+ }
1318
+
1319
+ const on: ServerEventDispatcher<methods, transport>['on'] = (name, handler) => {
1320
+ switch (name) {
1321
+ case '*':
1322
+ case 'challenge.created':
1323
+ case 'payment.failed':
1324
+ case 'payment.success':
1325
+ handlers[name].add(handler as never)
1326
+ return () => handlers[name].delete(handler as never)
1327
+ default:
1328
+ throw new Error(`Unknown server event "${String(name)}".`)
1329
+ }
1330
+ }
1331
+
1332
+ return {
1333
+ async emit(name, context) {
1334
+ await emitServerEventHandlers(handlers[name], context)
1335
+ await emitServerEventHandlers(handlers['*'], toServerEventEnvelope(name, context))
1336
+ },
1337
+ on,
1338
+ }
1339
+ }
1340
+
1341
+ function toServerEventEnvelope<
1342
+ methods extends readonly Method.Method[],
1343
+ transport extends Transport.AnyTransport,
1344
+ name extends keyof ServerEventMap<methods, transport>,
1345
+ >(
1346
+ name: name,
1347
+ payload: ServerEventMap<methods, transport>[name],
1348
+ ): ServerEventPayload<methods, transport, '*'> {
1349
+ return Object.freeze({ name, payload }) as ServerEventPayload<methods, transport, '*'>
1350
+ }
1351
+
1352
+ async function emitServerEventHandlers(
1353
+ handlers: ReadonlySet<(context: never) => MaybePromise<void>>,
1354
+ context: unknown,
1355
+ ): Promise<void> {
1356
+ for (const handler of handlers) {
1357
+ try {
1358
+ await handler(context as never)
1359
+ } catch {
1360
+ // Errors are isolated, but handlers are still awaited inline.
1361
+ }
1362
+ }
1363
+ }
1364
+
1365
+ function assertNoReservedMppxKeys(methods: readonly Method.AnyServer[]) {
1366
+ for (const method of methods) {
1367
+ if (reservedMppxKeys.has(method.name as ReservedKey))
1368
+ throw new Error(`Method name "${method.name}" conflicts with a reserved Mppx property.`)
1369
+ if (reservedMppxKeys.has(method.intent as ReservedKey))
1370
+ throw new Error(`Method intent "${method.intent}" conflicts with a reserved Mppx property.`)
1371
+ }
1372
+ }
1373
+
1374
+ function createChallengeContext(parameters: {
1375
+ capturedRequest?: Method.CapturedRequest | undefined
1376
+ challenge: Challenge.Challenge
1377
+ credential?: Credential.Credential | null | undefined
1378
+ error?: Errors.PaymentError | undefined
1379
+ input?: unknown
1380
+ method: Method.Method | ServerMethodDescriptor
1381
+ request: Record<string, unknown>
1382
+ }): ChallengeContext {
1383
+ return Object.freeze({
1384
+ ...(parameters.capturedRequest
1385
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
1386
+ : {}),
1387
+ challenge: snapshotValue(parameters.challenge),
1388
+ credential:
1389
+ parameters.credential === undefined
1390
+ ? undefined
1391
+ : snapshotNullableValue(parameters.credential),
1392
+ error: snapshotError(parameters.error),
1393
+ ...snapshotInputProperty(parameters.input),
1394
+ method: snapshotMethod(parameters.method),
1395
+ request: snapshotValue(parameters.request),
1396
+ }) as never
1397
+ }
1398
+
1399
+ function createPaymentFailedContext(parameters: {
1400
+ capturedRequest?: Method.CapturedRequest | undefined
1401
+ challenge: Challenge.Challenge
1402
+ credential: Credential.Credential | null
1403
+ error: Errors.PaymentError
1404
+ input?: unknown
1405
+ method: Method.Method | ServerMethodDescriptor
1406
+ request: Record<string, unknown>
1407
+ retryChallenge?: Challenge.Challenge | undefined
1408
+ submittedChallenge?: Challenge.Challenge | undefined
1409
+ }): PaymentFailedContext {
1410
+ return Object.freeze({
1411
+ ...(parameters.capturedRequest
1412
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
1413
+ : {}),
1414
+ challenge: snapshotValue(parameters.challenge),
1415
+ credential: snapshotNullableValue(parameters.credential),
1416
+ error: snapshotError(parameters.error),
1417
+ ...snapshotInputProperty(parameters.input),
1418
+ method: snapshotMethod(parameters.method),
1419
+ request: snapshotValue(parameters.request),
1420
+ ...(parameters.retryChallenge
1421
+ ? { retryChallenge: snapshotValue(parameters.retryChallenge) }
1422
+ : {}),
1423
+ ...(parameters.submittedChallenge
1424
+ ? { submittedChallenge: snapshotValue(parameters.submittedChallenge) }
1425
+ : {}),
1426
+ }) as never
1427
+ }
1428
+
1429
+ function createPaymentSuccessContext(parameters: {
1430
+ capturedRequest?: Method.CapturedRequest | undefined
1431
+ challenge: Challenge.Challenge
1432
+ credential?: Credential.Credential | undefined
1433
+ envelope?: Method.VerifiedChallengeEnvelope | undefined
1434
+ input?: unknown
1435
+ method: Method.Method | ServerMethodDescriptor
1436
+ receipt: Receipt.Receipt
1437
+ request: Record<string, unknown>
1438
+ }): PaymentSuccessContext {
1439
+ return Object.freeze({
1440
+ ...(parameters.capturedRequest
1441
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
1442
+ : {}),
1443
+ challenge: snapshotValue(parameters.challenge),
1444
+ ...(parameters.credential ? { credential: snapshotValue(parameters.credential) } : {}),
1445
+ ...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
1446
+ ...snapshotInputProperty(parameters.input),
1447
+ method: snapshotMethod(parameters.method),
1448
+ receipt: snapshotValue(parameters.receipt),
1449
+ request: snapshotValue(parameters.request),
1450
+ }) as never
1451
+ }
1452
+
1453
+ function snapshotMethod<method extends Pick<Method.Method, 'intent' | 'name'>>(
1454
+ method: method,
1455
+ ): ServerMethodDescriptor<method> {
1456
+ return Object.freeze({
1457
+ intent: method.intent,
1458
+ name: method.name,
1459
+ }) as ServerMethodDescriptor<method>
1460
+ }
1461
+
1462
+ function snapshotError<error extends Errors.PaymentError | undefined>(error: error): error {
1463
+ if (!error) return error
1464
+ const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error)
1465
+ Object.defineProperties(snapshot, {
1466
+ message: { value: error.message, enumerable: false },
1467
+ name: { value: error.name, enumerable: false },
1468
+ })
1469
+ return Object.freeze(snapshot) as error
1470
+ }
1471
+
1472
+ function snapshotVerifiedEnvelope(
1473
+ envelope: Method.VerifiedChallengeEnvelope,
1474
+ ): Method.VerifiedChallengeEnvelope {
1475
+ return Object.freeze({
1476
+ capturedRequest: snapshotCapturedRequest(envelope.capturedRequest),
1477
+ challenge: snapshotValue(envelope.challenge),
1478
+ credential: snapshotValue(envelope.credential),
1479
+ request: snapshotValue(envelope.request),
1480
+ }) as Method.VerifiedChallengeEnvelope
1481
+ }
1482
+
1483
+ function snapshotCapturedRequest(capturedRequest: Method.CapturedRequest): Method.CapturedRequest {
1484
+ return Object.freeze({
1485
+ headers: new Headers(capturedRequest.headers),
1486
+ hasBody: capturedRequest.hasBody,
1487
+ method: capturedRequest.method,
1488
+ url: new URL(capturedRequest.url),
1489
+ })
1490
+ }
1491
+
1492
+ function snapshotNullableValue<value>(value: value | null): value | null {
1493
+ if (value === null) return null
1494
+ return snapshotValue(value)
1495
+ }
1496
+
1497
+ function snapshotValue<value>(value: value): value {
1498
+ try {
1499
+ return freezeSnapshot(structuredClone(value))
1500
+ } catch {
1501
+ return freezeSnapshot(value)
1502
+ }
1503
+ }
1504
+
1505
+ function snapshotInputProperty(input: unknown): { input: unknown } | {} {
1506
+ if (input === undefined) return {}
1507
+ const snapshot = snapshotTransportInput(input)
1508
+ return snapshot === undefined ? {} : { input: snapshot }
1509
+ }
1510
+
1511
+ function snapshotTransportInput<input>(input: input): input | undefined {
1512
+ if (input instanceof globalThis.Request) {
1513
+ try {
1514
+ return new globalThis.Request(input.url, {
1515
+ headers: new Headers(input.headers),
1516
+ method: input.method,
1517
+ }) as input
1518
+ } catch {
1519
+ return undefined
1520
+ }
1521
+ }
1522
+ try {
1523
+ return freezeSnapshot(structuredClone(input))
1524
+ } catch {
1525
+ warnOnce(
1526
+ Warnings.transportInputSnapshot,
1527
+ 'Could not clone server event input; omitting `context.input`. Use `capturedRequest` for request correlation.',
1528
+ )
1529
+ return undefined
1530
+ }
1531
+ }
1532
+
1533
+ // Event payloads are cloned before listeners see them; shallow freezing keeps
1534
+ // the guard simple while preventing top-level mutation of receipts/challenges.
1535
+ function freezeSnapshot<value>(value: value): value {
1536
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
1537
+ Object.freeze(value)
1538
+ return value
1539
+ }
1540
+
1541
+ function isServiceWorkerRequest(input: unknown): input is Request {
1542
+ return (
1543
+ input instanceof globalThis.Request &&
1544
+ new URL(input.url).searchParams.has(Html.params.serviceWorker)
1545
+ )
1546
+ }
1547
+
1548
+ function createServiceWorkerResponse() {
1549
+ return new Response(serviceWorker, {
1550
+ status: 200,
1551
+ headers: {
1552
+ 'Content-Type': 'application/javascript',
1553
+ 'Cache-Control': 'no-store',
1554
+ },
1555
+ })
1556
+ }
1557
+
1558
+ function isIssuedChallengeResponse(response: unknown): boolean {
1559
+ return !(response instanceof globalThis.Response) || response.status === 402
1560
+ }
1561
+
1562
+ function getSafeCredentialReason(error: unknown): string | undefined {
1563
+ if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
1564
+ if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
1565
+ if (error instanceof Credential.MissingPaymentSchemeError) return error.message
1566
+ return undefined
1567
+ }
1568
+
714
1569
  const defaultRealm = 'MPP Payment'
715
1570
  const Warnings = {
716
1571
  realmFallback: 'realm-fallback',
1572
+ transportInputSnapshot: 'transport-input-snapshot',
717
1573
  } as const
1574
+ const missingReceiptResponseErrorName = 'MissingReceiptResponseError'
1575
+ const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument'
1576
+
1577
+ /** Error thrown when `withReceipt()` needs a response but none was provided. */
1578
+ export class MissingReceiptResponseError extends Error {
1579
+ override name = missingReceiptResponseErrorName
1580
+
1581
+ constructor() {
1582
+ super(missingReceiptResponseErrorMessage)
1583
+ }
1584
+ }
1585
+
1586
+ /** Returns true when an error is the typed `withReceipt()` no-response sentinel. */
1587
+ export function isMissingReceiptResponseError(
1588
+ error: unknown,
1589
+ ): error is MissingReceiptResponseError {
1590
+ if (error instanceof MissingReceiptResponseError) return true
1591
+ if (!error || typeof error !== 'object') return false
1592
+ const value = error as { message?: unknown; name?: unknown }
1593
+ return (
1594
+ value.name === missingReceiptResponseErrorName &&
1595
+ value.message === missingReceiptResponseErrorMessage
1596
+ )
1597
+ }
1598
+
1599
+ function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined {
1600
+ return expires === undefined ? undefined : z.toDatetimeString(expires)
1601
+ }
718
1602
 
719
1603
  const _warned = new Set<string>()
720
1604
  function warnOnce(key: string, message: string) {
@@ -750,6 +1634,7 @@ async function resolveRouteChallenge(parameters: {
750
1634
  secretKey: string
751
1635
  }): Promise<{
752
1636
  challenge: Challenge.Challenge
1637
+ parsedRequest: Record<string, unknown>
753
1638
  request: Record<string, unknown>
754
1639
  }> {
755
1640
  // Resolve the route's canonical request exactly as the handler path does:
@@ -772,19 +1657,47 @@ async function resolveRouteChallenge(parameters: {
772
1657
  ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
773
1658
  : defaultRealm)
774
1659
 
1660
+ const challenge = Challenge.fromMethod(parameters.method, {
1661
+ description: parameters.description,
1662
+ expires: parameters.expires,
1663
+ meta: parameters.meta,
1664
+ realm: effectiveRealm,
1665
+ request: request as never,
1666
+ secretKey: parameters.secretKey,
1667
+ })
1668
+
775
1669
  return {
776
- challenge: Challenge.fromMethod(parameters.method, {
777
- description: parameters.description,
778
- expires: parameters.expires,
779
- meta: parameters.meta,
780
- realm: effectiveRealm,
781
- request: request as never,
782
- secretKey: parameters.secretKey,
783
- }),
1670
+ challenge,
1671
+ parsedRequest: challenge.request as Record<string, unknown>,
784
1672
  request,
785
1673
  }
786
1674
  }
787
1675
 
1676
+ function createFallbackChallenge(parameters: {
1677
+ capturedRequest?: Method.CapturedRequest | undefined
1678
+ defaults: Record<string, unknown>
1679
+ description?: string | undefined
1680
+ expires?: string | undefined
1681
+ meta?: Record<string, string> | undefined
1682
+ method: Method.Method
1683
+ realm?: string | undefined
1684
+ routeRequest: Record<string, unknown>
1685
+ secretKey: string
1686
+ }) {
1687
+ return Challenge.fromMethod(parameters.method, {
1688
+ description: parameters.description,
1689
+ expires: parameters.expires,
1690
+ meta: parameters.meta,
1691
+ realm:
1692
+ parameters.realm ??
1693
+ (parameters.capturedRequest
1694
+ ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
1695
+ : defaultRealm),
1696
+ request: { ...parameters.defaults, ...parameters.routeRequest } as never,
1697
+ secretKey: parameters.secretKey,
1698
+ })
1699
+ }
1700
+
788
1701
  /**
789
1702
  * Captures the transport request into a frozen snapshot at the start of the
790
1703
  * verification flow. This snapshot is threaded through request() → verify() →
@@ -794,7 +1707,7 @@ async function resolveRouteChallenge(parameters: {
794
1707
  *
795
1708
  * Note: Object.freeze is shallow — it prevents reassigning top-level properties
796
1709
  * but does not deep-freeze mutable class instances like Headers or URL. This is
797
- * an accidental-mutation guard for trusted server hooks, not a security boundary.
1710
+ * an accidental-mutation guard for trusted server events, not a security boundary.
798
1711
  */
799
1712
  async function captureRequest(
800
1713
  transport: Transport.AnyTransport,
@@ -831,6 +1744,26 @@ type CoreBindingField = (typeof coreBindingFields)[number]
831
1744
  type MethodBindingField = (typeof methodBindingFields)[number]
832
1745
  type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
833
1746
  type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField
1747
+ type StableBinding = Record<string, unknown>
1748
+
1749
+ function getChallengeBindingMismatch(
1750
+ expectedChallenge: Challenge.Challenge,
1751
+ actualChallenge: Challenge.Challenge,
1752
+ stableBinding?: Method.StableBindingFn<Method.Method> | undefined,
1753
+ ): string | undefined {
1754
+ if (!stableBinding) return getPinnedChallengeMismatch(expectedChallenge, actualChallenge)
1755
+
1756
+ for (const field of ['method', 'intent', 'realm'] as const) {
1757
+ if (actualChallenge[field] !== expectedChallenge[field]) return field
1758
+ }
1759
+
1760
+ if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque'
1761
+
1762
+ return getRequestBindingMismatch(
1763
+ getStableBinding(expectedChallenge.request as Record<string, unknown>, stableBinding),
1764
+ getStableBinding(actualChallenge.request as Record<string, unknown>, stableBinding),
1765
+ )
1766
+ }
834
1767
 
835
1768
  /**
836
1769
  * Compares only the fields that MUST be stable across request-hook transforms.
@@ -911,6 +1844,44 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
911
1844
  }
912
1845
  }
913
1846
 
1847
+ function getRequestBindingMismatch(
1848
+ expected: StableBinding,
1849
+ actual: StableBinding,
1850
+ ): string | undefined {
1851
+ const fields = [
1852
+ ...Object.keys(expected),
1853
+ ...Object.keys(actual).filter((key) => !(key in expected)),
1854
+ ]
1855
+
1856
+ return fields.find(
1857
+ (field) =>
1858
+ !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])),
1859
+ )
1860
+ }
1861
+
1862
+ function getStableBinding(
1863
+ request: Record<string, unknown>,
1864
+ stableBinding: Method.StableBindingFn<Method.Method>,
1865
+ ): StableBinding {
1866
+ return stableBinding(request as never)
1867
+ }
1868
+
1869
+ /** Top-level economic fields that should never drift after challenge issuance. */
1870
+ type CoreBinding = {
1871
+ [field in CoreBindingField]?: string
1872
+ }
1873
+
1874
+ /** Method-specific fields that are pinned by the fallback binding check. */
1875
+ type MethodBinding = {
1876
+ [field in MethodBindingField]?: unknown
1877
+ }
1878
+
1879
+ /** Normalized request subset used when a method does not provide a custom stable binding. */
1880
+ type PinnedRequestBinding = {
1881
+ coreBinding: CoreBinding
1882
+ methodBinding: MethodBinding
1883
+ }
1884
+
914
1885
  function normalizeScalar(value: unknown): string | undefined {
915
1886
  return value === undefined ? undefined : String(value)
916
1887
  }
@@ -958,19 +1929,15 @@ function hydrateCredentialMeta<payload>(
958
1929
  }
959
1930
  }
960
1931
 
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
1932
+ function withParsedCredentialPayload<payload>(
1933
+ credential: Credential.Credential,
1934
+ payload: payload,
1935
+ ): Credential.Credential<payload> {
1936
+ return {
1937
+ ...credential,
1938
+ payload,
1939
+ }
972
1940
  }
973
-
974
1941
  export type MethodFn<
975
1942
  method extends Method.Method,
976
1943
  transport extends Transport.AnyTransport,
@@ -991,8 +1958,8 @@ declare namespace MethodFn {
991
1958
  > = {
992
1959
  /** Optional human-readable description of the payment. */
993
1960
  description?: string | undefined
994
- /** Optional challenge expiration timestamp (ISO 8601). */
995
- expires?: string | undefined
1961
+ /** Optional challenge expiration timestamp (ISO 8601) or Date. */
1962
+ expires?: z.DatetimeInput | undefined
996
1963
  /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
997
1964
  meta?: Record<string, string> | undefined
998
1965
  /** Optional route/resource scope bound via reserved challenge metadata. */
@@ -1019,6 +1986,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
1019
1986
  meta?: Record<string, string> | undefined
1020
1987
  scope?: string | undefined
1021
1988
  _canonicalRequest: Record<string, unknown>
1989
+ _stableBinding?: Method.StableBindingFn<Method.Method> | undefined
1022
1990
  }
1023
1991
  }
1024
1992
 
@@ -1139,15 +2107,20 @@ export function compose(
1139
2107
  // transformed fields (e.g. amount with decimals) match correctly.
1140
2108
  // Also checks inside methodDetails for fields moved there by transforms.
1141
2109
  const candidates = handlers.filter((h) => {
1142
- const internal = (h as ConfiguredHandler)._internal
1143
- if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
2110
+ try {
2111
+ const internal = (h as ConfiguredHandler)._internal
2112
+ if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
2113
+ return false
2114
+ const mismatch = internal._stableBinding
2115
+ ? getRequestBindingMismatch(
2116
+ getStableBinding(internal._canonicalRequest, internal._stableBinding),
2117
+ getStableBinding(credReq, internal._stableBinding),
2118
+ )
2119
+ : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq)
2120
+ return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta)
2121
+ } catch {
1144
2122
  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
- )
2123
+ }
1151
2124
  })
1152
2125
 
1153
2126
  const match =
@@ -1164,8 +2137,14 @@ export function compose(
1164
2137
  return handlers[0]!(input)
1165
2138
  }
1166
2139
 
1167
- // No credential — call all handlers and merge 402 challenges.
1168
- const results = await Promise.all(handlers.map((h) => h(input)))
2140
+ // No credential — evaluate handlers sequentially so authorize()/renewal hooks
2141
+ // can safely claim the request without racing each other.
2142
+ const results: MethodFn.Response<Transport.Http>[] = []
2143
+ for (const handler of handlers) {
2144
+ const result = await handler(input)
2145
+ if (result.status === 200) return result
2146
+ results.push(result)
2147
+ }
1169
2148
 
1170
2149
  const challengeEntries = (() => {
1171
2150
  const entries: {
@@ -1316,6 +2295,12 @@ export function toNodeListener(
1316
2295
  if (result.status === 402) {
1317
2296
  await NodeListener.sendResponse(res, result.challenge as globalThis.Response)
1318
2297
  } else {
2298
+ const managementResponse = getManagementResponse(result)
2299
+ if (managementResponse) {
2300
+ await NodeListener.sendResponse(res, managementResponse)
2301
+ return { challenge: managementResponse, status: 402 }
2302
+ }
2303
+
1319
2304
  const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response
1320
2305
  res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!)
1321
2306
  }
@@ -1324,6 +2309,19 @@ export function toNodeListener(
1324
2309
  }
1325
2310
  }
1326
2311
 
2312
+ function getManagementResponse(
2313
+ result: Extract<MethodFn.Response<Transport.Http>, { status: 200 }>,
2314
+ ): globalThis.Response | null {
2315
+ try {
2316
+ return (result.withReceipt as () => globalThis.Response)()
2317
+ } catch (error) {
2318
+ if (isMissingReceiptResponseError(error)) {
2319
+ return null
2320
+ }
2321
+ throw error
2322
+ }
2323
+ }
2324
+
1327
2325
  /**
1328
2326
  * Flattens a methods config tuple, preserving positional types.
1329
2327
  * @internal