mppx 0.6.20 → 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 (34) hide show
  1. package/CHANGELOG.md +7 -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/server/internal/html.gen.d.ts +1 -1
  19. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  20. package/dist/tempo/server/internal/html.gen.js +1 -1
  21. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/Mppx.test-d.ts +55 -0
  24. package/src/client/Mppx.test.ts +181 -0
  25. package/src/client/Mppx.ts +248 -16
  26. package/src/client/internal/Fetch.test-d.ts +31 -0
  27. package/src/client/internal/Fetch.test.ts +261 -0
  28. package/src/client/internal/Fetch.ts +467 -24
  29. package/src/middlewares/internal/mppx.ts +5 -6
  30. package/src/proxy/Proxy.test.ts +69 -0
  31. package/src/server/Mppx.test-d.ts +50 -0
  32. package/src/server/Mppx.test.ts +893 -1
  33. package/src/server/Mppx.ts +862 -97
  34. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -7,6 +7,7 @@ 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'
@@ -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
  /**
@@ -108,7 +245,10 @@ export type Mppx<
108
245
  * check expiry, and call the method's verify function.
109
246
  *
110
247
  * Method verification can settle payments and persist state. For example,
111
- * subscription credentials may activate or renew a subscription.
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.
112
252
  *
113
253
  * @example
114
254
  * ```ts
@@ -137,6 +277,25 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
137
277
  ? defaultTransport
138
278
  : TransportOverrideOf<mi>
139
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
+
140
299
  /** True when exactly one method has the given intent (no name collision). */
141
300
  type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> =
142
301
  Extract<methods[number], { intent: intent }> extends infer M
@@ -153,7 +312,9 @@ type UniqueIntentHandlers<
153
312
  transport extends Transport.AnyTransport,
