mppx 0.5.1 → 0.5.4
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 +20 -0
- package/dist/Credential.d.ts +12 -0
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +22 -4
- package/dist/Credential.js.map +1 -1
- package/dist/Method.d.ts +4 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +2 -1
- package/dist/Method.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +11 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -3
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +10 -5
- package/dist/cli/utils.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +52 -8
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +7 -3
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +90 -71
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +5 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +71 -7
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/config.d.ts +144 -0
- package/dist/server/internal/html/config.d.ts.map +1 -0
- package/dist/server/internal/html/config.js +303 -0
- package/dist/server/internal/html/config.js.map +1 -0
- package/dist/server/internal/html/serviceWorker.gen.d.ts +2 -0
- package/dist/server/internal/html/serviceWorker.gen.d.ts.map +1 -0
- package/dist/server/internal/html/serviceWorker.gen.js +3 -0
- package/dist/server/internal/html/serviceWorker.gen.js.map +1 -0
- package/dist/stripe/internal/types.d.ts +6 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts +30 -16
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +35 -6
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/internal/html/types.d.ts +2 -0
- package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
- package/dist/stripe/server/internal/html/types.js +2 -0
- package/dist/stripe/server/internal/html/types.js.map +1 -0
- package/dist/stripe/server/internal/html.gen.d.ts +2 -0
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -0
- package/dist/stripe/server/internal/html.gen.js +3 -0
- package/dist/stripe/server/internal/html.gen.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +33 -26
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +46 -11
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +3 -2
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +2 -0
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -0
- package/dist/tempo/server/internal/html.gen.js +3 -0
- package/dist/tempo/server/internal/html.gen.js.map +1 -0
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +45 -58
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/Credential.ts +28 -4
- package/src/Method.ts +6 -1
- package/src/cli/cli.ts +11 -8
- package/src/cli/plugins/tempo.ts +3 -2
- package/src/cli/utils.test.ts +64 -0
- package/src/cli/utils.ts +10 -4
- package/src/env.d.ts +1 -0
- package/src/mcp-sdk/server/Transport.test.ts +6 -0
- package/src/proxy/Proxy.test.ts +188 -1
- package/src/proxy/Proxy.ts +58 -9
- package/src/proxy/internal/Route.test.ts +9 -0
- package/src/proxy/internal/Route.ts +5 -2
- package/src/server/Mppx.test.ts +171 -18
- package/src/server/Mppx.ts +120 -79
- package/src/server/Transport.test.ts +232 -2
- package/src/server/Transport.ts +84 -7
- package/src/server/internal/html/config.ts +414 -0
- package/src/server/internal/html/serviceWorker.client.ts +28 -0
- package/src/server/internal/html/serviceWorker.gen.ts +2 -0
- package/src/server/internal/html/serviceWorker.ts +27 -0
- package/src/server/internal/html/tsconfig.worker.client.json +8 -0
- package/src/server/internal/html/tsconfig.worker.json +8 -0
- package/src/stripe/internal/types.ts +20 -0
- package/src/stripe/server/Charge.ts +62 -6
- package/src/stripe/server/internal/html/main.ts +174 -0
- package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +21 -0
- package/src/stripe/server/internal/html/package.json +9 -0
- package/src/stripe/server/internal/html/stripe-js-pure.d.ts +7 -0
- package/src/stripe/server/internal/html/tsconfig.json +8 -0
- package/src/stripe/server/internal/html/types.ts +5 -0
- package/src/stripe/server/internal/html.gen.ts +2 -0
- package/src/tempo/server/Charge.ts +64 -10
- package/src/tempo/server/Session.ts +3 -2
- package/src/tempo/server/internal/html/main.ts +111 -0
- package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +21 -0
- package/src/tempo/server/internal/html/package.json +10 -0
- package/src/tempo/server/internal/html/tsconfig.json +8 -0
- package/src/tempo/server/internal/html.gen.ts +2 -0
- package/src/tempo/server/internal/transport.test.ts +37 -31
- package/src/tempo/server/internal/transport.ts +44 -58
- package/src/tsconfig.json +1 -1
package/src/server/Mppx.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
3
|
|
|
3
4
|
import * as Challenge from '../Challenge.js'
|
|
4
5
|
import * as Credential from '../Credential.js'
|
|
@@ -300,10 +301,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
300
301
|
|
|
301
302
|
// Credential was provided but malformed
|
|
302
303
|
if (credentialError) {
|
|
304
|
+
const reason = getSafeCredentialReason(credentialError)
|
|
303
305
|
const response = await transport.respondChallenge({
|
|
304
306
|
challenge,
|
|
305
307
|
input,
|
|
306
|
-
error: new Errors.MalformedCredentialError({ reason:
|
|
308
|
+
error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
|
|
309
|
+
html: method.html,
|
|
307
310
|
})
|
|
308
311
|
return { challenge: response, status: 402 }
|
|
309
312
|
}
|
|
@@ -314,6 +317,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
314
317
|
challenge,
|
|
315
318
|
input,
|
|
316
319
|
error: new Errors.PaymentRequiredError({ description }),
|
|
320
|
+
html: method.html,
|
|
317
321
|
})
|
|
318
322
|
return { challenge: response, status: 402 }
|
|
319
323
|
}
|
|
@@ -328,6 +332,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
328
332
|
id: credential.challenge.id,
|
|
329
333
|
reason: 'challenge was not issued by this server',
|
|
330
334
|
}),
|
|
335
|
+
html: method.html,
|
|
331
336
|
})
|
|
332
337
|
return { challenge: response, status: 402 }
|
|
333
338
|
}
|
|
@@ -339,13 +344,6 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
339
344
|
// Note: we compare specific payment parameters rather than the full
|
|
340
345
|
// request because the `request` hook may produce credential-dependent
|
|
341
346
|
// output (e.g. `feePayer` differs between 402 and credential calls).
|
|
342
|
-
//
|
|
343
|
-
// Skip this check for topUp and voucher actions: the route's
|
|
344
|
-
// `request` hook may produce a different amount because these
|
|
345
|
-
// requests carry no application body (e.g. no model field for
|
|
346
|
-
// dynamic pricing). The credential echoes a challenge obtained
|
|
347
|
-
// from the original request which had the correct amount; the
|
|
348
|
-
// on-chain voucher signature is the real validation.
|
|
349
347
|
{
|
|
350
348
|
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
351
349
|
if (credential.challenge[field] !== challenge[field]) {
|
|
@@ -356,65 +354,26 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
356
354
|
id: credential.challenge.id,
|
|
357
355
|
reason: `credential ${field} does not match this route's requirements`,
|
|
358
356
|
}),
|
|
357
|
+
html: method.html,
|
|
359
358
|
})
|
|
360
359
|
return { challenge: response, status: 402 }
|
|
361
360
|
}
|
|
362
361
|
}
|
|
363
362
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
379
|
-
const routeVal = routeReq[field] ?? routeDetails[field]
|
|
380
|
-
if (
|
|
381
|
-
routeVal !== undefined &&
|
|
382
|
-
String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
|
|
383
|
-
) {
|
|
384
|
-
const response = await transport.respondChallenge({
|
|
385
|
-
challenge,
|
|
386
|
-
input,
|
|
387
|
-
error: new Errors.InvalidChallengeError({
|
|
388
|
-
id: credential.challenge.id,
|
|
389
|
-
reason: `credential ${field} does not match this route's requirements`,
|
|
390
|
-
}),
|
|
391
|
-
})
|
|
392
|
-
return { challenge: response, status: 402 }
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Compare payment-relevant methodDetails fields (memo, splits).
|
|
397
|
-
// These are excluded from the top-level field check above but
|
|
398
|
-
// affect verification semantics — a credential issued for a
|
|
399
|
-
// no-splits route must not be accepted on a splits route.
|
|
400
|
-
for (const field of ['memo', 'splits'] as const) {
|
|
401
|
-
const routeVal = routeDetails[field]
|
|
402
|
-
const echoedVal = echoedDetails[field]
|
|
403
|
-
if (
|
|
404
|
-
routeVal !== undefined &&
|
|
405
|
-
JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
|
|
406
|
-
) {
|
|
407
|
-
const response = await transport.respondChallenge({
|
|
408
|
-
challenge,
|
|
409
|
-
input,
|
|
410
|
-
error: new Errors.InvalidChallengeError({
|
|
411
|
-
id: credential.challenge.id,
|
|
412
|
-
reason: `credential ${field} does not match this route's requirements`,
|
|
413
|
-
}),
|
|
414
|
-
})
|
|
415
|
-
return { challenge: response, status: 402 }
|
|
416
|
-
}
|
|
417
|
-
}
|
|
363
|
+
const mismatch = getRequestBindingMismatch(
|
|
364
|
+
challenge.request as Record<string, unknown>,
|
|
365
|
+
credential.challenge.request as Record<string, unknown>,
|
|
366
|
+
)
|
|
367
|
+
if (mismatch) {
|
|
368
|
+
const response = await transport.respondChallenge({
|
|
369
|
+
challenge,
|
|
370
|
+
input,
|
|
371
|
+
error: new Errors.InvalidChallengeError({
|
|
372
|
+
id: credential.challenge.id,
|
|
373
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
374
|
+
}),
|
|
375
|
+
})
|
|
376
|
+
return { challenge: response, status: 402 }
|
|
418
377
|
}
|
|
419
378
|
}
|
|
420
379
|
|
|
@@ -432,11 +391,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
432
391
|
// Validate payload structure against method schema
|
|
433
392
|
try {
|
|
434
393
|
method.schema.credential.payload.parse(credential.payload)
|
|
435
|
-
} catch
|
|
394
|
+
} catch {
|
|
436
395
|
const response = await transport.respondChallenge({
|
|
437
396
|
challenge,
|
|
438
397
|
input,
|
|
439
|
-
error: new Errors.InvalidPayloadError(
|
|
398
|
+
error: new Errors.InvalidPayloadError(),
|
|
440
399
|
})
|
|
441
400
|
return { challenge: response, status: 402 }
|
|
442
401
|
}
|
|
@@ -447,10 +406,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
447
406
|
try {
|
|
448
407
|
receiptData = await verify({ credential, request } as never)
|
|
449
408
|
} catch (e) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
: new Errors.VerificationFailedError({ reason: (e as Error).message })
|
|
409
|
+
if (!(e instanceof Errors.PaymentError))
|
|
410
|
+
console.error('mppx: internal verification error', e)
|
|
411
|
+
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
|
|
454
412
|
const response = await transport.respondChallenge({
|
|
455
413
|
challenge,
|
|
456
414
|
input,
|
|
@@ -473,6 +431,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
473
431
|
withReceipt<response>(response?: response) {
|
|
474
432
|
if (managementResponse) {
|
|
475
433
|
return transport.respondReceipt({
|
|
434
|
+
credential,
|
|
435
|
+
input,
|
|
476
436
|
receipt: receiptData,
|
|
477
437
|
response: managementResponse as never,
|
|
478
438
|
challengeId: credential.challenge.id,
|
|
@@ -480,6 +440,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
480
440
|
}
|
|
481
441
|
if (!response) throw new Error('withReceipt() requires a response argument')
|
|
482
442
|
return transport.respondReceipt({
|
|
443
|
+
credential,
|
|
444
|
+
input,
|
|
483
445
|
receipt: receiptData,
|
|
484
446
|
response: response as never,
|
|
485
447
|
challengeId: credential.challenge.id,
|
|
@@ -501,6 +463,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
501
463
|
}
|
|
502
464
|
}
|
|
503
465
|
|
|
466
|
+
function getSafeCredentialReason(error: unknown): string | undefined {
|
|
467
|
+
if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
|
|
468
|
+
if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
|
|
469
|
+
if (error instanceof Credential.MissingPaymentSchemeError) return error.message
|
|
470
|
+
return undefined
|
|
471
|
+
}
|
|
472
|
+
|
|
504
473
|
declare namespace createMethodFn {
|
|
505
474
|
type Parameters<
|
|
506
475
|
method extends Method.Method = Method.Method,
|
|
@@ -552,6 +521,88 @@ function resolveRealmFromRequest(input: unknown): string {
|
|
|
552
521
|
return defaultRealm
|
|
553
522
|
}
|
|
554
523
|
|
|
524
|
+
type RequestBindingField = 'amount' | 'currency' | 'recipient' | 'chainId' | 'memo' | 'splits'
|
|
525
|
+
|
|
526
|
+
const requestBindingFields = [
|
|
527
|
+
'amount',
|
|
528
|
+
'currency',
|
|
529
|
+
'recipient',
|
|
530
|
+
'chainId',
|
|
531
|
+
'memo',
|
|
532
|
+
'splits',
|
|
533
|
+
] as const satisfies readonly RequestBindingField[]
|
|
534
|
+
|
|
535
|
+
type RequestBinding = Partial<Record<RequestBindingField, unknown>>
|
|
536
|
+
|
|
537
|
+
function getRequestBindingMismatch(
|
|
538
|
+
expectedRequest: Record<string, unknown>,
|
|
539
|
+
actualRequest: Record<string, unknown>,
|
|
540
|
+
): RequestBindingField | undefined {
|
|
541
|
+
const expected = getRequestBinding(expectedRequest)
|
|
542
|
+
const actual = getRequestBinding(actualRequest)
|
|
543
|
+
|
|
544
|
+
return requestBindingFields.find(
|
|
545
|
+
(field) => !requestBindingValuesMatch(field, expected[field], actual[field]),
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function getRequestBinding(request: Record<string, unknown>): RequestBinding {
|
|
550
|
+
const methodDetails = (request.methodDetails ?? {}) as Record<string, unknown>
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
amount: request.amount ?? methodDetails.amount,
|
|
554
|
+
currency: request.currency ?? methodDetails.currency,
|
|
555
|
+
recipient: request.recipient ?? methodDetails.recipient,
|
|
556
|
+
chainId: request.chainId ?? methodDetails.chainId,
|
|
557
|
+
memo: methodDetails.memo,
|
|
558
|
+
splits: methodDetails.splits,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function requestBindingValuesMatch(
|
|
563
|
+
field: RequestBindingField,
|
|
564
|
+
expected: unknown,
|
|
565
|
+
actual: unknown,
|
|
566
|
+
): boolean {
|
|
567
|
+
return isDeepStrictEqual(
|
|
568
|
+
normalizeRequestBindingValue(field, expected),
|
|
569
|
+
normalizeRequestBindingValue(field, actual),
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeRequestBindingValue(field: RequestBindingField, value: unknown): unknown {
|
|
574
|
+
switch (field) {
|
|
575
|
+
case 'memo':
|
|
576
|
+
return normalizeHex(value)
|
|
577
|
+
case 'splits':
|
|
578
|
+
return normalizeComparable(value)
|
|
579
|
+
default:
|
|
580
|
+
return normalizeScalar(value)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function normalizeScalar(value: unknown): string | undefined {
|
|
585
|
+
return value === undefined ? undefined : String(value)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeHex(value: unknown): unknown {
|
|
589
|
+
return typeof value === 'string' && value.startsWith('0x') ? value.toLowerCase() : value
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function normalizeComparable(value: unknown): unknown {
|
|
593
|
+
if (Array.isArray(value)) return value.map(normalizeComparable)
|
|
594
|
+
|
|
595
|
+
if (value && typeof value === 'object') {
|
|
596
|
+
return Object.fromEntries(
|
|
597
|
+
Object.entries(value as Record<string, unknown>)
|
|
598
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
599
|
+
.map(([key, nested]) => [key, normalizeComparable(nested)]),
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return normalizeHex(value)
|
|
604
|
+
}
|
|
605
|
+
|
|
555
606
|
export type MethodFn<
|
|
556
607
|
method extends Method.Method,
|
|
557
608
|
transport extends Transport.AnyTransport,
|
|
@@ -668,7 +719,6 @@ export function compose(
|
|
|
668
719
|
if (credential) {
|
|
669
720
|
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
670
721
|
const credReq = credential.challenge.request as Record<string, unknown>
|
|
671
|
-
const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
|
|
672
722
|
|
|
673
723
|
// Filter by name+intent, then narrow by comparing stable request fields
|
|
674
724
|
// from the echoed challenge against each handler's canonical request.
|
|
@@ -680,16 +730,7 @@ export function compose(
|
|
|
680
730
|
if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
|
|
681
731
|
const canonical = meta._canonicalRequest
|
|
682
732
|
if (!canonical) return true
|
|
683
|
-
|
|
684
|
-
for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
|
|
685
|
-
const canonicalVal = canonical[field] ?? canonicalDetails[field]
|
|
686
|
-
if (
|
|
687
|
-
canonicalVal !== undefined &&
|
|
688
|
-
String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
|
|
689
|
-
)
|
|
690
|
-
return false
|
|
691
|
-
}
|
|
692
|
-
return true
|
|
733
|
+
return !getRequestBindingMismatch(canonical, credReq)
|
|
693
734
|
})
|
|
694
735
|
|
|
695
736
|
const match =
|
|
@@ -101,6 +101,222 @@ describe('http', () => {
|
|
|
101
101
|
})
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
describe('respondChallenge html', () => {
|
|
105
|
+
const htmlOptions = {
|
|
106
|
+
config: { foo: 'bar' },
|
|
107
|
+
content: '<script src="/pay.js"></script>',
|
|
108
|
+
formatAmount: () => '$10.00',
|
|
109
|
+
text: undefined,
|
|
110
|
+
theme: undefined,
|
|
111
|
+
} satisfies Parameters<Transport.Http['respondChallenge']>[0]['html']
|
|
112
|
+
|
|
113
|
+
test('returns html when Accept includes text/html', async () => {
|
|
114
|
+
const transport = Transport.http()
|
|
115
|
+
const request = new Request('https://example.com', {
|
|
116
|
+
headers: { Accept: 'text/html' },
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const response = await transport.respondChallenge({
|
|
120
|
+
challenge,
|
|
121
|
+
input: request,
|
|
122
|
+
html: htmlOptions,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(402)
|
|
126
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
|
127
|
+
expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
|
|
128
|
+
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
|
129
|
+
|
|
130
|
+
const body = await response.text()
|
|
131
|
+
expect(body).toContain('<!doctype html>')
|
|
132
|
+
expect(body).toContain('<title>Payment Required</title>')
|
|
133
|
+
expect(body).toContain('$10.00')
|
|
134
|
+
expect(body).toContain('Payment Required')
|
|
135
|
+
expect(body).toContain('<script src="/pay.js"></script>')
|
|
136
|
+
expect(body).toContain('__MPPX_DATA__')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('returns service worker script when __mppx_worker param is set', async () => {
|
|
140
|
+
const transport = Transport.http()
|
|
141
|
+
const request = new Request('https://example.com?__mppx_worker')
|
|
142
|
+
|
|
143
|
+
const response = await transport.respondChallenge({
|
|
144
|
+
challenge,
|
|
145
|
+
input: request,
|
|
146
|
+
html: htmlOptions,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(response.status).toBe(200)
|
|
150
|
+
expect(response.headers.get('Content-Type')).toBe('application/javascript')
|
|
151
|
+
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
|
152
|
+
|
|
153
|
+
const body = await response.text()
|
|
154
|
+
expect(body).toContain('addEventListener')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('does not return html when Accept does not include text/html', async () => {
|
|
158
|
+
const transport = Transport.http()
|
|
159
|
+
const request = new Request('https://example.com', {
|
|
160
|
+
headers: { Accept: 'application/json' },
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const response = await transport.respondChallenge({
|
|
164
|
+
challenge,
|
|
165
|
+
input: request,
|
|
166
|
+
html: htmlOptions,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(response.status).toBe(402)
|
|
170
|
+
expect(response.headers.get('Content-Type')).toBeNull()
|
|
171
|
+
expect(await response.text()).toBe('')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('renders description when challenge has one', async () => {
|
|
175
|
+
const transport = Transport.http()
|
|
176
|
+
const request = new Request('https://example.com', {
|
|
177
|
+
headers: { Accept: 'text/html' },
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const challengeWithDescription = {
|
|
181
|
+
...challenge,
|
|
182
|
+
description: 'Access to premium content',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const response = await transport.respondChallenge({
|
|
186
|
+
challenge: challengeWithDescription,
|
|
187
|
+
input: request,
|
|
188
|
+
html: htmlOptions,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const body = await response.text()
|
|
192
|
+
expect(body).toContain('Access to premium content')
|
|
193
|
+
expect(body).toContain('mppx-summary-description')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('renders expires when challenge has one', async () => {
|
|
197
|
+
const transport = Transport.http()
|
|
198
|
+
const request = new Request('https://example.com', {
|
|
199
|
+
headers: { Accept: 'text/html' },
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const response = await transport.respondChallenge({
|
|
203
|
+
challenge,
|
|
204
|
+
input: request,
|
|
205
|
+
html: htmlOptions,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const body = await response.text()
|
|
209
|
+
expect(body).toContain('Expires at')
|
|
210
|
+
expect(body).toContain('2025-01-01T00:00:00.000Z')
|
|
211
|
+
expect(body).toContain('mppx-summary-expires')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('does not render description when challenge lacks one', async () => {
|
|
215
|
+
const transport = Transport.http()
|
|
216
|
+
const request = new Request('https://example.com', {
|
|
217
|
+
headers: { Accept: 'text/html' },
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const challengeNoDescription = { ...challenge }
|
|
221
|
+
delete (challengeNoDescription as any).description
|
|
222
|
+
|
|
223
|
+
const response = await transport.respondChallenge({
|
|
224
|
+
challenge: challengeNoDescription,
|
|
225
|
+
input: request,
|
|
226
|
+
html: htmlOptions,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const body = await response.text()
|
|
230
|
+
expect(body).not.toMatch(/<p class="mppx-summary-description"/)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('applies custom text', async () => {
|
|
234
|
+
const transport = Transport.http()
|
|
235
|
+
const request = new Request('https://example.com', {
|
|
236
|
+
headers: { Accept: 'text/html' },
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const response = await transport.respondChallenge({
|
|
240
|
+
challenge,
|
|
241
|
+
input: request,
|
|
242
|
+
html: {
|
|
243
|
+
...htmlOptions,
|
|
244
|
+
text: { title: 'Pay Up', paymentRequired: 'Gotta Pay' },
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const body = await response.text()
|
|
249
|
+
expect(body).toContain('<title>Pay Up</title>')
|
|
250
|
+
expect(body).toContain('Gotta Pay')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('applies custom theme logo', async () => {
|
|
254
|
+
const transport = Transport.http()
|
|
255
|
+
const request = new Request('https://example.com', {
|
|
256
|
+
headers: { Accept: 'text/html' },
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const response = await transport.respondChallenge({
|
|
260
|
+
challenge,
|
|
261
|
+
input: request,
|
|
262
|
+
html: {
|
|
263
|
+
...htmlOptions,
|
|
264
|
+
theme: { logo: 'https://example.com/logo.png' },
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const body = await response.text()
|
|
269
|
+
expect(body).toContain('https://example.com/logo.png')
|
|
270
|
+
expect(body).toContain('mppx-logo')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('embeds config and challenge in data script', async () => {
|
|
274
|
+
const transport = Transport.http()
|
|
275
|
+
const request = new Request('https://example.com', {
|
|
276
|
+
headers: { Accept: 'text/html' },
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const response = await transport.respondChallenge({
|
|
280
|
+
challenge,
|
|
281
|
+
input: request,
|
|
282
|
+
html: htmlOptions,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const body = await response.text()
|
|
286
|
+
// Extract the JSON data from the script tag
|
|
287
|
+
const dataMatch = body.match(
|
|
288
|
+
/<script id="__MPPX_DATA__" type="application\/json">\s*([\s\S]*?)\s*<\/script>/,
|
|
289
|
+
)
|
|
290
|
+
expect(dataMatch).not.toBeNull()
|
|
291
|
+
|
|
292
|
+
const data = JSON.parse(dataMatch?.[1]?.replace(/\\u003c/g, '<') ?? '')
|
|
293
|
+
expect(data.config).toEqual({ foo: 'bar' })
|
|
294
|
+
expect(data.challenge.id).toBe(challenge.id)
|
|
295
|
+
expect(data.challenge.method).toBe('tempo')
|
|
296
|
+
expect(data.text.paymentRequired).toBe('Payment Required')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('sanitizes html in formatted amount', async () => {
|
|
300
|
+
const transport = Transport.http()
|
|
301
|
+
const request = new Request('https://example.com', {
|
|
302
|
+
headers: { Accept: 'text/html' },
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const response = await transport.respondChallenge({
|
|
306
|
+
challenge,
|
|
307
|
+
input: request,
|
|
308
|
+
html: {
|
|
309
|
+
...htmlOptions,
|
|
310
|
+
formatAmount: () => '<script>alert("xss")</script>',
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const body = await response.text()
|
|
315
|
+
expect(body).not.toContain('<script>alert("xss")</script>')
|
|
316
|
+
expect(body).toContain('<script>')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
104
320
|
describe('respondChallenge with error status codes', () => {
|
|
105
321
|
test('BadRequestError returns 400', async () => {
|
|
106
322
|
const transport = Transport.http()
|
|
@@ -135,6 +351,8 @@ describe('http', () => {
|
|
|
135
351
|
const originalResponse = new Response('OK', { status: 200 })
|
|
136
352
|
|
|
137
353
|
const response = transport.respondReceipt({
|
|
354
|
+
credential,
|
|
355
|
+
input: new Request('https://example.com'),
|
|
138
356
|
receipt,
|
|
139
357
|
response: originalResponse,
|
|
140
358
|
challengeId: challenge.id,
|
|
@@ -252,7 +470,13 @@ describe('mcp', () => {
|
|
|
252
470
|
}
|
|
253
471
|
|
|
254
472
|
expect(
|
|
255
|
-
transport.respondReceipt({
|
|
473
|
+
transport.respondReceipt({
|
|
474
|
+
credential,
|
|
475
|
+
input: mcpRequest,
|
|
476
|
+
receipt,
|
|
477
|
+
response: successResponse,
|
|
478
|
+
challengeId: challenge.id,
|
|
479
|
+
}),
|
|
256
480
|
).toMatchInlineSnapshot(`
|
|
257
481
|
{
|
|
258
482
|
"id": 1,
|
|
@@ -285,7 +509,13 @@ describe('mcp', () => {
|
|
|
285
509
|
}
|
|
286
510
|
|
|
287
511
|
expect(
|
|
288
|
-
transport.respondReceipt({
|
|
512
|
+
transport.respondReceipt({
|
|
513
|
+
credential,
|
|
514
|
+
input: mcpRequest,
|
|
515
|
+
receipt,
|
|
516
|
+
response: errorResponse,
|
|
517
|
+
challengeId: challenge.id,
|
|
518
|
+
}),
|
|
289
519
|
).toBe(errorResponse)
|
|
290
520
|
})
|
|
291
521
|
})
|