mppx 0.4.8 → 0.4.10

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 (267) hide show
  1. package/CHANGELOG.md +26 -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 +9 -0
  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 +157 -1
  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 +2 -1
  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/discovery/Discovery.d.ts +146 -0
  39. package/dist/discovery/Discovery.d.ts.map +1 -0
  40. package/dist/discovery/Discovery.js +60 -0
  41. package/dist/discovery/Discovery.js.map +1 -0
  42. package/dist/discovery/OpenApi.d.ts +61 -0
  43. package/dist/discovery/OpenApi.d.ts.map +1 -0
  44. package/dist/discovery/OpenApi.js +139 -0
  45. package/dist/discovery/OpenApi.js.map +1 -0
  46. package/dist/discovery/Validate.d.ts +10 -0
  47. package/dist/discovery/Validate.d.ts.map +1 -0
  48. package/dist/discovery/Validate.js +63 -0
  49. package/dist/discovery/Validate.js.map +1 -0
  50. package/dist/discovery/index.d.ts +4 -0
  51. package/dist/discovery/index.d.ts.map +1 -0
  52. package/dist/discovery/index.js +4 -0
  53. package/dist/discovery/index.js.map +1 -0
  54. package/dist/internal/types.d.ts.map +1 -1
  55. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  56. package/dist/mcp-sdk/client/McpClient.js +1 -1
  57. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  58. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  59. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  60. package/dist/middlewares/elysia.d.ts +52 -1
  61. package/dist/middlewares/elysia.d.ts.map +1 -1
  62. package/dist/middlewares/elysia.js +17 -0
  63. package/dist/middlewares/elysia.js.map +1 -1
  64. package/dist/middlewares/express.d.ts +13 -1
  65. package/dist/middlewares/express.d.ts.map +1 -1
  66. package/dist/middlewares/express.js +23 -2
  67. package/dist/middlewares/express.js.map +1 -1
  68. package/dist/middlewares/hono.d.ts +19 -1
  69. package/dist/middlewares/hono.d.ts.map +1 -1
  70. package/dist/middlewares/hono.js +51 -0
  71. package/dist/middlewares/hono.js.map +1 -1
  72. package/dist/middlewares/internal/mppx.d.ts +4 -2
  73. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  74. package/dist/middlewares/internal/mppx.js +10 -3
  75. package/dist/middlewares/internal/mppx.js.map +1 -1
  76. package/dist/middlewares/nextjs.d.ts +11 -0
  77. package/dist/middlewares/nextjs.d.ts.map +1 -1
  78. package/dist/middlewares/nextjs.js +15 -0
  79. package/dist/middlewares/nextjs.js.map +1 -1
  80. package/dist/proxy/Proxy.d.ts +6 -0
  81. package/dist/proxy/Proxy.d.ts.map +1 -1
  82. package/dist/proxy/Proxy.js +56 -80
  83. package/dist/proxy/Proxy.js.map +1 -1
  84. package/dist/proxy/Service.d.ts +16 -23
  85. package/dist/proxy/Service.d.ts.map +1 -1
  86. package/dist/proxy/Service.js +20 -84
  87. package/dist/proxy/Service.js.map +1 -1
  88. package/dist/proxy/internal/Route.js +1 -1
  89. package/dist/proxy/internal/Route.js.map +1 -1
  90. package/dist/proxy/services/anthropic.d.ts.map +1 -1
  91. package/dist/proxy/services/anthropic.js +5 -0
  92. package/dist/proxy/services/anthropic.js.map +1 -1
  93. package/dist/proxy/services/openai.d.ts.map +1 -1
  94. package/dist/proxy/services/openai.js +6 -3
  95. package/dist/proxy/services/openai.js.map +1 -1
  96. package/dist/proxy/services/stripe.d.ts.map +1 -1
  97. package/dist/proxy/services/stripe.js +6 -3
  98. package/dist/proxy/services/stripe.js.map +1 -1
  99. package/dist/server/Mppx.d.ts.map +1 -1
  100. package/dist/server/Mppx.js +35 -17
  101. package/dist/server/Mppx.js.map +1 -1
  102. package/dist/server/Request.d.ts.map +1 -1
  103. package/dist/server/Request.js.map +1 -1
  104. package/dist/stripe/Methods.d.ts.map +1 -1
  105. package/dist/stripe/Methods.js.map +1 -1
  106. package/dist/tempo/Methods.d.ts.map +1 -1
  107. package/dist/tempo/Methods.js.map +1 -1
  108. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  109. package/dist/tempo/client/ChannelOps.js.map +1 -1
  110. package/dist/tempo/client/Charge.d.ts.map +1 -1
  111. package/dist/tempo/client/Charge.js.map +1 -1
  112. package/dist/tempo/client/Session.d.ts.map +1 -1
  113. package/dist/tempo/client/Session.js.map +1 -1
  114. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  115. package/dist/tempo/client/SessionManager.js +1 -1
  116. package/dist/tempo/client/SessionManager.js.map +1 -1
  117. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  118. package/dist/tempo/internal/auto-swap.js +1 -1
  119. package/dist/tempo/internal/auto-swap.js.map +1 -1
  120. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  121. package/dist/tempo/internal/fee-payer.js +1 -1
  122. package/dist/tempo/internal/fee-payer.js.map +1 -1
  123. package/dist/tempo/server/Charge.d.ts.map +1 -1
  124. package/dist/tempo/server/Charge.js +1 -1
  125. package/dist/tempo/server/Charge.js.map +1 -1
  126. package/dist/tempo/server/Session.d.ts.map +1 -1
  127. package/dist/tempo/server/Session.js +18 -5
  128. package/dist/tempo/server/Session.js.map +1 -1
  129. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  130. package/dist/tempo/server/internal/transport.js +8 -0
  131. package/dist/tempo/server/internal/transport.js.map +1 -1
  132. package/dist/tempo/session/Chain.d.ts.map +1 -1
  133. package/dist/tempo/session/Chain.js +1 -1
  134. package/dist/tempo/session/Chain.js.map +1 -1
  135. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  136. package/dist/tempo/session/ChannelStore.js.map +1 -1
  137. package/dist/tempo/session/Receipt.d.ts.map +1 -1
  138. package/dist/tempo/session/Receipt.js.map +1 -1
  139. package/dist/tempo/session/Sse.d.ts.map +1 -1
  140. package/dist/tempo/session/Sse.js.map +1 -1
  141. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  142. package/dist/tempo/session/Voucher.js.map +1 -1
  143. package/dist/viem/Client.d.ts.map +1 -1
  144. package/dist/viem/Client.js.map +1 -1
  145. package/package.json +6 -1
  146. package/src/BodyDigest.test.ts +1 -1
  147. package/src/BodyDigest.ts +1 -0
  148. package/src/Challenge.fuzz.test.ts +121 -0
  149. package/src/Challenge.test-d.ts +2 -1
  150. package/src/Challenge.test.ts +1 -1
  151. package/src/Challenge.ts +1 -0
  152. package/src/Credential.fuzz.test.ts +62 -0
  153. package/src/Credential.test.ts +1 -1
  154. package/src/Credential.ts +1 -0
  155. package/src/Errors.test.ts +28 -40
  156. package/src/Expires.test.ts +2 -1
  157. package/src/Method.test.ts +1 -1
  158. package/src/PaymentRequest.test.ts +1 -1
  159. package/src/PaymentRequest.ts +1 -0
  160. package/src/Receipt.test.ts +1 -1
  161. package/src/Receipt.ts +1 -0
  162. package/src/Store.test-d.ts +2 -1
  163. package/src/Store.test.ts +57 -7
  164. package/src/Store.ts +25 -0
  165. package/src/cli/account.ts +65 -30
  166. package/src/cli/cli.test.ts +215 -2
  167. package/src/cli/cli.ts +166 -1
  168. package/src/cli/config.test.ts +1 -0
  169. package/src/cli/internal.ts +1 -0
  170. package/src/cli/plugins/stripe.ts +1 -0
  171. package/src/cli/plugins/tempo.ts +4 -1
  172. package/src/cli/utils.ts +1 -0
  173. package/src/client/Mppx.test-d.ts +2 -1
  174. package/src/client/Mppx.test.ts +1 -1
  175. package/src/client/Transport.test.ts +1 -1
  176. package/src/client/internal/Fetch.browser.test.ts +2 -1
  177. package/src/client/internal/Fetch.test-d.ts +2 -1
  178. package/src/client/internal/Fetch.test.ts +3 -1
  179. package/src/client/internal/Fetch.ts +1 -1
  180. package/src/discovery/Discovery.test.ts +152 -0
  181. package/src/discovery/Discovery.ts +72 -0
  182. package/src/discovery/OpenApi.test.ts +425 -0
  183. package/src/discovery/OpenApi.ts +224 -0
  184. package/src/discovery/Validate.test.ts +188 -0
  185. package/src/discovery/Validate.ts +76 -0
  186. package/src/discovery/index.ts +3 -0
  187. package/src/internal/constantTimeEqual.test.ts +2 -1
  188. package/src/internal/types.ts +1 -3
  189. package/src/mcp-sdk/client/McpClient.test-d.ts +2 -1
  190. package/src/mcp-sdk/client/McpClient.test.ts +2 -1
  191. package/src/mcp-sdk/client/McpClient.ts +2 -0
  192. package/src/mcp-sdk/server/Transport.test.ts +2 -1
  193. package/src/mcp-sdk/server/Transport.ts +1 -0
  194. package/src/middlewares/elysia.test.ts +28 -2
  195. package/src/middlewares/elysia.ts +36 -1
  196. package/src/middlewares/express.test.ts +95 -7
  197. package/src/middlewares/express.ts +40 -2
  198. package/src/middlewares/hono.test.ts +28 -6
  199. package/src/middlewares/hono.ts +74 -1
  200. package/src/middlewares/internal/mppx.test.ts +2 -1
  201. package/src/middlewares/internal/mppx.ts +14 -6
  202. package/src/middlewares/nextjs.test.ts +32 -6
  203. package/src/middlewares/nextjs.ts +28 -0
  204. package/src/proxy/Proxy.test.ts +55 -270
  205. package/src/proxy/Proxy.ts +73 -93
  206. package/src/proxy/Service.test.ts +24 -1
  207. package/src/proxy/Service.ts +48 -88
  208. package/src/proxy/internal/Headers.test.ts +2 -1
  209. package/src/proxy/internal/Route.test.ts +9 -1
  210. package/src/proxy/internal/Route.ts +1 -1
  211. package/src/proxy/services/anthropic.test.ts +132 -0
  212. package/src/proxy/services/anthropic.ts +5 -0
  213. package/src/proxy/services/openai.test.ts +2 -1
  214. package/src/proxy/services/openai.ts +6 -4
  215. package/src/proxy/services/stripe.test.ts +132 -0
  216. package/src/proxy/services/stripe.ts +6 -4
  217. package/src/server/Mppx.test-d.ts +1 -1
  218. package/src/server/Mppx.test.ts +194 -1
  219. package/src/server/Mppx.ts +38 -19
  220. package/src/server/NodeListener.test.ts +1 -1
  221. package/src/server/Request.test.ts +2 -1
  222. package/src/server/Request.ts +1 -0
  223. package/src/server/Response.test.ts +2 -1
  224. package/src/server/Transport.test.ts +2 -1
  225. package/src/stripe/Charge.integration.test.ts +1 -1
  226. package/src/stripe/Methods.test.ts +1 -1
  227. package/src/stripe/Methods.ts +1 -0
  228. package/src/stripe/client/Charge.test.ts +2 -1
  229. package/src/stripe/server/Charge.test.ts +2 -1
  230. package/src/tempo/Attribution.test.ts +2 -1
  231. package/src/tempo/Methods.test.ts +1 -1
  232. package/src/tempo/Methods.ts +1 -0
  233. package/src/tempo/client/ChannelOps.test.ts +7 -3
  234. package/src/tempo/client/ChannelOps.ts +1 -0
  235. package/src/tempo/client/Charge.ts +1 -0
  236. package/src/tempo/client/Session.test.ts +6 -2
  237. package/src/tempo/client/Session.ts +1 -0
  238. package/src/tempo/client/SessionManager.test.ts +29 -1
  239. package/src/tempo/client/SessionManager.ts +2 -1
  240. package/src/tempo/internal/auto-swap.test.ts +2 -1
  241. package/src/tempo/internal/auto-swap.ts +1 -0
  242. package/src/tempo/internal/defaults.test.ts +2 -1
  243. package/src/tempo/internal/fee-payer.test.ts +2 -1
  244. package/src/tempo/internal/fee-payer.ts +1 -0
  245. package/src/tempo/server/Charge.test.ts +2 -1
  246. package/src/tempo/server/Charge.ts +1 -0
  247. package/src/tempo/server/Session.test.ts +88 -37
  248. package/src/tempo/server/Session.ts +26 -8
  249. package/src/tempo/server/Sse.test.ts +2 -1
  250. package/src/tempo/server/internal/transport.test.ts +25 -1
  251. package/src/tempo/server/internal/transport.ts +11 -0
  252. package/src/tempo/session/Chain.test.ts +6 -2
  253. package/src/tempo/session/Chain.ts +2 -1
  254. package/src/tempo/session/Channel.test.ts +2 -1
  255. package/src/tempo/session/ChannelStore.test.ts +2 -1
  256. package/src/tempo/session/ChannelStore.ts +1 -0
  257. package/src/tempo/session/Receipt.test.ts +2 -1
  258. package/src/tempo/session/Receipt.ts +1 -0
  259. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  260. package/src/tempo/session/Sse.test.ts +2 -1
  261. package/src/tempo/session/Sse.ts +1 -0
  262. package/src/tempo/session/Voucher.test.ts +2 -1
  263. package/src/tempo/session/Voucher.ts +1 -0
  264. package/src/viem/Account.test.ts +2 -1
  265. package/src/viem/Client.test.ts +2 -1
  266. package/src/viem/Client.ts +1 -0
  267. package/src/zod.test.ts +147 -0
