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.
Files changed (111) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/Credential.d.ts +12 -0
  3. package/dist/Credential.d.ts.map +1 -1
  4. package/dist/Credential.js +22 -4
  5. package/dist/Credential.js.map +1 -1
  6. package/dist/Method.d.ts +4 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +2 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/cli/cli.d.ts.map +1 -1
  11. package/dist/cli/cli.js +11 -9
  12. package/dist/cli/cli.js.map +1 -1
  13. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  14. package/dist/cli/plugins/tempo.js +3 -3
  15. package/dist/cli/plugins/tempo.js.map +1 -1
  16. package/dist/cli/utils.d.ts +2 -0
  17. package/dist/cli/utils.d.ts.map +1 -1
  18. package/dist/cli/utils.js +10 -5
  19. package/dist/cli/utils.js.map +1 -1
  20. package/dist/proxy/Proxy.d.ts.map +1 -1
  21. package/dist/proxy/Proxy.js +52 -8
  22. package/dist/proxy/Proxy.js.map +1 -1
  23. package/dist/proxy/internal/Route.d.ts.map +1 -1
  24. package/dist/proxy/internal/Route.js +7 -3
  25. package/dist/proxy/internal/Route.js.map +1 -1
  26. package/dist/server/Mppx.d.ts.map +1 -1
  27. package/dist/server/Mppx.js +90 -71
  28. package/dist/server/Mppx.js.map +1 -1
  29. package/dist/server/Transport.d.ts +5 -1
  30. package/dist/server/Transport.d.ts.map +1 -1
  31. package/dist/server/Transport.js +71 -7
  32. package/dist/server/Transport.js.map +1 -1
  33. package/dist/server/internal/html/config.d.ts +144 -0
  34. package/dist/server/internal/html/config.d.ts.map +1 -0
  35. package/dist/server/internal/html/config.js +303 -0
  36. package/dist/server/internal/html/config.js.map +1 -0
  37. package/dist/server/internal/html/serviceWorker.gen.d.ts +2 -0
  38. package/dist/server/internal/html/serviceWorker.gen.d.ts.map +1 -0
  39. package/dist/server/internal/html/serviceWorker.gen.js +3 -0
  40. package/dist/server/internal/html/serviceWorker.gen.js.map +1 -0
  41. package/dist/stripe/internal/types.d.ts +6 -0
  42. package/dist/stripe/internal/types.d.ts.map +1 -1
  43. package/dist/stripe/server/Charge.d.ts +30 -16
  44. package/dist/stripe/server/Charge.d.ts.map +1 -1
  45. package/dist/stripe/server/Charge.js +35 -6
  46. package/dist/stripe/server/Charge.js.map +1 -1
  47. package/dist/stripe/server/internal/html/types.d.ts +2 -0
  48. package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
  49. package/dist/stripe/server/internal/html/types.js +2 -0
  50. package/dist/stripe/server/internal/html/types.js.map +1 -0
  51. package/dist/stripe/server/internal/html.gen.d.ts +2 -0
  52. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -0
  53. package/dist/stripe/server/internal/html.gen.js +3 -0
  54. package/dist/stripe/server/internal/html.gen.js.map +1 -0
  55. package/dist/tempo/server/Charge.d.ts +33 -26
  56. package/dist/tempo/server/Charge.d.ts.map +1 -1
  57. package/dist/tempo/server/Charge.js +46 -11
  58. package/dist/tempo/server/Charge.js.map +1 -1
  59. package/dist/tempo/server/Session.d.ts.map +1 -1
  60. package/dist/tempo/server/Session.js +3 -2
  61. package/dist/tempo/server/Session.js.map +1 -1
  62. package/dist/tempo/server/internal/html.gen.d.ts +2 -0
  63. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -0
  64. package/dist/tempo/server/internal/html.gen.js +3 -0
  65. package/dist/tempo/server/internal/html.gen.js.map +1 -0
  66. package/dist/tempo/server/internal/transport.d.ts +1 -1
  67. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  68. package/dist/tempo/server/internal/transport.js +45 -58
  69. package/dist/tempo/server/internal/transport.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Credential.ts +28 -4
  72. package/src/Method.ts +6 -1
  73. package/src/cli/cli.ts +11 -8
  74. package/src/cli/plugins/tempo.ts +3 -2
  75. package/src/cli/utils.test.ts +64 -0
  76. package/src/cli/utils.ts +10 -4
  77. package/src/env.d.ts +1 -0
  78. package/src/mcp-sdk/server/Transport.test.ts +6 -0
  79. package/src/proxy/Proxy.test.ts +188 -1
  80. package/src/proxy/Proxy.ts +58 -9
  81. package/src/proxy/internal/Route.test.ts +9 -0
  82. package/src/proxy/internal/Route.ts +5 -2
  83. package/src/server/Mppx.test.ts +171 -18
  84. package/src/server/Mppx.ts +120 -79
  85. package/src/server/Transport.test.ts +232 -2
  86. package/src/server/Transport.ts +84 -7
  87. package/src/server/internal/html/config.ts +414 -0
  88. package/src/server/internal/html/serviceWorker.client.ts +28 -0
  89. package/src/server/internal/html/serviceWorker.gen.ts +2 -0
  90. package/src/server/internal/html/serviceWorker.ts +27 -0
  91. package/src/server/internal/html/tsconfig.worker.client.json +8 -0
  92. package/src/server/internal/html/tsconfig.worker.json +8 -0
  93. package/src/stripe/internal/types.ts +20 -0
  94. package/src/stripe/server/Charge.ts +62 -6
  95. package/src/stripe/server/internal/html/main.ts +174 -0
  96. package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +21 -0
  97. package/src/stripe/server/internal/html/package.json +9 -0
  98. package/src/stripe/server/internal/html/stripe-js-pure.d.ts +7 -0
  99. package/src/stripe/server/internal/html/tsconfig.json +8 -0
  100. package/src/stripe/server/internal/html/types.ts +5 -0
  101. package/src/stripe/server/internal/html.gen.ts +2 -0
  102. package/src/tempo/server/Charge.ts +64 -10
  103. package/src/tempo/server/Session.ts +3 -2
  104. package/src/tempo/server/internal/html/main.ts +111 -0
  105. package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +21 -0
  106. package/src/tempo/server/internal/html/package.json +10 -0
  107. package/src/tempo/server/internal/html/tsconfig.json +8 -0
  108. package/src/tempo/server/internal/html.gen.ts +2 -0
  109. package/src/tempo/server/internal/transport.test.ts +37 -31
  110. package/src/tempo/server/internal/transport.ts +44 -58
  111. package/src/tsconfig.json +1 -1
