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.
- package/CHANGELOG.md +7 -0
- package/dist/client/Mppx.d.ts +12 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +127 -10
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +69 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +250 -20
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +2 -1
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +82 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +557 -83
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/client/Mppx.test-d.ts +55 -0
- package/src/client/Mppx.test.ts +181 -0
- package/src/client/Mppx.ts +248 -16
- package/src/client/internal/Fetch.test-d.ts +31 -0
- package/src/client/internal/Fetch.test.ts +261 -0
- package/src/client/internal/Fetch.ts +467 -24
- package/src/middlewares/internal/mppx.ts +5 -6
- package/src/proxy/Proxy.test.ts +69 -0
- package/src/server/Mppx.test-d.ts +50 -0
- package/src/server/Mppx.test.ts +893 -1
- package/src/server/Mppx.ts +862 -97
- 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
|
-
|
|
75
|
-
|
|
191
|
+
let challenge: Challenge.Challenge | undefined
|
|
192
|
+
let challenges: readonly Challenge.Challenge[] | undefined
|
|
193
|
+
let mi: methods[number] | undefined
|
|
76
194
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
//
|
|
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])
|
|
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>
|
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -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'],
|