mppx 0.6.20 → 0.6.22

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/client/Mppx.d.ts +12 -1
  3. package/dist/client/Mppx.d.ts.map +1 -1
  4. package/dist/client/Mppx.js +127 -10
  5. package/dist/client/Mppx.js.map +1 -1
  6. package/dist/client/internal/Fetch.d.ts +69 -1
  7. package/dist/client/internal/Fetch.d.ts.map +1 -1
  8. package/dist/client/internal/Fetch.js +250 -20
  9. package/dist/client/internal/Fetch.js.map +1 -1
  10. package/dist/middlewares/internal/mppx.d.ts +1 -1
  11. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  12. package/dist/middlewares/internal/mppx.js +2 -1
  13. package/dist/middlewares/internal/mppx.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +82 -3
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +557 -83
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/tempo/client/Subscription.d.ts.map +1 -1
  19. package/dist/tempo/client/Subscription.js +4 -3
  20. package/dist/tempo/client/Subscription.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/client/Mppx.test-d.ts +55 -0
  27. package/src/client/Mppx.test.ts +181 -0
  28. package/src/client/Mppx.ts +248 -16
  29. package/src/client/internal/Fetch.test-d.ts +31 -0
  30. package/src/client/internal/Fetch.test.ts +261 -0
  31. package/src/client/internal/Fetch.ts +467 -24
  32. package/src/middlewares/internal/mppx.ts +5 -6
  33. package/src/proxy/Proxy.test.ts +69 -0
  34. package/src/server/Mppx.test-d.ts +50 -0
  35. package/src/server/Mppx.test.ts +893 -1
  36. package/src/server/Mppx.ts +862 -97
  37. package/src/tempo/client/Subscription.test.ts +51 -0
  38. package/src/tempo/client/Subscription.ts +4 -3
  39. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -13,6 +13,20 @@ import * as Scope from './internal/scope.js';
13
13
  import * as NodeListener from './NodeListener.js';
14
14
  import * as Request from './Request.js';