154
313
  > = {
155
314
  [method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
156
- ? method_name
315
+ ? method_name extends ReservedKey
316
+ ? never
317
+ : method_name
157
318
  : never]: MethodFn<
158
319
  Extract<methods[number], { intent: method_name }>,
159
320
  EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
@@ -166,7 +327,7 @@ type NestedHandlers<
166
327
  methods extends readonly Method.AnyServer[],
167
328
  transport extends Transport.AnyTransport,
168
329
  > = {
169
- [name in methods[number]['name']]: {
330
+ [name in methods[number]['name'] as name extends ReservedKey ? never : name]: {
170
331
  [mi in Extract<methods[number], { name: name }> as mi['intent']]: MethodFn<
171
332
  mi,
172
333
  EffectiveTransportOf<mi, transport>,
@@ -235,17 +396,23 @@ export function create<
235
396
  }
236
397
 
237
398
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
399
+ const serverEvents = createServerEventDispatcher<FlattenMethods<methods>, transport>()
238
400
 
239
401
  const handlers: Record<string, unknown> = {}
240
402
  const intentCount: Record<string, number> = {}
241
403
 
242
404
  for (const mi of methods) {
243
405
  intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
406
+ }
407
+ assertNoReservedMppxKeys(methods as readonly Method.AnyServer[])
408
+
409
+ for (const mi of methods) {
244
410
  handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
245
411
  authorize: mi.authorize as never,
246
412
  defaults: mi.defaults,
247
413
  method: mi,
248
414
  realm,
415
+ events: serverEvents as never,
249
416
  request: mi.request as never,
250
417
  respond: mi.respond as never,
251
418
  secretKey,
@@ -290,37 +457,114 @@ export function create<
290
457
  typeof input === 'string' ? Credential.deserialize(input) : input,
291
458
  )
292
459
 
293
- // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
294
- if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
295
- throw new Errors.InvalidChallengeError({
296
- id: credential.challenge.id,
297
- reason: 'challenge was not issued by this server',
298
- })
299
-
300
- // Expiry check
301
- Expires.assert(credential.challenge.expires, credential.challenge.id)
302
-
303
460
  // Find matching method by name + intent
304
461
  const { method: credMethod, intent: credIntent } = credential.challenge
305
462
  const mi = (methods as readonly Method.AnyServer[]).find(
306
463
  (m) => m.name === credMethod && m.intent === credIntent,
307
464
  )
308
- if (!mi)
309
- 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({
310
491
  id: credential.challenge.id,
311
492
  reason: `no registered method for ${credMethod}/${credIntent}`,
312
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
+ }
313
534
 
314
535
  // Validate payload against method schema
315
- 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
+ }
316
552
 
317
553
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
318
554
 
319
555
  if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
320
- throw new Errors.InvalidChallengeError({
556
+ const error = new Errors.InvalidChallengeError({
321
557
  id: credential.challenge.id,
322
558
  reason: "credential scope does not match this route's requirements",
323
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
324
568
  }
325
569
 
326
570
  const shouldValidateRoute =
@@ -333,44 +577,87 @@ export function create<
333
577
  realm ??
334
578
  (options?.capturedRequest === undefined ? credential.challenge.realm : undefined)
335
579
 
336
- const request = shouldValidateRoute
337
- ? await resolveRouteChallenge({
338
- capturedRequest: options?.capturedRequest,
339
- credential,
340
- defaults: mi.defaults,
341
- expires: credential.challenge.expires,
342
- meta: expectedMeta,
343
- method: mi,
344
- realm: expectedRealm,
345
- request: mi.request as never,
346
- routeRequest: options?.request ?? {},
347
- secretKey: secretKey!,
348
- }).then((resolved) => {
349
- const mismatch = getChallengeBindingMismatch(
350
- resolved.challenge,
351
- credential.challenge,
352
- mi.stableBinding as never,
353
- )
354
- if (mismatch)
355
- throw new Errors.InvalidChallengeError({
356
- id: credential.challenge.id,
357
- reason: `credential ${mismatch} does not match this route's requirements`,
358
- })
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
+ })
359
606
 
360
- 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,
361
619
  })
362
- : (credential.challenge.request as z.input<typeof mi.schema.request>)
620
+ throw e
621
+ }
363
622
 
364
623
  const envelope = options?.capturedRequest
365
624
  ? ({
366
625
  capturedRequest: options.capturedRequest,
367
626
  challenge: credential.challenge,
368
- credential,
369
- request,
627
+ credential: parsedCredential,
628
+ request: parsedRequest,
370
629
  } as Method.VerifiedChallengeEnvelope)
371
630
  : undefined
372
631
 
373
- 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
374
661
  }
375
662
 
376
663
  function composeFn(
@@ -396,10 +683,32 @@ export function create<
396
683
  return compose(...(configured as ConfiguredHandler[]))
397
684
  }
398
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
+
399
704
  return {
400
705
  methods,
401
706
  challenge: challengeHandlers,
402
707
  compose: composeFn,
708
+ on: serverEvents.on,
709
+ onChallengeCreated,
710
+ onPaymentFailed,
711
+ onPaymentSuccess,
403
712
  realm: realm as string | undefined,
404
713
  transport,
405
714
  verifyCredential: verifyCredentialFn,
@@ -435,6 +744,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
435
744
  const {
436
745
  authorize,
437
746
  defaults,
747
+ events,
438
748
  method,
439
749
  realm,
440
750
  respond,
@@ -450,6 +760,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
450
760
 
451
761
  return Object.assign(
452
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
+
453
769
  const expires =
454
770
  'expires' in options
455
771
  ? normalizeExpires(options.expires as z.DatetimeInput | undefined)
@@ -469,6 +785,60 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
469
785
  return [null, e as Error] as const
470
786
  }
471
787
  })()
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
+
472
842
  const routeChallenge = await resolveRouteChallenge({
473
843
  capturedRequest,
474
844
  credential,
@@ -494,24 +864,43 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
494
864
  routeRequest: rest,
495
865
  secretKey,
496
866
  })
497
- const response = await transport.respondChallenge({
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({
498
877
  challenge,
499
- input,
878
+ credential,
879
+ request: challenge.request,
500
880
  error: e,
501
881
  html: method.html,
502
882
  })
503
883
  return { response }
504
884
  })
505
885
  if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 }
506
- const { challenge, request } = routeChallenge
886
+ const { challenge, parsedRequest, request } = routeChallenge
507
887
 
508
888
  // Credential was provided but malformed
509
889
  if (credentialError) {
510
890
  const reason = getSafeCredentialReason(credentialError)
511
- const response = await transport.respondChallenge({
891
+ const error = new Errors.MalformedCredentialError(reason ? { reason } : {})
892
+ await emitPaymentFailed({
512
893
  challenge,
513
- input,
514
- 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,
515
904
  html: method.html,
516
905
  })
517
906
  return { challenge: response, status: 402 }
@@ -569,6 +958,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
569
958
  request: challenge.request,
570
959
  } as never)
571
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
+ )
572
972
  return success(authorized.receipt, {
573
973
  managementResponse: authorized.response,
574
974
  })
@@ -578,9 +978,16 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
578
978
  console.error('mppx: internal authorization error', e)
579
979
  const error =
580
980
  e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
581
- const response = await transport.respondChallenge({
981
+ await emitPaymentFailed({
582
982
  challenge,
583
- input,
983
+ credential: null,
984
+ error,
985
+ request: parsedRequest,
986
+ retryChallenge: challenge,
987
+ })
988
+ const response = await emitChallenge({
989
+ challenge,
990
+ request: parsedRequest,
584
991
  error,
585
992
  html: method.html,
586
993
  })
@@ -588,10 +995,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
588
995
  }
589
996
  }
590
997
 
591
- const response = await transport.respondChallenge({
998
+ const error = new Errors.PaymentRequiredError({ description })
999
+ const response = await emitChallenge({
592
1000
  challenge,
593
- input,
594
- error: new Errors.PaymentRequiredError({ description }),
1001
+ credential: null,
1002
+ request: parsedRequest,
1003
+ error,
595
1004
  html: method.html,
596
1005
  })
597
1006
  return { challenge: response, status: 402 }
@@ -609,13 +1018,23 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
609
1018
  // (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
610
1019
  // No database lookup is needed; the HMAC is stateless verification.
611
1020
  if (!Challenge.verify(credential.challenge, { secretKey })) {
612
- 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({
613
1026
  challenge,
614
- input,
615
- error: new Errors.InvalidChallengeError({
616
- id: credential.challenge.id,
617
- reason: 'challenge was not issued by this server',
618
- }),
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,
619
1038
  html: method.html,
620
1039
  })
621
1040
  return { challenge: response, status: 402 }
@@ -650,13 +1069,23 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
650
1069
  stableBinding as never,
651
1070
  )
652
1071
  if (mismatch) {
653
- 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({
654
1077
  challenge,
655
- input,
656
- error: new Errors.InvalidChallengeError({
657
- id: credential.challenge.id,
658
- reason: `credential ${mismatch} does not match this route's requirements`,
659
- }),
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,
660
1089
  html: method.html,
661
1090
  })
662
1091
  return { challenge: response, status: 402 }
@@ -667,21 +1096,44 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
667
1096
  try {
668
1097
  Expires.assert(credential.challenge.expires, credential.challenge.id)
669
1098
  } catch (error) {
670
- const response = await transport.respondChallenge({
1099
+ await emitPaymentFailed({
671
1100
  challenge,
672
- 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,
673
1111
  error: error as Errors.PaymentError,
674
1112
  })
675
1113
  return { challenge: response, status: 402 }
676
1114
  }
677
1115
  // Validate payload structure against method schema
1116
+ let parsedCredential: Credential.Credential
678
1117
  try {
679
- method.schema.credential.payload.parse(credential.payload)
1118
+ parsedCredential = withParsedCredentialPayload(
1119
+ credential,
1120
+ method.schema.credential.payload.parse(credential.payload),
1121
+ )
680
1122
  } catch {
681
- const response = await transport.respondChallenge({
1123
+ const error = new Errors.InvalidPayloadError()
1124
+ await emitPaymentFailed({
682
1125
  challenge,
683
- input,
684
- 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,
685
1137
  })
686
1138
  return { challenge: response, status: 402 }
687
1139
  }
@@ -689,22 +1141,31 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
689
1141
  const envelope: Method.VerifiedChallengeEnvelope = Object.freeze({
690
1142
  capturedRequest,
691
1143
  challenge: credential.challenge,
692
- credential,
693
- request,
1144
+ credential: parsedCredential,
1145
+ request: parsedRequest,
694
1146
  })
695
1147
 
696
1148
  // User-provided verification (e.g., check signature, submit tx, verify payment).
697
1149
  // If verification fails, re-issue the challenge so the client can retry.
698
1150
  let receiptData: Receipt.Receipt
699
1151
  try {
700
- receiptData = await verify({ credential, envelope, request } as never)
1152
+ receiptData = await verify({ credential: parsedCredential, envelope, request } as never)
701
1153
  } catch (e) {
702
1154
  if (!(e instanceof Errors.PaymentError))
703
1155
  console.error('mppx: internal verification error', e)
704
1156
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
705
- const response = await transport.respondChallenge({
1157
+ await emitPaymentFailed({
706
1158
  challenge,
707
- 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,
708
1169
  error,
709
1170
  })
710
1171
  return { challenge: response, status: 402 }
@@ -716,12 +1177,32 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
716
1177
  // return the management response directly. If undefined, `withReceipt()`
717
1178
  // expects the caller to pass the user handler's response instead.
718
1179
  const managementResponse = respond
719
- ? await respond({ credential, envelope, input, receipt: receiptData, request } as never)
1180
+ ? await respond({
1181
+ credential: parsedCredential,
1182
+ envelope,
1183
+ input,
1184
+ receipt: receiptData,
1185
+ request,
1186
+ } as never)
720
1187
  : undefined
721
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
+
722
1203
  return success(receiptData, {
723
1204
  challengeId: credential.challenge.id,
724
- credentialForReceipt: credential,
1205
+ credentialForReceipt: parsedCredential,
725
1206
  envelopeForReceipt: envelope,
726
1207
  managementResponse,
727
1208
  })
@@ -784,13 +1265,6 @@ function createChallengeFn(parameters: {
784
1265
  }
785
1266
  }
786
1267
 
787
- function getSafeCredentialReason(error: unknown): string | undefined {
788
- if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
789
- if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
790
- if (error instanceof Credential.MissingPaymentSchemeError) return error.message
791
- return undefined
792
- }
793
-
794
1268
  declare namespace createMethodFn {
795
1269
  type Parameters<
796
1270
  method extends Method.Method = Method.Method,
@@ -800,6 +1274,7 @@ declare namespace createMethodFn {
800
1274
  authorize?: Method.AuthorizeFn<method>
801
1275
  defaults?: defaults
802
1276
  method: method
1277
+ events: ServerEventDispatcher<readonly [method], transport>
803
1278
  realm: string | undefined
804
1279
  request?: Method.RequestFn<method>
805
1280
  respond?: Method.RespondFn<method>
@@ -816,9 +1291,285 @@ declare namespace createMethodFn {
816
1291
  > = MethodFn<method, transport, defaults>
817
1292
  }
818
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
+
819
1569
  const defaultRealm = 'MPP Payment'
820
1570
  const Warnings = {
821
1571
  realmFallback: 'realm-fallback',
1572
+ transportInputSnapshot: 'transport-input-snapshot',
822
1573
  } as const
823
1574
  const missingReceiptResponseErrorName = 'MissingReceiptResponseError'
824
1575
  const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument'
@@ -883,6 +1634,7 @@ async function resolveRouteChallenge(parameters: {
883
1634
  secretKey: string
884
1635
  }): Promise<{
885
1636
  challenge: Challenge.Challenge
1637
+ parsedRequest: Record<string, unknown>
886
1638
  request: Record<string, unknown>
887
1639
  }> {
888
1640
  // Resolve the route's canonical request exactly as the handler path does:
@@ -905,15 +1657,18 @@ async function resolveRouteChallenge(parameters: {
905
1657
  ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
906
1658
  : defaultRealm)
907
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
+
908
1669
  return {
909
- challenge: Challenge.fromMethod(parameters.method, {
910
- description: parameters.description,
911
- expires: parameters.expires,
912
- meta: parameters.meta,
913
- realm: effectiveRealm,
914
- request: request as never,
915
- secretKey: parameters.secretKey,
916
- }),
1670
+ challenge,
1671
+ parsedRequest: challenge.request as Record<string, unknown>,
917
1672
  request,
918
1673
  }
919
1674
  }
@@ -952,7 +1707,7 @@ function createFallbackChallenge(parameters: {
952
1707
  *
953
1708
  * Note: Object.freeze is shallow — it prevents reassigning top-level properties
954
1709
  * but does not deep-freeze mutable class instances like Headers or URL. This is
955
- * 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.
956
1711
  */
957
1712
  async function captureRequest(
958
1713
  transport: Transport.AnyTransport,
@@ -1173,6 +1928,16 @@ function hydrateCredentialMeta<payload>(
1173
1928
  },
1174
1929
  }
1175
1930
  }
1931
+
1932
+ function withParsedCredentialPayload<payload>(
1933
+ credential: Credential.Credential,
1934
+ payload: payload,
1935
+ ): Credential.Credential<payload> {
1936
+ return {
1937
+ ...credential,
1938
+ payload,
1939
+ }
1940
+ }
1176
1941
  export type MethodFn<
1177
1942
  method extends Method.Method,
1178
1943
  transport extends Transport.AnyTransport,