mppx 0.4.8 → 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 (183) hide show
  1. package/CHANGELOG.md +9 -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 +2 -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/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.map +1 -1
  46. package/dist/middlewares/express.d.ts.map +1 -1
  47. package/dist/middlewares/express.js +5 -2
  48. package/dist/middlewares/express.js.map +1 -1
  49. package/dist/middlewares/hono.d.ts.map +1 -1
  50. package/dist/middlewares/hono.js.map +1 -1
  51. package/dist/proxy/Proxy.d.ts.map +1 -1
  52. package/dist/proxy/Proxy.js.map +1 -1
  53. package/dist/proxy/Service.js +1 -1
  54. package/dist/proxy/Service.js.map +1 -1
  55. package/dist/server/Mppx.d.ts.map +1 -1
  56. package/dist/server/Mppx.js +35 -17
  57. package/dist/server/Mppx.js.map +1 -1
  58. package/dist/server/Request.d.ts.map +1 -1
  59. package/dist/server/Request.js.map +1 -1
  60. package/dist/stripe/Methods.d.ts.map +1 -1
  61. package/dist/stripe/Methods.js.map +1 -1
  62. package/dist/tempo/Methods.d.ts.map +1 -1
  63. package/dist/tempo/Methods.js.map +1 -1
  64. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  65. package/dist/tempo/client/ChannelOps.js.map +1 -1
  66. package/dist/tempo/client/Charge.d.ts.map +1 -1
  67. package/dist/tempo/client/Charge.js.map +1 -1
  68. package/dist/tempo/client/Session.d.ts.map +1 -1
  69. package/dist/tempo/client/Session.js.map +1 -1
  70. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  71. package/dist/tempo/client/SessionManager.js +1 -1
  72. package/dist/tempo/client/SessionManager.js.map +1 -1
  73. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  74. package/dist/tempo/internal/auto-swap.js +1 -1
  75. package/dist/tempo/internal/auto-swap.js.map +1 -1
  76. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  77. package/dist/tempo/internal/fee-payer.js +1 -1
  78. package/dist/tempo/internal/fee-payer.js.map +1 -1
  79. package/dist/tempo/server/Charge.d.ts.map +1 -1
  80. package/dist/tempo/server/Charge.js +1 -1
  81. package/dist/tempo/server/Charge.js.map +1 -1
  82. package/dist/tempo/server/Session.d.ts.map +1 -1
  83. package/dist/tempo/server/Session.js.map +1 -1
  84. package/dist/tempo/session/Chain.d.ts.map +1 -1
  85. package/dist/tempo/session/Chain.js.map +1 -1
  86. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  87. package/dist/tempo/session/ChannelStore.js.map +1 -1
  88. package/dist/tempo/session/Receipt.d.ts.map +1 -1
  89. package/dist/tempo/session/Receipt.js.map +1 -1
  90. package/dist/tempo/session/Sse.d.ts.map +1 -1
  91. package/dist/tempo/session/Sse.js.map +1 -1
  92. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  93. package/dist/tempo/session/Voucher.js.map +1 -1
  94. package/dist/viem/Client.d.ts.map +1 -1
  95. package/dist/viem/Client.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/BodyDigest.ts +1 -0
  98. package/src/Challenge.test-d.ts +1 -0
  99. package/src/Challenge.ts +1 -0
  100. package/src/Credential.ts +1 -0
  101. package/src/Errors.test.ts +27 -39
  102. package/src/Expires.test.ts +1 -0
  103. package/src/PaymentRequest.ts +1 -0
  104. package/src/Receipt.ts +1 -0
  105. package/src/Store.test-d.ts +1 -0
  106. package/src/Store.test.ts +56 -6
  107. package/src/Store.ts +25 -0
  108. package/src/cli/account.ts +65 -30
  109. package/src/cli/cli.test.ts +3 -1
  110. package/src/cli/cli.ts +4 -1
  111. package/src/cli/config.test.ts +1 -0
  112. package/src/cli/internal.ts +1 -0
  113. package/src/cli/plugins/stripe.ts +1 -0
  114. package/src/cli/plugins/tempo.ts +4 -1
  115. package/src/cli/utils.ts +1 -0
  116. package/src/client/Mppx.test-d.ts +1 -0
  117. package/src/client/internal/Fetch.browser.test.ts +1 -0
  118. package/src/client/internal/Fetch.test-d.ts +1 -0
  119. package/src/client/internal/Fetch.test.ts +1 -0
  120. package/src/client/internal/Fetch.ts +1 -1
  121. package/src/internal/constantTimeEqual.test.ts +1 -0
  122. package/src/internal/types.ts +1 -3
  123. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -0
  124. package/src/mcp-sdk/client/McpClient.test.ts +1 -0
  125. package/src/mcp-sdk/client/McpClient.ts +2 -0
  126. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  127. package/src/mcp-sdk/server/Transport.ts +1 -0
  128. package/src/middlewares/elysia.test.ts +1 -0
  129. package/src/middlewares/elysia.ts +1 -0
  130. package/src/middlewares/express.test.ts +62 -2
  131. package/src/middlewares/express.ts +6 -2
  132. package/src/middlewares/hono.ts +1 -0
  133. package/src/middlewares/internal/mppx.test.ts +1 -0
  134. package/src/middlewares/nextjs.test.ts +1 -0
  135. package/src/proxy/Proxy.test.ts +1 -0
  136. package/src/proxy/Proxy.ts +2 -0
  137. package/src/proxy/Service.test.ts +1 -0
  138. package/src/proxy/Service.ts +8 -2
  139. package/src/proxy/internal/Headers.test.ts +1 -0
  140. package/src/proxy/services/openai.test.ts +1 -0
  141. package/src/server/Mppx.test.ts +192 -0
  142. package/src/server/Mppx.ts +38 -19
  143. package/src/server/Request.test.ts +1 -0
  144. package/src/server/Request.ts +1 -0
  145. package/src/server/Response.test.ts +1 -0
  146. package/src/server/Transport.test.ts +1 -0
  147. package/src/stripe/Methods.ts +1 -0
  148. package/src/stripe/client/Charge.test.ts +1 -0
  149. package/src/stripe/server/Charge.test.ts +1 -0
  150. package/src/tempo/Attribution.test.ts +1 -0
  151. package/src/tempo/Methods.ts +1 -0
  152. package/src/tempo/client/ChannelOps.test.ts +1 -0
  153. package/src/tempo/client/ChannelOps.ts +1 -0
  154. package/src/tempo/client/Charge.ts +1 -0
  155. package/src/tempo/client/Session.test.ts +1 -0
  156. package/src/tempo/client/Session.ts +1 -0
  157. package/src/tempo/client/SessionManager.test.ts +28 -0
  158. package/src/tempo/client/SessionManager.ts +2 -1
  159. package/src/tempo/internal/auto-swap.test.ts +1 -0
  160. package/src/tempo/internal/auto-swap.ts +1 -0
  161. package/src/tempo/internal/defaults.test.ts +1 -0
  162. package/src/tempo/internal/fee-payer.test.ts +1 -0
  163. package/src/tempo/internal/fee-payer.ts +1 -0
  164. package/src/tempo/server/Charge.test.ts +1 -0
  165. package/src/tempo/server/Charge.ts +1 -0
  166. package/src/tempo/server/Session.test.ts +1 -0
  167. package/src/tempo/server/Session.ts +1 -0
  168. package/src/tempo/server/Sse.test.ts +1 -0
  169. package/src/tempo/server/internal/transport.test.ts +1 -0
  170. package/src/tempo/session/Chain.test.ts +1 -0
  171. package/src/tempo/session/Chain.ts +1 -0
  172. package/src/tempo/session/Channel.test.ts +1 -0
  173. package/src/tempo/session/ChannelStore.test.ts +1 -0
  174. package/src/tempo/session/ChannelStore.ts +1 -0
  175. package/src/tempo/session/Receipt.test.ts +1 -0
  176. package/src/tempo/session/Receipt.ts +1 -0
  177. package/src/tempo/session/Sse.test.ts +1 -0
  178. package/src/tempo/session/Sse.ts +1 -0
  179. package/src/tempo/session/Voucher.test.ts +1 -0
  180. package/src/tempo/session/Voucher.ts +1 -0
  181. package/src/viem/Account.test.ts +1 -0
  182. package/src/viem/Client.test.ts +1 -0
  183. package/src/viem/Client.ts +1 -0
