mppx 0.4.7 → 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 +6 -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/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 +31 -23
- 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 +2 -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/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 +896 -108
- package/src/tempo/server/Session.ts +40 -23
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Voucher.js","sourceRoot":"","sources":["../../../src/tempo/session/Voucher.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,SAAS,EAAE,MAAM,IAAI,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA;AAE5C,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"Voucher.js","sourceRoot":"","sources":["../../../src/tempo/session/Voucher.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mppx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.8",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"elysia": ">=1",
|
|
97
97
|
"express": ">=5",
|
|
98
98
|
"hono": ">=4",
|
|
99
|
-
"viem": ">=2.
|
|
99
|
+
"viem": ">=2.47.5"
|
|
100
100
|
},
|
|
101
101
|
"peerDependenciesMeta": {
|
|
102
102
|
"@modelcontextprotocol/sdk": {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expectTypeOf, test } from 'vitest'
|
|
2
|
+
import * as Store from './Store.js'
|
|
3
|
+
|
|
4
|
+
test('default Store accepts any string key', () => {
|
|
5
|
+
const store = Store.memory()
|
|
6
|
+
expectTypeOf(store.get).parameter(0).toBeString()
|
|
7
|
+
expectTypeOf(store.put).parameter(0).toBeString()
|
|
8
|
+
expectTypeOf(store.delete).parameter(0).toBeString()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('default Store get returns unknown', async () => {
|
|
12
|
+
const store = Store.memory()
|
|
13
|
+
const value = await store.get('anything')
|
|
14
|
+
expectTypeOf(value).toEqualTypeOf<unknown>()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('typed Store constrains keys', () => {
|
|
18
|
+
type ItemMap = { [key: `mppx:charge:${string}`]: number }
|
|
19
|
+
const store = {} as Store.Store<ItemMap>
|
|
20
|
+
|
|
21
|
+
expectTypeOf(store.get).parameter(0).toEqualTypeOf<`mppx:charge:${string}`>()
|
|
22
|
+
expectTypeOf(store.put).parameter(0).toEqualTypeOf<`mppx:charge:${string}`>()
|
|
23
|
+
expectTypeOf(store.delete).parameter(0).toEqualTypeOf<`mppx:charge:${string}`>()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('typed Store infers value from key', async () => {
|
|
27
|
+
type ItemMap = { [key: `mppx:charge:${string}`]: number }
|
|
28
|
+
const store = {} as Store.Store<ItemMap>
|
|
29
|
+
|
|
30
|
+
const value = await store.get('mppx:charge:0x123')
|
|
31
|
+
expectTypeOf(value).toEqualTypeOf<number | null>()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('typed Store enforces value type on put', () => {
|
|
35
|
+
type ItemMap = { [key: `mppx:charge:${string}`]: number }
|
|
36
|
+
const store = {} as Store.Store<ItemMap>
|
|
37
|
+
|
|
38
|
+
// @ts-expect-error — value must be number, not string
|
|
39
|
+
store.put('mppx:charge:0x123', 'wrong')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('cloudflare returns generic Store', () => {
|
|
43
|
+
const store = Store.cloudflare({
|
|
44
|
+
get: async () => null,
|
|
45
|
+
put: async () => {},
|
|
46
|
+
delete: async () => {},
|
|
47
|
+
})
|
|
48
|
+
expectTypeOf(store).toEqualTypeOf<Store.Store>()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('upstash returns generic Store', () => {
|
|
52
|
+
const store = Store.upstash({
|
|
53
|
+
get: async () => null,
|
|
54
|
+
set: async () => null,
|
|
55
|
+
del: async () => null,
|
|
56
|
+
})
|
|
57
|
+
expectTypeOf(store).toEqualTypeOf<Store.Store>()
|
|
58
|
+
})
|
package/src/Store.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Json } from 'ox'
|
|
8
8
|
|
|
9
|
-
export type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
export type StoreItemMap = Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
export type Store<itemMap extends StoreItemMap = StoreItemMap> = {
|
|
12
|
+
get: <key extends keyof itemMap & string>(key: key) => Promise<itemMap[key] | null>
|
|
13
|
+
put: <key extends keyof itemMap & string>(key: key, value: itemMap[key]) => Promise<void>
|
|
14
|
+
delete: <key extends keyof itemMap & string>(key: key) => Promise<void>
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/** Creates a {@link Store} from an existing implementation. */
|
package/src/cli/cli.test.ts
CHANGED
|
@@ -10,11 +10,13 @@ import * as Http from '~test/Http.js'
|
|
|
10
10
|
import { rpcUrl } from '~test/tempo/prool.js'
|
|
11
11
|
import { deployEscrow } from '~test/tempo/session.js'
|
|
12
12
|
import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
|
|
13
|
+
import * as Credential from '../Credential.js'
|
|
13
14
|
import * as Store from '../Store.js'
|
|
14
15
|
import * as Mppx_server from '../server/Mppx.js'
|
|
15
16
|
import { toNodeListener } from '../server/Mppx.js'
|
|
16
17
|
import { stripe as stripe_server } from '../stripe/server/Methods.js'
|
|
17
18
|
import { tempo } from '../tempo/server/Methods.js'
|
|
19
|
+
import type { SessionCredentialPayload } from '../tempo/session/Types.js'
|
|
18
20
|
import cli from './cli.js'
|
|
19
21
|
|
|
20
22
|
const testPrivateKey = generatePrivateKey()
|
|
@@ -192,6 +194,128 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
|
|
|
192
194
|
}
|
|
193
195
|
})
|
|
194
196
|
|
|
197
|
+
test('bug: non-SSE open should not double-charge tick amount', { timeout: 120_000 }, async () => {
|
|
198
|
+
await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
|
|
199
|
+
await fundAccount({ address: testAccount.address, token: asset })
|
|
200
|
+
|
|
201
|
+
const escrow = await deployEscrow()
|
|
202
|
+
const store = Store.memory()
|
|
203
|
+
const tickAmount = '0.001'
|
|
204
|
+
const server = Mppx_server.create({
|
|
205
|
+
methods: [
|
|
206
|
+
tempo.session({
|
|
207
|
+
account: accounts[0],
|
|
208
|
+
store,
|
|
209
|
+
getClient: () => client,
|
|
210
|
+
currency: asset,
|
|
211
|
+
escrowContract: escrow,
|
|
212
|
+
chainId: client.chain.id,
|
|
213
|
+
feePayer: true,
|
|
214
|
+
}),
|
|
215
|
+
],
|
|
216
|
+
realm: 'cli-test-double-charge',
|
|
217
|
+
secretKey: 'cli-test-secret',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Track voucher cumulative amounts from credential payloads
|
|
221
|
+
const voucherAmounts: string[] = []
|
|
222
|
+
|
|
223
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
224
|
+
const authHeader = req.headers.authorization
|
|
225
|
+
if (authHeader) {
|
|
226
|
+
try {
|
|
227
|
+
const cred = Credential.deserialize<SessionCredentialPayload>(authHeader)
|
|
228
|
+
if (cred.payload.action === 'voucher' && 'cumulativeAmount' in cred.payload) {
|
|
229
|
+
voucherAmounts.push(cred.payload.cumulativeAmount)
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const result = await toNodeListener(
|
|
235
|
+
server.session({
|
|
236
|
+
amount: tickAmount,
|
|
237
|
+
recipient: accounts[0].address,
|
|
238
|
+
unitType: 'page',
|
|
239
|
+
}),
|
|
240
|
+
)(req, res)
|
|
241
|
+
if (result.status === 402) return
|
|
242
|
+
// Non-SSE: plain text response (not text/event-stream)
|
|
243
|
+
res.end('scraped-content')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await serve([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'], {
|
|
248
|
+
env: { MPPX_PRIVATE_KEY: testPrivateKey },
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// No follow-up voucher should be sent after a non-SSE open.
|
|
252
|
+
// The open credential already paid for this unit, so the CLI
|
|
253
|
+
// should NOT send a redundant voucher that would double-charge.
|
|
254
|
+
expect(voucherAmounts.length).toBe(0)
|
|
255
|
+
} finally {
|
|
256
|
+
httpServer.close()
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('bug: closeChannel sends action "close" not "voucher"', { timeout: 120_000 }, async () => {
|
|
261
|
+
await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
|
|
262
|
+
await fundAccount({ address: testAccount.address, token: asset })
|
|
263
|
+
|
|
264
|
+
const escrow = await deployEscrow()
|
|
265
|
+
const store = Store.memory()
|
|
266
|
+
const server = Mppx_server.create({
|
|
267
|
+
methods: [
|
|
268
|
+
tempo.session({
|
|
269
|
+
account: accounts[0],
|
|
270
|
+
store,
|
|
271
|
+
getClient: () => client,
|
|
272
|
+
currency: asset,
|
|
273
|
+
escrowContract: escrow,
|
|
274
|
+
chainId: client.chain.id,
|
|
275
|
+
feePayer: true,
|
|
276
|
+
}),
|
|
277
|
+
],
|
|
278
|
+
realm: 'cli-test-close-action',
|
|
279
|
+
secretKey: 'cli-test-secret',
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Track the credential payload action from the close request
|
|
283
|
+
const credentialActions: string[] = []
|
|
284
|
+
|
|
285
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
286
|
+
// Capture credential action from every request with Authorization header
|
|
287
|
+
const authHeader = req.headers.authorization
|
|
288
|
+
if (authHeader) {
|
|
289
|
+
try {
|
|
290
|
+
const cred = Credential.deserialize<SessionCredentialPayload>(authHeader)
|
|
291
|
+
credentialActions.push(cred.payload.action)
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const result = await toNodeListener(
|
|
296
|
+
server.session({
|
|
297
|
+
amount: '0.001',
|
|
298
|
+
recipient: accounts[0].address,
|
|
299
|
+
unitType: 'page',
|
|
300
|
+
}),
|
|
301
|
+
)(req, res)
|
|
302
|
+
if (result.status === 402) return
|
|
303
|
+
res.end('scraped-content')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await serve([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'], {
|
|
308
|
+
env: { MPPX_PRIVATE_KEY: testPrivateKey },
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// The last credential sent should be the close request with action: 'close'
|
|
312
|
+
const lastAction = credentialActions[credentialActions.length - 1]
|
|
313
|
+
expect(lastAction).toBe('close')
|
|
314
|
+
} finally {
|
|
315
|
+
httpServer.close()
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
195
319
|
test('error: --fail exits on server error', { timeout: 60_000 }, async () => {
|
|
196
320
|
const httpServer = await Http.createServer(async (_req, res) => {
|
|
197
321
|
res.writeHead(500)
|
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 }
|