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
@@ -1,6 +1,7 @@
1
1
  import * as Challenge from '../../Challenge.js'
2
2
  import * as Expires from '../../Expires.js'
3
3
  import * as AcceptPayment from '../../internal/AcceptPayment.js'
4
+ import type { MaybePromise } from '../../internal/types.js'
4
5
  import type * as Method from '../../Method.js'
5
6
  import type * as z from '../../zod.js'
6
7
 
@@ -15,6 +16,121 @@ type WrappedFetch = typeof globalThis.fetch & {
15
16
 
16
17
  let originalFetch: typeof globalThis.fetch | undefined
17
18
 
19
+ export type ClientEventMap<
20
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
21
+ response = Response,
22
+ > = {
23
+ 'challenge.received': ChallengeReceivedPayload<methods, response>
24
+ 'credential.created': CredentialCreatedPayload<methods, response>
25
+ 'payment.failed': PaymentFailedPayload<methods, response>
26
+ 'payment.response': PaymentResponsePayload<methods, response>
27
+ }
28
+
29
+ export type ClientEventName<
30
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
31
+ response = Response,
32
+ > = keyof ClientEventMap<methods, response> | '*'
33
+
34
+ export type ClientEventEnvelope<
35
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
36
+ response = Response,
37
+ > = {
38
+ [name in keyof ClientEventMap<methods, response>]: Readonly<{
39
+ name: name
40
+ payload: ClientEventMap<methods, response>[name]
41
+ }>
42
+ }[keyof ClientEventMap<methods, response>]
43
+
44
+ export type ClientEventPayload<
45
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
46
+ name extends ClientEventName<methods> = ClientEventName<methods>,
47
+ response = Response,
48
+ > = name extends '*'
49
+ ? ClientEventEnvelope<methods, response>
50
+ : name extends keyof ClientEventMap<methods, response>
51
+ ? ClientEventMap<methods, response>[name]
52
+ : never
53
+
54
+ export type ClientEventResult<
55
+ methods extends readonly Method.AnyClient[],
56
+ name extends ClientEventName<methods> = ClientEventName<methods>,
57
+ _response = Response,
58
+ > = name extends 'challenge.received' ? string | undefined : void
59
+
60
+ export type ClientEventHandler<
61
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
62
+ name extends ClientEventName<methods> = ClientEventName<methods>,
63
+ response = Response,
64
+ > = (
65
+ payload: ClientEventPayload<methods, name, response>,
66
+ ) => MaybePromise<ClientEventResult<methods, name, response>>
67
+
68
+ export type Unsubscribe = () => void
69
+
70
+ export type ChallengeReceivedPayload<
71
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
72
+ response = Response,
73
+ > = Readonly<{
74
+ challenge: Challenge.Challenge
75
+ challenges: readonly Challenge.Challenge[]
76
+ createCredential: (context?: AnyContextFor<methods>) => Promise<string>
77
+ init?: from.RequestInit<methods> | undefined
78
+ input?: RequestInfo | URL | undefined
79
+ method: methods[number]
80
+ response: response
81
+ }>
82
+
83
+ export type CredentialCreatedPayload<
84
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
85
+ response = Response,
86
+ > = Readonly<{
87
+ challenge: Challenge.Challenge
88
+ credential: string
89
+ init?: from.RequestInit<methods> | undefined
90
+ input?: RequestInfo | URL | undefined
91
+ method: methods[number]
92
+ response?: response | undefined
93
+ }>
94
+
95
+ export type PaymentResponsePayload<
96
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
97
+ response = Response,
98
+ > = Readonly<{
99
+ challenge: Challenge.Challenge
100
+ credential: string
101
+ init?: from.RequestInit<methods> | undefined
102
+ input?: RequestInfo | URL | undefined
103
+ method: methods[number]
104
+ response: response
105
+ }>
106
+
107
+ export type PaymentFailedPayload<
108
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
109
+ response = Response,
110
+ > = Readonly<{
111
+ challenge?: Challenge.Challenge | undefined
112
+ challenges?: readonly Challenge.Challenge[] | undefined
113
+ error: unknown
114
+ init?: from.RequestInit<methods> | undefined
115
+ input?: RequestInfo | URL | undefined
116
+ method?: methods[number] | undefined
117
+ response?: response | undefined
118
+ }>
119
+
120
+ export type ClientEventDispatcher<
121
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
122
+ response = Response,
123
+ > = {
124
+ emit<name extends Exclude<ClientEventName<methods, response>, '*'>>(
125
+ name: name,
126
+ payload: ClientEventMap<methods, response>[name],
127
+ ): Promise<ClientEventResult<methods, name, response>>
128
+ on<name extends ClientEventName<methods, response>>(
129
+ name: name,
130
+ handler: ClientEventHandler<methods, name, response>,
131
+ ): Unsubscribe
132
+ }
133
+
18
134
  /**
19
135
  * Creates a fetch wrapper that automatically handles 402 Payment Required responses.
20
136
  *
@@ -46,6 +162,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
46
162
  methods,
47
163
  onChallenge,
48
164
  } = config
165
+ const events = config.eventDispatcher ?? createEventDispatcher()
49
166
  const resolvedAcceptPayment = acceptPayment ?? AcceptPayment.resolve(methods)
50
167
  // Always operate on the true underlying fetch to avoid wrapper-on-wrapper stacking,
51
168
  // which can duplicate retries and make restore semantics fragile.
@@ -71,31 +188,97 @@ export function from<const methods extends readonly Method.AnyClient[]>(
71
188
  const context = (init as Record<string, unknown> | undefined)?.context
72
189
  const { context: _, ...fetchInit } = (initialRequest.init ?? {}) as Record<string, unknown>
73
190
 
74
- // Parse all challenges from the response (supports merged WWW-Authenticate headers).
75
- const challenges = Challenge.fromResponseList(response)
191
+ let challenge: Challenge.Challenge | undefined
192
+ let challenges: readonly Challenge.Challenge[] | undefined
193
+ let mi: methods[number] | undefined
76
194
 
77
- const selected = AcceptPayment.selectChallenge(challenges, methods, paymentPreferences.entries)
78
- if (!selected)
79
- throw new Error(
80
- `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
81
- )
195
+ try {
196
+ // Parse all challenges from the response (supports merged WWW-Authenticate headers).
197
+ challenges = Challenge.fromResponseList(response)
82
198
 
83
- const { challenge, method: mi } = selected
84
- if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
85
-
86
- const onChallengeCredential = onChallenge
87
- ? await onChallenge(challenge, {
88
- createCredential: async (overrideContext?: AnyContextFor<methods>) =>
89
- resolveCredential(challenge, mi!, overrideContext ?? context),
90
- })
91
- : undefined
92
- const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
93
- validateCredentialHeaderValue(credential)
199
+ const selected = AcceptPayment.selectChallenge(
200
+ challenges,
201
+ methods,
202
+ paymentPreferences.entries,
203
+ )
204
+ if (!selected)
205
+ throw new Error(
206
+ `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
207
+ )
208
+
209
+ const selectedChallenge = selected.challenge
210
+ challenge = selectedChallenge
211
+ mi = selected.method
212
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
213
+
214
+ const createCredential = memoizeCreateCredential((overrideContext?: AnyContextFor<methods>) =>
215
+ resolveCredential(selectedChallenge, selected.method, overrideContext ?? context),
216
+ )
217
+ const eventCredential = await events.emit(
218
+ 'challenge.received',
219
+ createChallengeReceivedPayload({
220
+ challenge: selectedChallenge,
221
+ challenges,
222
+ createCredential,
223
+ init,
224
+ input,
225
+ method: selected.method,
226
+ response,
227
+ }),
228
+ )
229
+ const onChallengeCredential =
230
+ eventCredential ??
231
+ (onChallenge
232
+ ? await onChallenge(challenge, {
233
+ createCredential,
234
+ })
235
+ : undefined)
236
+ const credential = onChallengeCredential ?? (await createCredential())
237
+ validateCredentialHeaderValue(credential)
238
+ await events.emit(
239
+ 'credential.created',
240
+ createCredentialCreatedPayload({
241
+ challenge: selectedChallenge,
242
+ credential,
243
+ init,
244
+ input,
245
+ method: selected.method,
246
+ response,
247
+ }),
248
+ )
94
249
 
95
- return baseFetch(initialRequest.input, {
96
- ...fetchInit,
97
- headers: withAuthorizationHeader(initialRequest.headers, credential),
98
- })
250
+ const paymentResponse = await baseFetch(initialRequest.input, {
251
+ ...fetchInit,
252
+ headers: withAuthorizationHeader(initialRequest.headers, credential),
253
+ })
254
+ if (paymentResponse.ok)
255
+ await events.emit(
256
+ 'payment.response',
257
+ createPaymentResponsePayload({
258
+ challenge: selectedChallenge,
259
+ credential,
260
+ init,
261
+ input,
262
+ method: selected.method,
263
+ response: paymentResponse,
264
+ }),
265
+ )
266
+ return paymentResponse
267
+ } catch (error) {
268
+ await events.emit(
269
+ 'payment.failed',
270
+ createPaymentFailedPayload({
271
+ challenge,
272
+ challenges,
273
+ error,
274
+ init,
275
+ input,
276
+ method: mi,
277
+ response,
278
+ }),
279
+ )
280
+ throw error
281
+ }
99
282
  }
100
283
 
101
284
  // Record the wrapped target so future polyfill() / restore() calls can detect origin
@@ -126,9 +309,11 @@ export declare namespace from {
126
309
  | undefined
127
310
  /** Custom fetch function to wrap. Defaults to `globalThis.fetch`. */
128
311
  fetch?: typeof globalThis.fetch
312
+ /** Advanced shared event dispatcher. `challenge.received` handlers run before `onChallenge`; the first non-empty credential returned by a handler skips `onChallenge`. */
313
+ eventDispatcher?: ClientEventDispatcher<methods, any> | undefined
129
314
  /** Array of methods to use. */
130
315
  methods: methods
131
- /** Called when a 402 challenge is received, before credential creation. */
316
+ /** Called when a 402 challenge is received and no event handler supplies a credential. */
132
317
  onChallenge?:
133
318
  | ((
134
319
  challenge: Challenge.Challenge,
@@ -230,6 +415,264 @@ export function normalizeHeaders(headers: unknown): Record<string, string> {
230
415
  return headers as Record<string, string>
231
416
  }
232
417
 
418
+ /**
419
+ * Creates a typed client payment event dispatcher.
420
+ *
421
+ * `challenge.received` handlers run before `onChallenge`; the first non-empty
422
+ * credential returned by a handler wins. Observation handlers are isolated, so
423
+ * thrown listener errors do not stop sibling listeners or payment flow.
424
+ */
425
+ export function createEventDispatcher<
426
+ methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
427
+ response = Response,
428
+ >(): ClientEventDispatcher<methods, response> {
429
+ const handlers = {
430
+ '*': new Set<ClientEventHandler<methods, '*', response>>(),
431
+ 'challenge.received': new Set<ClientEventHandler<methods, 'challenge.received', response>>(),
432
+ 'credential.created': new Set<ClientEventHandler<methods, 'credential.created', response>>(),
433
+ 'payment.failed': new Set<ClientEventHandler<methods, 'payment.failed', response>>(),
434
+ 'payment.response': new Set<ClientEventHandler<methods, 'payment.response', response>>(),
435
+ }
436
+
437
+ const on: ClientEventDispatcher<methods, response>['on'] = (name, handler) => {
438
+ switch (name) {
439
+ case '*':
440
+ case 'challenge.received':
441
+ case 'credential.created':
442
+ case 'payment.failed':
443
+ case 'payment.response':
444
+ handlers[name].add(handler as never)
445
+ return () => handlers[name].delete(handler as never)
446
+ default:
447
+ throw new Error(`Unknown client event "${String(name)}".`)
448
+ }
449
+ }
450
+
451
+ return {
452
+ async emit(name, payload) {
453
+ switch (name) {
454
+ case 'challenge.received': {
455
+ let credential: string | undefined
456
+ for (const handler of handlers['challenge.received']) {
457
+ const value = await emitChallengeReceived(
458
+ handler,
459
+ payload as ChallengeReceivedPayload<methods, response>,
460
+ )
461
+ if (typeof value === 'string' && value.length > 0) {
462
+ credential = value
463
+ break
464
+ }
465
+ }
466
+ await emitCatchall(handlers['*'], name, payload)
467
+ return credential as ClientEventResult<methods, typeof name, response>
468
+ }
469
+ case 'credential.created':
470
+ await emitObserveHandlers(handlers['credential.created'], payload)
471
+ await emitCatchall(handlers['*'], name, payload)
472
+ return undefined as ClientEventResult<methods, typeof name, response>
473
+ case 'payment.failed':
474
+ await emitObserveHandlers(handlers['payment.failed'], payload)
475
+ await emitCatchall(handlers['*'], name, payload)
476
+ return undefined as ClientEventResult<methods, typeof name, response>
477
+ case 'payment.response':
478
+ await emitObserveHandlers(handlers['payment.response'], payload)
479
+ await emitCatchall(handlers['*'], name, payload)
480
+ return undefined as ClientEventResult<methods, typeof name, response>
481
+ }
482
+ },
483
+ on,
484
+ }
485
+ }
486
+
487
+ async function emitChallengeReceived<methods extends readonly Method.AnyClient[], response>(
488
+ handler: ClientEventHandler<methods, 'challenge.received', response>,
489
+ payload: ChallengeReceivedPayload<methods, response>,
490
+ ): Promise<string | undefined> {
491
+ try {
492
+ return await handler(payload)
493
+ } catch {
494
+ return undefined
495
+ }
496
+ }
497
+
498
+ async function emitObserveHandlers(
499
+ handlers: ReadonlySet<(payload: never) => MaybePromise<unknown>>,
500
+ payload: unknown,
501
+ ): Promise<void> {
502
+ for (const handler of handlers) {
503
+ try {
504
+ await handler(payload as never)
505
+ } catch {
506
+ // Client observation events must not alter payment flow.
507
+ }
508
+ }
509
+ }
510
+
511
+ async function emitCatchall<
512
+ methods extends readonly Method.AnyClient[],
513
+ response,
514
+ name extends keyof ClientEventMap<methods, response>,
515
+ >(
516
+ handlers: ReadonlySet<ClientEventHandler<methods, '*', response>>,
517
+ name: name,
518
+ payload: ClientEventMap<methods, response>[name],
519
+ ) {
520
+ await emitObserveHandlers(
521
+ handlers as ReadonlySet<(payload: never) => MaybePromise<unknown>>,
522
+ Object.freeze({ name, payload }) as ClientEventPayload<methods, '*', response>,
523
+ )
524
+ }
525
+
526
+ function memoizeCreateCredential<methods extends readonly Method.AnyClient[]>(
527
+ createCredential: (context?: AnyContextFor<methods>) => Promise<string>,
528
+ ) {
529
+ let promise: Promise<string> | undefined
530
+ return (context?: AnyContextFor<methods>) => {
531
+ promise ??= createCredential(context)
532
+ return promise
533
+ }
534
+ }
535
+
536
+ function createChallengeReceivedPayload<
537
+ methods extends readonly Method.AnyClient[],
538
+ response,
539
+ >(parameters: {
540
+ challenge: Challenge.Challenge
541
+ challenges: readonly Challenge.Challenge[]
542
+ createCredential: (context?: AnyContextFor<methods>) => Promise<string>
543
+ init?: from.RequestInit<methods> | undefined
544
+ input?: RequestInfo | URL | undefined
545
+ method: methods[number]
546
+ response: response
547
+ }): ChallengeReceivedPayload<methods, response> {
548
+ return Object.freeze({
549
+ challenge: snapshotValue(parameters.challenge),
550
+ challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)),
551
+ createCredential: parameters.createCredential,
552
+ init: snapshotInit(parameters.init),
553
+ input: snapshotInput(parameters.input),
554
+ method: snapshotMethod(parameters.method),
555
+ response: snapshotResponse(parameters.response),
556
+ }) as never
557
+ }
558
+
559
+ function createCredentialCreatedPayload<
560
+ methods extends readonly Method.AnyClient[],
561
+ response,
562
+ >(parameters: {
563
+ challenge: Challenge.Challenge
564
+ credential: string
565
+ init?: from.RequestInit<methods> | undefined
566
+ input?: RequestInfo | URL | undefined
567
+ method: methods[number]
568
+ response?: response | undefined
569
+ }): CredentialCreatedPayload<methods, response> {
570
+ return Object.freeze({
571
+ challenge: snapshotValue(parameters.challenge),
572
+ credential: parameters.credential,
573
+ init: snapshotInit(parameters.init),
574
+ input: snapshotInput(parameters.input),
575
+ method: snapshotMethod(parameters.method),
576
+ ...(parameters.response !== undefined
577
+ ? { response: snapshotResponse(parameters.response) }
578
+ : {}),
579
+ }) as never
580
+ }
581
+
582
+ function createPaymentResponsePayload<
583
+ methods extends readonly Method.AnyClient[],
584
+ response,
585
+ >(parameters: {
586
+ challenge: Challenge.Challenge
587
+ credential: string
588
+ init?: from.RequestInit<methods> | undefined
589
+ input?: RequestInfo | URL | undefined
590
+ method: methods[number]
591
+ response: response
592
+ }): PaymentResponsePayload<methods, response> {
593
+ return Object.freeze({
594
+ challenge: snapshotValue(parameters.challenge),
595
+ credential: parameters.credential,
596
+ init: snapshotInit(parameters.init),
597
+ input: snapshotInput(parameters.input),
598
+ method: snapshotMethod(parameters.method),
599
+ response: snapshotResponse(parameters.response),
600
+ }) as never
601
+ }
602
+
603
+ function createPaymentFailedPayload<
604
+ methods extends readonly Method.AnyClient[],
605
+ response,
606
+ >(parameters: {
607
+ challenge?: Challenge.Challenge | undefined
608
+ challenges?: readonly Challenge.Challenge[] | undefined
609
+ error: unknown
610
+ init?: from.RequestInit<methods> | undefined
611
+ input?: RequestInfo | URL | undefined
612
+ method?: methods[number] | undefined
613
+ response?: response | undefined
614
+ }): PaymentFailedPayload<methods, response> {
615
+ return Object.freeze({
616
+ ...(parameters.challenge ? { challenge: snapshotValue(parameters.challenge) } : {}),
617
+ ...(parameters.challenges
618
+ ? { challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)) }
619
+ : {}),
620
+ error: parameters.error,
621
+ init: snapshotInit(parameters.init),
622
+ input: snapshotInput(parameters.input),
623
+ ...(parameters.method ? { method: snapshotMethod(parameters.method) } : {}),
624
+ ...(parameters.response !== undefined
625
+ ? { response: snapshotResponse(parameters.response) }
626
+ : {}),
627
+ }) as never
628
+ }
629
+
630
+ function snapshotInit<methods extends readonly Method.AnyClient[]>(
631
+ init: from.RequestInit<methods> | undefined,
632
+ ): from.RequestInit<methods> | undefined {
633
+ if (!init) return undefined
634
+ return freezeSnapshot({
635
+ ...init,
636
+ ...(init.headers ? { headers: new Headers(init.headers) } : {}),
637
+ }) as from.RequestInit<methods>
638
+ }
639
+
640
+ function snapshotInput(input: RequestInfo | URL | undefined): RequestInfo | URL | undefined {
641
+ if (input instanceof Request) return input.clone()
642
+ if (input instanceof URL) return new URL(input)
643
+ return input
644
+ }
645
+
646
+ function snapshotMethod<method extends Method.AnyClient>(method: method): method {
647
+ return freezeSnapshot(Object.assign({}, method)) as method
648
+ }
649
+
650
+ function snapshotResponse<response>(response: response): response {
651
+ if (response instanceof Response) return response.clone() as response
652
+ return snapshotValue(response)
653
+ }
654
+
655
+ function snapshotValue<value>(value: value): value {
656
+ try {
657
+ return deepFreeze(structuredClone(value))
658
+ } catch {
659
+ return freezeSnapshot(value)
660
+ }
661
+ }
662
+
663
+ function deepFreeze<value>(value: value): value {
664
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
665
+ Object.freeze(value)
666
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child)
667
+ return value
668
+ }
669
+
670
+ function freezeSnapshot<value>(value: value): value {
671
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
672
+ Object.freeze(value)
673
+ return value
674
+ }
675
+
233
676
  /** @internal */
