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