mppx 0.4.6 → 0.4.8

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 (89) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Store.d.ts +5 -4
  3. package/dist/Store.d.ts.map +1 -1
  4. package/dist/Store.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +22 -7
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +9 -22
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/middlewares/elysia.d.ts.map +1 -1
  12. package/dist/middlewares/elysia.js +5 -1
  13. package/dist/middlewares/elysia.js.map +1 -1
  14. package/dist/proxy/Proxy.d.ts.map +1 -1
  15. package/dist/proxy/Proxy.js +3 -1
  16. package/dist/proxy/Proxy.js.map +1 -1
  17. package/dist/proxy/internal/Route.d.ts +2 -2
  18. package/dist/proxy/internal/Route.d.ts.map +1 -1
  19. package/dist/proxy/internal/Route.js +4 -2
  20. package/dist/proxy/internal/Route.js.map +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +26 -8
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  25. package/dist/tempo/client/SessionManager.js +12 -1
  26. package/dist/tempo/client/SessionManager.js.map +1 -1
  27. package/dist/tempo/internal/address.d.ts +3 -0
  28. package/dist/tempo/internal/address.d.ts.map +1 -0
  29. package/dist/tempo/internal/address.js +4 -0
  30. package/dist/tempo/internal/address.js.map +1 -0
  31. package/dist/tempo/internal/auto-swap.js +3 -3
  32. package/dist/tempo/internal/auto-swap.js.map +1 -1
  33. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +11 -3
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/Charge.d.ts +11 -0
  38. package/dist/tempo/server/Charge.d.ts.map +1 -1
  39. package/dist/tempo/server/Charge.js +109 -50
  40. package/dist/tempo/server/Charge.js.map +1 -1
  41. package/dist/tempo/server/Session.d.ts +1 -1
  42. package/dist/tempo/server/Session.d.ts.map +1 -1
  43. package/dist/tempo/server/Session.js +39 -32
  44. package/dist/tempo/server/Session.js.map +1 -1
  45. package/dist/tempo/server/internal/transport.d.ts +1 -1
  46. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/transport.js +41 -1
  48. package/dist/tempo/server/internal/transport.js.map +1 -1
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +51 -10
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  53. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  54. package/dist/tempo/session/ChannelStore.js +4 -2
  55. package/dist/tempo/session/ChannelStore.js.map +1 -1
  56. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  57. package/dist/tempo/session/Voucher.js +3 -2
  58. package/dist/tempo/session/Voucher.js.map +1 -1
  59. package/package.json +6 -2
  60. package/src/Store.test-d.ts +58 -0
  61. package/src/Store.ts +6 -4
  62. package/src/cli/cli.test.ts +124 -0
  63. package/src/cli/cli.ts +19 -7
  64. package/src/cli/plugins/tempo.ts +17 -23
  65. package/src/middlewares/elysia.test.ts +89 -0
  66. package/src/middlewares/elysia.ts +4 -1
  67. package/src/proxy/Proxy.test.ts +56 -0
  68. package/src/proxy/Proxy.ts +6 -1
  69. package/src/proxy/internal/Route.test.ts +57 -0
  70. package/src/proxy/internal/Route.ts +3 -1
  71. package/src/server/Mppx.test.ts +246 -0
  72. package/src/server/Mppx.ts +27 -8
  73. package/src/tempo/client/SessionManager.ts +11 -1
  74. package/src/tempo/internal/address.ts +6 -0
  75. package/src/tempo/internal/auto-swap.ts +3 -3
  76. package/src/tempo/internal/fee-payer.ts +18 -4
  77. package/src/tempo/server/Charge.test.ts +1080 -31
  78. package/src/tempo/server/Charge.ts +158 -63
  79. package/src/tempo/server/Session.test.ts +929 -111
  80. package/src/tempo/server/Session.ts +48 -33
  81. package/src/tempo/server/Sse.test.ts +1 -0
  82. package/src/tempo/server/internal/transport.test.ts +29 -0
  83. package/src/tempo/server/internal/transport.ts +41 -2
  84. package/src/tempo/session/Chain.test.ts +144 -0
  85. package/src/tempo/session/Chain.ts +58 -10
  86. package/src/tempo/session/ChannelStore.test.ts +10 -0
  87. package/src/tempo/session/ChannelStore.ts +6 -3
  88. package/src/tempo/session/Sse.test.ts +1 -0
  89. package/src/tempo/session/Voucher.ts +3 -2