@@ -256,9 +256,7 @@ export type LastInUnion<U> =
256
256
 
257
257
  /** @internal */
258
258
  export type UnionToIntersection<union> = (
259
- union extends unknown
260
- ? (arg: union) => 0
261
- : never
259
+ union extends unknown ? (arg: union) => 0 : never
262
260
  ) extends (arg: infer i) => 0
263
261
  ? i
264
262
  : never
@@ -2,6 +2,7 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import { tempo } from 'mppx/client'
3
3
  import type { Account } from 'viem'
4
4
  import { describe, expectTypeOf, test } from 'vitest'
5
+
5
6
  import * as McpClient from './McpClient.js'
6
7
 
7
8
  describe('McpClient.wrap', () => {
@@ -8,6 +8,7 @@ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
8
8
  import { createClient } from 'viem'
9
9
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
10
10
  import { accounts, asset, chain, http, client as testClient } from '~test/tempo/viem.js'
11
+
11
12
  import * as McpServer_transport from '../server/Transport.js'
12
13
  import * as McpClient from './McpClient.js'
13
14
 
@@ -1,5 +1,6 @@
1
1
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import type { McpError } from '@modelcontextprotocol/sdk/types.js'
3
+
3
4
  import type * as Challenge from '../../Challenge.js'
4
5
  import * as Credential from '../../Credential.js'
5
6
  import * as core_Mcp from '../../Mcp.js'
@@ -84,6 +85,7 @@ export function wrap<
84
85
  const installed = methods.map((m) => `${m.name}.${m.intent}`).join(', ')
85
86
  throw new Error(
86
87
  `No compatible payment method. Server offers: ${available}. Client has: ${installed}`,
88
+ { cause: error },
87
89
  )
88
90
  }
89
91
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest'
2
+
2
3
  import type { Challenge } from '../../Challenge.js'
3
4
  import type { Credential } from '../../Credential.js'
4
5
  import { VerificationFailedError } from '../../Errors.js'
@@ -1,4 +1,5 @@
1
1
  import type { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'
2
+
2
3
  import type * as Credential from '../../Credential.js'
3
4
  import * as core_Mcp from '../../Mcp.js'
4
5
  import * as Transport from '../../server/Transport.js'
@@ -1,4 +1,5 @@
1
1
  import * as http from 'node:http'
2
+
2
3
  import { Elysia } from 'elysia'
3
4
  import { Receipt } from 'mppx'
4
5
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
@@ -1,4 +1,5 @@
1
1
  import type { Context } from 'elysia'
2
+
2
3
  import * as Mppx_core from '../server/Mppx.js'
3
4
  import * as Mppx_internal from './internal/mppx.js'
4
5
 
@@ -1,8 +1,8 @@
1
1
  import express from 'express'
2
2
  import { Receipt } from 'mppx'
3
3
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
4
- import { Mppx } from 'mppx/express'
5
- import { tempo as tempo_server } from 'mppx/server'
4
+ import { Mppx, payment } from 'mppx/express'
5
+ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
6
6
  import type { Address } from 'viem'
7
7
  import { Addresses } from 'viem/tempo'
8
8
  import { beforeAll, describe, expect, test } from 'vitest'
@@ -158,3 +158,63 @@ describe('session', () => {
158
158
  server.close()
159
159
  })
160
160
  })
