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.
Files changed (85) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/Store.d.ts +5 -4
  3. package/dist/Store.d.ts.map +1 -1
  4. package/dist/Store.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +22 -7
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +9 -22
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/middlewares/elysia.d.ts.map +1 -1
  12. package/dist/middlewares/elysia.js +5 -1
  13. package/dist/middlewares/elysia.js.map +1 -1
  14. package/dist/proxy/Proxy.d.ts.map +1 -1
  15. package/dist/proxy/Proxy.js +3 -1
  16. package/dist/proxy/Proxy.js.map +1 -1
  17. package/dist/proxy/internal/Route.d.ts +2 -2
  18. package/dist/proxy/internal/Route.d.ts.map +1 -1
  19. package/dist/proxy/internal/Route.js +4 -2
  20. package/dist/proxy/internal/Route.js.map +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +26 -8
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/tempo/internal/address.d.ts +3 -0
  25. package/dist/tempo/internal/address.d.ts.map +1 -0
  26. package/dist/tempo/internal/address.js +4 -0
  27. package/dist/tempo/internal/address.js.map +1 -0
  28. package/dist/tempo/internal/auto-swap.js +3 -3
  29. package/dist/tempo/internal/auto-swap.js.map +1 -1
  30. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  31. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  32. package/dist/tempo/internal/fee-payer.js +11 -3
  33. package/dist/tempo/internal/fee-payer.js.map +1 -1
  34. package/dist/tempo/server/Charge.d.ts +11 -0
  35. package/dist/tempo/server/Charge.d.ts.map +1 -1
  36. package/dist/tempo/server/Charge.js +109 -50
  37. package/dist/tempo/server/Charge.js.map +1 -1
  38. package/dist/tempo/server/Session.d.ts +1 -1
  39. package/dist/tempo/server/Session.d.ts.map +1 -1
  40. package/dist/tempo/server/Session.js +31 -23
  41. package/dist/tempo/server/Session.js.map +1 -1
  42. package/dist/tempo/server/internal/transport.d.ts +1 -1
  43. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  44. package/dist/tempo/server/internal/transport.js +41 -1
  45. package/dist/tempo/server/internal/transport.js.map +1 -1
  46. package/dist/tempo/session/Chain.d.ts.map +1 -1
  47. package/dist/tempo/session/Chain.js +51 -10
  48. package/dist/tempo/session/Chain.js.map +1 -1
  49. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  50. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  51. package/dist/tempo/session/ChannelStore.js +4 -2
  52. package/dist/tempo/session/ChannelStore.js.map +1 -1
  53. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  54. package/dist/tempo/session/Voucher.js +3 -2
  55. package/dist/tempo/session/Voucher.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/Store.test-d.ts +58 -0
  58. package/src/Store.ts +6 -4
  59. package/src/cli/cli.test.ts +124 -0
  60. package/src/cli/cli.ts +19 -7
  61. package/src/cli/plugins/tempo.ts +17 -23
  62. package/src/middlewares/elysia.test.ts +89 -0
  63. package/src/middlewares/elysia.ts +4 -1
  64. package/src/proxy/Proxy.test.ts +56 -0
  65. package/src/proxy/Proxy.ts +6 -1
  66. package/src/proxy/internal/Route.test.ts +57 -0
  67. package/src/proxy/internal/Route.ts +3 -1
  68. package/src/server/Mppx.test.ts +246 -0
  69. package/src/server/Mppx.ts +27 -8
  70. package/src/tempo/internal/address.ts +6 -0
  71. package/src/tempo/internal/auto-swap.ts +3 -3
  72. package/src/tempo/internal/fee-payer.ts +18 -4
  73. package/src/tempo/server/Charge.test.ts +1080 -31
  74. package/src/tempo/server/Charge.ts +158 -63
  75. package/src/tempo/server/Session.test.ts +896 -108
  76. package/src/tempo/server/Session.ts +40 -23
  77. package/src/tempo/server/Sse.test.ts +1 -0
  78. package/src/tempo/server/internal/transport.test.ts +29 -0
  79. package/src/tempo/server/internal/transport.ts +41 -2
  80. package/src/tempo/session/Chain.test.ts +144 -0
  81. package/src/tempo/session/Chain.ts +58 -10
  82. package/src/tempo/session/ChannelStore.test.ts +10 -0
  83. package/src/tempo/session/ChannelStore.ts +6 -3
  84. package/src/tempo/session/Sse.test.ts +1 -0
  85. 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":"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"}
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.7",
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.46.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 Store = {
10
- get: <value = unknown>(key: string) => Promise<value | null>
11
- put: (key: string, value: unknown) => Promise<void>
12
- delete: (key: string) => Promise<void>
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. */
@@ -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
- if (
133
+ const insecure =
134
134
  c.options.insecure ||
135
135
  hostname === 'localhost' ||
136
136
  hostname.endsWith('.localhost') ||
137
137
  hostname.endsWith('.local')
138
- ) {
139
- process.removeAllListeners('warning')
140
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
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 globalThis.fetch(fetchUrl, init)
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 globalThis.fetch(fetchUrl, credentialFetchInit)
322
+ const credentialResponse = await targetFetch(fetchUrl, credentialFetchInit)
311
323
 
312
324
  if (c.options.fail && credentialResponse.status >= 400)
313
325
  return c.error({
@@ -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({ channelId, cumulativeAmount, escrowContract, chainId }) {
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: 'voucher',
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
- let credentialResponse = response
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
- const tickAmount = BigInt(challengeRequest.amount as string)
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
  }
@@ -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({
@@ -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(service.routes, upstreamPath)
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 just the path, ignoring the HTTP method. Used for management POST fallback. */
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 }