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
@@ -6,12 +6,27 @@ import * as Expires from '../Expires.js';
6
6
  import * as AcceptPayment from '../internal/AcceptPayment.js';
7
7
  import * as Env from '../internal/env.js';
8
8
  import * as PaymentRequest from '../PaymentRequest.js';
9
+ import * as z from '../zod.js';
9
10
  import * as Html from './internal/html/config.js';
10
11
  import { serviceWorker } from './internal/html/serviceWorker.gen.js';
11
12
  import * as Scope from './internal/scope.js';
12
13
  import * as NodeListener from './NodeListener.js';
13
14
  import * as Request from './Request.js';
14
15
  import * as Transport from './Transport.js';
16
+ const reservedMppxKeyValues = [
17
+ 'challenge',
18
+ 'compose',
19
+ 'methods',
20
+ 'on',
21
+ 'onChallengeCreated',
22
+ 'onPaymentFailed',
23
+ 'onPaymentSuccess',
24
+ 'realm',
25
+ 'transport',
26
+ 'verifyCredential',
27
+ ];
28
+ /** Public instance keys that payment method names and shorthand intents cannot shadow. */
29
+ export const reservedMppxKeys = new Set(reservedMppxKeyValues);
15
30
  /**
16
31
  * Creates a server-side payment handler from methods.
17
32
  *
@@ -34,17 +49,24 @@ export function create(config) {
34
49
  throw new Error('Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().');
35
50
  }
36
51
  const methods = config.methods.flat();
52
+ const serverEvents = createServerEventDispatcher();
37
53
  const handlers = {};
38
54
  const intentCount = {};
39
55
  for (const mi of methods) {
40
56
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1;
57
+ }
58
+ assertNoReservedMppxKeys(methods);
59
+ for (const mi of methods) {
41
60
  handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
61
+ authorize: mi.authorize,
42
62
  defaults: mi.defaults,
43
63
  method: mi,
44
64
  realm,
65
+ events: serverEvents,
45
66
  request: mi.request,
46
67
  respond: mi.respond,
47
68
  secretKey,
69
+ stableBinding: mi.stableBinding,
48
70
  transport: (mi.transport ?? transport),
49
71
  verify: mi.verify,
50
72
  });
@@ -78,30 +100,94 @@ export function create(config) {
78
100
  // verifyCredential: single-call end-to-end verification
79
101
  async function verifyCredentialFn(input, options) {
80
102
  const credential = hydrateCredentialMeta(typeof input === 'string' ? Credential.deserialize(input) : input);
81
- // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
82
- if (!Challenge.verify(credential.challenge, { secretKey: secretKey }))
83
- throw new Errors.InvalidChallengeError({
84
- id: credential.challenge.id,
85
- reason: 'challenge was not issued by this server',
86
- });
87
- // Expiry check
88
- Expires.assert(credential.challenge.expires, credential.challenge.id);
89
103
  // Find matching method by name + intent
90
104
  const { method: credMethod, intent: credIntent } = credential.challenge;
91
105
  const mi = methods.find((m) => m.name === credMethod && m.intent === credIntent);
92
- if (!mi)
93
- throw new Errors.InvalidChallengeError({
106
+ const eventMethod = mi ?? { intent: credIntent, name: credMethod };
107
+ const emitStandalonePaymentFailed = async (parameters) => {
108
+ await serverEvents.emit('payment.failed', createPaymentFailedContext({
109
+ capturedRequest: options?.capturedRequest,
110
+ challenge: parameters.challenge,
111
+ credential: parameters.credential,
112
+ error: parameters.error,
113
+ method: eventMethod,
114
+ request: parameters.request,
115
+ submittedChallenge: parameters.submittedChallenge,
116
+ }));
117
+ };
118
+ if (!mi) {
119
+ const error = new Errors.InvalidChallengeError({
94
120
  id: credential.challenge.id,
95
121
  reason: `no registered method for ${credMethod}/${credIntent}`,
96
122
  });
123
+ await emitStandalonePaymentFailed({
124
+ challenge: credential.challenge,
125
+ credential,
126
+ error,
127
+ request: credential.challenge.request,
128
+ submittedChallenge: credential.challenge,
129
+ });
130
+ throw error;
131
+ }
132
+ // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
133
+ if (!Challenge.verify(credential.challenge, { secretKey: secretKey })) {
134
+ const error = new Errors.InvalidChallengeError({
135
+ id: credential.challenge.id,
136
+ reason: 'challenge was not issued by this server',
137
+ });
138
+ await emitStandalonePaymentFailed({
139
+ challenge: credential.challenge,
140
+ credential,
141
+ error,
142
+ request: credential.challenge.request,
143
+ submittedChallenge: credential.challenge,
144
+ });
145
+ throw error;
146
+ }
147
+ // Expiry check
148
+ try {
149
+ Expires.assert(credential.challenge.expires, credential.challenge.id);
150
+ }
151
+ catch (e) {
152
+ if (e instanceof Errors.PaymentError)
153
+ await emitStandalonePaymentFailed({
154
+ challenge: credential.challenge,
155
+ credential,
156
+ error: e,
157
+ request: credential.challenge.request,
158
+ submittedChallenge: credential.challenge,
159
+ });
160
+ throw e;
161
+ }
97
162
  // Validate payload against method schema
98
- mi.schema.credential.payload.parse(credential.payload);
163
+ let parsedCredential;
164
+ try {
165
+ parsedCredential = withParsedCredentialPayload(credential, mi.schema.credential.payload.parse(credential.payload));
166
+ }
167
+ catch (e) {
168
+ await emitStandalonePaymentFailed({
169
+ challenge: credential.challenge,
170
+ credential,
171
+ error: new Errors.InvalidPayloadError(),
172
+ request: credential.challenge.request,
173
+ submittedChallenge: credential.challenge,
174
+ });
175
+ throw e;
176
+ }
99
177
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope });
100
178
  if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
101
- throw new Errors.InvalidChallengeError({
179
+ const error = new Errors.InvalidChallengeError({
102
180
  id: credential.challenge.id,
103
181
  reason: "credential scope does not match this route's requirements",
104
182
  });
183
+ await emitStandalonePaymentFailed({
184
+ challenge: credential.challenge,
185
+ credential: parsedCredential,
186
+ error,
187
+ request: credential.challenge.request,
188
+ submittedChallenge: credential.challenge,
189
+ });
190
+ throw error;
105
191
  }
106
192
  const shouldValidateRoute = options?.capturedRequest !== undefined ||
107
193
  options?.meta !== undefined ||
@@ -110,37 +196,77 @@ export function create(config) {
110
196
  const expectedRealm = options?.realm ??
111
197
  realm ??
112
198
  (options?.capturedRequest === undefined ? credential.challenge.realm : undefined);
113
- const request = shouldValidateRoute
114
- ? await resolveRouteChallenge({
115
- capturedRequest: options?.capturedRequest,
116
- credential,
117
- defaults: mi.defaults,
118
- expires: credential.challenge.expires,
119
- meta: expectedMeta,
120
- method: mi,
121
- realm: expectedRealm,
122
- request: mi.request,
123
- routeRequest: options?.request ?? {},
124
- secretKey: secretKey,
125
- }).then((resolved) => {
126
- const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge);
127
- if (mismatch)
128
- throw new Errors.InvalidChallengeError({
129
- id: credential.challenge.id,
130
- reason: `credential ${mismatch} does not match this route's requirements`,
131
- });
132
- return resolved.request;
133
- })
134
- : credential.challenge.request;
199
+ let parsedRequest = credential.challenge.request;
200
+ let request;
201
+ try {
202
+ request = shouldValidateRoute
203
+ ? await resolveRouteChallenge({
204
+ capturedRequest: options?.capturedRequest,
205
+ credential: parsedCredential,
206
+ defaults: mi.defaults,
207
+ expires: credential.challenge.expires,
208
+ meta: expectedMeta,
209
+ method: mi,
210
+ realm: expectedRealm,
211
+ request: mi.request,
212
+ routeRequest: options?.request ?? {},
213
+ secretKey: secretKey,
214
+ }).then((resolved) => {
215
+ const mismatch = getChallengeBindingMismatch(resolved.challenge, credential.challenge, mi.stableBinding);
216
+ if (mismatch)
217
+ throw new Errors.InvalidChallengeError({
218
+ id: credential.challenge.id,
219
+ reason: `credential ${mismatch} does not match this route's requirements`,
220
+ });
221
+ parsedRequest = resolved.parsedRequest;
222
+ return resolved.request;
223
+ })
224
+ : credential.challenge.request;
225
+ }
226
+ catch (e) {
227
+ if (e instanceof Errors.PaymentError)
228
+ await emitStandalonePaymentFailed({
229
+ challenge: credential.challenge,
230
+ credential: parsedCredential,
231
+ error: e,
232
+ request: credential.challenge.request,
233
+ submittedChallenge: credential.challenge,
234
+ });
235
+ throw e;
236
+ }
135
237
  const envelope = options?.capturedRequest
136
238
  ? {
137
239
  capturedRequest: options.capturedRequest,
138
240
  challenge: credential.challenge,
139
- credential,
140
- request,
241
+ credential: parsedCredential,
242
+ request: parsedRequest,
141
243
  }
142
244
  : undefined;
143
- return mi.verify({ credential, envelope, request });
245
+ let receipt;
246
+ try {
247
+ receipt = await mi.verify({ credential: parsedCredential, envelope, request });
248
+ }
249
+ catch (e) {
250
+ const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
251
+ await emitStandalonePaymentFailed({
252
+ challenge: credential.challenge,
253
+ credential: parsedCredential,
254
+ error,
255
+ request: parsedRequest,
256
+ submittedChallenge: credential.challenge,
257
+ });
258
+ throw e;
259
+ }
260
+ await serverEvents.emit('payment.success', createPaymentSuccessContext({
261
+ capturedRequest: options?.capturedRequest,
262
+ challenge: credential.challenge,
263
+ credential: parsedCredential,
264
+ envelope,
265
+ method: mi,
266
+ receipt,
267
+ request: parsedRequest,
268
+ }));
269
+ return receipt;
144
270
  }
145
271
  function composeFn(...entries) {
146
272
  if (transport.name !== 'http')
@@ -160,10 +286,23 @@ export function create(config) {
160
286
  });
161
287
  return compose(...configured);
162
288
  }
289
+ function onChallengeCreated(handler) {
290
+ return serverEvents.on('challenge.created', handler);
291
+ }
292
+ function onPaymentFailed(handler) {
293
+ return serverEvents.on('payment.failed', handler);
294
+ }
295
+ function onPaymentSuccess(handler) {
296
+ return serverEvents.on('payment.success', handler);
297
+ }
163
298
  return {
164
299
  methods,
165
300
  challenge: challengeHandlers,
166
301
  compose: composeFn,
302
+ on: serverEvents.on,
303
+ onChallengeCreated,
304
+ onPaymentFailed,
305
+ onPaymentSuccess,
167
306
  realm: realm,
168
307
  transport,
169
308
  verifyCredential: verifyCredentialFn,
@@ -172,12 +311,19 @@ export function create(config) {
172
311
  }
173
312
  // biome-ignore lint/correctness/noUnusedVariables: _
174
313
  function createMethodFn(parameters) {
175
- const { defaults, method, realm, respond, secretKey, transport, verify } = parameters;
314
+ const { authorize, defaults, events, method, realm, respond, secretKey, stableBinding, transport, verify, } = parameters;
176
315
  return (options) => {
177
316
  const { description, meta, scope, ...rest } = options;
178
317
  const staticMeta = Scope.merge({ meta, scope });
179
318
  return Object.assign(async (input) => {
180
- const expires = 'expires' in options ? options.expires : Expires.minutes(5);
319
+ if (method.html && isServiceWorkerRequest(input))
320
+ return {
321
+ status: 402,
322
+ challenge: createServiceWorkerResponse(),
323
+ };
324
+ const expires = 'expires' in options
325
+ ? normalizeExpires(options.expires)
326
+ : Expires.minutes(5);
181
327
  const capturedRequest = await captureRequest(transport, input);
182
328
  const effectiveMeta = scope === undefined && input instanceof globalThis.Request
183
329
  ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
@@ -192,7 +338,39 @@ function createMethodFn(parameters) {
192
338
  return [null, e];
193
339
  }
194
340
  })();
195
- const { challenge, request } = await resolveRouteChallenge({
341
+ const emitChallenge = async (parameters) => {
342
+ const response = await transport.respondChallenge({
343
+ challenge: parameters.challenge,
344
+ input,
345
+ ...(parameters.error && { error: parameters.error }),
346
+ ...(parameters.html && { html: parameters.html }),
347
+ });
348
+ if (isIssuedChallengeResponse(response))
349
+ await events.emit('challenge.created', createChallengeContext({
350
+ capturedRequest,
351
+ challenge: parameters.challenge,
352
+ credential: parameters.credential,
353
+ error: parameters.error,
354
+ input,
355
+ method,
356
+ request: parameters.request,
357
+ }));
358
+ return response;
359
+ };
360
+ const emitPaymentFailed = async (parameters) => {
361
+ await events.emit('payment.failed', createPaymentFailedContext({
362
+ capturedRequest,
363
+ challenge: parameters.challenge,
364
+ credential: parameters.credential,
365
+ error: parameters.error,
366
+ input,
367
+ method,
368
+ request: parameters.request,
369
+ retryChallenge: parameters.retryChallenge,
370
+ submittedChallenge: parameters.submittedChallenge,
371
+ }));
372
+ };
373
+ const routeChallenge = await resolveRouteChallenge({
196
374
  capturedRequest,
197
375
  credential,
198
376
  defaults,
@@ -204,24 +382,138 @@ function createMethodFn(parameters) {
204
382
  request: parameters.request,
205
383
  routeRequest: rest,
206
384
  secretKey,
385
+ }).catch(async (e) => {
386
+ if (!(e instanceof Errors.PaymentError))
387
+ throw e;
388
+ const challenge = createFallbackChallenge({
389
+ capturedRequest,
390
+ defaults: defaults ?? {},
391
+ description,
392
+ expires,
393
+ meta: effectiveMeta,
394
+ method,
395
+ realm,
396
+ routeRequest: rest,
397
+ secretKey,
398
+ });
399
+ if (credential)
400
+ await emitPaymentFailed({
401
+ challenge,
402
+ credential,
403
+ error: e,
404
+ request: challenge.request,
405
+ retryChallenge: challenge,
406
+ submittedChallenge: credential.challenge,
407
+ });
408
+ const response = await emitChallenge({
409
+ challenge,
410
+ credential,
411
+ request: challenge.request,
412
+ error: e,
413
+ html: method.html,
414
+ });
415
+ return { response };
207
416
  });
417
+ if ('response' in routeChallenge)
418
+ return { challenge: routeChallenge.response, status: 402 };
419
+ const { challenge, parsedRequest, request } = routeChallenge;
208
420
  // Credential was provided but malformed
209
421
  if (credentialError) {
210
422
  const reason = getSafeCredentialReason(credentialError);
211
- const response = await transport.respondChallenge({
423
+ const error = new Errors.MalformedCredentialError(reason ? { reason } : {});
424
+ await emitPaymentFailed({
212
425
  challenge,
213
- input,
214
- error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
426
+ credential: null,
427
+ error,
428
+ request: parsedRequest,
429
+ retryChallenge: challenge,
430
+ });
431
+ const response = await emitChallenge({
432
+ challenge,
433
+ credential: null,
434
+ request: parsedRequest,
435
+ error,
215
436
  html: method.html,
216
437
  });
217
438
  return { challenge: response, status: 402 };
218
439
  }
440
+ const success = (receiptData, options = {}) => {
441
+ const { challengeId = challenge.id, credentialForReceipt = { challenge, payload: {} }, envelopeForReceipt, managementResponse, } = options;
442
+ return {
443
+ status: 200,
444
+ withReceipt(response) {
445
+ if (managementResponse) {
446
+ return transport.respondReceipt({
447
+ challengeId,
448
+ credential: credentialForReceipt,
449
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
450
+ input,
451
+ receipt: receiptData,
452
+ response: managementResponse,
453
+ });
454
+ }
455
+ if (!response)
456
+ throw new MissingReceiptResponseError();
457
+ return transport.respondReceipt({
458
+ challengeId,
459
+ credential: credentialForReceipt,
460
+ ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
461
+ input,
462
+ receipt: receiptData,
463
+ response: response,
464
+ });
465
+ },
466
+ };
467
+ };
219
468
  // No credential provided—issue challenge
220
469
  if (!credential) {
221
- const response = await transport.respondChallenge({
470
+ if (authorize && input instanceof globalThis.Request) {
471
+ try {
472
+ const authorized = await authorize({
473
+ challenge,
474
+ input,
475
+ request: challenge.request,
476
+ });
477
+ if (authorized) {
478
+ await events.emit('payment.success', createPaymentSuccessContext({
479
+ capturedRequest,
480
+ challenge,
481
+ input,
482
+ method,
483
+ receipt: authorized.receipt,
484
+ request: parsedRequest,
485
+ }));
486
+ return success(authorized.receipt, {
487
+ managementResponse: authorized.response,
488
+ });
489
+ }
490
+ }
491
+ catch (e) {
492
+ if (!(e instanceof Errors.PaymentError))
493
+ console.error('mppx: internal authorization error', e);
494
+ const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
495
+ await emitPaymentFailed({
496
+ challenge,
497
+ credential: null,
498
+ error,
499
+ request: parsedRequest,
500
+ retryChallenge: challenge,
501
+ });
502
+ const response = await emitChallenge({
503
+ challenge,
504
+ request: parsedRequest,
505
+ error,
506
+ html: method.html,
507
+ });
508
+ return { challenge: response, status: 402 };
509
+ }
510
+ }
511
+ const error = new Errors.PaymentRequiredError({ description });
512
+ const response = await emitChallenge({
222
513
  challenge,
223
- input,
224
- error: new Errors.PaymentRequiredError({ description }),
514
+ credential: null,
515
+ request: parsedRequest,
516
+ error,
225
517
  html: method.html,
226
518
  });
227
519
  return { challenge: response, status: 402 };
@@ -238,13 +530,23 @@ function createMethodFn(parameters) {
238
530
  // (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
239
531
  // No database lookup is needed; the HMAC is stateless verification.
240
532
  if (!Challenge.verify(credential.challenge, { secretKey })) {
241
- const response = await transport.respondChallenge({
533
+ const error = new Errors.InvalidChallengeError({
534
+ id: credential.challenge.id,
535
+ reason: 'challenge was not issued by this server',
536
+ });
537
+ await emitPaymentFailed({
242
538
  challenge,
243
- input,
244
- error: new Errors.InvalidChallengeError({
245
- id: credential.challenge.id,
246
- reason: 'challenge was not issued by this server',
247
- }),
539
+ credential,
540
+ error,
541
+ request: parsedRequest,
542
+ retryChallenge: challenge,
543
+ submittedChallenge: credential.challenge,
544
+ });
545
+ const response = await emitChallenge({
546
+ challenge,
547
+ credential,
548
+ request: parsedRequest,
549
+ error,
248
550
  html: method.html,
249
551
  });
250
552
  return { challenge: response, status: 402 };
@@ -272,15 +574,25 @@ function createMethodFn(parameters) {
272
574
  // `expires` still is not pinned here because its default is generated
273
575
  // per invocation, and `digest` is already bound by the echoed HMAC.
274
576
  {
275
- const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge);
577
+ const mismatch = getChallengeBindingMismatch(challenge, credential.challenge, stableBinding);
276
578
  if (mismatch) {
277
- const response = await transport.respondChallenge({
579
+ const error = new Errors.InvalidChallengeError({
580
+ id: credential.challenge.id,
581
+ reason: `credential ${mismatch} does not match this route's requirements`,
582
+ });
583
+ await emitPaymentFailed({
278
584
  challenge,
279
- input,
280
- error: new Errors.InvalidChallengeError({
281
- id: credential.challenge.id,
282
- reason: `credential ${mismatch} does not match this route's requirements`,
283
- }),
585
+ credential,
586
+ error,
587
+ request: parsedRequest,
588
+ retryChallenge: challenge,
589
+ submittedChallenge: credential.challenge,
590
+ });
591
+ const response = await emitChallenge({
592
+ challenge,
593
+ credential,
594
+ request: parsedRequest,
595
+ error,
284
596
  html: method.html,
285
597
  });
286
598
  return { challenge: response, status: 402 };
@@ -291,44 +603,73 @@ function createMethodFn(parameters) {
291
603
  Expires.assert(credential.challenge.expires, credential.challenge.id);
292
604
  }
293
605
  catch (error) {
294
- const response = await transport.respondChallenge({
606
+ await emitPaymentFailed({
295
607
  challenge,
296
- input,
608
+ credential,
609
+ error: error,
610
+ request: parsedRequest,
611
+ retryChallenge: challenge,
612
+ submittedChallenge: credential.challenge,
613
+ });
614
+ const response = await emitChallenge({
615
+ challenge,
616
+ credential,
617
+ request: parsedRequest,
297
618
  error: error,
298
619
  });
299
620
  return { challenge: response, status: 402 };
300
621
  }
301
622
  // Validate payload structure against method schema
623
+ let parsedCredential;
302
624
  try {
303
- method.schema.credential.payload.parse(credential.payload);
625
+ parsedCredential = withParsedCredentialPayload(credential, method.schema.credential.payload.parse(credential.payload));
304
626
  }
305
627
  catch {
306
- const response = await transport.respondChallenge({
628
+ const error = new Errors.InvalidPayloadError();
629
+ await emitPaymentFailed({
307
630
  challenge,
308
- input,
309
- error: new Errors.InvalidPayloadError(),
631
+ credential,
632
+ error,
633
+ request: parsedRequest,
634
+ retryChallenge: challenge,
635
+ submittedChallenge: credential.challenge,
636
+ });
637
+ const response = await emitChallenge({
638
+ challenge,
639
+ credential,
640
+ request: parsedRequest,
641
+ error,
310
642
  });
311
643
  return { challenge: response, status: 402 };
312
644
  }
313
645
  const envelope = Object.freeze({
314
646
  capturedRequest,
315
647
  challenge: credential.challenge,
316
- credential,
317
- request,
648
+ credential: parsedCredential,
649
+ request: parsedRequest,
318
650
  });
319
651
  // User-provided verification (e.g., check signature, submit tx, verify payment).
320
652
  // If verification fails, re-issue the challenge so the client can retry.
321
653
  let receiptData;
322
654
  try {
323
- receiptData = await verify({ credential, envelope, request });
655
+ receiptData = await verify({ credential: parsedCredential, envelope, request });
324
656
  }
325
657
  catch (e) {
326
658
  if (!(e instanceof Errors.PaymentError))
327
659
  console.error('mppx: internal verification error', e);
328
660
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
329
- const response = await transport.respondChallenge({
661
+ await emitPaymentFailed({
330
662
  challenge,
331
- input,
663
+ credential: parsedCredential,
664
+ error,
665
+ request: parsedRequest,
666
+ retryChallenge: challenge,
667
+ submittedChallenge: credential.challenge,
668
+ });
669
+ const response = await emitChallenge({
670
+ challenge,
671
+ credential: parsedCredential,
672
+ request: parsedRequest,
332
673
  error,
333
674
  });
334
675
  return { challenge: response, status: 402 };
@@ -339,33 +680,30 @@ function createMethodFn(parameters) {
339
680
  // return the management response directly. If undefined, `withReceipt()`
340
681
  // expects the caller to pass the user handler's response instead.
341
682
  const managementResponse = respond
342
- ? await respond({ credential, envelope, input, receipt: receiptData, request })
683
+ ? await respond({
684
+ credential: parsedCredential,
685
+ envelope,
686
+ input,
687
+ receipt: receiptData,
688
+ request,
689
+ })
343
690
  : undefined;
344
- return {
345
- status: 200,
346
- withReceipt(response) {
347
- if (managementResponse) {
348
- return transport.respondReceipt({
349
- challengeId: credential.challenge.id,
350
- credential,
351
- envelope,
352
- input,
353
- receipt: receiptData,
354
- response: managementResponse,
355
- });
356
- }
357
- if (!response)
358
- throw new Error('withReceipt() requires a response argument');
359
- return transport.respondReceipt({
360
- challengeId: credential.challenge.id,
361
- credential,
362
- envelope,
363
- input,
364
- receipt: receiptData,
365
- response: response,
366
- });
367
- },
368
- };
691
+ await events.emit('payment.success', createPaymentSuccessContext({
692
+ capturedRequest,
693
+ challenge: credential.challenge,
694
+ credential: parsedCredential,
695
+ envelope,
696
+ input,
697
+ method,
698
+ receipt: receiptData,
699
+ request: parsedRequest,
700
+ }));
701
+ return success(receiptData, {
702
+ challengeId: credential.challenge.id,
703
+ credentialForReceipt: parsedCredential,
704
+ envelopeForReceipt: envelope,
705
+ managementResponse,
706
+ });
369
707
  }, {
370
708
  _internal: {
371
709
  ...method,
@@ -375,6 +713,7 @@ function createMethodFn(parameters) {
375
713
  name: method.name,
376
714
  intent: method.intent,
377
715
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
716
+ _stableBinding: stableBinding,
378
717
  },
379
718
  });
380
719
  };
@@ -389,7 +728,9 @@ function createChallengeFn(parameters) {
389
728
  return async (options) => {
390
729
  const { description, meta, scope, ...rest } = options;
391
730
  const effectiveMeta = Scope.merge({ meta, scope });
392
- const expires = 'expires' in options ? options.expires : Expires.minutes(5);
731
+ const expires = 'expires' in options
732
+ ? normalizeExpires(options.expires)
733
+ : Expires.minutes(5);
393
734
  return resolveRouteChallenge({
394
735
  defaults,
395
736
  description,
@@ -403,6 +744,197 @@ function createChallengeFn(parameters) {
403
744
  }).then((resolved) => resolved.challenge);
404
745
  };
405
746
  }
747
+ function createServerEventDispatcher() {
748
+ const handlers = {
749
+ '*': new Set(),
750
+ 'challenge.created': new Set(),
751
+ 'payment.failed': new Set(),
752
+ 'payment.success': new Set(),
753
+ };
754
+ const on = (name, handler) => {
755
+ switch (name) {
756
+ case '*':
757
+ case 'challenge.created':
758
+ case 'payment.failed':
759
+ case 'payment.success':
760
+ handlers[name].add(handler);
761
+ return () => handlers[name].delete(handler);
762
+ default:
763
+ throw new Error(`Unknown server event "${String(name)}".`);
764
+ }
765
+ };
766
+ return {
767
+ async emit(name, context) {
768
+ await emitServerEventHandlers(handlers[name], context);
769
+ await emitServerEventHandlers(handlers['*'], toServerEventEnvelope(name, context));
770
+ },
771
+ on,
772
+ };
773
+ }
774
+ function toServerEventEnvelope(name, payload) {
775
+ return Object.freeze({ name, payload });
776
+ }
777
+ async function emitServerEventHandlers(handlers, context) {
778
+ for (const handler of handlers) {
779
+ try {
780
+ await handler(context);
781
+ }
782
+ catch {
783
+ // Errors are isolated, but handlers are still awaited inline.
784
+ }
785
+ }
786
+ }
787
+ function assertNoReservedMppxKeys(methods) {
788
+ for (const method of methods) {
789
+ if (reservedMppxKeys.has(method.name))
790
+ throw new Error(`Method name "${method.name}" conflicts with a reserved Mppx property.`);
791
+ if (reservedMppxKeys.has(method.intent))
792
+ throw new Error(`Method intent "${method.intent}" conflicts with a reserved Mppx property.`);
793
+ }
794
+ }
795
+ function createChallengeContext(parameters) {
796
+ return Object.freeze({
797
+ ...(parameters.capturedRequest
798
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
799
+ : {}),
800
+ challenge: snapshotValue(parameters.challenge),
801
+ credential: parameters.credential === undefined
802
+ ? undefined
803
+ : snapshotNullableValue(parameters.credential),
804
+ error: snapshotError(parameters.error),
805
+ ...snapshotInputProperty(parameters.input),
806
+ method: snapshotMethod(parameters.method),
807
+ request: snapshotValue(parameters.request),
808
+ });
809
+ }
810
+ function createPaymentFailedContext(parameters) {
811
+ return Object.freeze({
812
+ ...(parameters.capturedRequest
813
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
814
+ : {}),
815
+ challenge: snapshotValue(parameters.challenge),
816
+ credential: snapshotNullableValue(parameters.credential),
817
+ error: snapshotError(parameters.error),
818
+ ...snapshotInputProperty(parameters.input),
819
+ method: snapshotMethod(parameters.method),
820
+ request: snapshotValue(parameters.request),
821
+ ...(parameters.retryChallenge
822
+ ? { retryChallenge: snapshotValue(parameters.retryChallenge) }
823
+ : {}),
824
+ ...(parameters.submittedChallenge
825
+ ? { submittedChallenge: snapshotValue(parameters.submittedChallenge) }
826
+ : {}),
827
+ });
828
+ }
829
+ function createPaymentSuccessContext(parameters) {
830
+ return Object.freeze({
831
+ ...(parameters.capturedRequest
832
+ ? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
833
+ : {}),
834
+ challenge: snapshotValue(parameters.challenge),
835
+ ...(parameters.credential ? { credential: snapshotValue(parameters.credential) } : {}),
836
+ ...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
837
+ ...snapshotInputProperty(parameters.input),
838
+ method: snapshotMethod(parameters.method),
839
+ receipt: snapshotValue(parameters.receipt),
840
+ request: snapshotValue(parameters.request),
841
+ });
842
+ }
843
+ function snapshotMethod(method) {
844
+ return Object.freeze({
845
+ intent: method.intent,
846
+ name: method.name,
847
+ });
848
+ }
849
+ function snapshotError(error) {
850
+ if (!error)
851
+ return error;
852
+ const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error);
853
+ Object.defineProperties(snapshot, {
854
+ message: { value: error.message, enumerable: false },
855
+ name: { value: error.name, enumerable: false },
856
+ });
857
+ return Object.freeze(snapshot);
858
+ }
859
+ function snapshotVerifiedEnvelope(envelope) {
860
+ return Object.freeze({
861
+ capturedRequest: snapshotCapturedRequest(envelope.capturedRequest),
862
+ challenge: snapshotValue(envelope.challenge),
863
+ credential: snapshotValue(envelope.credential),
864
+ request: snapshotValue(envelope.request),
865
+ });
866
+ }
867
+ function snapshotCapturedRequest(capturedRequest) {
868
+ return Object.freeze({
869
+ headers: new Headers(capturedRequest.headers),
870
+ hasBody: capturedRequest.hasBody,
871
+ method: capturedRequest.method,
872
+ url: new URL(capturedRequest.url),
873
+ });
874
+ }
875
+ function snapshotNullableValue(value) {
876
+ if (value === null)
877
+ return null;
878
+ return snapshotValue(value);
879
+ }
880
+ function snapshotValue(value) {
881
+ try {
882
+ return freezeSnapshot(structuredClone(value));
883
+ }
884
+ catch {
885
+ return freezeSnapshot(value);
886
+ }
887
+ }
888
+ function snapshotInputProperty(input) {
889
+ if (input === undefined)
890
+ return {};
891
+ const snapshot = snapshotTransportInput(input);
892
+ return snapshot === undefined ? {} : { input: snapshot };
893
+ }
894
+ function snapshotTransportInput(input) {
895
+ if (input instanceof globalThis.Request) {
896
+ try {
897
+ return new globalThis.Request(input.url, {
898
+ headers: new Headers(input.headers),
899
+ method: input.method,
900
+ });
901
+ }
902
+ catch {
903
+ return undefined;
904
+ }
905
+ }
906
+ try {
907
+ return freezeSnapshot(structuredClone(input));
908
+ }
909
+ catch {
910
+ warnOnce(Warnings.transportInputSnapshot, 'Could not clone server event input; omitting `context.input`. Use `capturedRequest` for request correlation.');
911
+ return undefined;
912
+ }
913
+ }
914
+ // Event payloads are cloned before listeners see them; shallow freezing keeps
915
+ // the guard simple while preventing top-level mutation of receipts/challenges.
916
+ function freezeSnapshot(value) {
917
+ if (!value || typeof value !== 'object' || Object.isFrozen(value))
918
+ return value;
919
+ Object.freeze(value);
920
+ return value;
921
+ }
922
+ function isServiceWorkerRequest(input) {
923
+ return (input instanceof globalThis.Request &&
924
+ new URL(input.url).searchParams.has(Html.params.serviceWorker));
925
+ }
926
+ function createServiceWorkerResponse() {
927
+ return new Response(serviceWorker, {
928
+ status: 200,
929
+ headers: {
930
+ 'Content-Type': 'application/javascript',
931
+ 'Cache-Control': 'no-store',
932
+ },
933
+ });
934
+ }
935
+ function isIssuedChallengeResponse(response) {
936
+ return !(response instanceof globalThis.Response) || response.status === 402;
937
+ }
406
938
  function getSafeCredentialReason(error) {
407
939
  if (error instanceof Credential.InvalidCredentialEncodingError)
408
940
  return error.message;
@@ -415,7 +947,30 @@ function getSafeCredentialReason(error) {
415
947
  const defaultRealm = 'MPP Payment';
416
948
  const Warnings = {
417
949
  realmFallback: 'realm-fallback',
950
+ transportInputSnapshot: 'transport-input-snapshot',
418
951
  };
952
+ const missingReceiptResponseErrorName = 'MissingReceiptResponseError';
953
+ const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument';
954
+ /** Error thrown when `withReceipt()` needs a response but none was provided. */
955
+ export class MissingReceiptResponseError extends Error {
956
+ name = missingReceiptResponseErrorName;
957
+ constructor() {
958
+ super(missingReceiptResponseErrorMessage);
959
+ }
960
+ }
961
+ /** Returns true when an error is the typed `withReceipt()` no-response sentinel. */
962
+ export function isMissingReceiptResponseError(error) {
963
+ if (error instanceof MissingReceiptResponseError)
964
+ return true;
965
+ if (!error || typeof error !== 'object')
966
+ return false;
967
+ const value = error;
968
+ return (value.name === missingReceiptResponseErrorName &&
969
+ value.message === missingReceiptResponseErrorMessage);
970
+ }
971
+ function normalizeExpires(expires) {
972
+ return expires === undefined ? undefined : z.toDatetimeString(expires);
973
+ }
419
974
  const _warned = new Set();
