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
package/src/server/Mppx.ts
CHANGED
|
@@ -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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
891
|
+
const error = new Errors.MalformedCredentialError(reason ? { reason } : {})
|
|
892
|
+
await emitPaymentFailed({
|
|
512
893
|
challenge,
|
|
513
|
-
|
|
514
|
-
error
|
|
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
|
-
|
|
981
|
+
await emitPaymentFailed({
|
|
582
982
|
challenge,
|
|
583
|
-
|
|
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
|
|
998
|
+
const error = new Errors.PaymentRequiredError({ description })
|
|
999
|
+
const response = await emitChallenge({
|
|
592
1000
|
challenge,
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
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
|
-
|
|
615
|
-
error
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
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
|
-
|
|
656
|
-
error
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
1099
|
+
await emitPaymentFailed({
|
|
671
1100
|
challenge,
|
|
672
|
-
|
|
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
|
-
|
|
1118
|
+
parsedCredential = withParsedCredentialPayload(
|
|
1119
|
+
credential,
|
|
1120
|
+
method.schema.credential.payload.parse(credential.payload),
|
|
1121
|
+
)
|
|
680
1122
|
} catch {
|
|
681
|
-
const
|
|
1123
|
+
const error = new Errors.InvalidPayloadError()
|
|
1124
|
+
await emitPaymentFailed({
|
|
682
1125
|
challenge,
|
|
683
|
-
|
|
684
|
-
error
|
|
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
|
-
|
|
1157
|
+
await emitPaymentFailed({
|
|
706
1158
|
challenge,
|
|
707
|
-
|
|
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({
|
|
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:
|
|
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
|
|
910
|
-
|
|
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
|
|
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,
|