mppx 0.5.0 → 0.5.3

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 (104) hide show
  1. package/CHANGELOG.md +19 -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/account.d.ts.map +1 -1
  11. package/dist/cli/account.js +12 -2
  12. package/dist/cli/account.js.map +1 -1
  13. package/dist/proxy/Proxy.d.ts.map +1 -1
  14. package/dist/proxy/Proxy.js +52 -8
  15. package/dist/proxy/Proxy.js.map +1 -1
  16. package/dist/proxy/internal/Route.d.ts.map +1 -1
  17. package/dist/proxy/internal/Route.js +7 -3
  18. package/dist/proxy/internal/Route.js.map +1 -1
  19. package/dist/server/Mppx.d.ts.map +1 -1
  20. package/dist/server/Mppx.js +90 -71
  21. package/dist/server/Mppx.js.map +1 -1
  22. package/dist/server/Transport.d.ts +5 -1
  23. package/dist/server/Transport.d.ts.map +1 -1
  24. package/dist/server/Transport.js +52 -7
  25. package/dist/server/Transport.js.map +1 -1
  26. package/dist/server/internal/html/config.d.ts +7 -0
  27. package/dist/server/internal/html/config.d.ts.map +1 -0
  28. package/dist/server/internal/html/config.js +3 -0
  29. package/dist/server/internal/html/config.js.map +1 -0
  30. package/dist/server/internal/html/serviceWorker.gen.d.ts +2 -0
  31. package/dist/server/internal/html/serviceWorker.gen.d.ts.map +1 -0
  32. package/dist/server/internal/html/serviceWorker.gen.js +3 -0
  33. package/dist/server/internal/html/serviceWorker.gen.js.map +1 -0
  34. package/dist/stripe/server/Charge.d.ts +5 -0
  35. package/dist/stripe/server/Charge.d.ts.map +1 -1
  36. package/dist/stripe/server/Charge.js +14 -6
  37. package/dist/stripe/server/Charge.js.map +1 -1
  38. package/dist/stripe/server/internal/html.gen.d.ts +2 -0
  39. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -0
  40. package/dist/stripe/server/internal/html.gen.js +3 -0
  41. package/dist/stripe/server/internal/html.gen.js.map +1 -0
  42. package/dist/tempo/internal/proof.d.ts +6 -0
  43. package/dist/tempo/internal/proof.d.ts.map +1 -1
  44. package/dist/tempo/internal/proof.js +15 -0
  45. package/dist/tempo/internal/proof.js.map +1 -1
  46. package/dist/tempo/server/Charge.d.ts +10 -3
  47. package/dist/tempo/server/Charge.d.ts.map +1 -1
  48. package/dist/tempo/server/Charge.js +38 -10
  49. package/dist/tempo/server/Charge.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts.map +1 -1
  51. package/dist/tempo/server/Session.js +3 -2
  52. package/dist/tempo/server/Session.js.map +1 -1
  53. package/dist/tempo/server/internal/html.gen.d.ts +2 -0
  54. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -0
  55. package/dist/tempo/server/internal/html.gen.js +3 -0
  56. package/dist/tempo/server/internal/html.gen.js.map +1 -0
  57. package/dist/tempo/server/internal/transport.d.ts +1 -1
  58. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/transport.js +45 -58
  60. package/dist/tempo/server/internal/transport.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/Credential.ts +28 -4
  63. package/src/Method.ts +6 -1
  64. package/src/cli/account.ts +13 -2
  65. package/src/env.d.ts +1 -0
  66. package/src/mcp-sdk/server/Transport.test.ts +6 -0
  67. package/src/middlewares/elysia.test.ts +3 -5
  68. package/src/middlewares/express.test.ts +3 -5
  69. package/src/middlewares/hono.test.ts +8 -5
  70. package/src/middlewares/nextjs.test.ts +3 -5
  71. package/src/proxy/Proxy.test.ts +188 -1
  72. package/src/proxy/Proxy.ts +58 -9
  73. package/src/proxy/internal/Route.test.ts +9 -0
  74. package/src/proxy/internal/Route.ts +5 -2
  75. package/src/server/Mppx.test.ts +171 -18
  76. package/src/server/Mppx.ts +120 -79
  77. package/src/server/Transport.test.ts +16 -2
  78. package/src/server/Transport.ts +61 -7
  79. package/src/server/internal/html/config.ts +8 -0
  80. package/src/server/internal/html/serviceWorker.client.ts +28 -0
  81. package/src/server/internal/html/serviceWorker.gen.ts +2 -0
  82. package/src/server/internal/html/serviceWorker.ts +27 -0
  83. package/src/server/internal/html/tsconfig.worker.client.json +8 -0
  84. package/src/server/internal/html/tsconfig.worker.json +8 -0
  85. package/src/stripe/server/Charge.ts +19 -5
  86. package/src/stripe/server/internal/html/main.ts +106 -0
  87. package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +21 -0
  88. package/src/stripe/server/internal/html/package.json +9 -0
  89. package/src/stripe/server/internal/html/stripe-js-pure.d.ts +7 -0
  90. package/src/stripe/server/internal/html/tsconfig.json +8 -0
  91. package/src/stripe/server/internal/html.gen.ts +2 -0
  92. package/src/tempo/internal/proof.test.ts +47 -0
  93. package/src/tempo/internal/proof.ts +16 -0
  94. package/src/tempo/server/Charge.test.ts +298 -0
  95. package/src/tempo/server/Charge.ts +61 -12
  96. package/src/tempo/server/Session.ts +3 -2
  97. package/src/tempo/server/internal/html/main.ts +71 -0
  98. package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +21 -0
  99. package/src/tempo/server/internal/html/package.json +10 -0
  100. package/src/tempo/server/internal/html/tsconfig.json +8 -0
  101. package/src/tempo/server/internal/html.gen.ts +2 -0
  102. package/src/tempo/server/internal/transport.test.ts +37 -31
  103. package/src/tempo/server/internal/transport.ts +44 -58
  104. 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 =