234
677
  function withAuthorizationHeader(headers: unknown, credential: string): Record<string, string> {
235
678
  const normalized = normalizeHeaders(headers)
@@ -300,7 +743,7 @@ function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
300
743
  }
301
744
 
302
745
  /** @internal */
303
- function validateCredentialHeaderValue(credential: string): void {
746
+ export function validateCredentialHeaderValue(credential: string): void {
304
747
  if (!credential.trim()) throw new Error('Credential header value must be non-empty')
305
748
  if (credential.includes('\r') || credential.includes('\n')) {
306
749
  throw new Error('Credential header value contains illegal newline characters')
@@ -1,5 +1,6 @@
1
1
  import type { DiscoveryHandler } from '../../discovery/OpenApi.js'
2
2
  import type * as Method from '../../Method.js'
3
+ import { reservedMppxKeys } from '../../server/Mppx.js'
3
4
  import type * as Mppx from '../../server/Mppx.js'
4
5
 
5
6
  export type AnyMethodFn = Mppx.AnyMethodFn
@@ -17,11 +18,8 @@ type WrapNested<obj, handler> = {
17
18
  export type Wrap<mppx, handler> = {
18
19
  // `compose` is omitted — it returns a raw HTTP handler, not a
19
20
  // middleware-shaped result. Use `Mppx.compose()` static instead.
20
- // `methods`, `realm`, `transport` are data properties not handlers.
21
- [key in keyof mppx as key extends 'compose' ? never : key]: key extends
22
- | 'methods'
23
- | 'realm'
24
- | 'transport'
21
+ // Reserved Mppx keys are copied through unchanged, not wrapped as handlers.
22
+ [key in keyof mppx as key extends 'compose' ? never : key]: key extends Mppx.ReservedKey
25
23
  ? mppx[key]
26
24
  : mppx[key] extends (options: infer options) => any
27
25
  ? (o: options) => handler & DiscoveryMeta
@@ -54,7 +52,8 @@ export function wrap<mppx extends Mppx.Mppx<any, any>, handler>(
54
52
  }
55
53
  result[key] = wrapWithMeta
56
54
  // Also set shorthand intent key if Mppx registered it (no collision)
57
- if ((mppx as any)[mi.intent]) result[mi.intent] = wrapWithMeta
55
+ if (!reservedMppxKeys.has(mi.intent) && (mppx as any)[mi.intent])
56
+ result[mi.intent] = wrapWithMeta
58
57
  // Build nested handlers: wrapped.tempo.charge(...)
59
58
  if (!result[mi.name] || typeof result[mi.name] !== 'object')
60
59
  result[mi.name] = {} as Record<string, unknown>
@@ -111,6 +111,75 @@ function createUpstream(handler: (req: Request) => Response | Promise<Response>)
111
111
  }
112
112
 
113
113
  describe('create', () => {
114
+ test('behavior: paid routes emit mppx server events', async () => {
115
+ const events: string[] = []
116
+ upstream = await createUpstream(() => Response.json({ data: 'ok' }))
117
+
118
+ const method = Method.from({
119
+ name: 'mock',
120
+ intent: 'charge',
121
+ schema: {
122
+ credential: { payload: z.object({ token: z.string() }) },
123
+ request: z.object({ amount: z.string() }),
124
+ },
125
+ })
126
+ const handler = Mppx_server.create({
127
+ methods: [
128
+ Method.toServer(method, {
129
+ async verify() {
130
+ return Receipt.from({
131
+ method: 'mock',
132
+ status: 'success',
133
+ timestamp: new Date().toISOString(),
134
+ reference: 'proxy-reference',
135
+ })
136
+ },
137
+ }),
138
+ ],
139
+ realm: 'api.example.com',
140
+ secretKey,
141
+ })
142
+ handler.onChallengeCreated((context) => {
143
+ events.push(`challenge:${context.error?.name}`)
144
+ })
145
+ handler.onPaymentSuccess((context) => {
146
+ events.push(`payment:${context.receipt.reference}`)
147
+ })
148
+ handler.onPaymentFailed((context) => {
149
+ events.push(`failed:${context.error.name}`)
150
+ })
151
+ const proxy = ApiProxy.create({
152
+ services: [
153
+ Service.from('api', {
154
+ baseUrl: upstream.url,
155
+ routes: {
156
+ 'GET /v1/data': handler['mock/charge']({ amount: '1' }),
157
+ },
158
+ }),
159
+ ],
160
+ })
161
+ proxyServer = await Http.createServer(proxy.listener)
162
+
163
+ const challengeResponse = await fetch(`${proxyServer.url}/api/v1/data`)
164
+ expect(challengeResponse.status).toBe(402)
165
+
166
+ const challenge = Challenge.fromResponse(challengeResponse)
167
+ const authorization = Credential.serialize(
168
+ Credential.from({
169
+ challenge,
170
+ payload: { token: 'ok' },
171
+ }),
172
+ )
173
+ const paid = await fetch(`${proxyServer.url}/api/v1/data`, {
174
+ headers: { Authorization: authorization },
175
+ })
176
+
177
+ expect(paid.status).toBe(200)
178
+ expect(paid.headers.get('Payment-Receipt')).toBeTruthy()
179
+ expect(await paid.json()).toEqual({ data: 'ok' })
180
+ expect(events).toEqual(['challenge:PaymentRequiredError', 'payment:proxy-reference'])
181
+ })
182
+
114
183
  test('behavior: GET /openapi.json returns discovery JSON', async () => {
115
184
  const proxy = ApiProxy.create({
116
185
  categories: ['gateway'],