@@ -1,6 +1,6 @@
1
1
  import { Challenge, Credential, Method, z } from 'mppx'
2
2
  import { Mppx, Transport, tempo } from 'mppx/server'
3
- import { describe, expect, test } from 'vitest'
3
+ import { describe, expect, test } from 'vp/test'
4
4
  import * as Http from '~test/Http.js'
5
5
  import { accounts, asset, client } from '~test/tempo/viem.js'
6
6
 
@@ -9,6 +9,7 @@ const secretKey = 'test-secret-key'
9
9
 
10
10
  const method = tempo({
11
11
  getClient: () => client,
12
+ account: accounts[0],
12
13
  })
13
14
 
14
15
  describe('create', () => {
@@ -180,6 +181,198 @@ describe('request handler', () => {
180
181
  expect(body.detail).toContain('does not match')
181
182
  })
182
183
 
184
+ test('topUp credential bypasses cross-route amount validation', async () => {
185
+ // Use a session method whose schema defines action: 'topUp'
186
+ const sessionMethod = Method.from({
187
+ name: 'mock',
188
+ intent: 'session',
189
+ schema: {
190
+ credential: {
191
+ payload: z.discriminatedUnion('action', [
192
+ z.object({ action: z.literal('open'), token: z.string() }),
193
+ z.object({ action: z.literal('topUp'), token: z.string() }),
194
+ ]),
195
+ },
196
+ request: z.object({
197
+ amount: z.string(),
198
+ currency: z.string(),
199
+ recipient: z.string(),
200
+ }),
201
+ },
202
+ })
203
+ const sessionServerMethod = Method.toServer(sessionMethod, {
204
+ async verify() {
205
+ return {
206
+ status: 'settled',
207
+ method: 'mock',
208
+ timestamp: new Date().toISOString(),
209
+ reference: 'ref',
210
+ } as any
211
+ },
212
+ })
213
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
214
+
215
+ // Get a challenge from the "cheap" route (simulates HEAD-obtained challenge)
216
+ const cheapHandle = handler['mock/session']({
217
+ amount: '1',
218
+ currency: asset,
219
+ expires: new Date(Date.now() + 60_000).toISOString(),
220
+ recipient: accounts[0].address,
221
+ })
222
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
223
+ expect(cheapResult.status).toBe(402)
224
+ if (cheapResult.status !== 402) throw new Error()
225
+
226
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
227
+
228
+ // Build a topUp credential from the cheap challenge (echoed from HEAD)
229
+ const credential = Credential.from({
230
+ challenge: cheapChallenge,
231
+ payload: { action: 'topUp', token: 'valid' },
232
+ })
233
+
234
+ // Present it at the "expensive" route — topUp should bypass amount check
235
+ const expensiveHandle = handler['mock/session']({
236
+ amount: '1000000',
237
+ currency: asset,
238
+ expires: new Date(Date.now() + 60_000).toISOString(),
239
+ recipient: accounts[0].address,
240
+ })
241
+ const result = await expensiveHandle(
242
+ new Request('https://example.com/expensive', {
243
+ headers: { Authorization: Credential.serialize(credential) },
244
+ }),
245
+ )
246
+
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
+ }
254
+ })
255
+
256
+ test('voucher credential bypasses cross-route amount validation', async () => {
257
+ const sessionMethod = Method.from({
258
+ name: 'mock',
259
+ intent: 'session',
260
+ schema: {
261
+ credential: {
262
+ payload: z.discriminatedUnion('action', [
263
+ z.object({ action: z.literal('open'), token: z.string() }),
264
+ z.object({
265
+ action: z.literal('voucher'),
266
+ cumulativeAmount: z.string(),
267
+ signature: z.string(),
268
+ }),
269
+ ]),
270
+ },
271
+ request: z.object({
272
+ amount: z.string(),
273
+ currency: z.string(),
274
+ recipient: z.string(),
275
+ }),
276
+ },
277
+ })
278
+ const sessionServerMethod = Method.toServer(sessionMethod, {
279
+ async verify() {
280
+ return {
281
+ status: 'settled',
282
+ method: 'mock',
283
+ timestamp: new Date().toISOString(),
284
+ reference: 'ref',
285
+ } as any
286
+ },
287
+ })
288
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
289
+
290
+ // Get a challenge from the "cheap" route (simulates initial SSE request)
291
+ const cheapHandle = handler['mock/session']({
292
+ amount: '1',
293
+ currency: asset,
294
+ expires: new Date(Date.now() + 60_000).toISOString(),
295
+ recipient: accounts[0].address,
296
+ })
297
+ const cheapResult = await cheapHandle(new Request('https://example.com/chat'))
298
+ expect(cheapResult.status).toBe(402)
299
+ if (cheapResult.status !== 402) throw new Error()
300
+
301
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
302
+
303
+ // Build a voucher credential echoing the original challenge — mid-stream
304
+ // the server may re-price (dynamic pricing), so the route's amount differs
305
+ const credential = Credential.from({
306
+ challenge: cheapChallenge,
307
+ payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
308
+ })
309
+
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
312
+ const expensiveHandle = handler['mock/session']({
313
+ amount: '1000000',
314
+ currency: asset,
315
+ expires: new Date(Date.now() + 60_000).toISOString(),
316
+ recipient: accounts[0].address,
317
+ })
318
+ const result = await expensiveHandle(
319
+ new Request('https://example.com/chat', {
320
+ headers: { Authorization: Credential.serialize(credential) },
321
+ }),
322
+ )
323
+
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
+ }
329
+ })
330
+
331
+ test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
332
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
333
+
334
+ // Get a challenge from the "cheap" route
335
+ const cheapHandle = handler.charge({
336
+ amount: '1',
337
+ currency: asset,
338
+ expires: new Date(Date.now() + 60_000).toISOString(),
339
+ recipient: accounts[0].address,
340
+ })
341
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
342
+ expect(cheapResult.status).toBe(402)
343
+ if (cheapResult.status !== 402) throw new Error()
344
+
345
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
346
+
347
+ // Malicious client injects action: 'topUp' into a regular charge credential
348
+ // to try to bypass the cross-route amount check
349
+ const credential = Credential.from({
350
+ challenge: cheapChallenge,
351
+ payload: { action: 'topUp', signature: '0x123', type: 'transaction' },
352
+ })
353
+
354
+ // Present it at the "expensive" route — should still be rejected
355
+ const expensiveHandle = handler.charge({
356
+ amount: '1000000',
357
+ currency: asset,
358
+ expires: new Date(Date.now() + 60_000).toISOString(),
359
+ recipient: accounts[0].address,
360
+ })
361
+ const result = await expensiveHandle(
362
+ new Request('https://example.com/expensive', {
363
+ headers: { Authorization: Credential.serialize(credential) },
364
+ }),
365
+ )
366
+
367
+ // Injecting action: 'topUp' on a charge credential must not bypass
368
+ // the cross-route amount check. The credential should be rejected
369
+ // with "does not match" just like a normal charge credential would be.
370
+ expect(result.status).toBe(402)
371
+ if (result.status !== 402) throw new Error()
372
+ const body = (await result.challenge.json()) as { detail: string }
373
+ expect(body.detail).toContain('does not match')
374
+ })
375
+
183
376
  test('returns 402 when credential challenge is expired', async () => {
184
377
  const pastExpires = new Date(Date.now() - 60_000).toISOString()
185
378
 
@@ -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'
@@ -335,6 +336,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
335
336
  // Note: we compare specific payment parameters rather than the full
336
337
  // request because the `request` hook may produce credential-dependent
337
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.
338
346
  {
339
347
  for (const field of ['method', 'intent', 'realm'] as const) {
340
348
  if (credential.challenge[field] !== challenge[field]) {
@@ -350,25 +358,36 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
350
358
  }
351
359
  }
352
360
 
353
- const routeReq = challenge.request as Record<string, unknown>
354
- const echoedReq = credential.challenge.request as Record<string, unknown>
355
- const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
356
- const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
357
- for (const field of ['amount', 'currency', 'recipient'] as const) {
358
- const routeVal = routeReq[field] ?? routeDetails[field]
359
- if (
360
- routeVal !== undefined &&
361
- String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
362
- ) {
363
- const response = await transport.respondChallenge({
364
- challenge,
365
- input,
366
- error: new Errors.InvalidChallengeError({
367
- id: credential.challenge.id,
368
- reason: `credential ${field} does not match this route's requirements`,
369
- }),
370
- })
371
- return { challenge: response, status: 402 }
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
+ }
372
391
  }
373
392
  }
374
393
  }
@@ -1,5 +1,5 @@
1
1
  import { NodeListener, Request } from 'mppx/server'
2
- import { afterEach, describe, expect, test } from 'vitest'
2
+ import { afterEach, describe, expect, test } from 'vp/test'
3
3
  import * as Http from '~test/Http.js'
4
4
 
5
5
  let server: Awaited<ReturnType<typeof Http.createServer>> | undefined
@@ -1,7 +1,8 @@
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
- import { describe, expect, test } from 'vitest'
5
+ import { describe, expect, test } from 'vp/test'
5
6
 
6
7
  function createMockRequest(options: {
7
8
  method?: string
@@ -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
@@ -1,6 +1,7 @@
1
1
  import { Challenge } from 'mppx'
2
2
  import { Response } from 'mppx/server'
3
- import { describe, expect, test } from 'vitest'
3
+ import { describe, expect, test } from 'vp/test'
4
+
4
5
  import * as Errors from '../Errors.js'
5
6
 
6
7
  const challenge = Challenge.from({
@@ -1,7 +1,8 @@
1
1
  import { Challenge, Credential, Mcp, Receipt } from 'mppx'
2
2
  import { Transport } from 'mppx/server'
3
3
  import { Methods } from 'mppx/tempo'
4
- import { describe, expect, test } from 'vitest'
4
+ import { describe, expect, test } from 'vp/test'
5
+
5
6
  import { BadRequestError, ChannelClosedError } from '../Errors.js'
6
7
 
7
8
  const realm = 'api.example.com'
@@ -1,7 +1,7 @@
1
1
  import { Challenge, Credential, Receipt } from 'mppx'
2
2
  import { Mppx as Mppx_client, stripe as stripe_client } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, stripe as stripe_server } from 'mppx/server'
4
- import { afterEach, describe, expect, test } from 'vitest'
4
+ import { afterEach, describe, expect, test } from 'vp/test'
5
5
  import * as Http from '~test/Http.js'
6
6
 
7
7
  const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY
@@ -1,5 +1,5 @@
1
1
  import { Methods } from 'mppx/stripe'
2
- import { describe, expect, expectTypeOf, test } from 'vitest'
2
+ import { describe, expect, expectTypeOf, test } from 'vp/test'
3
3
 
4
4
  describe('charge', () => {
5
5
  test('has correct name and intent', () => {
@@ -1,4 +1,5 @@
1
1
  import { parseUnits } from 'viem'
2
+
2
3
  import * as Method from '../Method.js'
3
4
  import * as z from '../zod.js'
4
5
 
@@ -1,7 +1,8 @@
1
1
  import { Challenge, Credential } from 'mppx'
2
2
  import { Mppx, stripe } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, stripe as stripe_server } from 'mppx/server'
4
- import { describe, expect, test, vi } from 'vitest'
4
+ import { describe, expect, test, vi } from 'vp/test'
5
+
5
6
  import type { StripeJs } from '../internal/types.js'
6
7
  import { charge as clientCharge_ } from './Charge.js'
7
8
 
@@ -1,7 +1,8 @@
1
1
  import { Challenge, Credential } from 'mppx'
2
2
  import { Mppx, stripe } from 'mppx/server'
3
- import { afterEach, describe, expect, test, vi } from 'vitest'
3
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
4
4
  import * as Http from '~test/Http.js'
5
+
5
6
  import type { StripeClient } from '../internal/types.js'
6
7
 
7
8
  const realm = 'api.example.com'
@@ -1,5 +1,6 @@
1
1
  import { Bytes, Hash, Hex } from 'ox'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
+
3
4
  import * as Attribution from './Attribution.js'
4
5
 
5
6
  describe('Attribution', () => {
@@ -1,5 +1,5 @@
1
1
  import { Methods } from 'mppx/tempo'
2
- import { describe, expect, expectTypeOf, test } from 'vitest'
2
+ import { describe, expect, expectTypeOf, test } from 'vp/test'
3
3
 
4
4
  describe('charge', () => {
5
5
  test('has correct name and intent', () => {
@@ -1,5 +1,6 @@
1
1
  import type { Account } from 'viem'
2
2
  import { parseUnits } from 'viem'
3
+
3
4
  import * as Method from '../Method.js'
4
5
  import * as z from '../zod.js'
5
6
 
@@ -2,9 +2,13 @@ import { Hex } from 'ox'
2
2
  import { type Address, createClient } from 'viem'
3
3
  import { privateKeyToAccount } from 'viem/accounts'
4
4
  import { Addresses } from 'viem/tempo'
5
- import { beforeAll, describe, expect, test } from 'vitest'
5
+ import { beforeAll, describe, expect, test } from 'vp/test'
6
+ import { nodeEnv } from '~test/config.js'
6
7
  import { deployEscrow, openChannel } from '~test/tempo/session.js'
7
8
  import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
9
+
10
+ const isLocalnet = nodeEnv === 'localnet'
11
+
8
12
  import type { Challenge } from '../../Challenge.js'
9
13
  import * as Credential from '../../Credential.js'
10
14
  import {
@@ -165,7 +169,7 @@ describe('createClosePayload', () => {
165
169
  })
166
170
  })
167
171
 
168
- describe('createOpenPayload', () => {
172
+ describe.runIf(isLocalnet)('createOpenPayload', () => {
169
173
  const payer = accounts[2]
170
174
  const payee = accounts[1].address
171
175
  const currency = asset
@@ -249,7 +253,7 @@ describe('createOpenPayload', () => {
249
253
  })
250
254
  })
251
255
 
252
- describe('tryRecoverChannel', () => {
256
+ describe.runIf(isLocalnet)('tryRecoverChannel', () => {
253
257
  const payer = accounts[3]
254
258
  const payee = accounts[1].address
255
259
  const currency = asset
@@ -15,6 +15,7 @@ import {
15
15
  } from 'viem'
16
16
  import { prepareTransactionRequest, signTransaction } from 'viem/actions'
17
17
  import { Abis } from 'viem/tempo'
18
+
18
19
  import type { Challenge } from '../../Challenge.js'
19
20
  import * as Credential from '../../Credential.js'
20
21
  import * as defaults from '../internal/defaults.js'
@@ -3,6 +3,7 @@ import type { Address } from 'viem'
3
3
  import { prepareTransactionRequest, sendCallsSync, signTransaction } from 'viem/actions'
4
4
  import { tempo as tempo_chain } from 'viem/chains'
5
5
  import { Actions } from 'viem/tempo'
6
+
6
7
  import * as Credential from '../../Credential.js'
7
8
  import * as Method from '../../Method.js'
8
9
  import * as Account from '../../viem/Account.js'
@@ -1,9 +1,13 @@
1
1
  import { type Address, createClient, type Hex, http } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { Addresses } from 'viem/tempo'
4
- import { beforeAll, describe, expect, test } from 'vitest'
4
+ import { beforeAll, describe, expect, test } from 'vp/test'
5
+ import { nodeEnv } from '~test/config.js'
5
6
  import { deployEscrow, openChannel } from '~test/tempo/session.js'
6
7
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
8
+
9
+ const isLocalnet = nodeEnv === 'localnet'
10
+
7
11
  import * as Challenge from '../../Challenge.js'
8
12
  import * as Credential from '../../Credential.js'
9
13
  import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
@@ -200,7 +204,7 @@ describe('session (pure)', () => {
200
204
  })
201
205
  })
202
206
 
203
- describe('session (on-chain)', () => {
207
+ describe.runIf(isLocalnet)('session (on-chain)', () => {
204
208
  const payer = accounts[2]
205
209
  const payee = accounts[1].address
206
210
  let escrowContract: Address
@@ -1,6 +1,7 @@
1
1
  import type { Hex } from 'ox'
2
2
  import { type Address, parseUnits, type Account as viem_Account } from 'viem'
3
3
  import { tempo as tempo_chain } from 'viem/chains'
4
+
4
5
  import type * as Challenge from '../../Challenge.js'
5
6
  import * as Method from '../../Method.js'
6
7
  import * as Account from '../../viem/Account.js'
@@ -1,5 +1,6 @@
1
1
  import type { Hex } from 'viem'
2
- import { describe, expect, test, vi } from 'vitest'
2
+ import { describe, expect, test, vi } from 'vp/test'
3
+
3
4
  import * as Challenge from '../../Challenge.js'
4
5
  import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js'
5
6
  import type { NeedVoucherEvent, SessionReceipt } from '../session/Types.js'
@@ -207,6 +208,33 @@ describe('Session', () => {
207
208
  })
208
209
  })
209
210
 
211
+ describe('.sse() headers normalization', () => {
212
+ test('preserves Headers instance properties when passed as headers', async () => {
213
+ const mockFetch = vi.fn().mockResolvedValue(makeSseResponse(['event: message\ndata: ok\n\n']))
214
+
215
+ const s = sessionManager({
216
+ account: '0x0000000000000000000000000000000000000001',
217
+ fetch: mockFetch as typeof globalThis.fetch,
218
+ })
219
+
220
+ const iterable = await s.sse('https://api.example.com/stream', {
221
+ headers: new Headers({ 'Content-Type': 'application/json', 'X-Custom': 'value' }),
222
+ })
223
+
224
+ for await (const _ of iterable) {
225
+ // drain
226
+ }
227
+
228
+ const calledHeaders = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record<
229
+ string,
230
+ string
231
+ >
232
+ expect(calledHeaders['content-type']).toBe('application/json')
233
+ expect(calledHeaders['x-custom']).toBe('value')
234
+ expect(calledHeaders.Accept).toBe('text/event-stream')
235
+ })
236
+ })
237
+
210
238
  describe('.close()', () => {
211
239
  test('is no-op when not opened', async () => {
212
240
  const mockFetch = vi.fn()
@@ -1,5 +1,6 @@
1
1
  import type { Hex } from 'ox'
2
2
  import type { Address } from 'viem'
3
+
3
4
  import type * as Challenge from '../../Challenge.js'
4
5
  import * as Fetch from '../../client/internal/Fetch.js'
5
6
  import type * as Account from '../../viem/Account.js'
@@ -157,7 +158,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
157
158
  const sseInit = {
158
159
  ...fetchInit,
159
160
  headers: {
160
- ...fetchInit.headers,
161
+ ...Fetch.normalizeHeaders(fetchInit.headers),
161
162
  Accept: 'text/event-stream',
162
163
  },
163
164
  ...(signal ? { signal } : {}),
@@ -1,5 +1,6 @@
1
1
  import type { Address } from 'viem'
2
- import { describe, expect, test } from 'vitest'
2
+ import { describe, expect, test } from 'vp/test'
3
+
3
4
  import { defaultCurrencies, InsufficientFundsError, resolve } from './auto-swap.js'
4
5
 
5
6
  describe('defaultCurrencies', () => {
@@ -1,6 +1,7 @@
1
1
  import type { Address, Client } from 'viem'
2
2
  import { readContract } from 'viem/actions'
3
3
  import { Actions, Addresses } from 'viem/tempo'
4
+
4
5
  import * as TempoAddress from './address.js'
5
6
  import * as defaults from './defaults.js'
6
7
 
@@ -1,4 +1,5 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
+
2
3
  import {
3
4
  chainId,
4
5
  currency,
@@ -1,6 +1,7 @@
1
1
  import { encodeFunctionData } from 'viem'
2
2
  import { Abis, Addresses } from 'viem/tempo'
3
- import { describe, expect, test } from 'vitest'
3
+ import { describe, expect, test } from 'vp/test'
4
+
4
5
  import { callScopes, FeePayerValidationError, validateCalls } from './fee-payer.js'
5
6
  import * as Selectors from './selectors.js'
6
7
 
@@ -2,6 +2,7 @@ import type { TempoAddress } from 'ox/tempo'
2
2
  import { TxEnvelopeTempo } from 'ox/tempo'
3
3
  import { decodeFunctionData } from 'viem'
4
4
  import { Abis, Addresses } from 'viem/tempo'
5
+
5
6
  import * as TempoAddress_internal from './address.js'
6
7
  import * as Selectors from './selectors.js'
7
8
 
@@ -7,10 +7,11 @@ import { Handler } from 'tempo.ts/server'
7
7
  import { createClient, custom, encodeFunctionData, parseUnits } from 'viem'
8
8
  import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions'
9
9
  import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo'
10
- import { beforeAll, describe, expect, test } from 'vitest'
10
+ import { beforeAll, describe, expect, test } from 'vp/test'
11
11
  import * as Http from '~test/Http.js'
12
12
  import { closeChannelOnChain, deployEscrow, openChannel } from '~test/tempo/session.js'
13
13
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
14
+
14
15
  import * as Store from '../../Store.js'
15
16
  import * as Attribution from '../Attribution.js'
16
17
  import { signVoucher } from '../session/Voucher.js'
@@ -9,6 +9,7 @@ import {
9
9
  } from 'viem/actions'
10
10
  import { tempo as tempo_chain } from 'viem/chains'
11
11
  import { Abis, Transaction } from 'viem/tempo'
12
+
12
13
  import { PaymentExpiredError } from '../../Errors.js'
13
14
  import type { LooseOmit } from '../../internal/types.js'
14
15
  import * as Method from '../../Method.js'