15
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);
16
30
  /**
17
31
  * Creates a server-side payment handler from methods.
18
32
  *
@@ -35,15 +49,20 @@ export function create(config) {
35
49
  throw new Error('Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().');
36
50
  }
37
51
  const methods = config.methods.flat();
52
+ const serverEvents = createServerEventDispatcher();
38
53
  const handlers = {};
39
54
  const intentCount = {};
40
55
  for (const mi of methods) {
41
56
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1;
57
+ }
58
+ assertNoReservedMppxKeys(methods);
59
+ for (const mi of methods) {
42
60
  handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
43
61
  authorize: mi.authorize,
44
62
  defaults: mi.defaults,
45
63
  method: mi,
46
64
  realm,
65
+ events: serverEvents,
47
66
  request: mi.request,
48
67
  respond: mi.respond,
49
68
  secretKey,
@@ -81,30 +100,94 @@ export function create(config) {
81
100
  // verifyCredential: single-call end-to-end verification
82
101
  async function verifyCredentialFn(input, options) {
83
102
  const credential = hydrateCredentialMeta(typeof input === 'string' ? Credential.deserialize(input) : input);
84
- // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
85
- if (!Challenge.verify(credential.challenge, { secretKey: secretKey }))
86
- throw new Errors.InvalidChallengeError({
87
- id: credential.challenge.id,
88
- reason: 'challenge was not issued by this server',
89
- });
90
- // Expiry check
91
- Expires.assert(credential.challenge.expires, credential.challenge.id);
92
103
  // Find matching method by name + intent
93
104
  const { method: credMethod, intent: credIntent } = credential.challenge;
94
105
  const mi = methods.find((m) => m.name === credMethod && m.intent === credIntent);
95
- if (!mi)
96
- 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({
97
120
  id: credential.challenge.id,
98
121
  reason: `no registered method for ${credMethod}/${credIntent}`,
99
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
+ }
100
162
  // Validate payload against method schema
101
- 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
+ }
102
177
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope });
103
178
  if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
104
- throw new Errors.InvalidChallengeError({
179
+ const error = new Errors.InvalidChallengeError({
105
180
  id: credential.challenge.id,
106
181
  reason: "credential scope does not match this route's requirements",
107
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;
108
191
  }
109
192
  const shouldValidateRoute = options?.capturedRequest !== undefined ||
110
193
  options?.meta !== undefined ||
@@ -113,37 +196,77 @@ export function create(config) {
113
196
  const expectedRealm = options?.realm ??
114
197
  realm ??
115
198
  (options?.capturedRequest === undefined ? credential.challenge.realm : undefined);
116
- const request = shouldValidateRoute
117
- ? await resolveRouteChallenge({
118
- capturedRequest: options?.capturedRequest,
119
- credential,
120
- defaults: mi.defaults,
121
- expires: credential.challenge.expires,
122
- meta: expectedMeta,
123
- method: mi,
124
- realm: expectedRealm,
125
- request: mi.request,
126
- routeRequest: options?.request ?? {},
127
- secretKey: secretKey,
128
- }).then((resolved) => {
129
- const mismatch = getChallengeBindingMismatch(resolved.challenge, credential.challenge, mi.stableBinding);
130
- if (mismatch)
131
- throw new Errors.InvalidChallengeError({
132
- id: credential.challenge.id,
133
- reason: `credential ${mismatch} does not match this route's requirements`,
134
- });
135
- return resolved.request;
136
- })
137
- : 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
+ }
138
237
  const envelope = options?.capturedRequest
139
238
  ? {
140
239
  capturedRequest: options.capturedRequest,
141
240
  challenge: credential.challenge,
142
- credential,
143
- request,
241
+ credential: parsedCredential,
242
+ request: parsedRequest,
144
243
  }
145
244
  : undefined;
146
- 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;
147
270
  }
148
271
  function composeFn(...entries) {
149
272
  if (transport.name !== 'http')
@@ -163,10 +286,23 @@ export function create(config) {
163
286
  });
164
287
  return compose(...configured);
165
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
+ }
166
298
  return {
167
299
  methods,
168
300
  challenge: challengeHandlers,
169
301
  compose: composeFn,
302
+ on: serverEvents.on,
303
+ onChallengeCreated,
304
+ onPaymentFailed,
305
+ onPaymentSuccess,
170
306
  realm: realm,
171
307
  transport,
172
308
  verifyCredential: verifyCredentialFn,
@@ -175,11 +311,16 @@ export function create(config) {
175
311
  }
176
312
  // biome-ignore lint/correctness/noUnusedVariables: _
177
313
  function createMethodFn(parameters) {
178
- const { authorize, defaults, method, realm, respond, secretKey, stableBinding, transport, verify, } = parameters;
314
+ const { authorize, defaults, events, method, realm, respond, secretKey, stableBinding, transport, verify, } = parameters;
179
315
  return (options) => {
180
316
  const { description, meta, scope, ...rest } = options;
181
317
  const staticMeta = Scope.merge({ meta, scope });
182
318
  return Object.assign(async (input) => {
319
+ if (method.html && isServiceWorkerRequest(input))
320
+ return {
321
+ status: 402,
322
+ challenge: createServiceWorkerResponse(),
323
+ };
183
324
  const expires = 'expires' in options
184
325
  ? normalizeExpires(options.expires)
185
326
  : Expires.minutes(5);
@@ -197,6 +338,38 @@ function createMethodFn(parameters) {
197
338
  return [null, e];
198
339
  }
199
340
  })();
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
+ };
200
373
  const routeChallenge = await resolveRouteChallenge({
201
374
  capturedRequest,
202
375
  credential,
@@ -223,9 +396,19 @@ function createMethodFn(parameters) {
223
396
  routeRequest: rest,
224
397
  secretKey,
225
398
  });
226
- const response = await transport.respondChallenge({
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({
227
409
  challenge,
228
- input,
410
+ credential,
411
+ request: challenge.request,
229
412
  error: e,
230
413
  html: method.html,
231
414
  });
@@ -233,14 +416,23 @@ function createMethodFn(parameters) {
233
416
  });
234
417
  if ('response' in routeChallenge)
235
418
  return { challenge: routeChallenge.response, status: 402 };
236
- const { challenge, request } = routeChallenge;
419
+ const { challenge, parsedRequest, request } = routeChallenge;
237
420
  // Credential was provided but malformed
238
421
  if (credentialError) {
239
422
  const reason = getSafeCredentialReason(credentialError);
240
- const response = await transport.respondChallenge({
423
+ const error = new Errors.MalformedCredentialError(reason ? { reason } : {});
424
+ await emitPaymentFailed({
241
425
  challenge,
242
- input,
243
- 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,
244
436
  html: method.html,
245
437
  });
246
438
  return { challenge: response, status: 402 };
@@ -283,6 +475,14 @@ function createMethodFn(parameters) {
283
475
  request: challenge.request,
284
476
  });
285
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
+ }));
286
486
  return success(authorized.receipt, {
287
487
  managementResponse: authorized.response,
288
488
  });
@@ -292,19 +492,28 @@ function createMethodFn(parameters) {
292
492
  if (!(e instanceof Errors.PaymentError))
293
493
  console.error('mppx: internal authorization error', e);
294
494
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
295
- const response = await transport.respondChallenge({
495
+ await emitPaymentFailed({
296
496
  challenge,
297
- input,
497
+ credential: null,
498
+ error,
499
+ request: parsedRequest,
500
+ retryChallenge: challenge,
501
+ });
502
+ const response = await emitChallenge({
503
+ challenge,
504
+ request: parsedRequest,
298
505
  error,
299
506
  html: method.html,
300
507
  });
301
508
  return { challenge: response, status: 402 };
302
509
  }
303
510
  }
304
- const response = await transport.respondChallenge({
511
+ const error = new Errors.PaymentRequiredError({ description });
512
+ const response = await emitChallenge({
305
513
  challenge,
306
- input,
307
- error: new Errors.PaymentRequiredError({ description }),
514
+ credential: null,
515
+ request: parsedRequest,
516
+ error,
308
517
  html: method.html,
309
518
  });
310
519
  return { challenge: response, status: 402 };
@@ -321,13 +530,23 @@ function createMethodFn(parameters) {
321
530
  // (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
322
531
  // No database lookup is needed; the HMAC is stateless verification.
323
532
  if (!Challenge.verify(credential.challenge, { secretKey })) {
324
- 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({
325
538
  challenge,
326
- input,
327
- error: new Errors.InvalidChallengeError({
328
- id: credential.challenge.id,
329
- reason: 'challenge was not issued by this server',
330
- }),
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,
331
550
  html: method.html,
332
551
  });
333
552
  return { challenge: response, status: 402 };
@@ -357,13 +576,23 @@ function createMethodFn(parameters) {
357
576
  {
358
577
  const mismatch = getChallengeBindingMismatch(challenge, credential.challenge, stableBinding);
359
578
  if (mismatch) {
360
- 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({
361
584
  challenge,
362
- input,
363
- error: new Errors.InvalidChallengeError({
364
- id: credential.challenge.id,
365
- reason: `credential ${mismatch} does not match this route's requirements`,
366
- }),
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,
367
596
  html: method.html,
368
597
  });
369
598
  return { challenge: response, status: 402 };
@@ -374,44 +603,73 @@ function createMethodFn(parameters) {
374
603
  Expires.assert(credential.challenge.expires, credential.challenge.id);
375
604
  }
376
605
  catch (error) {
377
- const response = await transport.respondChallenge({
606
+ await emitPaymentFailed({
378
607
  challenge,
379
- 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,
380
618
  error: error,
381
619
  });
382
620
  return { challenge: response, status: 402 };
383
621
  }
384
622
  // Validate payload structure against method schema
623
+ let parsedCredential;
385
624
  try {
386
- method.schema.credential.payload.parse(credential.payload);
625
+ parsedCredential = withParsedCredentialPayload(credential, method.schema.credential.payload.parse(credential.payload));
387
626
  }
388
627
  catch {
389
- const response = await transport.respondChallenge({
628
+ const error = new Errors.InvalidPayloadError();
629
+ await emitPaymentFailed({
390
630
  challenge,
391
- input,
392
- 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,
393
642
  });
394
643
  return { challenge: response, status: 402 };
395
644
  }
396
645
  const envelope = Object.freeze({
397
646
  capturedRequest,
398
647
  challenge: credential.challenge,
399
- credential,
400
- request,
648
+ credential: parsedCredential,
649
+ request: parsedRequest,
401
650
  });
402
651
  // User-provided verification (e.g., check signature, submit tx, verify payment).
403
652
  // If verification fails, re-issue the challenge so the client can retry.
404
653
  let receiptData;
405
654
  try {
406
- receiptData = await verify({ credential, envelope, request });
655
+ receiptData = await verify({ credential: parsedCredential, envelope, request });
407
656
  }
408
657
  catch (e) {
409
658
  if (!(e instanceof Errors.PaymentError))
410
659
  console.error('mppx: internal verification error', e);
411
660
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
412
- const response = await transport.respondChallenge({
661
+ await emitPaymentFailed({
413
662
  challenge,
414
- 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,
415
673
  error,
416
674
  });
417
675
  return { challenge: response, status: 402 };
@@ -422,11 +680,27 @@ function createMethodFn(parameters) {
422
680
  // return the management response directly. If undefined, `withReceipt()`
423
681
  // expects the caller to pass the user handler's response instead.
424
682
  const managementResponse = respond
425
- ? await respond({ credential, envelope, input, receipt: receiptData, request })
683
+ ? await respond({
684
+ credential: parsedCredential,
685
+ envelope,
686
+ input,
687
+ receipt: receiptData,
688
+ request,
689
+ })
426
690
  : undefined;
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
+ }));
427
701
  return success(receiptData, {
428
702
  challengeId: credential.challenge.id,
429
- credentialForReceipt: credential,
703
+ credentialForReceipt: parsedCredential,
430
704
  envelopeForReceipt: envelope,
431
705
  managementResponse,
432
706
  });
@@ -470,6 +744,197 @@ function createChallengeFn(parameters) {
470
744
  }).then((resolved) => resolved.challenge);
471
745
  };
472
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
+ }
473
938
  function getSafeCredentialReason(error) {
474
939
  if (error instanceof Credential.InvalidCredentialEncodingError)
475
940
  return error.message;
@@ -482,6 +947,7 @@ function getSafeCredentialReason(error) {
482
947
  const defaultRealm = 'MPP Payment';
483
948
  const Warnings = {
484
949
  realmFallback: 'realm-fallback',
950
+ transportInputSnapshot: 'transport-input-snapshot',
485
951
  };
486
952
  const missingReceiptResponseErrorName = 'MissingReceiptResponseError';
487
953
  const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument';
@@ -541,15 +1007,17 @@ async function resolveRouteChallenge(parameters) {
541
1007
  (parameters.capturedRequest
542
1008
  ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
543
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
+ });
544
1018
  return {
545
- challenge: Challenge.fromMethod(parameters.method, {
546
- description: parameters.description,
547
- expires: parameters.expires,
548
- meta: parameters.meta,
549
- realm: effectiveRealm,
550
- request: request,
551
- secretKey: parameters.secretKey,
552
- }),
1019
+ challenge,
1020
+ parsedRequest: challenge.request,
553
1021
  request,
554
1022
  };
555
1023
  }
@@ -575,7 +1043,7 @@ function createFallbackChallenge(parameters) {
575
1043
  *
576
1044
  * Note: Object.freeze is shallow — it prevents reassigning top-level properties
577
1045
  * but does not deep-freeze mutable class instances like Headers or URL. This is
578
- * 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.
579
1047
  */
580
1048
  async function captureRequest(transport, input) {
581
1049
  const capturedRequest = transport.captureRequest
@@ -707,6 +1175,12 @@ function hydrateCredentialMeta(credential) {
707
1175
  },
708
1176
  };
709
1177
  }
1178
+ function withParsedCredentialPayload(credential, payload) {
1179
+ return {
1180
+ ...credential,
1181
+ payload,
1182
+ };
1183
+ }
710
1184
  export function compose(...args) {
711
1185
  // Extract optional html options from last argument
712
1186
  const last = args[args.length - 1];