420
975
  function warnOnce(key, message) {
421
976
  if (_warned.has(key))
@@ -452,18 +1007,33 @@ async function resolveRouteChallenge(parameters) {
452
1007
  (parameters.capturedRequest
453
1008
  ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
454
1009
  : defaultRealm);
1010
+ const challenge = Challenge.fromMethod(parameters.method, {
1011
+ description: parameters.description,
1012
+ expires: parameters.expires,
1013
+ meta: parameters.meta,
1014
+ realm: effectiveRealm,
1015
+ request: request,
1016
+ secretKey: parameters.secretKey,
1017
+ });
455
1018
  return {
456
- challenge: Challenge.fromMethod(parameters.method, {
457
- description: parameters.description,
458
- expires: parameters.expires,
459
- meta: parameters.meta,
460
- realm: effectiveRealm,
461
- request: request,
462
- secretKey: parameters.secretKey,
463
- }),
1019
+ challenge,
1020
+ parsedRequest: challenge.request,
464
1021
  request,
465
1022
  };
466
1023
  }
1024
+ function createFallbackChallenge(parameters) {
1025
+ return Challenge.fromMethod(parameters.method, {
1026
+ description: parameters.description,
1027
+ expires: parameters.expires,
1028
+ meta: parameters.meta,
1029
+ realm: parameters.realm ??
1030
+ (parameters.capturedRequest
1031
+ ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
1032
+ : defaultRealm),
1033
+ request: { ...parameters.defaults, ...parameters.routeRequest },
1034
+ secretKey: parameters.secretKey,
1035
+ });
1036
+ }
467
1037
  /**
468
1038
  * Captures the transport request into a frozen snapshot at the start of the
469
1039
  * verification flow. This snapshot is threaded through request() → verify() →
@@ -473,7 +1043,7 @@ async function resolveRouteChallenge(parameters) {
473
1043
  *
474
1044
  * Note: Object.freeze is shallow — it prevents reassigning top-level properties
475
1045
  * but does not deep-freeze mutable class instances like Headers or URL. This is
476
- * an accidental-mutation guard for trusted server hooks, not a security boundary.
1046
+ * an accidental-mutation guard for trusted server events, not a security boundary.
477
1047
  */
478
1048
  async function captureRequest(transport, input) {
479
1049
  const capturedRequest = transport.captureRequest
@@ -493,6 +1063,17 @@ function captureRequestFromInput(input) {
493
1063
  const coreBindingFields = ['amount', 'currency', 'recipient'];
494
1064
  const methodBindingFields = ['chainId', 'memo', 'splits', 'unitType'];
495
1065
  const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields];
1066
+ function getChallengeBindingMismatch(expectedChallenge, actualChallenge, stableBinding) {
1067
+ if (!stableBinding)
1068
+ return getPinnedChallengeMismatch(expectedChallenge, actualChallenge);
1069
+ for (const field of ['method', 'intent', 'realm']) {
1070
+ if (actualChallenge[field] !== expectedChallenge[field])
1071
+ return field;
1072
+ }
1073
+ if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta))
1074
+ return 'opaque';
1075
+ return getRequestBindingMismatch(getStableBinding(expectedChallenge.request, stableBinding), getStableBinding(actualChallenge.request, stableBinding));
1076
+ }
496
1077
  /**
497
1078
  * Compares only the fields that MUST be stable across request-hook transforms.
498
1079
  *
@@ -548,6 +1129,16 @@ function getPinnedRequestBinding(request) {
548
1129
  },
549
1130
  };
550
1131
  }
1132
+ function getRequestBindingMismatch(expected, actual) {
1133
+ const fields = [
1134
+ ...Object.keys(expected),
1135
+ ...Object.keys(actual).filter((key) => !(key in expected)),
1136
+ ];
1137
+ return fields.find((field) => !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])));
1138
+ }
1139
+ function getStableBinding(request, stableBinding) {
1140
+ return stableBinding(request);
1141
+ }
551
1142
  function normalizeScalar(value) {
552
1143
  return value === undefined ? undefined : String(value);
553
1144
  }
@@ -584,6 +1175,12 @@ function hydrateCredentialMeta(credential) {
584
1175
  },
585
1176
  };
586
1177
  }
1178
+ function withParsedCredentialPayload(credential, payload) {
1179
+ return {
1180
+ ...credential,
1181
+ payload,
1182
+ };
1183
+ }
587
1184
  export function compose(...args) {
588
1185
  // Extract optional html options from last argument
589
1186
  const last = args[args.length - 1];
@@ -642,14 +1239,18 @@ export function compose(...args) {
642
1239
  // transformed fields (e.g. amount with decimals) match correctly.
643
1240
  // Also checks inside methodDetails for fields moved there by transforms.
644
1241
  const candidates = handlers.filter((h) => {
645
- const internal = h._internal;
646
- if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
1242
+ try {
1243
+ const internal = h._internal;
1244
+ if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
1245
+ return false;
1246
+ const mismatch = internal._stableBinding
1247
+ ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credReq, internal._stableBinding))
1248
+ : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq);
1249
+ return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta);
1250
+ }
1251
+ catch {
647
1252
  return false;
648
- const canonical = internal._canonicalRequest;
649
- if (!canonical)
650
- return true;
651
- return (!getPinnedRequestBindingMismatch(canonical, credReq) &&
652
- opaqueValuesMatch(internal.meta, credential.challenge.meta));
1253
+ }
653
1254
  });
654
1255
  const match = candidates[0] ??
655
1256
  handlers.find((h) => {
@@ -663,8 +1264,15 @@ export function compose(...args) {
663
1264
  // handler which will reject with an appropriate error (invalid challenge, etc.).
664
1265
  return handlers[0](input);
665
1266
  }
666
- // No credential — call all handlers and merge 402 challenges.
667
- const results = await Promise.all(handlers.map((h) => h(input)));
1267
+ // No credential — evaluate handlers sequentially so authorize()/renewal hooks
1268
+ // can safely claim the request without racing each other.
1269
+ const results = [];
1270
+ for (const handler of handlers) {
1271
+ const result = await handler(input);
1272
+ if (result.status === 200)
1273
+ return result;
1274
+ results.push(result);
1275
+ }
668
1276
  const challengeEntries = (() => {
669
1277
  const entries = [];
670
1278
  for (let i = 0; i < handlers.length; i++) {
@@ -792,10 +1400,26 @@ export function toNodeListener(handler) {
792
1400
  await NodeListener.sendResponse(res, result.challenge);
793
1401
  }
794
1402
  else {
1403
+ const managementResponse = getManagementResponse(result);
1404
+ if (managementResponse) {
1405
+ await NodeListener.sendResponse(res, managementResponse);
1406
+ return { challenge: managementResponse, status: 402 };
1407
+ }
795
1408
  const wrapped = result.withReceipt(new globalThis.Response());
796
1409
  res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt'));
797
1410
  }
798
1411
  return result;
799
1412
  };
800
1413
  }
1414
+ function getManagementResponse(result) {
1415
+ try {
1416
+ return result.withReceipt();
1417
+ }
1418
+ catch (error) {
1419
+ if (isMissingReceiptResponseError(error)) {
1420
+ return null;
1421
+ }
1422
+ throw error;
1423
+ }
1424
+ }
801
1425
  //# sourceMappingURL=Mppx.js.map