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.
- package/CHANGELOG.md +13 -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/client/Subscription.d.ts.map +1 -1
- package/dist/tempo/client/Subscription.js +4 -3
- package/dist/tempo/client/Subscription.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/client/Subscription.test.ts +51 -0
- package/src/tempo/client/Subscription.ts +4 -3
- package/src/tempo/server/internal/html.gen.ts +1 -1
package/dist/server/Mppx.js
CHANGED
|
@@ -13,6 +13,20 @@ import * as Scope from './internal/scope.js';
|
|
|
13
13
|
import * as NodeListener from './NodeListener.js';
|
|
14
14
|
import * as Request from './Request.js';
|
|
15
15
|
import * as Transport from './Transport.js';
|
|
16
|
+
const reservedMppxKeyValues = [
|
|
17
|
+
'challenge',
|
|
18
|
+
'compose',
|
|
19
|
+
'methods',
|
|
20
|
+
'on',
|
|
21
|
+
'onChallengeCreated',
|
|
22
|
+
'onPaymentFailed',
|
|
23
|
+
'onPaymentSuccess',
|
|
24
|
+
'realm',
|
|
25
|
+
'transport',
|
|
26
|
+
'verifyCredential',
|
|
27
|
+
];
|
|
28
|
+
/** Public instance keys that payment method names and shorthand intents cannot shadow. */
|
|
29
|
+
export const reservedMppxKeys = new Set(reservedMppxKeyValues);
|
|
16
30
|
/**
|
|
17
31
|
* Creates a server-side payment handler from methods.
|
|
18
32
|
*
|
|
@@ -35,15 +49,20 @@ export function create(config) {
|
|
|
35
49
|
throw new Error('Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().');
|
|
36
50
|
}
|
|
37
51
|
const methods = config.methods.flat();
|
|
52
|
+
const serverEvents = createServerEventDispatcher();
|
|
38
53
|
const handlers = {};
|
|
39
54
|
const intentCount = {};
|
|
40
55
|
for (const mi of methods) {
|
|
41
56
|
intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1;
|
|
57
|
+
}
|
|
58
|
+
assertNoReservedMppxKeys(methods);
|
|
59
|
+
for (const mi of methods) {
|
|
42
60
|
handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
|
|
43
61
|
authorize: mi.authorize,
|
|
44
62
|
defaults: mi.defaults,
|
|
45
63
|
method: mi,
|
|
46
64
|
realm,
|
|
65
|
+
events: serverEvents,
|
|
47
66
|
request: mi.request,
|
|
48
67
|
respond: mi.respond,
|
|
49
68
|
secretKey,
|
|
@@ -81,30 +100,94 @@ export function create(config) {
|
|
|
81
100
|
// verifyCredential: single-call end-to-end verification
|
|
82
101
|
async function verifyCredentialFn(input, options) {
|
|
83
102
|
const credential = hydrateCredentialMeta(typeof input === 'string' ? Credential.deserialize(input) : input);
|
|
84
|
-
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
85
|
-
if (!Challenge.verify(credential.challenge, { secretKey: secretKey }))
|
|
86
|
-
throw new Errors.InvalidChallengeError({
|
|
87
|
-
id: credential.challenge.id,
|
|
88
|
-
reason: 'challenge was not issued by this server',
|
|
89
|
-
});
|
|
90
|
-
// Expiry check
|
|
91
|
-
Expires.assert(credential.challenge.expires, credential.challenge.id);
|
|
92
103
|
// Find matching method by name + intent
|
|
93
104
|
const { method: credMethod, intent: credIntent } = credential.challenge;
|
|
94
105
|
const mi = methods.find((m) => m.name === credMethod && m.intent === credIntent);
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
const eventMethod = mi ?? { intent: credIntent, name: credMethod };
|
|
107
|
+
const emitStandalonePaymentFailed = async (parameters) => {
|
|
108
|
+
await serverEvents.emit('payment.failed', createPaymentFailedContext({
|
|
109
|
+
capturedRequest: options?.capturedRequest,
|
|
110
|
+
challenge: parameters.challenge,
|
|
111
|
+
credential: parameters.credential,
|
|
112
|
+
error: parameters.error,
|
|
113
|
+
method: eventMethod,
|
|
114
|
+
request: parameters.request,
|
|
115
|
+
submittedChallenge: parameters.submittedChallenge,
|
|
116
|
+
}));
|
|
117
|
+
};
|
|
118
|
+
if (!mi) {
|
|
119
|
+
const error = new Errors.InvalidChallengeError({
|
|
97
120
|
id: credential.challenge.id,
|
|
98
121
|
reason: `no registered method for ${credMethod}/${credIntent}`,
|
|
99
122
|
});
|
|
123
|
+
await emitStandalonePaymentFailed({
|
|
124
|
+
challenge: credential.challenge,
|
|
125
|
+
credential,
|
|
126
|
+
error,
|
|
127
|
+
request: credential.challenge.request,
|
|
128
|
+
submittedChallenge: credential.challenge,
|
|
129
|
+
});
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
133
|
+
if (!Challenge.verify(credential.challenge, { secretKey: secretKey })) {
|
|
134
|
+
const error = new Errors.InvalidChallengeError({
|
|
135
|
+
id: credential.challenge.id,
|
|
136
|
+
reason: 'challenge was not issued by this server',
|
|
137
|
+
});
|
|
138
|
+
await emitStandalonePaymentFailed({
|
|
139
|
+
challenge: credential.challenge,
|
|
140
|
+
credential,
|
|
141
|
+
error,
|
|
142
|
+
request: credential.challenge.request,
|
|
143
|
+
submittedChallenge: credential.challenge,
|
|
144
|
+
});
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
// Expiry check
|
|
148
|
+
try {
|
|
149
|
+
Expires.assert(credential.challenge.expires, credential.challenge.id);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
if (e instanceof Errors.PaymentError)
|
|
153
|
+
await emitStandalonePaymentFailed({
|
|
154
|
+
challenge: credential.challenge,
|
|
155
|
+
credential,
|
|
156
|
+
error: e,
|
|
157
|
+
request: credential.challenge.request,
|
|
158
|
+
submittedChallenge: credential.challenge,
|
|
159
|
+
});
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
100
162
|
// Validate payload against method schema
|
|
101
|
-
|
|
163
|
+
let parsedCredential;
|
|
164
|
+
try {
|
|
165
|
+
parsedCredential = withParsedCredentialPayload(credential, mi.schema.credential.payload.parse(credential.payload));
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
await emitStandalonePaymentFailed({
|
|
169
|
+
challenge: credential.challenge,
|
|
170
|
+
credential,
|
|
171
|
+
error: new Errors.InvalidPayloadError(),
|
|
172
|
+
request: credential.challenge.request,
|
|
173
|
+
submittedChallenge: credential.challenge,
|
|
174
|
+
});
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
102
177
|
const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope });
|
|
103
178
|
if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
|
|
104
|
-
|
|
179
|
+
const error = new Errors.InvalidChallengeError({
|
|
105
180
|
id: credential.challenge.id,
|
|
106
181
|
reason: "credential scope does not match this route's requirements",
|
|
107
182
|
});
|
|
183
|
+
await emitStandalonePaymentFailed({
|
|
184
|
+
challenge: credential.challenge,
|
|
185
|
+
credential: parsedCredential,
|
|
186
|
+
error,
|
|
187
|
+
request: credential.challenge.request,
|
|
188
|
+
submittedChallenge: credential.challenge,
|
|
189
|
+
});
|
|
190
|
+
throw error;
|
|
108
191
|
}
|
|
109
192
|
const shouldValidateRoute = options?.capturedRequest !== undefined ||
|
|
110
193
|
options?.meta !== undefined ||
|
|
@@ -113,37 +196,77 @@ export function create(config) {
|
|
|
113
196
|
const expectedRealm = options?.realm ??
|
|
114
197
|
realm ??
|
|
115
198
|
(options?.capturedRequest === undefined ? credential.challenge.realm : undefined);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
199
|
+
let parsedRequest = credential.challenge.request;
|
|
200
|
+
let request;
|
|
201
|
+
try {
|
|
202
|
+
request = shouldValidateRoute
|
|
203
|
+
? await resolveRouteChallenge({
|
|
204
|
+
capturedRequest: options?.capturedRequest,
|
|
205
|
+
credential: parsedCredential,
|
|
206
|
+
defaults: mi.defaults,
|
|
207
|
+
expires: credential.challenge.expires,
|
|
208
|
+
meta: expectedMeta,
|
|
209
|
+
method: mi,
|
|
210
|
+
realm: expectedRealm,
|
|
211
|
+
request: mi.request,
|
|
212
|
+
routeRequest: options?.request ?? {},
|
|
213
|
+
secretKey: secretKey,
|
|
214
|
+
}).then((resolved) => {
|
|
215
|
+
const mismatch = getChallengeBindingMismatch(resolved.challenge, credential.challenge, mi.stableBinding);
|
|
216
|
+
if (mismatch)
|
|
217
|
+
throw new Errors.InvalidChallengeError({
|
|
218
|
+
id: credential.challenge.id,
|
|
219
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
220
|
+
});
|
|
221
|
+
parsedRequest = resolved.parsedRequest;
|
|
222
|
+
return resolved.request;
|
|
223
|
+
})
|
|
224
|
+
: credential.challenge.request;
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
if (e instanceof Errors.PaymentError)
|
|
228
|
+
await emitStandalonePaymentFailed({
|
|
229
|
+
challenge: credential.challenge,
|
|
230
|
+
credential: parsedCredential,
|
|
231
|
+
error: e,
|
|
232
|
+
request: credential.challenge.request,
|
|
233
|
+
submittedChallenge: credential.challenge,
|
|
234
|
+
});
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
138
237
|
const envelope = options?.capturedRequest
|
|
139
238
|
? {
|
|
140
239
|
capturedRequest: options.capturedRequest,
|
|
141
240
|
challenge: credential.challenge,
|
|
142
|
-
credential,
|
|
143
|
-
request,
|
|
241
|
+
credential: parsedCredential,
|
|
242
|
+
request: parsedRequest,
|
|
144
243
|
}
|
|
145
244
|
: undefined;
|
|
146
|
-
|
|
245
|
+
let receipt;
|
|
246
|
+
try {
|
|
247
|
+
receipt = await mi.verify({ credential: parsedCredential, envelope, request });
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
|
|
251
|
+
await emitStandalonePaymentFailed({
|
|
252
|
+
challenge: credential.challenge,
|
|
253
|
+
credential: parsedCredential,
|
|
254
|
+
error,
|
|
255
|
+
request: parsedRequest,
|
|
256
|
+
submittedChallenge: credential.challenge,
|
|
257
|
+
});
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
await serverEvents.emit('payment.success', createPaymentSuccessContext({
|
|
261
|
+
capturedRequest: options?.capturedRequest,
|
|
262
|
+
challenge: credential.challenge,
|
|
263
|
+
credential: parsedCredential,
|
|
264
|
+
envelope,
|
|
265
|
+
method: mi,
|
|
266
|
+
receipt,
|
|
267
|
+
request: parsedRequest,
|
|
268
|
+
}));
|
|
269
|
+
return receipt;
|
|
147
270
|
}
|
|
148
271
|
function composeFn(...entries) {
|
|
149
272
|
if (transport.name !== 'http')
|
|
@@ -163,10 +286,23 @@ export function create(config) {
|
|
|
163
286
|
});
|
|
164
287
|
return compose(...configured);
|
|
165
288
|
}
|
|
289
|
+
function onChallengeCreated(handler) {
|
|
290
|
+
return serverEvents.on('challenge.created', handler);
|
|
291
|
+
}
|
|
292
|
+
function onPaymentFailed(handler) {
|
|
293
|
+
return serverEvents.on('payment.failed', handler);
|
|
294
|
+
}
|
|
295
|
+
function onPaymentSuccess(handler) {
|
|
296
|
+
return serverEvents.on('payment.success', handler);
|
|
297
|
+
}
|
|
166
298
|
return {
|
|
167
299
|
methods,
|
|
168
300
|
challenge: challengeHandlers,
|
|
169
301
|
compose: composeFn,
|
|
302
|
+
on: serverEvents.on,
|
|
303
|
+
onChallengeCreated,
|
|
304
|
+
onPaymentFailed,
|
|
305
|
+
onPaymentSuccess,
|
|
170
306
|
realm: realm,
|
|
171
307
|
transport,
|
|
172
308
|
verifyCredential: verifyCredentialFn,
|
|
@@ -175,11 +311,16 @@ export function create(config) {
|
|
|
175
311
|
}
|
|
176
312
|
// biome-ignore lint/correctness/noUnusedVariables: _
|
|
177
313
|
function createMethodFn(parameters) {
|
|
178
|
-
const { authorize, defaults, method, realm, respond, secretKey, stableBinding, transport, verify, } = parameters;
|
|
314
|
+
const { authorize, defaults, events, method, realm, respond, secretKey, stableBinding, transport, verify, } = parameters;
|
|
179
315
|
return (options) => {
|
|
180
316
|
const { description, meta, scope, ...rest } = options;
|
|
181
317
|
const staticMeta = Scope.merge({ meta, scope });
|
|
182
318
|
return Object.assign(async (input) => {
|
|
319
|
+
if (method.html && isServiceWorkerRequest(input))
|
|
320
|
+
return {
|
|
321
|
+
status: 402,
|
|
322
|
+
challenge: createServiceWorkerResponse(),
|
|
323
|
+
};
|
|
183
324
|
const expires = 'expires' in options
|
|
184
325
|
? normalizeExpires(options.expires)
|
|
185
326
|
: Expires.minutes(5);
|
|
@@ -197,6 +338,38 @@ function createMethodFn(parameters) {
|
|
|
197
338
|
return [null, e];
|
|
198
339
|
}
|
|
199
340
|
})();
|
|
341
|
+
const emitChallenge = async (parameters) => {
|
|
342
|
+
const response = await transport.respondChallenge({
|
|
343
|
+
challenge: parameters.challenge,
|
|
344
|
+
input,
|
|
345
|
+
...(parameters.error && { error: parameters.error }),
|
|
346
|
+
...(parameters.html && { html: parameters.html }),
|
|
347
|
+
});
|
|
348
|
+
if (isIssuedChallengeResponse(response))
|
|
349
|
+
await events.emit('challenge.created', createChallengeContext({
|
|
350
|
+
capturedRequest,
|
|
351
|
+
challenge: parameters.challenge,
|
|
352
|
+
credential: parameters.credential,
|
|
353
|
+
error: parameters.error,
|
|
354
|
+
input,
|
|
355
|
+
method,
|
|
356
|
+
request: parameters.request,
|
|
357
|
+
}));
|
|
358
|
+
return response;
|
|
359
|
+
};
|
|
360
|
+
const emitPaymentFailed = async (parameters) => {
|
|
361
|
+
await events.emit('payment.failed', createPaymentFailedContext({
|
|
362
|
+
capturedRequest,
|
|
363
|
+
challenge: parameters.challenge,
|
|
364
|
+
credential: parameters.credential,
|
|
365
|
+
error: parameters.error,
|
|
366
|
+
input,
|
|
367
|
+
method,
|
|
368
|
+
request: parameters.request,
|
|
369
|
+
retryChallenge: parameters.retryChallenge,
|
|
370
|
+
submittedChallenge: parameters.submittedChallenge,
|
|
371
|
+
}));
|
|
372
|
+
};
|
|
200
373
|
const routeChallenge = await resolveRouteChallenge({
|
|
201
374
|
capturedRequest,
|
|
202
375
|
credential,
|
|
@@ -223,9 +396,19 @@ function createMethodFn(parameters) {
|
|
|
223
396
|
routeRequest: rest,
|
|
224
397
|
secretKey,
|
|
225
398
|
});
|
|
226
|
-
|
|
399
|
+
if (credential)
|
|
400
|
+
await emitPaymentFailed({
|
|
401
|
+
challenge,
|
|
402
|
+
credential,
|
|
403
|
+
error: e,
|
|
404
|
+
request: challenge.request,
|
|
405
|
+
retryChallenge: challenge,
|
|
406
|
+
submittedChallenge: credential.challenge,
|
|
407
|
+
});
|
|
408
|
+
const response = await emitChallenge({
|
|
227
409
|
challenge,
|
|
228
|
-
|
|
410
|
+
credential,
|
|
411
|
+
request: challenge.request,
|
|
229
412
|
error: e,
|
|
230
413
|
html: method.html,
|
|
231
414
|
});
|
|
@@ -233,14 +416,23 @@ function createMethodFn(parameters) {
|
|
|
233
416
|
});
|
|
234
417
|
if ('response' in routeChallenge)
|
|
235
418
|
return { challenge: routeChallenge.response, status: 402 };
|
|
236
|
-
const { challenge, request } = routeChallenge;
|
|
419
|
+
const { challenge, parsedRequest, request } = routeChallenge;
|
|
237
420
|
// Credential was provided but malformed
|
|
238
421
|
if (credentialError) {
|
|
239
422
|
const reason = getSafeCredentialReason(credentialError);
|
|
240
|
-
const
|
|
423
|
+
const error = new Errors.MalformedCredentialError(reason ? { reason } : {});
|
|
424
|
+
await emitPaymentFailed({
|
|
241
425
|
challenge,
|
|
242
|
-
|
|
243
|
-
error
|
|
426
|
+
credential: null,
|
|
427
|
+
error,
|
|
428
|
+
request: parsedRequest,
|
|
429
|
+
retryChallenge: challenge,
|
|
430
|
+
});
|
|
431
|
+
const response = await emitChallenge({
|
|
432
|
+
challenge,
|
|
433
|
+
credential: null,
|
|
434
|
+
request: parsedRequest,
|
|
435
|
+
error,
|
|
244
436
|
html: method.html,
|
|
245
437
|
});
|
|
246
438
|
return { challenge: response, status: 402 };
|
|
@@ -283,6 +475,14 @@ function createMethodFn(parameters) {
|
|
|
283
475
|
request: challenge.request,
|
|
284
476
|
});
|
|
285
477
|
if (authorized) {
|
|
478
|
+
await events.emit('payment.success', createPaymentSuccessContext({
|
|
479
|
+
capturedRequest,
|
|
480
|
+
challenge,
|
|
481
|
+
input,
|
|
482
|
+
method,
|
|
483
|
+
receipt: authorized.receipt,
|
|
484
|
+
request: parsedRequest,
|
|
485
|
+
}));
|
|
286
486
|
return success(authorized.receipt, {
|
|
287
487
|
managementResponse: authorized.response,
|
|
288
488
|
});
|
|
@@ -292,19 +492,28 @@ function createMethodFn(parameters) {
|
|
|
292
492
|
if (!(e instanceof Errors.PaymentError))
|
|
293
493
|
console.error('mppx: internal authorization error', e);
|
|
294
494
|
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
|
|
295
|
-
|
|
495
|
+
await emitPaymentFailed({
|
|
296
496
|
challenge,
|
|
297
|
-
|
|
497
|
+
credential: null,
|
|
498
|
+
error,
|
|
499
|
+
request: parsedRequest,
|
|
500
|
+
retryChallenge: challenge,
|
|
501
|
+
});
|
|
502
|
+
const response = await emitChallenge({
|
|
503
|
+
challenge,
|
|
504
|
+
request: parsedRequest,
|
|
298
505
|
error,
|
|
299
506
|
html: method.html,
|
|
300
507
|
});
|
|
301
508
|
return { challenge: response, status: 402 };
|
|
302
509
|
}
|
|
303
510
|
}
|
|
304
|
-
const
|
|
511
|
+
const error = new Errors.PaymentRequiredError({ description });
|
|
512
|
+
const response = await emitChallenge({
|
|
305
513
|
challenge,
|
|
306
|
-
|
|
307
|
-
|
|
514
|
+
credential: null,
|
|
515
|
+
request: parsedRequest,
|
|
516
|
+
error,
|
|
308
517
|
html: method.html,
|
|
309
518
|
});
|
|
310
519
|
return { challenge: response, status: 402 };
|
|
@@ -321,13 +530,23 @@ function createMethodFn(parameters) {
|
|
|
321
530
|
// (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
|
|
322
531
|
// No database lookup is needed; the HMAC is stateless verification.
|
|
323
532
|
if (!Challenge.verify(credential.challenge, { secretKey })) {
|
|
324
|
-
const
|
|
533
|
+
const error = new Errors.InvalidChallengeError({
|
|
534
|
+
id: credential.challenge.id,
|
|
535
|
+
reason: 'challenge was not issued by this server',
|
|
536
|
+
});
|
|
537
|
+
await emitPaymentFailed({
|
|
325
538
|
challenge,
|
|
326
|
-
|
|
327
|
-
error
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
539
|
+
credential,
|
|
540
|
+
error,
|
|
541
|
+
request: parsedRequest,
|
|
542
|
+
retryChallenge: challenge,
|
|
543
|
+
submittedChallenge: credential.challenge,
|
|
544
|
+
});
|
|
545
|
+
const response = await emitChallenge({
|
|
546
|
+
challenge,
|
|
547
|
+
credential,
|
|
548
|
+
request: parsedRequest,
|
|
549
|
+
error,
|
|
331
550
|
html: method.html,
|
|
332
551
|
});
|
|
333
552
|
return { challenge: response, status: 402 };
|
|
@@ -357,13 +576,23 @@ function createMethodFn(parameters) {
|
|
|
357
576
|
{
|
|
358
577
|
const mismatch = getChallengeBindingMismatch(challenge, credential.challenge, stableBinding);
|
|
359
578
|
if (mismatch) {
|
|
360
|
-
const
|
|
579
|
+
const error = new Errors.InvalidChallengeError({
|
|
580
|
+
id: credential.challenge.id,
|
|
581
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
582
|
+
});
|
|
583
|
+
await emitPaymentFailed({
|
|
361
584
|
challenge,
|
|
362
|
-
|
|
363
|
-
error
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
585
|
+
credential,
|
|
586
|
+
error,
|
|
587
|
+
request: parsedRequest,
|
|
588
|
+
retryChallenge: challenge,
|
|
589
|
+
submittedChallenge: credential.challenge,
|
|
590
|
+
});
|
|
591
|
+
const response = await emitChallenge({
|
|
592
|
+
challenge,
|
|
593
|
+
credential,
|
|
594
|
+
request: parsedRequest,
|
|
595
|
+
error,
|
|
367
596
|
html: method.html,
|
|
368
597
|
});
|
|
369
598
|
return { challenge: response, status: 402 };
|
|
@@ -374,44 +603,73 @@ function createMethodFn(parameters) {
|
|
|
374
603
|
Expires.assert(credential.challenge.expires, credential.challenge.id);
|
|
375
604
|
}
|
|
376
605
|
catch (error) {
|
|
377
|
-
|
|
606
|
+
await emitPaymentFailed({
|
|
378
607
|
challenge,
|
|
379
|
-
|
|
608
|
+
credential,
|
|
609
|
+
error: error,
|
|
610
|
+
request: parsedRequest,
|
|
611
|
+
retryChallenge: challenge,
|
|
612
|
+
submittedChallenge: credential.challenge,
|
|
613
|
+
});
|
|
614
|
+
const response = await emitChallenge({
|
|
615
|
+
challenge,
|
|
616
|
+
credential,
|
|
617
|
+
request: parsedRequest,
|
|
380
618
|
error: error,
|
|
381
619
|
});
|
|
382
620
|
return { challenge: response, status: 402 };
|
|
383
621
|
}
|
|
384
622
|
// Validate payload structure against method schema
|
|
623
|
+
let parsedCredential;
|
|
385
624
|
try {
|
|
386
|
-
method.schema.credential.payload.parse(credential.payload);
|
|
625
|
+
parsedCredential = withParsedCredentialPayload(credential, method.schema.credential.payload.parse(credential.payload));
|
|
387
626
|
}
|
|
388
627
|
catch {
|
|
389
|
-
const
|
|
628
|
+
const error = new Errors.InvalidPayloadError();
|
|
629
|
+
await emitPaymentFailed({
|
|
390
630
|
challenge,
|
|
391
|
-
|
|
392
|
-
error
|
|
631
|
+
credential,
|
|
632
|
+
error,
|
|
633
|
+
request: parsedRequest,
|
|
634
|
+
retryChallenge: challenge,
|
|
635
|
+
submittedChallenge: credential.challenge,
|
|
636
|
+
});
|
|
637
|
+
const response = await emitChallenge({
|
|
638
|
+
challenge,
|
|
639
|
+
credential,
|
|
640
|
+
request: parsedRequest,
|
|
641
|
+
error,
|
|
393
642
|
});
|
|
394
643
|
return { challenge: response, status: 402 };
|
|
395
644
|
}
|
|
396
645
|
const envelope = Object.freeze({
|
|
397
646
|
capturedRequest,
|
|
398
647
|
challenge: credential.challenge,
|
|
399
|
-
credential,
|
|
400
|
-
request,
|
|
648
|
+
credential: parsedCredential,
|
|
649
|
+
request: parsedRequest,
|
|
401
650
|
});
|
|
402
651
|
// User-provided verification (e.g., check signature, submit tx, verify payment).
|
|
403
652
|
// If verification fails, re-issue the challenge so the client can retry.
|
|
404
653
|
let receiptData;
|
|
405
654
|
try {
|
|
406
|
-
receiptData = await verify({ credential, envelope, request });
|
|
655
|
+
receiptData = await verify({ credential: parsedCredential, envelope, request });
|
|
407
656
|
}
|
|
408
657
|
catch (e) {
|
|
409
658
|
if (!(e instanceof Errors.PaymentError))
|
|
410
659
|
console.error('mppx: internal verification error', e);
|
|
411
660
|
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
|
|
412
|
-
|
|
661
|
+
await emitPaymentFailed({
|
|
413
662
|
challenge,
|
|
414
|
-
|
|
663
|
+
credential: parsedCredential,
|
|
664
|
+
error,
|
|
665
|
+
request: parsedRequest,
|
|
666
|
+
retryChallenge: challenge,
|
|
667
|
+
submittedChallenge: credential.challenge,
|
|
668
|
+
});
|
|
669
|
+
const response = await emitChallenge({
|
|
670
|
+
challenge,
|
|
671
|
+
credential: parsedCredential,
|
|
672
|
+
request: parsedRequest,
|
|
415
673
|
error,
|
|
416
674
|
});
|
|
417
675
|
return { challenge: response, status: 402 };
|
|
@@ -422,11 +680,27 @@ function createMethodFn(parameters) {
|
|
|
422
680
|
// return the management response directly. If undefined, `withReceipt()`
|
|
423
681
|
// expects the caller to pass the user handler's response instead.
|
|
424
682
|
const managementResponse = respond
|
|
425
|
-
? await respond({
|
|
683
|
+
? await respond({
|
|
684
|
+
credential: parsedCredential,
|
|
685
|
+
envelope,
|
|
686
|
+
input,
|
|
687
|
+
receipt: receiptData,
|
|
688
|
+
request,
|
|
689
|
+
})
|
|
426
690
|
: undefined;
|
|
691
|
+
await events.emit('payment.success', createPaymentSuccessContext({
|
|
692
|
+
capturedRequest,
|
|
693
|
+
challenge: credential.challenge,
|
|
694
|
+
credential: parsedCredential,
|
|
695
|
+
envelope,
|
|
696
|
+
input,
|
|
697
|
+
method,
|
|
698
|
+
receipt: receiptData,
|
|
699
|
+
request: parsedRequest,
|
|
700
|
+
}));
|
|
427
701
|
return success(receiptData, {
|
|
428
702
|
challengeId: credential.challenge.id,
|
|
429
|
-
credentialForReceipt:
|
|
703
|
+
credentialForReceipt: parsedCredential,
|
|
430
704
|
envelopeForReceipt: envelope,
|
|
431
705
|
managementResponse,
|
|
432
706
|
});
|
|
@@ -470,6 +744,197 @@ function createChallengeFn(parameters) {
|
|
|
470
744
|
}).then((resolved) => resolved.challenge);
|
|
471
745
|
};
|
|
472
746
|
}
|
|
747
|
+
function createServerEventDispatcher() {
|
|
748
|
+
const handlers = {
|
|
749
|
+
'*': new Set(),
|
|
750
|
+
'challenge.created': new Set(),
|
|
751
|
+
'payment.failed': new Set(),
|
|
752
|
+
'payment.success': new Set(),
|
|
753
|
+
};
|
|
754
|
+
const on = (name, handler) => {
|
|
755
|
+
switch (name) {
|
|
756
|
+
case '*':
|
|
757
|
+
case 'challenge.created':
|
|
758
|
+
case 'payment.failed':
|
|
759
|
+
case 'payment.success':
|
|
760
|
+
handlers[name].add(handler);
|
|
761
|
+
return () => handlers[name].delete(handler);
|
|
762
|
+
default:
|
|
763
|
+
throw new Error(`Unknown server event "${String(name)}".`);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
async emit(name, context) {
|
|
768
|
+
await emitServerEventHandlers(handlers[name], context);
|
|
769
|
+
await emitServerEventHandlers(handlers['*'], toServerEventEnvelope(name, context));
|
|
770
|
+
},
|
|
771
|
+
on,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function toServerEventEnvelope(name, payload) {
|
|
775
|
+
return Object.freeze({ name, payload });
|
|
776
|
+
}
|
|
777
|
+
async function emitServerEventHandlers(handlers, context) {
|
|
778
|
+
for (const handler of handlers) {
|
|
779
|
+
try {
|
|
780
|
+
await handler(context);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
// Errors are isolated, but handlers are still awaited inline.
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function assertNoReservedMppxKeys(methods) {
|
|
788
|
+
for (const method of methods) {
|
|
789
|
+
if (reservedMppxKeys.has(method.name))
|
|
790
|
+
throw new Error(`Method name "${method.name}" conflicts with a reserved Mppx property.`);
|
|
791
|
+
if (reservedMppxKeys.has(method.intent))
|
|
792
|
+
throw new Error(`Method intent "${method.intent}" conflicts with a reserved Mppx property.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function createChallengeContext(parameters) {
|
|
796
|
+
return Object.freeze({
|
|
797
|
+
...(parameters.capturedRequest
|
|
798
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
799
|
+
: {}),
|
|
800
|
+
challenge: snapshotValue(parameters.challenge),
|
|
801
|
+
credential: parameters.credential === undefined
|
|
802
|
+
? undefined
|
|
803
|
+
: snapshotNullableValue(parameters.credential),
|
|
804
|
+
error: snapshotError(parameters.error),
|
|
805
|
+
...snapshotInputProperty(parameters.input),
|
|
806
|
+
method: snapshotMethod(parameters.method),
|
|
807
|
+
request: snapshotValue(parameters.request),
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
function createPaymentFailedContext(parameters) {
|
|
811
|
+
return Object.freeze({
|
|
812
|
+
...(parameters.capturedRequest
|
|
813
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
814
|
+
: {}),
|
|
815
|
+
challenge: snapshotValue(parameters.challenge),
|
|
816
|
+
credential: snapshotNullableValue(parameters.credential),
|
|
817
|
+
error: snapshotError(parameters.error),
|
|
818
|
+
...snapshotInputProperty(parameters.input),
|
|
819
|
+
method: snapshotMethod(parameters.method),
|
|
820
|
+
request: snapshotValue(parameters.request),
|
|
821
|
+
...(parameters.retryChallenge
|
|
822
|
+
? { retryChallenge: snapshotValue(parameters.retryChallenge) }
|
|
823
|
+
: {}),
|
|
824
|
+
...(parameters.submittedChallenge
|
|
825
|
+
? { submittedChallenge: snapshotValue(parameters.submittedChallenge) }
|
|
826
|
+
: {}),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function createPaymentSuccessContext(parameters) {
|
|
830
|
+
return Object.freeze({
|
|
831
|
+
...(parameters.capturedRequest
|
|
832
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
833
|
+
: {}),
|
|
834
|
+
challenge: snapshotValue(parameters.challenge),
|
|
835
|
+
...(parameters.credential ? { credential: snapshotValue(parameters.credential) } : {}),
|
|
836
|
+
...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
|
|
837
|
+
...snapshotInputProperty(parameters.input),
|
|
838
|
+
method: snapshotMethod(parameters.method),
|
|
839
|
+
receipt: snapshotValue(parameters.receipt),
|
|
840
|
+
request: snapshotValue(parameters.request),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function snapshotMethod(method) {
|
|
844
|
+
return Object.freeze({
|
|
845
|
+
intent: method.intent,
|
|
846
|
+
name: method.name,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function snapshotError(error) {
|
|
850
|
+
if (!error)
|
|
851
|
+
return error;
|
|
852
|
+
const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error);
|
|
853
|
+
Object.defineProperties(snapshot, {
|
|
854
|
+
message: { value: error.message, enumerable: false },
|
|
855
|
+
name: { value: error.name, enumerable: false },
|
|
856
|
+
});
|
|
857
|
+
return Object.freeze(snapshot);
|
|
858
|
+
}
|
|
859
|
+
function snapshotVerifiedEnvelope(envelope) {
|
|
860
|
+
return Object.freeze({
|
|
861
|
+
capturedRequest: snapshotCapturedRequest(envelope.capturedRequest),
|
|
862
|
+
challenge: snapshotValue(envelope.challenge),
|
|
863
|
+
credential: snapshotValue(envelope.credential),
|
|
864
|
+
request: snapshotValue(envelope.request),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
function snapshotCapturedRequest(capturedRequest) {
|
|
868
|
+
return Object.freeze({
|
|
869
|
+
headers: new Headers(capturedRequest.headers),
|
|
870
|
+
hasBody: capturedRequest.hasBody,
|
|
871
|
+
method: capturedRequest.method,
|
|
872
|
+
url: new URL(capturedRequest.url),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
function snapshotNullableValue(value) {
|
|
876
|
+
if (value === null)
|
|
877
|
+
return null;
|
|
878
|
+
return snapshotValue(value);
|
|
879
|
+
}
|
|
880
|
+
function snapshotValue(value) {
|
|
881
|
+
try {
|
|
882
|
+
return freezeSnapshot(structuredClone(value));
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return freezeSnapshot(value);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function snapshotInputProperty(input) {
|
|
889
|
+
if (input === undefined)
|
|
890
|
+
return {};
|
|
891
|
+
const snapshot = snapshotTransportInput(input);
|
|
892
|
+
return snapshot === undefined ? {} : { input: snapshot };
|
|
893
|
+
}
|
|
894
|
+
function snapshotTransportInput(input) {
|
|
895
|
+
if (input instanceof globalThis.Request) {
|
|
896
|
+
try {
|
|
897
|
+
return new globalThis.Request(input.url, {
|
|
898
|
+
headers: new Headers(input.headers),
|
|
899
|
+
method: input.method,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return undefined;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
return freezeSnapshot(structuredClone(input));
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
warnOnce(Warnings.transportInputSnapshot, 'Could not clone server event input; omitting `context.input`. Use `capturedRequest` for request correlation.');
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Event payloads are cloned before listeners see them; shallow freezing keeps
|
|
915
|
+
// the guard simple while preventing top-level mutation of receipts/challenges.
|
|
916
|
+
function freezeSnapshot(value) {
|
|
917
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
918
|
+
return value;
|
|
919
|
+
Object.freeze(value);
|
|
920
|
+
return value;
|
|
921
|
+
}
|
|
922
|
+
function isServiceWorkerRequest(input) {
|
|
923
|
+
return (input instanceof globalThis.Request &&
|
|
924
|
+
new URL(input.url).searchParams.has(Html.params.serviceWorker));
|
|
925
|
+
}
|
|
926
|
+
function createServiceWorkerResponse() {
|
|
927
|
+
return new Response(serviceWorker, {
|
|
928
|
+
status: 200,
|
|
929
|
+
headers: {
|
|
930
|
+
'Content-Type': 'application/javascript',
|
|
931
|
+
'Cache-Control': 'no-store',
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
function isIssuedChallengeResponse(response) {
|
|
936
|
+
return !(response instanceof globalThis.Response) || response.status === 402;
|
|
937
|
+
}
|
|
473
938
|
function getSafeCredentialReason(error) {
|
|
474
939
|
if (error instanceof Credential.InvalidCredentialEncodingError)
|
|
475
940
|
return error.message;
|
|
@@ -482,6 +947,7 @@ function getSafeCredentialReason(error) {
|
|
|
482
947
|
const defaultRealm = 'MPP Payment';
|
|
483
948
|
const Warnings = {
|
|
484
949
|
realmFallback: 'realm-fallback',
|
|
950
|
+
transportInputSnapshot: 'transport-input-snapshot',
|
|
485
951
|
};
|
|
486
952
|
const missingReceiptResponseErrorName = 'MissingReceiptResponseError';
|
|
487
953
|
const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument';
|
|
@@ -541,15 +1007,17 @@ async function resolveRouteChallenge(parameters) {
|
|
|
541
1007
|
(parameters.capturedRequest
|
|
542
1008
|
? resolveRealmFromCapturedRequest(parameters.capturedRequest)
|
|
543
1009
|
: defaultRealm);
|
|
1010
|
+
const challenge = Challenge.fromMethod(parameters.method, {
|
|
1011
|
+
description: parameters.description,
|
|
1012
|
+
expires: parameters.expires,
|
|
1013
|
+
meta: parameters.meta,
|
|
1014
|
+
realm: effectiveRealm,
|
|
1015
|
+
request: request,
|
|
1016
|
+
secretKey: parameters.secretKey,
|
|
1017
|
+
});
|
|
544
1018
|
return {
|
|
545
|
-
challenge
|
|
546
|
-
|
|
547
|
-
expires: parameters.expires,
|
|
548
|
-
meta: parameters.meta,
|
|
549
|
-
realm: effectiveRealm,
|
|
550
|
-
request: request,
|
|
551
|
-
secretKey: parameters.secretKey,
|
|
552
|
-
}),
|
|
1019
|
+
challenge,
|
|
1020
|
+
parsedRequest: challenge.request,
|
|
553
1021
|
request,
|
|
554
1022
|
};
|
|
555
1023
|
}
|
|
@@ -575,7 +1043,7 @@ function createFallbackChallenge(parameters) {
|
|
|
575
1043
|
*
|
|
576
1044
|
* Note: Object.freeze is shallow — it prevents reassigning top-level properties
|
|
577
1045
|
* but does not deep-freeze mutable class instances like Headers or URL. This is
|
|
578
|
-
* an accidental-mutation guard for trusted server
|
|
1046
|
+
* an accidental-mutation guard for trusted server events, not a security boundary.
|
|
579
1047
|
*/
|
|
580
1048
|
async function captureRequest(transport, input) {
|
|
581
1049
|
const capturedRequest = transport.captureRequest
|
|
@@ -707,6 +1175,12 @@ function hydrateCredentialMeta(credential) {
|
|
|
707
1175
|
},
|
|
708
1176
|
};
|
|
709
1177
|
}
|
|
1178
|
+
function withParsedCredentialPayload(credential, payload) {
|
|
1179
|
+
return {
|
|
1180
|
+
...credential,
|
|
1181
|
+
payload,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
710
1184
|
export function compose(...args) {
|
|
711
1185
|
// Extract optional html options from last argument
|
|
712
1186
|
const last = args[args.length - 1];
|