mppx 0.4.7 → 0.4.9

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 (209) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/README.md +13 -13
  3. package/dist/BodyDigest.d.ts.map +1 -1
  4. package/dist/BodyDigest.js.map +1 -1
  5. package/dist/Challenge.d.ts.map +1 -1
  6. package/dist/Challenge.js.map +1 -1
  7. package/dist/Credential.d.ts.map +1 -1
  8. package/dist/Credential.js.map +1 -1
  9. package/dist/Errors.js +64 -67
  10. package/dist/Errors.js.map +1 -1
  11. package/dist/PaymentRequest.d.ts.map +1 -1
  12. package/dist/PaymentRequest.js.map +1 -1
  13. package/dist/Receipt.d.ts.map +1 -1
  14. package/dist/Receipt.js.map +1 -1
  15. package/dist/Store.d.ts +14 -4
  16. package/dist/Store.d.ts.map +1 -1
  17. package/dist/Store.js +17 -0
  18. package/dist/Store.js.map +1 -1
  19. package/dist/cli/account.d.ts.map +1 -1
  20. package/dist/cli/account.js +40 -5
  21. package/dist/cli/account.js.map +1 -1
  22. package/dist/cli/cli.d.ts.map +1 -1
  23. package/dist/cli/cli.js +24 -8
  24. package/dist/cli/cli.js.map +1 -1
  25. package/dist/cli/internal.d.ts.map +1 -1
  26. package/dist/cli/internal.js.map +1 -1
  27. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  28. package/dist/cli/plugins/stripe.js.map +1 -1
  29. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  30. package/dist/cli/plugins/tempo.js +11 -23
  31. package/dist/cli/plugins/tempo.js.map +1 -1
  32. package/dist/cli/utils.d.ts.map +1 -1
  33. package/dist/cli/utils.js.map +1 -1
  34. package/dist/client/internal/Fetch.d.ts +2 -0
  35. package/dist/client/internal/Fetch.d.ts.map +1 -1
  36. package/dist/client/internal/Fetch.js +1 -1
  37. package/dist/client/internal/Fetch.js.map +1 -1
  38. package/dist/internal/types.d.ts.map +1 -1
  39. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  40. package/dist/mcp-sdk/client/McpClient.js +1 -1
  41. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  42. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  43. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  44. package/dist/middlewares/elysia.d.ts.map +1 -1
  45. package/dist/middlewares/elysia.js +5 -1
  46. package/dist/middlewares/elysia.js.map +1 -1
  47. package/dist/middlewares/express.d.ts.map +1 -1
  48. package/dist/middlewares/express.js +5 -2
  49. package/dist/middlewares/express.js.map +1 -1
  50. package/dist/middlewares/hono.d.ts.map +1 -1
  51. package/dist/middlewares/hono.js.map +1 -1
  52. package/dist/proxy/Proxy.d.ts.map +1 -1
  53. package/dist/proxy/Proxy.js +3 -1
  54. package/dist/proxy/Proxy.js.map +1 -1
  55. package/dist/proxy/Service.js +1 -1
  56. package/dist/proxy/Service.js.map +1 -1
  57. package/dist/proxy/internal/Route.d.ts +2 -2
  58. package/dist/proxy/internal/Route.d.ts.map +1 -1
  59. package/dist/proxy/internal/Route.js +4 -2
  60. package/dist/proxy/internal/Route.js.map +1 -1
  61. package/dist/server/Mppx.d.ts.map +1 -1
  62. package/dist/server/Mppx.js +47 -11
  63. package/dist/server/Mppx.js.map +1 -1
  64. package/dist/server/Request.d.ts.map +1 -1
  65. package/dist/server/Request.js.map +1 -1
  66. package/dist/stripe/Methods.d.ts.map +1 -1
  67. package/dist/stripe/Methods.js.map +1 -1
  68. package/dist/tempo/Methods.d.ts.map +1 -1
  69. package/dist/tempo/Methods.js.map +1 -1
  70. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  71. package/dist/tempo/client/ChannelOps.js.map +1 -1
  72. package/dist/tempo/client/Charge.d.ts.map +1 -1
  73. package/dist/tempo/client/Charge.js.map +1 -1
  74. package/dist/tempo/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/client/Session.js.map +1 -1
  76. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  77. package/dist/tempo/client/SessionManager.js +1 -1
  78. package/dist/tempo/client/SessionManager.js.map +1 -1
  79. package/dist/tempo/internal/address.d.ts +3 -0
  80. package/dist/tempo/internal/address.d.ts.map +1 -0
  81. package/dist/tempo/internal/address.js +4 -0
  82. package/dist/tempo/internal/address.js.map +1 -0
  83. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  84. package/dist/tempo/internal/auto-swap.js +4 -4
  85. package/dist/tempo/internal/auto-swap.js.map +1 -1
  86. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  87. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  88. package/dist/tempo/internal/fee-payer.js +12 -4
  89. package/dist/tempo/internal/fee-payer.js.map +1 -1
  90. package/dist/tempo/server/Charge.d.ts +11 -0
  91. package/dist/tempo/server/Charge.d.ts.map +1 -1
  92. package/dist/tempo/server/Charge.js +110 -51
  93. package/dist/tempo/server/Charge.js.map +1 -1
  94. package/dist/tempo/server/Session.d.ts +1 -1
  95. package/dist/tempo/server/Session.d.ts.map +1 -1
  96. package/dist/tempo/server/Session.js +31 -23
  97. package/dist/tempo/server/Session.js.map +1 -1
  98. package/dist/tempo/server/internal/transport.d.ts +1 -1
  99. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  100. package/dist/tempo/server/internal/transport.js +41 -1
  101. package/dist/tempo/server/internal/transport.js.map +1 -1
  102. package/dist/tempo/session/Chain.d.ts.map +1 -1
  103. package/dist/tempo/session/Chain.js +51 -10
  104. package/dist/tempo/session/Chain.js.map +1 -1
  105. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  106. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  107. package/dist/tempo/session/ChannelStore.js +4 -2
  108. package/dist/tempo/session/ChannelStore.js.map +1 -1
  109. package/dist/tempo/session/Receipt.d.ts.map +1 -1
  110. package/dist/tempo/session/Receipt.js.map +1 -1
  111. package/dist/tempo/session/Sse.d.ts.map +1 -1
  112. package/dist/tempo/session/Sse.js.map +1 -1
  113. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  114. package/dist/tempo/session/Voucher.js +3 -2
  115. package/dist/tempo/session/Voucher.js.map +1 -1
  116. package/dist/viem/Client.d.ts.map +1 -1
  117. package/dist/viem/Client.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/BodyDigest.ts +1 -0
  120. package/src/Challenge.test-d.ts +1 -0
  121. package/src/Challenge.ts +1 -0
  122. package/src/Credential.ts +1 -0
  123. package/src/Errors.test.ts +27 -39
  124. package/src/Expires.test.ts +1 -0
  125. package/src/PaymentRequest.ts +1 -0
  126. package/src/Receipt.ts +1 -0
  127. package/src/Store.test-d.ts +59 -0
  128. package/src/Store.test.ts +56 -6
  129. package/src/Store.ts +31 -4
  130. package/src/cli/account.ts +65 -30
  131. package/src/cli/cli.test.ts +127 -1
  132. package/src/cli/cli.ts +23 -8
  133. package/src/cli/config.test.ts +1 -0
  134. package/src/cli/internal.ts +1 -0
  135. package/src/cli/plugins/stripe.ts +1 -0
  136. package/src/cli/plugins/tempo.ts +21 -24
  137. package/src/cli/utils.ts +1 -0
  138. package/src/client/Mppx.test-d.ts +1 -0
  139. package/src/client/internal/Fetch.browser.test.ts +1 -0
  140. package/src/client/internal/Fetch.test-d.ts +1 -0
  141. package/src/client/internal/Fetch.test.ts +1 -0
  142. package/src/client/internal/Fetch.ts +1 -1
  143. package/src/internal/constantTimeEqual.test.ts +1 -0
  144. package/src/internal/types.ts +1 -3
  145. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -0
  146. package/src/mcp-sdk/client/McpClient.test.ts +1 -0
  147. package/src/mcp-sdk/client/McpClient.ts +2 -0
  148. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  149. package/src/mcp-sdk/server/Transport.ts +1 -0
  150. package/src/middlewares/elysia.test.ts +90 -0
  151. package/src/middlewares/elysia.ts +5 -1
  152. package/src/middlewares/express.test.ts +62 -2
  153. package/src/middlewares/express.ts +6 -2
  154. package/src/middlewares/hono.ts +1 -0
  155. package/src/middlewares/internal/mppx.test.ts +1 -0
  156. package/src/middlewares/nextjs.test.ts +1 -0
  157. package/src/proxy/Proxy.test.ts +57 -0
  158. package/src/proxy/Proxy.ts +8 -1
  159. package/src/proxy/Service.test.ts +1 -0
  160. package/src/proxy/Service.ts +8 -2
  161. package/src/proxy/internal/Headers.test.ts +1 -0
  162. package/src/proxy/internal/Route.test.ts +57 -0
  163. package/src/proxy/internal/Route.ts +3 -1
  164. package/src/proxy/services/openai.test.ts +1 -0
  165. package/src/server/Mppx.test.ts +438 -0
  166. package/src/server/Mppx.ts +51 -13
  167. package/src/server/Request.test.ts +1 -0
  168. package/src/server/Request.ts +1 -0
  169. package/src/server/Response.test.ts +1 -0
  170. package/src/server/Transport.test.ts +1 -0
  171. package/src/stripe/Methods.ts +1 -0
  172. package/src/stripe/client/Charge.test.ts +1 -0
  173. package/src/stripe/server/Charge.test.ts +1 -0
  174. package/src/tempo/Attribution.test.ts +1 -0
  175. package/src/tempo/Methods.ts +1 -0
  176. package/src/tempo/client/ChannelOps.test.ts +1 -0
  177. package/src/tempo/client/ChannelOps.ts +1 -0
  178. package/src/tempo/client/Charge.ts +1 -0
  179. package/src/tempo/client/Session.test.ts +1 -0
  180. package/src/tempo/client/Session.ts +1 -0
  181. package/src/tempo/client/SessionManager.test.ts +28 -0
  182. package/src/tempo/client/SessionManager.ts +2 -1
  183. package/src/tempo/internal/address.ts +6 -0
  184. package/src/tempo/internal/auto-swap.test.ts +1 -0
  185. package/src/tempo/internal/auto-swap.ts +4 -3
  186. package/src/tempo/internal/defaults.test.ts +1 -0
  187. package/src/tempo/internal/fee-payer.test.ts +1 -0
  188. package/src/tempo/internal/fee-payer.ts +19 -4
  189. package/src/tempo/server/Charge.test.ts +1081 -31
  190. package/src/tempo/server/Charge.ts +159 -63
  191. package/src/tempo/server/Session.test.ts +896 -107
  192. package/src/tempo/server/Session.ts +41 -23
  193. package/src/tempo/server/Sse.test.ts +2 -0
  194. package/src/tempo/server/internal/transport.test.ts +30 -0
  195. package/src/tempo/server/internal/transport.ts +41 -2
  196. package/src/tempo/session/Chain.test.ts +145 -0
  197. package/src/tempo/session/Chain.ts +59 -10
  198. package/src/tempo/session/Channel.test.ts +1 -0
  199. package/src/tempo/session/ChannelStore.test.ts +11 -0
  200. package/src/tempo/session/ChannelStore.ts +7 -3
  201. package/src/tempo/session/Receipt.test.ts +1 -0
  202. package/src/tempo/session/Receipt.ts +1 -0
  203. package/src/tempo/session/Sse.test.ts +2 -0
  204. package/src/tempo/session/Sse.ts +1 -0
  205. package/src/tempo/session/Voucher.test.ts +1 -0
  206. package/src/tempo/session/Voucher.ts +4 -2
  207. package/src/viem/Account.test.ts +1 -0
  208. package/src/viem/Client.test.ts +1 -0
  209. package/src/viem/Client.ts +1 -0