@@ -135,6 +135,8 @@ describe('http', () => {
135
135
  const originalResponse = new Response('OK', { status: 200 })
136
136
 
137
137
  const response = transport.respondReceipt({
138
+ credential,
139
+ input: new Request('https://example.com'),
138
140
  receipt,
139
141
  response: originalResponse,
140
142
  challengeId: challenge.id,
@@ -252,7 +254,13 @@ describe('mcp', () => {
252
254
  }
253
255
 
254
256
  expect(
255
- transport.respondReceipt({ receipt, response: successResponse, challengeId: challenge.id }),
257
+ transport.respondReceipt({
258
+ credential,
259
+ input: mcpRequest,
260
+ receipt,
261
+ response: successResponse,
262
+ challengeId: challenge.id,
263
+ }),
256
264
  ).toMatchInlineSnapshot(`
257
265
  {
258
266
  "id": 1,
@@ -285,7 +293,13 @@ describe('mcp', () => {
285
293
  }
286
294
 
287
295
  expect(
288
- transport.respondReceipt({ receipt, response: errorResponse, challengeId: challenge.id }),
296
+ transport.respondReceipt({
297
+ credential,
298
+ input: mcpRequest,
299
+ receipt,
300
+ response: errorResponse,
301
+ challengeId: challenge.id,
302
+ }),
289
303
  ).toBe(errorResponse)
290
304
  })
291
305
  })
@@ -1,9 +1,13 @@
1
+ import { Json } from 'ox'
2
+
1
3
  import * as Challenge from '../Challenge.js'
2
4
  import * as Credential from '../Credential.js'
3
5
  import * as Errors from '../Errors.js'
4
6
  import type { Distribute, UnionToIntersection } from '../internal/types.js'
5
7
  import * as core_Mcp from '../Mcp.js'
6
8
  import * as Receipt from '../Receipt.js'
9
+ import * as Html from './internal/html/config.js'
10
+ import { serviceWorker } from './internal/html/serviceWorker.gen.js'
7
11
 
8
12
  export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js'
9
13
 
@@ -30,11 +34,14 @@ export type Transport<
30
34
  respondChallenge: (options: {
31
35
  challenge: Challenge.Challenge
32
36
  error?: Errors.PaymentError | undefined
37
+ html?: Html.Options | undefined
33
38
  input: input
34
39
  }) => challengeOutput | Promise<challengeOutput>
35
40
  /** Attaches a receipt to a successful response. */
36
41
  respondReceipt: (options: {
37
42
  challengeId: string
43
+ credential: Credential.Credential
44
+ input: input
38
45
  receipt: Receipt.Receipt
39
46
  response: receiptResponse
40
47
  }) => receiptOutput
@@ -87,7 +94,7 @@ export type WithReceipt<transport extends AnyTransport = Http> = WithReceiptOver
87
94
  * name: 'custom',
88
95
  * getCredential(input) { ... },
89
96
  * respondChallenge({ challenge, input }) { ... },
90
- * respondReceipt({ receipt, response, challengeId }) { ... },
97
+ * respondReceipt({ receipt, response, challengeId, credential, input }) { ... },
91
98
  * })
92
99
  * ```
93
100
  */
@@ -121,17 +128,64 @@ export function http(): Http {
121
128
  return Credential.deserialize(payment)
122
129
  },
123
130
 
124
- respondChallenge({ challenge, error }) {
131
+ respondChallenge(options) {
132
+ const { challenge, error, input } = options
133
+
134
+ if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam))
135
+ return new Response(serviceWorker, {
136
+ status: 200,
137
+ headers: {
138
+ 'Content-Type': 'application/javascript',
139
+ 'Cache-Control': 'no-store',
140
+ },
141
+ })
142
+
125
143
  const headers: Record<string, string> = {
126
144
  'WWW-Authenticate': Challenge.serialize(challenge),
127
145
  'Cache-Control': 'no-store',
128
146
  }
129
147
 
130
- let body: string | null = null
131
- if (error) {
132
- headers['Content-Type'] = 'application/problem+json'
133
- body = JSON.stringify(error.toProblemDetails(challenge.id))
134
- }
148
+ const body = (() => {
149
+ if (options.html && input.headers.get('Accept')?.includes('text/html')) {
150
+ headers['Content-Type'] = 'text/html; charset=utf-8'
151
+ const html = String.raw
152
+ return html`<!doctype html>
153
+ <html lang="en">
154
+ <head>
155
+ <meta charset="UTF-8" />
156
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
157
+ <title>Payment Required</title>
158
+ <style>
159
+ :root {
160
+ color-scheme: dark light;
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <h1>Payment Required</h1>
166
+ <pre>
167
+ ${Json.stringify(challenge, null, 2)
168
+ .replace(/&/g, '&amp;')
169
+ .replace(/</g, '&lt;')
170
+ .replace(/>/g, '&gt;')}</pre
171
+ >
172
+ <div id="root"></div>
173
+ <script id="${Html.dataId}" type="application/json">
174
+ ${Json.stringify({ config: options.html.config, challenge }).replace(
175
+ /</g,
176
+ '\\u003c',
177
+ )}
178
+ </script>
179
+ ${options.html.content}
180
+ </body>
181
+ </html> `
182
+ }
183
+ if (error) {
184
+ headers['Content-Type'] = 'application/problem+json'
185
+ return JSON.stringify(error.toProblemDetails(challenge.id))
186
+ }
187
+ return null
188
+ })()
135
189
 
136
190
  return new Response(body, { status: error?.status ?? 402, headers })
137
191
  },
@@ -0,0 +1,8 @@
1
+ export type Options = {
2
+ config: Record<string, unknown>
3
+ content: string
4
+ }
5
+
6
+ export const dataId = '__MPPX_DATA__'
7
+
8
+ export const serviceWorkerParam = '__mppx_worker'
@@ -0,0 +1,28 @@
1
+ import { serviceWorkerParam } from './config.js'
2
+
3
+ export async function submitCredential(credential: string): Promise<void> {
4
+ const url = new URL(location.href)
5
+ url.searchParams.set(serviceWorkerParam, '')
6
+
7
+ const registration = await navigator.serviceWorker.register(url.pathname + url.search)
8
+
9
+ const serviceWorker = await new Promise<ServiceWorker>((resolve) => {
10
+ const mppxWorker = registration.installing ?? registration.waiting ?? registration.active
11
+ if (mppxWorker?.state === 'activated') return resolve(mppxWorker)
12
+ const target = mppxWorker ?? registration
13
+ target.addEventListener('statechange', function handler() {
14
+ const active = registration.active
15
+ if (active?.state === 'activated') {
16
+ target.removeEventListener('statechange', handler)
17
+ resolve(active)
18
+ }
19
+ })
20
+ })
21
+
22
+ await new Promise<void>((resolve) => {
23
+ const channel = new MessageChannel()
24
+ channel.port1.onmessage = () => resolve()
25
+ serviceWorker.postMessage({ credential }, [channel.port2])
26
+ })
27
+ location.reload()
28
+ }
@@ -0,0 +1,2 @@
1
+ // Generated — do not edit.
2
+ export const serviceWorker = "(function(){let e=self,t;e.addEventListener(`activate`,t=>{t.waitUntil(e.clients.claim())}),e.addEventListener(`message`,e=>{if(!e.source)return;let n=e.data?.credential;typeof n!=`string`||!n.startsWith(`Payment `)||(t=n,e.ports[0]?.postMessage(`ack`))}),e.addEventListener(`fetch`,n=>{if(!t||n.request.mode!==`navigate`||new URL(n.request.url).origin!==e.location.origin)return;let r=new Headers(n.request.headers);r.set(`Authorization`,t),t=void 0,n.respondWith(fetch(n.request,{headers:r})),e.registration.unregister()})})();"
@@ -0,0 +1,27 @@
1
+ const serviceWorker = self as unknown as ServiceWorkerGlobalScope
2
+
3
+ let credential: string | undefined
4
+
5
+ serviceWorker.addEventListener('activate', (event) => {
6
+ event.waitUntil(serviceWorker.clients.claim())
7
+ })
8
+
9
+ serviceWorker.addEventListener('message', (event) => {
10
+ if (!event.source) return
11
+ const value = event.data?.credential
12
+ if (typeof value !== 'string' || !value.startsWith('Payment ')) return
13
+ credential = value
14
+ event.ports[0]?.postMessage('ack')
15
+ })
16
+
17
+ serviceWorker.addEventListener('fetch', (event) => {
18
+ if (!credential || event.request.mode !== 'navigate') return
19
+ if (new URL(event.request.url).origin !== serviceWorker.location.origin) return
20
+
21
+ const headers = new Headers(event.request.headers)
22
+ headers.set('Authorization', credential)
23
+ credential = undefined
24
+
25
+ event.respondWith(fetch(event.request, { headers }))
26
+ serviceWorker.registration.unregister()
27
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["es2022", "dom"],
5
+ "types": []
6
+ },
7
+ "include": ["serviceWorker.client.ts"]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["es2022", "webworker"],
5
+ "types": []
6
+ },
7
+ "include": ["serviceWorker.ts"]
8
+ }
@@ -5,6 +5,7 @@ import type { LooseOmit, OneOf } from '../../internal/types.js'
5
5
  import * as Method from '../../Method.js'
6
6
  import type { StripeClient } from '../internal/types.js'
7
7
  import * as Methods from '../Methods.js'
8
+ import { html as htmlContent } from './internal/html.gen.js'
8
9
 
9
10
  /**
10
11
  * Creates a Stripe charge method intent for usage on the server.
@@ -38,6 +39,7 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
38
39
  decimals,
39
40
  description,
40
41
  externalId,
42
+ html,
41
43
  metadata,
42
44
  networkId,
43
45
  paymentMethodTypes,
@@ -59,9 +61,11 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
59
61
  paymentMethodTypes,
60
62
  } as unknown as Defaults,
61
63
 
62
- async verify({ credential }) {
64
+ html: html ? { config: html, content: htmlContent } : undefined,
65
+
66
+ async verify({ credential, request }) {
63
67
  const { challenge } = credential
64
- const { request } = challenge
68
+ const resolvedRequest = Methods.charge.schema.request.parse(request)
65
69
 
66
70
  Expires.assert(challenge.expires, challenge.id)
67
71
 
@@ -72,15 +76,23 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
72
76
  externalId?: string
73
77
  }
74
78
 
75
- const userMetadata = request.methodDetails?.metadata as Record<string, string> | undefined
79
+ const userMetadata = resolvedRequest.methodDetails?.metadata as
80
+ | Record<string, string>
81
+ | undefined
76
82
  const resolvedMetadata = { ...buildAnalytics({ credential }), ...userMetadata }
77
83
 
78
84
  const pi = client
79
- ? await createWithClient({ client, challenge, request, spt, metadata: resolvedMetadata })
85
+ ? await createWithClient({
86
+ client,
87
+ challenge,
88
+ request: resolvedRequest,
89
+ spt,
90
+ metadata: resolvedMetadata,
91
+ })
80
92
  : await createWithSecretKey({
81
93
  secretKey: secretKey!,
82
94
  challenge,
83
- request,
95
+ request: resolvedRequest,
84
96
  spt,
85
97
  metadata: resolvedMetadata,
86
98
  })
@@ -108,6 +120,8 @@ export declare namespace charge {
108
120
  type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.charge>, 'recipient'>
109
121
 
110
122
  type Parameters = {
123
+ /** Render payment page when Accept header is text/html (e.g. in browsers) */
124
+ html?: { createTokenUrl: string; publishableKey: string } | undefined
111
125
  /** Optional metadata to include in SPT creation requests. */
112
126
  metadata?: Record<string, string> | undefined
113
127
  } & Defaults &