161
+
162
+ describe('payment', () => {
163
+ const mppx = Mppx_server.create({
164
+ methods: [
165
+ tempo_server({
166
+ getClient: () => client,
167
+ currency: asset,
168
+ recipient: accounts[0].address,
169
+ }),
170
+ ],
171
+ secretKey,
172
+ })
173
+
174
+ const { fetch } = Mppx_client.create({
175
+ polyfill: false,
176
+ methods: [
177
+ tempo_client({
178
+ account: accounts[1],
179
+ getClient: () => client,
180
+ }),
181
+ ],
182
+ })
183
+
184
+ test('returns 402 when no credential', async () => {
185
+ const app = express()
186
+ app.get('/', payment(mppx.charge, { amount: '1' }), (_req, res) => {
187
+ res.json({ fortune: 'You will be rich' })
188
+ })
189
+
190
+ const server = await createServer(app)
191
+ const response = await globalThis.fetch(server.url)
192
+ expect(response.status).toBe(402)
193
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
194
+
195
+ server.close()
196
+ })
197
+
198
+ test('returns 200 with receipt on valid payment', async () => {
199
+ const app = express()
200
+ app.get('/', payment(mppx.charge, { amount: '1' }), (_req, res) => {
201
+ res.json({ fortune: 'You will be rich' })
202
+ })
203
+
204
+ const server = await createServer(app)
205
+ const response = await fetch(server.url)
206
+ expect(response.status).toBe(200)
207
+
208
+ const body = await response.json()
209
+ expect(body).toEqual({ fortune: 'You will be rich' })
210
+
211
+ const receiptHeader = response.headers.get('Payment-Receipt')
212
+ expect(receiptHeader).toBeTruthy()
213
+
214
+ const receipt = Receipt.fromResponse(response)
215
+ expect(receipt.status).toBe('success')
216
+ expect(receipt.method).toBe('tempo')
217
+
218
+ server.close()
219
+ })
220
+ })
@@ -4,8 +4,8 @@ import type {
4
4
  NextFunction,
5
5
  RequestHandler,
6
6
  } from 'express'