@@ -267,8 +267,14 @@ function resolvePayment(endpoint: Endpoint): Record<string, unknown> | null {
267
267
  if (endpoint === true) return null
268
268
  const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay
269
269
  if (!('_internal' in handler)) return {}
270
- const { name, intent, defaults, schema, _canonicalRequest, ...rest } =
271
- handler._internal as Record<string, unknown>
270
+ const {
271
+ name,
272
+ intent,
273
+ defaults: _,
274
+ schema: _s,
275
+ _canonicalRequest,
276
+ ...rest
277
+ } = handler._internal as Record<string, unknown>
272
278
  const amount = (() => {
273
279
  if (typeof rest.amount === 'string' && typeof rest.decimals === 'number')
274
280
  return String(Value.from(rest.amount, rest.decimals))
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest'
2
+
2
3
  import * as Headers from './Headers.js'
3
4
 
4
5
  describe('scrub', () => {
@@ -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 }
@@ -4,6 +4,7 @@ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { afterEach, describe, expect, test } from 'vitest'
5
5
  import * as Http from '~test/Http.js'
6
6
  import { accounts, asset, client } from '~test/tempo/viem.js'
7
+
7
8
  import * as ApiProxy from '../Proxy.js'
8
9
  import { openai } from './openai.js'
9
10
 
@@ -180,6 +180,198 @@ describe('request handler', () => {
180
180
  expect(body.detail).toContain('does not match')
181
181
  })
182
182
 
183
+ test('topUp credential bypasses cross-route amount validation', async () => {
184
+ // Use a session method whose schema defines action: 'topUp'
185
+ const sessionMethod = Method.from({
186
+ name: 'mock',
187
+ intent: 'session',
188
+ schema: {
189
+ credential: {
190
+ payload: z.discriminatedUnion('action', [
191
+ z.object({ action: z.literal('open'), token: z.string() }),
192
+ z.object({ action: z.literal('topUp'), token: z.string() }),
193
+ ]),
194
+ },
195
+ request: z.object({
196
+ amount: z.string(),
197
+ currency: z.string(),
198
+ recipient: z.string(),
199
+ }),
200
+ },
201
+ })
202
+ const sessionServerMethod = Method.toServer(sessionMethod, {
203
+ async verify() {
204
+ return {
205
+ status: 'settled',
206
+ method: 'mock',
207
+ timestamp: new Date().toISOString(),
208
+ reference: 'ref',
209
+ } as any
210
+ },
211
+ })
212
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
213
+
214
+ // Get a challenge from the "cheap" route (simulates HEAD-obtained challenge)
215
+ const cheapHandle = handler['mock/session']({
216
+ amount: '1',
217
+ currency: asset,
218
+ expires: new Date(Date.now() + 60_000).toISOString(),
219
+ recipient: accounts[0].address,
220
+ })
221
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
222
+ expect(cheapResult.status).toBe(402)
223
+ if (cheapResult.status !== 402) throw new Error()
224
+
225
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
226
+
227
+ // Build a topUp credential from the cheap challenge (echoed from HEAD)
228
+ const credential = Credential.from({
229
+ challenge: cheapChallenge,
230
+ payload: { action: 'topUp', token: 'valid' },
231
+ })
232
+
233
+ // Present it at the "expensive" route — topUp should bypass amount check
234
+ const expensiveHandle = handler['mock/session']({
235
+ amount: '1000000',
236
+ currency: asset,
237
+ expires: new Date(Date.now() + 60_000).toISOString(),
238
+ recipient: accounts[0].address,
239
+ })
240
+ const result = await expensiveHandle(
241
+ new Request('https://example.com/expensive', {
242
+ headers: { Authorization: Credential.serialize(credential) },
243
+ }),
244
+ )
245
+
246
+ // Should NOT get 402 for amount mismatch — topUp bypasses the check.
247
+ // It will fail at a later stage (payload validation), but not with
248
+ // "does not match this route's requirements".
249
+ if (result.status === 402) {
250
+ const body = (await result.challenge.json()) as { detail?: string }
251
+ expect(body.detail).not.toContain('does not match')
252
+ }
253
+ })
254
+
255
+ test('voucher credential bypasses cross-route amount validation', async () => {
256
+ const sessionMethod = Method.from({
257
+ name: 'mock',
258
+ intent: 'session',
259
+ schema: {
260
+ credential: {
261
+ payload: z.discriminatedUnion('action', [
262
+ z.object({ action: z.literal('open'), token: z.string() }),
263
+ z.object({
264
+ action: z.literal('voucher'),
265
+ cumulativeAmount: z.string(),
266
+ signature: z.string(),
267
+ }),
268
+ ]),
269
+ },
270
+ request: z.object({
271
+ amount: z.string(),
272
+ currency: z.string(),
273
+ recipient: z.string(),
274
+ }),
275
+ },
276
+ })
277
+ const sessionServerMethod = Method.toServer(sessionMethod, {
278
+ async verify() {
279
+ return {
280
+ status: 'settled',
281
+ method: 'mock',
282
+ timestamp: new Date().toISOString(),
283
+ reference: 'ref',
284
+ } as any
285
+ },
286
+ })
287
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
288
+
289
+ // Get a challenge from the "cheap" route (simulates initial SSE request)
290
+ const cheapHandle = handler['mock/session']({
291
+ amount: '1',
292
+ currency: asset,
293
+ expires: new Date(Date.now() + 60_000).toISOString(),
294
+ recipient: accounts[0].address,
295
+ })
296
+ const cheapResult = await cheapHandle(new Request('https://example.com/chat'))
297
+ expect(cheapResult.status).toBe(402)
298
+ if (cheapResult.status !== 402) throw new Error()
299
+
300
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
301
+
302
+ // Build a voucher credential echoing the original challenge — mid-stream
303
+ // the server may re-price (dynamic pricing), so the route's amount differs
304
+ const credential = Credential.from({
305
+ challenge: cheapChallenge,
306
+ payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
307
+ })
308
+
309
+ // Present it at the same route but with a higher price — voucher should
310
+ // bypass the cross-route amount check just like topUp does
311
+ const expensiveHandle = handler['mock/session']({
312
+ amount: '1000000',
313
+ currency: asset,
314
+ expires: new Date(Date.now() + 60_000).toISOString(),
315
+ recipient: accounts[0].address,
316
+ })
317
+ const result = await expensiveHandle(
318
+ new Request('https://example.com/chat', {
319
+ headers: { Authorization: Credential.serialize(credential) },
320
+ }),
321
+ )
322
+
323
+ // Should NOT get 402 for amount mismatch — voucher bypasses the check.
324
+ if (result.status === 402) {
325
+ const body = (await result.challenge.json()) as { detail?: string }
326
+ expect(body.detail).not.toContain('does not match')
327
+ }
328
+ })
329
+
330
+ test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
331
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
332
+
333
+ // Get a challenge from the "cheap" route
334
+ const cheapHandle = handler.charge({
335
+ amount: '1',
336
+ currency: asset,
337
+ expires: new Date(Date.now() + 60_000).toISOString(),
338
+ recipient: accounts[0].address,
339
+ })
340
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
341
+ expect(cheapResult.status).toBe(402)
342
+ if (cheapResult.status !== 402) throw new Error()
343
+
344
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
345
+
346
+ // Malicious client injects action: 'topUp' into a regular charge credential
347
+ // to try to bypass the cross-route amount check
348
+ const credential = Credential.from({
349
+ challenge: cheapChallenge,
350
+ payload: { action: 'topUp', signature: '0x123', type: 'transaction' },
351
+ })
352
+
353
+ // Present it at the "expensive" route — should still be rejected
354
+ const expensiveHandle = handler.charge({
355
+ amount: '1000000',
356
+ currency: asset,
357
+ expires: new Date(Date.now() + 60_000).toISOString(),
358
+ recipient: accounts[0].address,
359
+ })
360
+ const result = await expensiveHandle(
361
+ new Request('https://example.com/expensive', {
362
+ headers: { Authorization: Credential.serialize(credential) },
363
+ }),
364
+ )
365
+
366
+ // Injecting action: 'topUp' on a charge credential must not bypass
367
+ // the cross-route amount check. The credential should be rejected
368
+ // with "does not match" just like a normal charge credential would be.
369
+ expect(result.status).toBe(402)
370
+ if (result.status !== 402) throw new Error()
371
+ const body = (await result.challenge.json()) as { detail: string }
372
+ expect(body.detail).toContain('does not match')
373
+ })
374
+
183
375
  test('returns 402 when credential challenge is expired', async () => {
184
376
  const pastExpires = new Date(Date.now() - 60_000).toISOString()
185
377
 
@@ -1130,6 +1322,252 @@ describe('nested accessors', () => {
1130
1322
  })
1131
1323
  })
1132
1324
 
1325
+ describe('cross-route credential replay via scope binding flaw', () => {
1326
+ // Method whose schema transform moves `amount`, `currency`, and `recipient`
1327
+ // into `methodDetails`, removing them from the top-level request. This mirrors
1328
+ // real-world methods (e.g. Tempo) that restructure fields via z.transform.
1329
+ const transformingMethod = Method.from({
1330
+ name: 'mock',
1331
+ intent: 'charge',
1332
+ schema: {
1333
+ credential: {
1334
+ payload: z.object({ token: z.string() }),
1335
+ },
1336
+ request: z.pipe(
1337
+ z.object({
1338
+ amount: z.string(),
1339
+ currency: z.string(),
1340
+ decimals: z.number(),
1341
+ recipient: z.string(),
1342
+ }),
1343
+ z.transform(({ amount, currency, decimals, recipient }) => ({
1344
+ methodDetails: { amount: String(Number(amount) * 10 ** decimals), currency, recipient },
1345
+ })),
1346
+ ),
1347
+ },
1348
+ })
1349
+
1350
+ function mockReceipt() {
1351
+ return {
1352
+ method: 'mock',
1353
+ reference: 'tx-ref',
1354
+ status: 'success' as const,
1355
+ timestamp: new Date().toISOString(),
1356
+ }
1357
+ }
1358
+
1359
+ const serverMethod = Method.toServer(transformingMethod, {
1360
+ async verify() {
1361
+ return mockReceipt()
1362
+ },
1363
+ })
1364
+
1365
+ test('rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
1366
+ const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
1367
+
1368
+ // Get a challenge from the "cheap" route ($0.01)
1369
+ const cheapHandle = handler.charge({
1370
+ amount: '0.01',
1371
+ currency: '0x0000000000000000000000000000000000000001',
1372
+ decimals: 6,
1373
+ expires: new Date(Date.now() + 60_000).toISOString(),
1374
+ recipient: '0x0000000000000000000000000000000000000002',
1375
+ })
1376
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
1377
+ expect(cheapResult.status).toBe(402)
1378
+ if (cheapResult.status !== 402) throw new Error()
1379
+
1380
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
1381
+
1382
+ // Build a credential from the cheap challenge
1383
+ const credential = Credential.from({
1384
+ challenge: cheapChallenge,
1385
+ payload: { token: 'valid' },
1386
+ })
1387
+
1388
+ // Present the cheap credential at the "expensive" route ($100)
1389
+ const expensiveHandle = handler.charge({
1390
+ amount: '100',
1391
+ currency: '0x0000000000000000000000000000000000000001',
1392
+ decimals: 6,
1393
+ expires: new Date(Date.now() + 60_000).toISOString(),
1394
+ recipient: '0x0000000000000000000000000000000000000002',
1395
+ })
1396
+ const result = await expensiveHandle(
1397
+ new Request('https://example.com/expensive', {
1398
+ headers: { Authorization: Credential.serialize(credential) },
1399
+ }),
1400
+ )
1401
+
1402
+ // Should be 402 (credential was for $0.01, not $100)
1403
+ expect(result.status).toBe(402)
1404
+ })
1405
+
1406
+ test('rejects credential with mismatched method field', async () => {
1407
+ const otherMethod = Method.from({
1408
+ name: 'other',
1409
+ intent: 'charge',
1410
+ schema: {
1411
+ credential: { payload: z.object({ token: z.string() }) },
1412
+ request: z.object({
1413
+ amount: z.string(),
1414
+ currency: z.string(),
1415
+ decimals: z.number(),
1416
+ recipient: z.string(),
1417
+ }),
1418
+ },
1419
+ })
1420
+
1421
+ const otherServerMethod = Method.toServer(otherMethod, {
1422
+ async verify() {
1423
+ return mockReceipt()
1424
+ },
1425
+ })
1426
+
1427
+ const handler = Mppx.create({ methods: [serverMethod, otherServerMethod], realm, secretKey })
1428
+
1429
+ // Get challenge from mock/charge
1430
+ const mockHandle = handler['mock/charge']({
1431
+ amount: '1',
1432
+ currency: '0x0000000000000000000000000000000000000001',
1433
+ decimals: 6,
1434
+ expires: new Date(Date.now() + 60_000).toISOString(),
1435
+ recipient: '0x0000000000000000000000000000000000000002',
1436
+ })
1437
+ const mockResult = await mockHandle(new Request('https://example.com/mock'))
1438
+ expect(mockResult.status).toBe(402)
1439
+ if (mockResult.status !== 402) throw new Error()
1440
+
1441
+ const mockChallenge = Challenge.fromResponse(mockResult.challenge)
1442
+ const credential = Credential.from({
1443
+ challenge: mockChallenge,
1444
+ payload: { token: 'valid' },
1445
+ })
1446
+
1447
+ // Present mock/charge credential at other/charge route
1448
+ const otherHandle = handler['other/charge']({
1449
+ amount: '1',
1450
+ currency: '0x0000000000000000000000000000000000000001',
1451
+ decimals: 6,
1452
+ expires: new Date(Date.now() + 60_000).toISOString(),
1453
+ recipient: '0x0000000000000000000000000000000000000002',
1454
+ })
1455
+ const result = await otherHandle(
1456
+ new Request('https://example.com/other', {
1457
+ headers: { Authorization: Credential.serialize(credential) },
1458
+ }),
1459
+ )
1460
+
1461
+ // Should reject (credential was for method "mock", not "other")
1462
+ expect(result.status).toBe(402)
1463
+ })
1464
+
1465
+ test('rejects credential with mismatched intent field', async () => {
1466
+ const sessionMethod = Method.from({
1467
+ name: 'mock',
1468
+ intent: 'session',
1469
+ schema: {
1470
+ credential: { payload: z.object({ token: z.string() }) },
1471
+ request: z.object({
1472
+ amount: z.string(),
1473
+ currency: z.string(),
1474
+ decimals: z.number(),
1475
+ recipient: z.string(),
1476
+ }),
1477
+ },
1478
+ })
1479
+
1480
+ const sessionServerMethod = Method.toServer(sessionMethod, {
1481
+ async verify() {
1482
+ return mockReceipt()
1483
+ },
1484
+ })
1485
+
1486
+ const handler = Mppx.create({ methods: [serverMethod, sessionServerMethod], realm, secretKey })
1487
+
1488
+ // Get challenge from mock/charge
1489
+ const chargeHandle = handler['mock/charge']({
1490
+ amount: '1',
1491
+ currency: '0x0000000000000000000000000000000000000001',
1492
+ decimals: 6,
1493
+ expires: new Date(Date.now() + 60_000).toISOString(),
1494
+ recipient: '0x0000000000000000000000000000000000000002',
1495
+ })
1496
+ const chargeResult = await chargeHandle(new Request('https://example.com/charge'))
1497
+ expect(chargeResult.status).toBe(402)
1498
+ if (chargeResult.status !== 402) throw new Error()
1499
+
1500
+ const chargeChallenge = Challenge.fromResponse(chargeResult.challenge)
1501
+ const credential = Credential.from({
1502
+ challenge: chargeChallenge,
1503
+ payload: { token: 'valid' },
1504
+ })
1505
+
1506
+ // Present charge credential at session route
1507
+ const sessionHandle = handler['mock/session']({
1508
+ amount: '1',
1509
+ currency: '0x0000000000000000000000000000000000000001',
1510
+ decimals: 6,
1511
+ expires: new Date(Date.now() + 60_000).toISOString(),
1512
+ recipient: '0x0000000000000000000000000000000000000002',
1513
+ })
1514
+ const result = await sessionHandle(
1515
+ new Request('https://example.com/session', {
1516
+ headers: { Authorization: Credential.serialize(credential) },
1517
+ }),
1518
+ )
1519
+
1520
+ // Should reject (credential was for intent "charge", not "session")
1521
+ expect(result.status).toBe(402)
1522
+ })
1523
+
1524
+ test('compose: rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
1525
+ const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
1526
+
1527
+ const cheapHandle = handler.charge({
1528
+ amount: '0.01',
1529
+ currency: '0x0000000000000000000000000000000000000001',
1530
+ decimals: 6,
1531
+ expires: new Date(Date.now() + 60_000).toISOString(),
1532
+ recipient: '0x0000000000000000000000000000000000000002',
1533
+ })
1534
+ const expensiveHandle = handler.charge({
1535
+ amount: '100',
1536
+ currency: '0x0000000000000000000000000000000000000001',
1537
+ decimals: 6,
1538
+ expires: new Date(Date.now() + 60_000).toISOString(),
1539
+ recipient: '0x0000000000000000000000000000000000000002',
1540
+ })
1541
+
1542
+ const composed = Mppx.compose(cheapHandle, expensiveHandle)
1543
+
1544
+ // Get challenge (pick the cheap one)
1545
+ const firstResult = await composed(new Request('https://example.com/resource'))
1546
+ expect(firstResult.status).toBe(402)
1547
+ if (firstResult.status !== 402) throw new Error()
1548
+
1549
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
1550
+ const cheapChallenge = challenges[0]!
1551
+
1552
+ const credential = Credential.from({
1553
+ challenge: cheapChallenge,
1554
+ payload: { token: 'valid' },
1555
+ })
1556
+
1557
+ // The composed handler should NOT route the cheap credential to the expensive handler
1558
+ const result = await composed(
1559
+ new Request('https://example.com/resource', {
1560
+ headers: { Authorization: Credential.serialize(credential) },
1561
+ }),
1562
+ )
1563
+
1564
+ // If scope binding works, the credential should route to the cheap handler only.
1565
+ // It should NOT match the expensive handler's canonical request.
1566
+ // The result should be 200 (matched to cheap), not routed to expensive.
1567
+ expect(result.status).toBe(200)
1568
+ })
1569
+ })
1570
+
1133
1571
  describe('withReceipt', () => {
1134
1572
  const mockCharge = Method.from({
1135
1573
  name: 'mock',
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
+
2
3
  import * as Challenge from '../Challenge.js'
3
4
  import * as Credential from '../Credential.js'
4
5
  import * as Errors from '../Errors.js'
@@ -329,20 +330,22 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
329
330
  }
330
331
 
331
332
  // Verify the credential's challenge matches this route's configured
332
- // request. Prevents cross-route scope confusion where a credential
333
- // issued for a cheap route is presented at an expensive route.
333
+ // method, intent, realm, and request. Prevents cross-route scope
334
+ // confusion where a credential issued for a cheap route (or different
335
+ // method/intent) is presented at an expensive route.
334
336
  // Note: we compare specific payment parameters rather than the full
335
337
  // request because the `request` hook may produce credential-dependent
336
338
  // output (e.g. `feePayer` differs between 402 and credential calls).
339
+ //
340
+ // Skip this check for topUp and voucher actions: the route's
341
+ // `request` hook may produce a different amount because these
342
+ // requests carry no application body (e.g. no model field for
343
+ // dynamic pricing). The credential echoes a challenge obtained
344
+ // from the original request which had the correct amount; the
345
+ // on-chain voucher signature is the real validation.
337
346
  {
338
- const routeReq = challenge.request as Record<string, unknown>
339
- const echoedReq = credential.challenge.request as Record<string, unknown>
340
- for (const field of ['amount', 'currency', 'recipient'] as const) {
341
- if (
342
- routeReq[field] !== undefined &&
343
- echoedReq[field] !== undefined &&
344
- String(routeReq[field]) !== String(echoedReq[field])
345
- ) {
347
+ for (const field of ['method', 'intent', 'realm'] as const) {
348
+ if (credential.challenge[field] !== challenge[field]) {
346
349
  const response = await transport.respondChallenge({
347
350
  challenge,
348
351
  input,
@@ -354,6 +357,39 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
354
357
  return { challenge: response, status: 402 }
355
358
  }
356
359
  }
360
+
361
+ // Use safeParse (not raw payload) so only methods whose schema
362
+ // defines `action` can trigger the skip. Without this, a client
363
+ // could inject `action: 'topUp'` on a charge credential to bypass
364
+ // the amount check. Zod strips unknown keys, so charge payloads
365
+ // (which don't define `action`) will have it removed.
366
+ const parsed = method.schema.credential.payload.safeParse(credential.payload)
367
+ const action = parsed.success
368
+ ? (parsed.data as Record<string, unknown>)?.action
369
+ : undefined
370
+ if (action !== 'topUp' && action !== 'voucher') {
371
+ const routeReq = challenge.request as Record<string, unknown>
372
+ const echoedReq = credential.challenge.request as Record<string, unknown>
373
+ const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
374
+ const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
375
+ for (const field of ['amount', 'currency', 'recipient'] as const) {
376
+ const routeVal = routeReq[field] ?? routeDetails[field]
377
+ if (
378
+ routeVal !== undefined &&
379
+ String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
380
+ ) {
381
+ const response = await transport.respondChallenge({
382
+ challenge,
383
+ input,
384
+ error: new Errors.InvalidChallengeError({
385
+ id: credential.challenge.id,
386
+ reason: `credential ${field} does not match this route's requirements`,
387
+ }),
388
+ })
389
+ return { challenge: response, status: 402 }
390
+ }
391
+ }
392
+ }
357
393
  }
358
394
 
359
395
  // Reject expired credentials
@@ -578,22 +614,24 @@ export function compose(
578
614
  if (credential) {
579
615
  const { method: credMethod, intent: credIntent } = credential.challenge
580
616
  const credReq = credential.challenge.request as Record<string, unknown>
617
+ const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
581
618
 
582
619
  // Filter by name+intent, then narrow by comparing stable request fields
583
620
  // from the echoed challenge against each handler's canonical request.
584
621
  // Uses the schema-parsed canonical form (not raw options) so that
585
622
  // transformed fields (e.g. amount with decimals) match correctly.
623
+ // Also checks inside methodDetails for fields moved there by transforms.
586
624
  const candidates = handlers.filter((h) => {
587
625
  const meta = (h as ConfiguredHandler)._internal
588
626
  if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
589
627
  const canonical = meta._canonicalRequest
590
628
  if (!canonical) return true
629
+ const canonicalDetails = (canonical.methodDetails ?? {}) as Record<string, unknown>
591
630
  for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
592
- const canonicalVal = canonical[field]
631
+ const canonicalVal = canonical[field] ?? canonicalDetails[field]
593
632
  if (
594
633
  canonicalVal !== undefined &&
595
- credReq[field] !== undefined &&
596
- String(canonicalVal) !== String(credReq[field])
634
+ String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
597
635
  )
598
636
  return false
599
637
  }
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
+
3
4
  import { Request } from 'mppx/server'
4
5
  import { describe, expect, test } from 'vitest'
5
6
 
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http'
2
+
2
3
  import * as FetchServer from '@remix-run/node-fetch-server'
3
4
 
4
5
  export type FetchHandler = (request: Request) => Promise<Response> | Response