package/src/cli/cli.ts CHANGED
@@ -130,15 +130,27 @@ const cli = Cli.create('mppx', {
130
130
  return hasProtocol ? c.args.url : `${isLocal ? 'http' : 'https'}://${c.args.url}`
131
131
  })()
132
132
  const { hostname } = new URL(url)
133
- if (
133
+ const insecure =
134
134
  c.options.insecure ||
135
135
  hostname === 'localhost' ||
136
136
  hostname.endsWith('.localhost') ||
137
137
  hostname.endsWith('.local')
138
- ) {
139
- process.removeAllListeners('warning')
140
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
141
- }
138
+
139
+ // Scoped fetch that temporarily disables TLS verification only for
140
+ // the target connection when `insecure` is true, then restores
141
+ // the original value so other HTTPS connections are unaffected.
142
+ const targetFetch: typeof globalThis.fetch = insecure
143
+ ? async (input, init) => {
144
+ const orig = process.env.NODE_TLS_REJECT_UNAUTHORIZED
145
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
146
+ try {
147
+ return await globalThis.fetch(input, init)
148
+ } finally {
149
+ if (orig === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
150
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = orig
151
+ }
152
+ }
153
+ : globalThis.fetch
142
154
 
143
155
  // Node.js doesn't resolve *.localhost subdomains to loopback (unlike
144
156
  // browsers per RFC 6761). Rewrite the URL to 127.0.0.1 and set the
@@ -170,7 +182,7 @@ const cli = Cli.create('mppx', {
170
182
  }
171
183
 
172
184
  if (c.options.verbose >= 2) printRequestHeaders(url, init, info)
173
- const challengeResponse = await globalThis.fetch(fetchUrl, init)
185
+ const challengeResponse = await targetFetch(fetchUrl, init)
174
186
  if (challengeResponse.status !== 402) {
175
187
  if (c.options.fail && challengeResponse.status >= 400)
176
188
  return c.error({
@@ -307,7 +319,7 @@ const cli = Cli.create('mppx', {
307
319
 
308
320
  const credentialFetchInit = { ...init, headers: credentialHeaders }
309
321
  if (c.options.verbose >= 2) printRequestHeaders(url, credentialFetchInit, info)
310
- const credentialResponse = await globalThis.fetch(fetchUrl, credentialFetchInit)
322
+ const credentialResponse = await targetFetch(fetchUrl, credentialFetchInit)
311
323
 
312
324
  if (c.options.fail && credentialResponse.status >= 400)
313
325
  return c.error({
@@ -34,6 +34,7 @@ export function tempo() {
34
34
  cumulativeAmount: bigint
35
35
  escrowContract: Address
36
36
  chainId: number
37
+ action?: 'voucher' | 'close'
37
38
  }): Promise<string>
38
39
  source: string
39
40
  }
@@ -183,11 +184,17 @@ export function tempo() {
183
184
 
184
185
  // Store session support for use in lifecycle hooks
185
186
  _session = {
186
- async signVoucher({ channelId, cumulativeAmount, escrowContract, chainId }) {
187
+ async signVoucher({
188
+ channelId,
189
+ cumulativeAmount,
190
+ escrowContract,
191
+ chainId,
192
+ action = 'voucher',
193
+ }) {
187
194
  return Credential.serialize({
188
195
  challenge,
189
196
  payload: {
190
- action: 'voucher',
197
+ action,
191
198
  channelId,
192
199
  cumulativeAmount: cumulativeAmount.toString(),
193
200
  signature: await signVoucher(
@@ -254,32 +261,17 @@ export function tempo() {
254
261
  }
255
262
  }
256
263
 
257
- // Handle non-SSE session response (server returned non-streaming)
258
- let credentialResponse = response
264
+ // Handle non-SSE session response (server returned non-streaming).
265
+ // The open credential already paid for this unit — no follow-up
266
+ // voucher is needed. Just record the cumulativeAmount so the
267
+ // channel close uses the correct value.
268
+ const credentialResponse = response
259
269
  if (
260
270
  credentialResponse.ok &&
261
271
  !credentialResponse.headers.get('Content-Type')?.includes('text/event-stream')
262
272
  ) {
263
273
  if (parsed.payload.action === 'open' && 'cumulativeAmount' in parsed.payload) {
264
- const tickAmount = BigInt(challengeRequest.amount as string)
265
- cumulativeAmount = BigInt(parsed.payload.cumulativeAmount) + tickAmount
266
-
267
- if (escrowContract) {
268
- const voucherCred = await _session.signVoucher({
269
- channelId,
270
- cumulativeAmount,
271
- escrowContract,
272
- chainId,
273
- })
274
- credentialResponse = await globalThis.fetch(fetchUrl, {
275
- ...fetchInit,
276
- headers: {
277
- ...(fetchInit.headers as Record<string, string>),
278
- Accept: 'text/event-stream',
279
- Authorization: voucherCred,
280
- },
281
- })
282
- }
274
+ cumulativeAmount = BigInt(parsed.payload.cumulativeAmount)
283
275
  }
284
276
  }
285
277
 
@@ -598,6 +590,7 @@ async function closeChannel(opts: {
598
590
  cumulativeAmount: bigint
599
591
  escrowContract: Address
600
592
  chainId: number
593
+ action?: 'voucher' | 'close'
601
594
  }): Promise<string>
602
595
  }
603
596
  info: (msg: string) => void
@@ -612,6 +605,7 @@ async function closeChannel(opts: {
612
605
  cumulativeAmount: opts.cumulativeAmount,
613
606
  escrowContract: opts.escrowContract,
614
607
  chainId: opts.chainId,
608
+ action: 'close',
615
609
  })
616
610
  const closeRes = await globalThis.fetch(opts.fetchUrl, {
617
611
  ...opts.fetchInit,
@@ -0,0 +1,89 @@
1
+ import * as http from 'node:http'
2
+ import { Elysia } from 'elysia'
3
+ import { Receipt } from 'mppx'
4
+ import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
5
+ import { Mppx } from 'mppx/elysia'
6
+ import { tempo as tempo_server } from 'mppx/server'
7
+ import { describe, expect, test } from 'vitest'
8
+ import { accounts, asset, client } from '~test/tempo/viem.js'
9
+
10
+ function createServer(app: Elysia<any, any, any, any, any, any, any>) {
11
+ return new Promise<{ url: string; close: () => void }>((resolve) => {
12
+ const server = http.createServer(async (req, res) => {
13
+ const url = `http://localhost${req.url}`
14
+ const headers = new Headers()
15
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
16
+ headers.append(req.rawHeaders[i]!, req.rawHeaders[i + 1]!)
17
+ const request = new Request(url, { method: req.method!, headers })
18
+ const response = await app.fetch(request)
19
+ res.writeHead(response.status, Object.fromEntries(response.headers))
20
+ const body = await response.text()
21
+ if (body) res.write(body)
22
+ res.end()
23
+ })
24
+ server.listen(0, () => {
25
+ const { port } = server.address() as { port: number }
26
+ resolve({
27
+ url: `http://localhost:${port}`,
28
+ close: () => server.close(),
29
+ })
30
+ })
31
+ })
32
+ }
33
+
34
+ const secretKey = 'test-secret-key'
35
+
36
+ describe('charge', () => {
37
+ const mppx = Mppx.create({
38
+ methods: [
39
+ tempo_server.charge({
40
+ getClient: () => client,
41
+ currency: asset,
42
+ recipient: accounts[0].address,
43
+ }),
44
+ ],
45
+ secretKey,
46
+ })
47
+
48
+ const { fetch } = Mppx_client.create({
49
+ polyfill: false,
50
+ methods: [
51
+ tempo_client.charge({
52
+ account: accounts[1],
53
+ getClient: () => client,
54
+ }),
55
+ ],
56
+ })
57
+
58
+ test('returns 402 when no credential', async () => {
59
+ const app = new Elysia().guard({ beforeHandle: mppx.charge({ amount: '1' }) }, (app) =>
60
+ app.get('/', () => ({ fortune: 'You will be rich' })),
61
+ )
62
+
63
+ const server = await createServer(app)
64
+ const response = await globalThis.fetch(server.url)
65
+ expect(response.status).toBe(402)
66
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
67
+
68
+ server.close()
69
+ })
70
+
71
+ test('returns 200 with receipt on valid payment', async () => {
72
+ const app = new Elysia().guard({ beforeHandle: mppx.charge({ amount: '1' }) }, (app) =>
73
+ app.get('/', () => ({ fortune: 'You will be rich' })),
74
+ )
75
+
76
+ const server = await createServer(app)
77
+ const response = await fetch(server.url)
78
+ expect(response.status).toBe(200)
79
+
80
+ const body = await response.json()
81
+ expect(body).toEqual({ fortune: 'You will be rich' })
82
+
83
+ const receipt = Receipt.fromResponse(response)
84
+ expect(receipt.status).toBe('success')
85
+ expect(receipt.method).toBe('tempo')
86
+
87
+ server.close()
88
+ })
89
+ })
@@ -59,8 +59,11 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
59
59
  intent: intent,
60
60
  options: intent extends (options: infer options) => any ? options : never,
61
61
  ): ElysiaHook {
62
- return async ({ request }) => {
62
+ return async ({ request, set }) => {
63
63
  const result = await intent(options)(request)
64
64
  if (result.status === 402) return result.challenge
65
+ const receipt = result.withReceipt(new Response())
66
+ const header = receipt.headers.get('Payment-Receipt')
67
+ if (header) set.headers['Payment-Receipt'] = header
65
68
  }
66
69
  }
@@ -630,6 +630,62 @@ describe('create', () => {
630
630
  })
631
631
  })
632
632
 
633
+ test('behavior: management POST falls back to paid route with different method', async () => {
634
+ upstream = await createUpstream(() => Response.json({ ok: true }))
635
+ const proxy = ApiProxy.create({
636
+ services: [
637
+ Service.from('api', {
638
+ baseUrl: upstream.url,
639
+ routes: {
640
+ // Registered as GET but management POSTs (e.g. session close)
641
+ // should still reach this paid endpoint via fallback.
642
+ 'GET /v1/stream': mppx_server.charge({ amount: '1', decimals: 6 }),
643
+ },
644
+ }),
645
+ ],
646
+ })
647
+ proxyServer = await Http.createServer(proxy.listener)
648
+
649
+ const res = await fetch(`${proxyServer.url}/api/v1/stream`, {
650
+ method: 'POST',
651
+ headers: { Authorization: 'x' },
652
+ })
653
+ // Should hit the paid endpoint and get a 402 challenge, not 404
654
+ expect(res.status).toBe(402)
655
+ })
656
+
657
+ test('behavior: POST to unregistered method does not fall back to free GET route', async () => {
658
+ upstream = await createUpstream(() => Response.json({ ok: true }))
659
+ const proxy = ApiProxy.create({
660
+ services: [
661
+ Service.from('api', {
662
+ baseUrl: upstream.url,
663
+ routes: {
664
+ // GET is free, but there is no POST handler
665
+ 'GET /v1beta/cachedContents': true,
666
+ 'POST /v1beta/models/:model': mppx_server.charge({
667
+ amount: '1',
668
+ decimals: 6,
669
+ }),
670
+ },
671
+ }),
672
+ ],
673
+ })
674
+ proxyServer = await Http.createServer(proxy.listener)
675
+
676
+ // A POST with a bogus authorization header should NOT fall back
677
+ // to the free GET route — it must return 404.
678
+ const res = await fetch(`${proxyServer.url}/api/v1beta/cachedContents`, {
679
+ method: 'POST',
680
+ headers: {
681
+ Authorization: 'x',
682
+ 'Content-Type': 'application/json',
683
+ },
684
+ body: JSON.stringify({ model: 'models/gemini-2.0-flash-001', contents: [] }),
685
+ })
686
+ expect(res.status).toBe(404)
687
+ })
688
+
633
689
  test('behavior: forwards query params to upstream', async () => {
634
690
  upstream = await createUpstream((req) => Response.json({ search: new URL(req.url).search }))
635
691
  const proxy = ApiProxy.create({
@@ -137,7 +137,12 @@ export function create(config: create.Config): Proxy {
137
137
  // is registered for a different HTTP method (e.g. GET). Fall back to
138
138
  // path-only matching so the payment handler can process the action.
139
139
  (request.method === 'POST' && request.headers.has('authorization')
140
- ? Route.matchPath(service.routes, upstreamPath)
140
+ ? Route.matchPath(
141
+ service.routes,
142
+ upstreamPath,
143
+ // skip free routes (e.g. `'GET /foo/bar': true`)
144
+ (endpoint) => endpoint !== true,
145
+ )
141
146
  : null)
142
147
  if (!matched) return new Response('Not Found', { status: 404 })
143
148
 
@@ -141,3 +141,60 @@ describe('match', () => {
141
141
  expect(Route.match(routes, 'GET', '/v1/chat/completions')).toBeNull()
142
142
  })
143
143
  })
144
+
145
+ describe('matchPath', () => {
146
+ const paidOnly = (v: unknown) => v !== true
147
+
148
+ test('behavior: matches route by path without filter', () => {
149
+ const routes = { 'GET /v1/models': true }
150
+ expect(Route.matchPath(routes, '/v1/models')).toMatchObject({
151
+ key: 'GET /v1/models',
152
+ })
153
+ })
154
+
155
+ test('behavior: matches paid endpoint by path', () => {
156
+ const routes = { 'GET /v1/generate': { pay: () => {} } }
157
+ expect(Route.matchPath(routes, '/v1/generate', paidOnly)).toMatchObject({
158
+ key: 'GET /v1/generate',
159
+ })
160
+ })
161
+
162
+ test('behavior: skips free passthrough routes with filter', () => {
163
+ const routes = {
164
+ 'GET /v1/models': true,
165
+ 'POST /v1/generate': { pay: () => {} },
166
+ }
167
+ expect(Route.matchPath(routes, '/v1/models', paidOnly)).toBeNull()
168
+ })
169
+
170
+ test('behavior: matches paid route even with different method', () => {
171
+ const routes = {
172
+ 'GET /v1/stream': { pay: () => {} },
173
+ }
174
+ expect(Route.matchPath(routes, '/v1/stream', paidOnly)).toMatchObject({
175
+ key: 'GET /v1/stream',
176
+ })
177
+ })
178
+
179
+ test('behavior: skips free and matches next paid route', () => {
180
+ const routes = {
181
+ 'GET /v1/*': true,
182
+ 'POST /v1/*': { pay: () => {} },
183
+ }
184
+ const result = Route.matchPath(routes, '/v1/cachedContents', paidOnly)
185
+ expect(result).toMatchObject({ key: 'POST /v1/*' })
186
+ })
187
+
188
+ test('error: returns null when all routes are free', () => {
189
+ const routes = {
190
+ 'GET /v1/models': true,
191
+ 'GET /v1/status': true,
192
+ }
193
+ expect(Route.matchPath(routes, '/v1/models', paidOnly)).toBeNull()
194
+ })
195
+
196
+ test('error: returns null when no path match', () => {
197
+ const routes = { 'POST /v1/generate': { pay: () => {} } }
198
+ expect(Route.matchPath(routes, '/v2/unknown', paidOnly)).toBeNull()
199
+ })
200
+ })
@@ -36,12 +36,14 @@ export function match(
36
36
  return null
37
37
  }
38
38
 
39
- /** Finds the first route matching just the path, ignoring the HTTP method. Used for management POST fallback. */
39
+ /** Finds the first route matching the path, ignoring the HTTP method. Optional `filter` predicate can exclude routes. */
40
40
  export function matchPath(
41
41
  routes: Record<string, unknown>,
42
42
  path: string,
43
+ filter?: (value: unknown) => boolean,
43
44
  ): { key: string; value: unknown } | null {
44
45
  for (const [key, value] of Object.entries(routes)) {
46
+ if (filter && !filter(value)) continue
45
47
  const { pattern } = parseRouteKey(key)
46
48
  const urlPattern = new URLPattern({ pathname: pattern })
47
49
  if (urlPattern.test({ pathname: path })) return { key, value }
@@ -1130,6 +1130,252 @@ describe('nested accessors', () => {
1130
1130
  })
1131
1131
  })
1132
1132
 
1133
+ describe('cross-route credential replay via scope binding flaw', () => {
1134
+ // Method whose schema transform moves `amount`, `currency`, and `recipient`
1135
+ // into `methodDetails`, removing them from the top-level request. This mirrors
1136
+ // real-world methods (e.g. Tempo) that restructure fields via z.transform.
1137
+ const transformingMethod = Method.from({
1138
+ name: 'mock',
1139
+ intent: 'charge',
1140
+ schema: {
1141
+ credential: {
1142
+ payload: z.object({ token: z.string() }),
1143
+ },
1144
+ request: z.pipe(
1145
+ z.object({
1146
+ amount: z.string(),
1147
+ currency: z.string(),
1148
+ decimals: z.number(),
1149
+ recipient: z.string(),
1150
+ }),
1151
+ z.transform(({ amount, currency, decimals, recipient }) => ({
1152
+ methodDetails: { amount: String(Number(amount) * 10 ** decimals), currency, recipient },
1153
+ })),
1154
+ ),
1155
+ },
1156
+ })
1157
+
1158
+ function mockReceipt() {
1159
+ return {
1160
+ method: 'mock',
1161
+ reference: 'tx-ref',
1162
+ status: 'success' as const,
1163
+ timestamp: new Date().toISOString(),
1164
+ }
1165
+ }
1166
+
1167
+ const serverMethod = Method.toServer(transformingMethod, {
1168
+ async verify() {
1169
+ return mockReceipt()
1170
+ },
1171
+ })
1172
+
1173
+ test('rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
1174
+ const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
1175
+
1176
+ // Get a challenge from the "cheap" route ($0.01)
1177
+ const cheapHandle = handler.charge({
1178
+ amount: '0.01',
1179
+ currency: '0x0000000000000000000000000000000000000001',
1180
+ decimals: 6,
1181
+ expires: new Date(Date.now() + 60_000).toISOString(),
1182
+ recipient: '0x0000000000000000000000000000000000000002',
1183
+ })
1184
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
1185
+ expect(cheapResult.status).toBe(402)
1186
+ if (cheapResult.status !== 402) throw new Error()
1187
+
1188
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
1189
+
1190
+ // Build a credential from the cheap challenge
1191
+ const credential = Credential.from({
1192
+ challenge: cheapChallenge,
1193
+ payload: { token: 'valid' },
1194
+ })
1195
+
1196
+ // Present the cheap credential at the "expensive" route ($100)
1197
+ const expensiveHandle = handler.charge({
1198
+ amount: '100',
1199
+ currency: '0x0000000000000000000000000000000000000001',
1200
+ decimals: 6,
1201
+ expires: new Date(Date.now() + 60_000).toISOString(),
1202
+ recipient: '0x0000000000000000000000000000000000000002',
1203
+ })
1204
+ const result = await expensiveHandle(
1205
+ new Request('https://example.com/expensive', {
1206
+ headers: { Authorization: Credential.serialize(credential) },
1207
+ }),
1208
+ )
1209
+
1210
+ // Should be 402 (credential was for $0.01, not $100)
1211
+ expect(result.status).toBe(402)
1212
+ })
1213
+
1214
+ test('rejects credential with mismatched method field', async () => {
1215
+ const otherMethod = Method.from({
1216
+ name: 'other',
1217
+ intent: 'charge',
1218
+ schema: {
1219
+ credential: { payload: z.object({ token: z.string() }) },
1220
+ request: z.object({
1221
+ amount: z.string(),
1222
+ currency: z.string(),
1223
+ decimals: z.number(),
1224
+ recipient: z.string(),
1225
+ }),
1226
+ },
1227
+ })
1228
+
1229
+ const otherServerMethod = Method.toServer(otherMethod, {
1230
+ async verify() {
1231
+ return mockReceipt()
1232
+ },
1233
+ })
1234
+
1235
+ const handler = Mppx.create({ methods: [serverMethod, otherServerMethod], realm, secretKey })
1236
+
1237
+ // Get challenge from mock/charge
1238
+ const mockHandle = handler['mock/charge']({
1239
+ amount: '1',
1240
+ currency: '0x0000000000000000000000000000000000000001',
1241
+ decimals: 6,
1242
+ expires: new Date(Date.now() + 60_000).toISOString(),
1243
+ recipient: '0x0000000000000000000000000000000000000002',
1244
+ })
1245
+ const mockResult = await mockHandle(new Request('https://example.com/mock'))
1246
+ expect(mockResult.status).toBe(402)
1247
+ if (mockResult.status !== 402) throw new Error()
1248
+
1249
+ const mockChallenge = Challenge.fromResponse(mockResult.challenge)
1250
+ const credential = Credential.from({
1251
+ challenge: mockChallenge,
1252
+ payload: { token: 'valid' },
1253
+ })
1254
+
1255
+ // Present mock/charge credential at other/charge route
1256
+ const otherHandle = handler['other/charge']({
1257
+ amount: '1',
1258
+ currency: '0x0000000000000000000000000000000000000001',
1259
+ decimals: 6,
1260
+ expires: new Date(Date.now() + 60_000).toISOString(),
1261
+ recipient: '0x0000000000000000000000000000000000000002',
1262
+ })
1263
+ const result = await otherHandle(
1264
+ new Request('https://example.com/other', {
1265
+ headers: { Authorization: Credential.serialize(credential) },
1266
+ }),
1267
+ )
1268
+
1269
+ // Should reject (credential was for method "mock", not "other")
1270
+ expect(result.status).toBe(402)
1271
+ })
1272
+
1273
+ test('rejects credential with mismatched intent field', async () => {
1274
+ const sessionMethod = Method.from({
1275
+ name: 'mock',
1276
+ intent: 'session',
1277
+ schema: {
1278
+ credential: { payload: z.object({ token: z.string() }) },
1279
+ request: z.object({
1280
+ amount: z.string(),
1281
+ currency: z.string(),
1282
+ decimals: z.number(),
1283
+ recipient: z.string(),
1284
+ }),
1285
+ },
1286
+ })
1287
+
1288
+ const sessionServerMethod = Method.toServer(sessionMethod, {
1289
+ async verify() {
1290
+ return mockReceipt()
1291
+ },
1292
+ })
1293
+
1294
+ const handler = Mppx.create({ methods: [serverMethod, sessionServerMethod], realm, secretKey })
1295
+
1296
+ // Get challenge from mock/charge
1297
+ const chargeHandle = handler['mock/charge']({
1298
+ amount: '1',
1299
+ currency: '0x0000000000000000000000000000000000000001',
1300
+ decimals: 6,
1301
+ expires: new Date(Date.now() + 60_000).toISOString(),
1302
+ recipient: '0x0000000000000000000000000000000000000002',
1303
+ })
1304
+ const chargeResult = await chargeHandle(new Request('https://example.com/charge'))
1305
+ expect(chargeResult.status).toBe(402)
1306
+ if (chargeResult.status !== 402) throw new Error()
1307
+
1308
+ const chargeChallenge = Challenge.fromResponse(chargeResult.challenge)
1309
+ const credential = Credential.from({
1310
+ challenge: chargeChallenge,
1311
+ payload: { token: 'valid' },
1312
+ })
1313
+
1314
+ // Present charge credential at session route
1315
+ const sessionHandle = handler['mock/session']({
1316
+ amount: '1',
1317
+ currency: '0x0000000000000000000000000000000000000001',
1318
+ decimals: 6,
1319
+ expires: new Date(Date.now() + 60_000).toISOString(),
1320
+ recipient: '0x0000000000000000000000000000000000000002',
1321
+ })
1322
+ const result = await sessionHandle(
1323
+ new Request('https://example.com/session', {
1324
+ headers: { Authorization: Credential.serialize(credential) },
1325
+ }),
1326
+ )
1327
+
1328
+ // Should reject (credential was for intent "charge", not "session")
1329
+ expect(result.status).toBe(402)
1330
+ })
1331
+
1332
+ test('compose: rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
1333
+ const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
1334
+
1335
+ const cheapHandle = handler.charge({
1336
+ amount: '0.01',
1337
+ currency: '0x0000000000000000000000000000000000000001',
1338
+ decimals: 6,
1339
+ expires: new Date(Date.now() + 60_000).toISOString(),
1340
+ recipient: '0x0000000000000000000000000000000000000002',
1341
+ })
1342
+ const expensiveHandle = handler.charge({
1343
+ amount: '100',
1344
+ currency: '0x0000000000000000000000000000000000000001',
1345
+ decimals: 6,
1346
+ expires: new Date(Date.now() + 60_000).toISOString(),
1347
+ recipient: '0x0000000000000000000000000000000000000002',
1348
+ })
1349
+
1350
+ const composed = Mppx.compose(cheapHandle, expensiveHandle)
1351
+
1352
+ // Get challenge (pick the cheap one)
1353
+ const firstResult = await composed(new Request('https://example.com/resource'))
1354
+ expect(firstResult.status).toBe(402)
1355
+ if (firstResult.status !== 402) throw new Error()
1356
+
1357
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
1358
+ const cheapChallenge = challenges[0]!
1359
+
1360
+ const credential = Credential.from({
1361
+ challenge: cheapChallenge,
1362
+ payload: { token: 'valid' },
1363
+ })
1364
+
1365
+ // The composed handler should NOT route the cheap credential to the expensive handler
1366
+ const result = await composed(
1367
+ new Request('https://example.com/resource', {
1368
+ headers: { Authorization: Credential.serialize(credential) },
1369
+ }),
1370
+ )
1371
+
1372
+ // If scope binding works, the credential should route to the cheap handler only.
1373
+ // It should NOT match the expensive handler's canonical request.
1374
+ // The result should be 200 (matched to cheap), not routed to expensive.
1375
+ expect(result.status).toBe(200)
1376
+ })
1377
+ })
1378
+
1133
1379
  describe('withReceipt', () => {
1134
1380
  const mockCharge = Method.from({
1135
1381
  name: 'mock',