@@ -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: credentialError.message }),
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
- // Use safeParse (not raw payload) so only methods whose schema
365
- // defines `action` can trigger the skip. Without this, a client
366
- // could inject `action: 'topUp'` on a charge credential to bypass
367
- // the amount check. Zod strips unknown keys, so charge payloads
368
- // (which don't define `action`) will have it removed.
369
- const parsed = method.schema.credential.payload.safeParse(credential.payload)
370
- const action = parsed.success
371
- ? (parsed.data as Record<string, unknown>)?.action
372
- : undefined
373
- if (action !== 'topUp' && action !== 'voucher') {
374
- const routeReq = challenge.request as Record<string, unknown>
375
- const echoedReq = credential.challenge.request as Record<string, unknown>
376
- const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
377
- const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
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 (e) {
394
+ } catch {
436
395
  const response = await transport.respondChallenge({
437
396
  challenge,
438
397
  input,
439
- error: new Errors.InvalidPayloadError({ reason: (e as Error).message }),
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
- const error =
451
- e instanceof Errors.PaymentError
452
- ? e
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
- const canonicalDetails = (canonical.methodDetails ?? {}) as Record<string, unknown>
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('&lt;script&gt;')
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({ receipt, response: successResponse, challengeId: challenge.id }),
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({ receipt, response: errorResponse, challengeId: challenge.id }),
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
  })