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.
- package/CHANGELOG.md +9 -3
- package/README.md +13 -13
- package/dist/BodyDigest.d.ts.map +1 -1
- package/dist/BodyDigest.js.map +1 -1
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js.map +1 -1
- package/dist/Errors.js +64 -67
- package/dist/Errors.js.map +1 -1
- package/dist/PaymentRequest.d.ts.map +1 -1
- package/dist/PaymentRequest.js.map +1 -1
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js.map +1 -1
- package/dist/Store.d.ts +9 -0
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +17 -0
- package/dist/Store.js.map +1 -1
- package/dist/cli/account.d.ts.map +1 -1
- package/dist/cli/account.js +40 -5
- package/dist/cli/account.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +2 -1
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +2 -1
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +1 -1
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +1 -1
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +5 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +35 -17
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.d.ts.map +1 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/stripe/Methods.d.ts.map +1 -1
- package/dist/stripe/Methods.js.map +1 -1
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/client/ChannelOps.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Session.d.ts.map +1 -1
- package/dist/tempo/client/Session.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +1 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +1 -1
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +1 -1
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +1 -1
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Receipt.d.ts.map +1 -1
- package/dist/tempo/session/Receipt.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js.map +1 -1
- package/package.json +1 -1
- package/src/BodyDigest.ts +1 -0
- package/src/Challenge.test-d.ts +1 -0
- package/src/Challenge.ts +1 -0
- package/src/Credential.ts +1 -0
- package/src/Errors.test.ts +27 -39
- package/src/Expires.test.ts +1 -0
- package/src/PaymentRequest.ts +1 -0
- package/src/Receipt.ts +1 -0
- package/src/Store.test-d.ts +1 -0
- package/src/Store.test.ts +56 -6
- package/src/Store.ts +25 -0
- package/src/cli/account.ts +65 -30
- package/src/cli/cli.test.ts +3 -1
- package/src/cli/cli.ts +4 -1
- package/src/cli/config.test.ts +1 -0
- package/src/cli/internal.ts +1 -0
- package/src/cli/plugins/stripe.ts +1 -0
- package/src/cli/plugins/tempo.ts +4 -1
- package/src/cli/utils.ts +1 -0
- package/src/client/Mppx.test-d.ts +1 -0
- package/src/client/internal/Fetch.browser.test.ts +1 -0
- package/src/client/internal/Fetch.test-d.ts +1 -0
- package/src/client/internal/Fetch.test.ts +1 -0
- package/src/client/internal/Fetch.ts +1 -1
- package/src/internal/constantTimeEqual.test.ts +1 -0
- package/src/internal/types.ts +1 -3
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -0
- package/src/mcp-sdk/client/McpClient.test.ts +1 -0
- package/src/mcp-sdk/client/McpClient.ts +2 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +1 -0
- package/src/middlewares/elysia.test.ts +1 -0
- package/src/middlewares/elysia.ts +1 -0
- package/src/middlewares/express.test.ts +62 -2
- package/src/middlewares/express.ts +6 -2
- package/src/middlewares/hono.ts +1 -0
- package/src/middlewares/internal/mppx.test.ts +1 -0
- package/src/middlewares/nextjs.test.ts +1 -0
- package/src/proxy/Proxy.test.ts +1 -0
- package/src/proxy/Proxy.ts +2 -0
- package/src/proxy/Service.test.ts +1 -0
- package/src/proxy/Service.ts +8 -2
- package/src/proxy/internal/Headers.test.ts +1 -0
- package/src/proxy/services/openai.test.ts +1 -0
- package/src/server/Mppx.test.ts +192 -0
- package/src/server/Mppx.ts +38 -19
- package/src/server/Request.test.ts +1 -0
- package/src/server/Request.ts +1 -0
- package/src/server/Response.test.ts +1 -0
- package/src/server/Transport.test.ts +1 -0
- package/src/stripe/Methods.ts +1 -0
- package/src/stripe/client/Charge.test.ts +1 -0
- package/src/stripe/server/Charge.test.ts +1 -0
- package/src/tempo/Attribution.test.ts +1 -0
- package/src/tempo/Methods.ts +1 -0
- package/src/tempo/client/ChannelOps.test.ts +1 -0
- package/src/tempo/client/ChannelOps.ts +1 -0
- package/src/tempo/client/Charge.ts +1 -0
- package/src/tempo/client/Session.test.ts +1 -0
- package/src/tempo/client/Session.ts +1 -0
- package/src/tempo/client/SessionManager.test.ts +28 -0
- package/src/tempo/client/SessionManager.ts +2 -1
- package/src/tempo/internal/auto-swap.test.ts +1 -0
- package/src/tempo/internal/auto-swap.ts +1 -0
- package/src/tempo/internal/defaults.test.ts +1 -0
- package/src/tempo/internal/fee-payer.test.ts +1 -0
- package/src/tempo/internal/fee-payer.ts +1 -0
- package/src/tempo/server/Charge.test.ts +1 -0
- package/src/tempo/server/Charge.ts +1 -0
- package/src/tempo/server/Session.test.ts +1 -0
- package/src/tempo/server/Session.ts +1 -0
- package/src/tempo/server/Sse.test.ts +1 -0
- package/src/tempo/server/internal/transport.test.ts +1 -0
- package/src/tempo/session/Chain.test.ts +1 -0
- package/src/tempo/session/Chain.ts +1 -0
- package/src/tempo/session/Channel.test.ts +1 -0
- package/src/tempo/session/ChannelStore.test.ts +1 -0
- package/src/tempo/session/ChannelStore.ts +1 -0
- package/src/tempo/session/Receipt.test.ts +1 -0
- package/src/tempo/session/Receipt.ts +1 -0
- package/src/tempo/session/Sse.test.ts +1 -0
- package/src/tempo/session/Sse.ts +1 -0
- package/src/tempo/session/Voucher.test.ts +1 -0
- package/src/tempo/session/Voucher.ts +1 -0
- package/src/viem/Account.test.ts +1 -0
- package/src/viem/Client.test.ts +1 -0
- package/src/viem/Client.ts +1 -0
package/src/internal/types.ts
CHANGED
|
@@ -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,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
|
|
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
|
package/src/middlewares/hono.ts
CHANGED
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -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'
|
package/src/proxy/Proxy.ts
CHANGED
package/src/proxy/Service.ts
CHANGED
|
@@ -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 {
|
|
271
|
-
|
|
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))
|
|
@@ -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
|
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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
|
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
}
|
package/src/server/Request.ts
CHANGED
|
@@ -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'
|
package/src/stripe/Methods.ts
CHANGED
|
@@ -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'
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -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'
|