7
+
7
8
  import * as Mppx_core from '../server/Mppx.js'
8
- import * as Request from '../server/Request.js'
9
9
  import * as Mppx_internal from './internal/mppx.js'
10
10
 
11
11
  export * from '../server/Methods.js'
@@ -60,7 +60,11 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
60
60
  options: intent extends (options: infer options) => any ? options : never,
61
61
  ): RequestHandler {
62
62
  return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
63
- const result = await intent(options)(Request.fromNodeListener(req, res))
63
+ const request = new Request(`${req.protocol}://${req.hostname}${req.originalUrl}`, {
64
+ method: req.method,
65
+ headers: req.headers as Record<string, string>,
66
+ })
67
+ const result = await intent(options)(request)
64
68
 
65
69
  if (result.status === 402) {
66
70
  const challenge = result.challenge as Response
@@ -1,4 +1,5 @@
1
1
  import type { MiddlewareHandler } from 'hono'
2
+
2
3
  import * as Mppx_core from '../server/Mppx.js'
3
4
  import * as Mppx_internal from './internal/mppx.js'
4
5
 
@@ -1,6 +1,7 @@
1
1
  import { Challenge, Credential, Method, z } from 'mppx'
2
2
  import { Mppx } from 'mppx/server'
3
3
  import { describe, expect, test } from 'vitest'
4
+
4
5
  import { wrap } from './mppx.js'
5
6
 
6
7
  const realm = 'api.example.com'
@@ -1,4 +1,5 @@
1
1
  import * as http from 'node:http'
2
+
2
3
  import { Receipt } from 'mppx'
3
4
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
4
5
  import { Mppx } from 'mppx/nextjs'
@@ -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 * as Service from './Service.js'
9
10
  import { anthropic } from './services/anthropic.js'
@@ -1,5 +1,7 @@
1
1
  import type * as http from 'node:http'
2
+
2
3
  import { createFetchProxy } from '@remix-run/fetch-proxy'
4
+
3
5
  import * as Request from '../server/Request.js'
4
6
  import * as Headers from './internal/Headers.js'
5
7
  import * as Route from './internal/Route.js'
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest'
2
+
2
3
  import * as Service from './Service.js'
3
4
 
4
5
  describe('from', () => {
@@ -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', () => {
@@ -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
 
@@ -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,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
@@ -1,6 +1,7 @@
1
1
  import { Challenge } from 'mppx'
2
2
  import { Response } from 'mppx/server'
3
3
  import { describe, expect, test } from 'vitest'
4
+
4
5
  import * as Errors from '../Errors.js'
5
6
 
6
7
  const challenge = Challenge.from({
@@ -2,6 +2,7 @@ import { Challenge, Credential, Mcp, Receipt } from 'mppx'
2
2
  import { Transport } from 'mppx/server'
3
3
  import { Methods } from 'mppx/tempo'
4
4
  import { describe, expect, test } from 'vitest'
5
+
5
6
  import { BadRequestError, ChannelClosedError } from '../Errors.js'
6
7
 
7
8
  const realm = 'api.example.com'
@@ -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
 
@@ -2,6 +2,7 @@ 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
4
  import { describe, expect, test, vi } from 'vitest'
5
+
5
6
  import type { StripeJs } from '../internal/types.js'
6
7
  import { charge as clientCharge_ } from './Charge.js'
7
8
 
@@ -2,6 +2,7 @@ import { Challenge, Credential } from 'mppx'
2
2
  import { Mppx, stripe } from 'mppx/server'
3
3
  import { afterEach, describe, expect, test, vi } from 'vitest'
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
2
  import { describe, expect, test } from 'vitest'
3
+
3
4
  import * as Attribution from './Attribution.js'
4
5
 
5
6
  describe('Attribution', () => {
@@ -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
 
@@ -5,6 +5,7 @@ import { Addresses } from 'viem/tempo'
5
5
  import { beforeAll, describe, expect, test } from 'vitest'
6
6
  import { deployEscrow, openChannel } from '~test/tempo/session.js'
7
7
  import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
8
+
8
9
  import type { Challenge } from '../../Challenge.js'
9
10
  import * as Credential from '../../Credential.js'
10
11
  import {
@@ -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'
@@ -4,6 +4,7 @@ import { Addresses } from 'viem/tempo'
4
4
  import { beforeAll, describe, expect, test } from 'vitest'
5
5
  import { deployEscrow, openChannel } from '~test/tempo/session.js'
6
6
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
7
+
7
8
  import * as Challenge from '../../Challenge.js'
8
9
  import * as Credential from '../../Credential.js'
9
10
  import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
@@ -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'