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,4 @@
1
- import { Receipt } from 'mppx'
1
+ import { Challenge, Credential, Method, Receipt, z } from 'mppx'
2
2
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { afterEach, describe, expect, test } from 'vp/test'
@@ -439,6 +439,193 @@ describe('create', () => {
439
439
  expect(res.status).toBe(402)
440
440
  })
441
441
 
442
+ test('behavior: management POST uses credential method binding to disambiguate same-path paid routes', async () => {
443
+ const alpha = Method.from({
444
+ name: 'alpha',
445
+ intent: 'charge',
446
+ schema: {
447
+ credential: { payload: z.object({ token: z.string() }) },
448
+ request: z.object({ amount: z.string() }),
449
+ },
450
+ })
451
+ const beta = Method.from({
452
+ name: 'beta',
453
+ intent: 'charge',
454
+ schema: {
455
+ credential: { payload: z.object({ token: z.string() }) },
456
+ request: z.object({ amount: z.string() }),
457
+ },
458
+ })
459
+
460
+ const handler = Mppx_server.create({
461
+ methods: [
462
+ Method.toServer(alpha, {
463
+ async verify() {
464
+ return Receipt.from({
465
+ method: 'alpha',
466
+ status: 'success',
467
+ timestamp: new Date().toISOString(),
468
+ reference: 'alpha-reference',
469
+ })
470
+ },
471
+ respond() {
472
+ return new Response(null, { status: 204 })
473
+ },
474
+ }),
475
+ Method.toServer(beta, {
476
+ async verify() {
477
+ return Receipt.from({
478
+ method: 'beta',
479
+ status: 'success',
480
+ timestamp: new Date().toISOString(),
481
+ reference: 'beta-reference',
482
+ })
483
+ },
484
+ respond() {
485
+ return new Response(null, { status: 205 })
486
+ },
487
+ }),
488
+ ],
489
+ secretKey,
490
+ })
491
+
492
+ const proxy = ApiProxy.create({
493
+ services: [
494
+ Service.from('api', {
495
+ baseUrl: 'https://example.com',
496
+ routes: {
497
+ 'GET /v1/stream': handler['alpha/charge']({ amount: '1' }),
498
+ 'PATCH /v1/stream': handler['beta/charge']({ amount: '1' }),
499
+ },
500
+ }),
501
+ ],
502
+ })
503
+ proxyServer = await Http.createServer(proxy.listener)
504
+
505
+ const challengeResponse = await fetch(`${proxyServer.url}/api/v1/stream`)
506
+ expect(challengeResponse.status).toBe(402)
507
+
508
+ const challenge = Challenge.fromResponse(challengeResponse)
509
+ const authorization = Credential.serialize(
510
+ Credential.from({
511
+ challenge,
512
+ payload: { token: 'ok' },
513
+ }),
514
+ )
515
+
516
+ const res = await fetch(`${proxyServer.url}/api/v1/stream`, {
517
+ method: 'POST',
518
+ headers: { Authorization: authorization },
519
+ })
520
+
521
+ expect(res.status).toBe(204)
522
+ })
523
+
524
+ test('behavior: exact-match management POST does not forward upstream', async () => {
525
+ let upstreamRequests = 0
526
+ upstream = await createUpstream(() => {
527
+ upstreamRequests += 1
528
+ return Response.json({ ok: true })
529
+ })
530
+
531
+ const method = Method.from({
532
+ name: 'mock',
533
+ intent: 'charge',
534
+ schema: {
535
+ credential: { payload: z.object({ token: z.string() }) },
536
+ request: z.object({ amount: z.string() }),
537
+ },
538
+ })
539
+ const handler = Mppx_server.create({
540
+ methods: [
541
+ Method.toServer(method, {
542
+ async verify() {
543
+ return Receipt.from({
544
+ method: 'mock',
545
+ status: 'success',
546
+ timestamp: new Date().toISOString(),
547
+ reference: 'mock-reference',
548
+ })
549
+ },
550
+ respond() {
551
+ return new Response(null, { status: 204 })
552
+ },
553
+ }),
554
+ ],
555
+ secretKey,
556
+ })
557
+
558
+ const proxy = ApiProxy.create({
559
+ services: [
560
+ Service.from('api', {
561
+ baseUrl: upstream.url,
562
+ routes: {
563
+ 'POST /v1/stream': handler['mock/charge']({ amount: '1' }),
564
+ },
565
+ }),
566
+ ],
567
+ })
568
+ proxyServer = await Http.createServer(proxy.listener)
569
+
570
+ const challengeResponse = await fetch(`${proxyServer.url}/api/v1/stream`, { method: 'POST' })
571
+ expect(challengeResponse.status).toBe(402)
572
+
573
+ const challenge = Challenge.fromResponse(challengeResponse)
574
+ const authorization = Credential.serialize(
575
+ Credential.from({
576
+ challenge,
577
+ payload: { token: 'ok' },
578
+ }),
579
+ )
580
+ const res = await fetch(`${proxyServer.url}/api/v1/stream`, {
581
+ method: 'POST',
582
+ headers: { Authorization: authorization },
583
+ })
584
+
585
+ expect(res.status).toBe(204)
586
+ expect(upstreamRequests).toBe(0)
587
+ })
588
+
589
+ test('behavior: paid GET fallback does not forward POST upstream', async () => {
590
+ let upstreamRequests = 0
591
+ upstream = await createUpstream(async (req) => {
592
+ upstreamRequests += 1
593
+ return Response.json({
594
+ body: await req.json(),
595
+ method: req.method,
596
+ })
597
+ })
598
+
599
+ const proxy = ApiProxy.create({
600
+ services: [
601
+ Service.from('api', {
602
+ baseUrl: upstream.url,
603
+ bearer: 'sk-upstream-key',
604
+ routes: { 'GET /v1/messages': mppx_server.charge({ amount: '1', decimals: 6 }) },
605
+ }),
606
+ ],
607
+ })
608
+ proxyServer = await Http.createServer(proxy.listener)
609
+
610
+ const challengeResponse = await fetch(`${proxyServer.url}/api/v1/messages`)
611
+ expect(challengeResponse.status).toBe(402)
612
+
613
+ const authorization = await mppx_client.createCredential(challengeResponse)
614
+ expect(Credential.extractPaymentScheme(authorization)).toBeTruthy()
615
+
616
+ const res = await fetch(`${proxyServer.url}/api/v1/messages`, {
617
+ method: 'POST',
618
+ headers: {
619
+ Authorization: authorization,
620
+ 'Content-Type': 'application/json',
621
+ },
622
+ body: JSON.stringify({ prompt: 'hello' }),
623
+ })
624
+
625
+ expect(res.status).toBe(405)
626
+ expect(upstreamRequests).toBe(0)
627
+ })
628
+
442
629
  test('behavior: POST to unregistered method does not fall back to free GET route', async () => {
443
630
  upstream = await createUpstream(() => Response.json({ ok: true }))
444
631
  const proxy = ApiProxy.create({
@@ -2,6 +2,7 @@ import type * as http from 'node:http'
2
2
 
3
3
  import { createFetchProxy } from '@remix-run/fetch-proxy'
4
4
 
5
+ import * as Credential from '../Credential.js'
5
6
  import { generateProxy } from '../discovery/OpenApi.js'
6
7
  import * as Request from '../server/Request.js'
7
8
  import * as Headers from './internal/Headers.js'
@@ -106,19 +107,26 @@ export function create(config: create.Config): Proxy {
106
107
 
107
108
  const { service, proxy } = entry
108
109
 
109
- const matched =
110
- Route.match(service.routes, request.method, upstreamPath) ??
111
- // Management POSTs (e.g. session close) may target a path whose route
112
- // is registered for a different HTTP method (e.g. GET). Fall back to
113
- // path-only matching so the payment handler can process the action.
114
- (request.method === 'POST' && request.headers.has('authorization')
115
- ? Route.matchPath(
110
+ const exactMatch = Route.match(service.routes, request.method, upstreamPath)
111
+ const fallbackBinding =
112
+ !exactMatch && request.method === 'POST' && request.headers.has('authorization')
113
+ ? getPaymentBinding(request)
114
+ : null
115
+ const fallbackMatch =
116
+ !exactMatch && request.method === 'POST' && request.headers.has('authorization')
117
+ ? // Management POSTs (e.g. session close) may target a path whose route
118
+ // is registered for a different HTTP method (e.g. GET). Fall back to
119
+ // path-only matching so the payment handler can process the action.
120
+ // When the credential parses cleanly, also bind on payment method+intent
121
+ // so same-path paid routes can coexist without sharing credentials.
122
+ Route.matchPath(
116
123
  service.routes,
117
124
  upstreamPath,
118
125
  // skip free routes (e.g. `'GET /foo/bar': true`)
119
- (endpoint) => endpoint !== true,
126
+ (endpoint) => endpoint !== true && matchesPaymentBinding(endpoint, fallbackBinding),
120
127
  )
121
- : null)
128
+ : null
129
+ const matched = exactMatch ?? fallbackMatch
122
130
  if (!matched) return new Response('Not Found', { status: 404 })
123
131
 
124
132
  const endpoint = matched.value as Service.Endpoint
@@ -130,6 +138,22 @@ export function create(config: create.Config): Proxy {
130
138
  const result = await handler(request)
131
139
  if (result.status === 402) return result.challenge
132
140
 
141
+ const managementResponse = (() => {
142
+ try {
143
+ return (result.withReceipt as () => Response)()
144
+ } catch (error) {
145
+ if (
146
+ error instanceof Error &&
147
+ error.message === 'withReceipt() requires a response argument'
148
+ )
149
+ return null
150
+ throw error
151
+ }
152
+ })()
153
+
154
+ if (managementResponse) return managementResponse
155
+ if (fallbackMatch) return new Response('Method Not Allowed', { status: 405 })
156
+
133
157
  const options = Service.getOptions(endpoint)
134
158
  const upstreamRes = await proxyUpstream({
135
159
  request,
@@ -246,3 +270,28 @@ function withBasePath(basePath: string | undefined, path: string) {
246
270
  const trimmed = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized
247
271
  return `${trimmed}${path}`
248
272
  }
273
+
274
+ type PaymentBinding = {
275
+ intent: string
276
+ method: string
277
+ }
278
+
279
+ function getPaymentBinding(request: Request): PaymentBinding | null {
280
+ try {
281
+ const credential = Credential.fromRequest(request)
282
+ return {
283
+ intent: credential.challenge.intent,
284
+ method: credential.challenge.method,
285
+ }
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
291
+ function matchesPaymentBinding(endpoint: unknown, binding: PaymentBinding | null): boolean {
292
+ if (endpoint === true) return false
293
+ if (!binding) return true
294
+ const payment = Service.paymentOf(endpoint as Exclude<Service.Endpoint, true>)
295
+ if (!payment) return true
296
+ return payment.method === binding.method && payment.intent === binding.intent
297
+ }
@@ -193,6 +193,15 @@ describe('matchPath', () => {
193
193
  expect(result).toMatchObject({ key: 'POST /v1/*' })
194
194
  })
195
195
 
196
+ // TODO (brendanryan): Relax this if `matchPath()` gains method-aware disambiguation.
197
+ test('error: returns null when multiple paid routes share the same path', () => {
198
+ const routes = {
199
+ 'GET /v1/stream': { pay: () => {} },
200
+ 'POST /v1/stream': { pay: () => {} },
201
+ }
202
+ expect(Route.matchPath(routes, '/v1/stream', paidOnly)).toBeNull()
203
+ })
204
+
196
205
  test('error: returns null when all routes are free', () => {
197
206
  const routes = {
198
207
  'GET /v1/models': true,
@@ -42,13 +42,16 @@ export function matchPath(
42
42
  path: string,
43
43
  filter?: (value: unknown) => boolean,
44
44
  ): { key: string; value: unknown } | null {
45
+ let match: { key: string; value: unknown } | null = null
45
46
  for (const [key, value] of Object.entries(routes)) {
46
47
  if (filter && !filter(value)) continue
47
48
  const { pattern } = parseRouteKey(key)
48
49
  const urlPattern = new URLPattern({ pathname: pattern })
49
- if (urlPattern.test({ pathname: path })) return { key, value }
50
+ if (!urlPattern.test({ pathname: path })) continue
51
+ if (match) return null
52
+ match = { key, value }
50
53
  }
51
- return null
54
+ return match
52
55
  }
53
56
 
54
57
  function parseRouteKey(key: string): { method: string | undefined; pattern: string } {
@@ -95,6 +95,36 @@ describe('request handler', () => {
95
95
  `)
96
96
  })
97
97
 
98
+ test('returns sanitized malformed credential error for unexpected transport failures', async () => {
99
+ const baseTransport = Transport.http()
100
+ const transport = Transport.from({
101
+ ...baseTransport,
102
+ name: 'leaking-http',
103
+ getCredential() {
104
+ throw new Error('request to https://rpc.example.com/?key=secret-key failed')
105
+ },
106
+ })
107
+
108
+ const result = await Mppx.create({ methods: [method], realm, secretKey, transport }).charge({
109
+ amount: '1000',
110
+ currency: asset,
111
+ expires: new Date(Date.now() + 60_000).toISOString(),
112
+ recipient: accounts[0].address,
113
+ })(
114
+ new Request('https://example.com/resource', {
115
+ headers: { Authorization: 'Payment invalid' },
116
+ }),
117
+ )
118
+
119
+ expect(result.status).toBe(402)
120
+ if (result.status !== 402) throw new Error()
121
+
122
+ const body = (await result.challenge.json()) as { detail: string }
123
+ expect(body.detail).toBe('Credential is malformed.')
124
+ expect(body.detail).not.toContain('secret-key')
125
+ expect(body.detail).not.toContain('rpc.example.com')
126
+ })
127
+
98
128
  test('returns 402 when challenge ID mismatch', async () => {
99
129
  const wrongChallenge = Challenge.from({
100
130
  id: 'wrong-id',
@@ -181,7 +211,7 @@ describe('request handler', () => {
181
211
  expect(body.detail).toContain('does not match')
182
212
  })
183
213
 
184
- test('topUp credential bypasses cross-route amount validation', async () => {
214
+ test('topUp credential is rejected when replayed across routes with different amounts', async () => {
185
215
  // Use a session method whose schema defines action: 'topUp'
186
216
  const sessionMethod = Method.from({
187
217
  name: 'mock',
@@ -231,7 +261,7 @@ describe('request handler', () => {
231
261
  payload: { action: 'topUp', token: 'valid' },
232
262
  })
233
263
 
234
- // Present it at the "expensive" route — topUp should bypass amount check
264
+ // Present it at the "expensive" route — topUp must still match scope.
235
265
  const expensiveHandle = handler['mock/session']({
236
266
  amount: '1000000',
237
267
  currency: asset,
@@ -244,16 +274,13 @@ describe('request handler', () => {
244
274
  }),
245
275
  )
246
276
 
247
- // Should NOT get 402 for amount mismatch — topUp bypasses the check.
248
- // It will fail at a later stage (payload validation), but not with
249
- // "does not match this route's requirements".
250
- if (result.status === 402) {
251
- const body = (await result.challenge.json()) as { detail?: string }
252
- expect(body.detail).not.toContain('does not match')
253
- }
277
+ expect(result.status).toBe(402)
278
+ if (result.status !== 402) throw new Error()
279
+ const body = (await result.challenge.json()) as { detail?: string }
280
+ expect(body.detail).toContain('does not match')
254
281
  })
255
282
 
256
- test('voucher credential bypasses cross-route amount validation', async () => {
283
+ test('voucher credential is rejected when replayed across routes with different amounts', async () => {
257
284
  const sessionMethod = Method.from({
258
285
  name: 'mock',
259
286
  intent: 'session',
@@ -307,8 +334,8 @@ describe('request handler', () => {
307
334
  payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
308
335
  })
309
336
 
310
- // Present it at the same route but with a higher price — voucher should
311
- // bypass the cross-route amount check just like topUp does
337
+ // Present it at the same route but with a higher price — voucher must
338
+ // still match the original priced scope.
312
339
  const expensiveHandle = handler['mock/session']({
313
340
  amount: '1000000',
314
341
  currency: asset,
@@ -321,11 +348,10 @@ describe('request handler', () => {
321
348
  }),
322
349
  )
323
350
 
324
- // Should NOT get 402 for amount mismatch — voucher bypasses the check.
325
- if (result.status === 402) {
326
- const body = (await result.challenge.json()) as { detail?: string }
327
- expect(body.detail).not.toContain('does not match')
328
- }
351
+ expect(result.status).toBe(402)
352
+ if (result.status !== 402) throw new Error()
353
+ const body = (await result.challenge.json()) as { detail?: string }
354
+ expect(body.detail).toContain('does not match')
329
355
  })
330
356
 
331
357
  test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
@@ -552,7 +578,63 @@ describe('request handler', () => {
552
578
  "type": "https://paymentauth.org/problems/invalid-payload",
553
579
  }
554
580
  `)
555
- expect(body.detail).toContain('Credential payload is invalid')
581
+ expect(body.detail).toBe('Credential payload is invalid.')
582
+ expect(body.detail).not.toContain('invalidField')
583
+ })
584
+
585
+ test('returns sanitized verification error for unexpected verifier failures', async () => {
586
+ const leakingMethod = Method.toServer(
587
+ Method.from({
588
+ name: 'mock',
589
+ intent: 'charge',
590
+ schema: {
591
+ credential: {
592
+ payload: z.object({ token: z.string() }),
593
+ },
594
+ request: z.object({
595
+ amount: z.string(),
596
+ currency: z.string(),
597
+ recipient: z.string(),
598
+ }),
599
+ },
600
+ }),
601
+ {
602
+ async verify() {
603
+ throw new Error('request to https://mainnet.infura.io/v3/secret-key failed')
604
+ },
605
+ },
606
+ )
607
+
608
+ const handle = Mppx.create({ methods: [leakingMethod], realm, secretKey })['mock/charge']({
609
+ amount: '1000',
610
+ currency: asset,
611
+ expires: new Date(Date.now() + 60_000).toISOString(),
612
+ recipient: accounts[0].address,
613
+ })
614
+
615
+ const firstResult = await handle(new Request('https://example.com/resource'))
616
+ expect(firstResult.status).toBe(402)
617
+ if (firstResult.status !== 402) throw new Error()
618
+
619
+ const challenge = Challenge.fromResponse(firstResult.challenge)
620
+ const credential = Credential.from({
621
+ challenge,
622
+ payload: { token: 'valid' },
623
+ })
624
+
625
+ const result = await handle(
626
+ new Request('https://example.com/resource', {
627
+ headers: { Authorization: Credential.serialize(credential) },
628
+ }),
629
+ )
630
+
631
+ expect(result.status).toBe(402)
632
+ if (result.status !== 402) throw new Error()
633
+
634
+ const body = (await result.challenge.json()) as { detail: string }
635
+ expect(body.detail).toBe('Payment verification failed.')
636
+ expect(body.detail).not.toContain('infura')
637
+ expect(body.detail).not.toContain('secret-key')
556
638
  })
557
639
  })
558
640
 
@@ -1724,6 +1806,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
1724
1806
 
1725
1807
  expect(result.status).toBe(402)
1726
1808
  })
1809
+
1810
+ test('compose dispatch includes methodDetails memo/splits binding', async () => {
1811
+ const splitsMethod = Method.from({
1812
+ name: 'mock',
1813
+ intent: 'charge',
1814
+ schema: {
1815
+ credential: { payload: z.object({ token: z.string() }) },
1816
+ request: z.pipe(
1817
+ z.object({
1818
+ amount: z.string(),
1819
+ currency: z.string(),
1820
+ decimals: z.number(),
1821
+ recipient: z.string(),
1822
+ splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
1823
+ }),
1824
+ z.transform(({ amount, currency, decimals, recipient, splits }) => ({
1825
+ methodDetails: {
1826
+ amount: String(Number(amount) * 10 ** decimals),
1827
+ currency,
1828
+ recipient,
1829
+ ...(splits && { splits }),
1830
+ },
1831
+ })),
1832
+ ),
1833
+ },
1834
+ })
1835
+
1836
+ const splitsServerMethod = Method.toServer(splitsMethod, {
1837
+ async verify() {
1838
+ return mockReceipt()
1839
+ },
1840
+ })
1841
+
1842
+ const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
1843
+
1844
+ const noSplitsHandle = handler.charge({
1845
+ amount: '1',
1846
+ currency: '0x0000000000000000000000000000000000000001',
1847
+ decimals: 6,
1848
+ expires: new Date(Date.now() + 60_000).toISOString(),
1849
+ recipient: '0x0000000000000000000000000000000000000002',
1850
+ })
1851
+ const splitsHandle = handler.charge({
1852
+ amount: '1',
1853
+ currency: '0x0000000000000000000000000000000000000001',
1854
+ decimals: 6,
1855
+ expires: new Date(Date.now() + 60_000).toISOString(),
1856
+ recipient: '0x0000000000000000000000000000000000000002',
1857
+ splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
1858
+ })
1859
+
1860
+ const composed = Mppx.compose(noSplitsHandle, splitsHandle)
1861
+ const firstResult = await composed(new Request('https://example.com/resource'))
1862
+ expect(firstResult.status).toBe(402)
1863
+ if (firstResult.status !== 402) throw new Error()
1864
+
1865
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
1866
+ const noSplitsChallenge = challenges[0]!
1867
+ const credential = Credential.from({
1868
+ challenge: noSplitsChallenge,
1869
+ payload: { token: 'valid' },
1870
+ })
1871
+
1872
+ const result = await composed(
1873
+ new Request('https://example.com/resource', {
1874
+ headers: { Authorization: Credential.serialize(credential) },
1875
+ }),
1876
+ )
1877
+
1878
+ expect(result.status).toBe(200)
1879
+ })
1727
1880
  })
1728
1881
 
1729
1882
  describe('withReceipt', () => {