mppx 0.4.12 → 0.5.0

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 (52) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/server/Mppx.js +6 -5
  7. package/dist/server/Mppx.js.map +1 -1
  8. package/dist/stripe/server/Charge.d.ts.map +1 -1
  9. package/dist/stripe/server/Charge.js +3 -3
  10. package/dist/stripe/server/Charge.js.map +1 -1
  11. package/dist/tempo/Methods.d.ts +3 -0
  12. package/dist/tempo/Methods.d.ts.map +1 -1
  13. package/dist/tempo/Methods.js +1 -0
  14. package/dist/tempo/Methods.js.map +1 -1
  15. package/dist/tempo/client/Charge.d.ts +3 -0
  16. package/dist/tempo/client/Charge.d.ts.map +1 -1
  17. package/dist/tempo/client/Charge.js +18 -2
  18. package/dist/tempo/client/Charge.js.map +1 -1
  19. package/dist/tempo/client/Methods.d.ts +3 -0
  20. package/dist/tempo/client/Methods.d.ts.map +1 -1
  21. package/dist/tempo/internal/proof.d.ts +23 -0
  22. package/dist/tempo/internal/proof.d.ts.map +1 -0
  23. package/dist/tempo/internal/proof.js +17 -0
  24. package/dist/tempo/internal/proof.js.map +1 -0
  25. package/dist/tempo/server/Charge.d.ts +3 -0
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +32 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Methods.d.ts +3 -0
  30. package/dist/tempo/server/Methods.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/Expires.ts +25 -0
  33. package/src/cli/cli.test.ts +230 -1
  34. package/src/middlewares/elysia.test.ts +127 -4
  35. package/src/middlewares/express.test.ts +120 -54
  36. package/src/middlewares/hono.test.ts +73 -34
  37. package/src/middlewares/nextjs.test.ts +159 -36
  38. package/src/server/Mppx.test.ts +86 -0
  39. package/src/server/Mppx.ts +5 -5
  40. package/src/stripe/server/Charge.ts +3 -7
  41. package/src/tempo/Methods.test.ts +26 -0
  42. package/src/tempo/Methods.ts +1 -0
  43. package/src/tempo/client/Charge.ts +26 -3
  44. package/src/tempo/internal/charge.test.ts +66 -0
  45. package/src/tempo/internal/proof.test.ts +36 -0
  46. package/src/tempo/internal/proof.ts +19 -0
  47. package/src/tempo/server/Charge.test.ts +362 -1
  48. package/src/tempo/server/Charge.ts +40 -2
  49. package/src/tempo/server/Session.test.ts +1123 -53
  50. package/src/tempo/server/internal/transport.test.ts +32 -0
  51. package/src/tempo/session/Chain.test.ts +35 -0
  52. package/src/tempo/session/Sse.test.ts +31 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.4.12",
4
+ "version": "0.5.0",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
package/src/Expires.ts CHANGED
@@ -1,3 +1,28 @@
1
+ import { InvalidChallengeError, PaymentExpiredError } from './Errors.js'
2
+
3
+ /**
4
+ * Asserts that `expires` is present, well-formed, and not in the past.
5
+ *
6
+ * Throws `InvalidChallengeError` when missing or malformed,
7
+ * and `PaymentExpiredError` when the timestamp is in the past.
8
+ */
9
+ export function assert(
10
+ expires: string | undefined,
11
+ challengeId?: string,
12
+ ): asserts expires is string {
13
+ if (!expires)
14
+ throw new InvalidChallengeError({
15
+ ...(challengeId && { id: challengeId }),
16
+ reason: 'missing required expires field',
17
+ })
18
+ if (Number.isNaN(new Date(expires).getTime()))
19
+ throw new InvalidChallengeError({
20
+ ...(challengeId && { id: challengeId }),
21
+ reason: 'malformed expires timestamp',
22
+ })
23
+ if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires })
24
+ }
25
+
1
26
  /** Returns an ISO 8601 datetime string `n` days from now. */
2
27
  export function days(n: number) {
3
28
  return new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString()
@@ -10,9 +10,11 @@ import { afterAll, describe, expect, test } from 'vp/test'
10
10
  import * as Http from '~test/Http.js'
11
11
  import { rpcUrl } from '~test/tempo/prool.js'
12
12
  import { deployEscrow } from '~test/tempo/session.js'
13
- import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
13
+ import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
14
14
 
15
+ import * as Challenge from '../Challenge.js'
15
16
  import * as Credential from '../Credential.js'
17
+ import * as Receipt from '../Receipt.js'
16
18
  import * as Mppx_server from '../server/Mppx.js'
17
19
  import { toNodeListener } from '../server/Mppx.js'
18
20
  import * as Store from '../Store.js'
@@ -327,6 +329,107 @@ describe('basic charge (examples/basic)', () => {
327
329
  }
328
330
  })
329
331
 
332
+ test(
333
+ 'zero-amount charge uses a proof credential and receives response',
334
+ { timeout: 120_000 },
335
+ async () => {
336
+ const server = Mppx_server.create({
337
+ methods: [tempo.charge({ getClient: () => client })],
338
+ realm: 'localhost',
339
+ secretKey: 'cli-test-secret',
340
+ })
341
+ let authorization: string | undefined
342
+
343
+ const httpServer = await Http.createServer(async (req, res) => {
344
+ authorization = req.headers.authorization
345
+ const result = await toNodeListener(
346
+ server.charge({
347
+ amount: '0',
348
+ currency: asset,
349
+ expires: new Date(Date.now() + 60_000).toISOString(),
350
+ recipient: accounts[0].address,
351
+ }),
352
+ )(req, res)
353
+ if (result.status === 402) return
354
+ res.end('zero-dollar-paid')
355
+ })
356
+
357
+ try {
358
+ const { output, exitCode } = await serve([httpServer.url, '--rpc-url', rpcUrl, '-s'], {
359
+ env: { MPPX_PRIVATE_KEY: testPrivateKey },
360
+ })
361
+ expect(exitCode).toBeUndefined()
362
+ expect(output).toContain('zero-dollar-paid')
363
+
364
+ const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(
365
+ authorization!,
366
+ )
367
+ expect(credential.challenge.request.amount).toBe('0')
368
+ expect(credential.payload.type).toBe('proof')
369
+ expect(credential.payload.signature).toMatch(/^0x/)
370
+ expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${testAccount.address}`)
371
+ } finally {
372
+ httpServer.close()
373
+ }
374
+ },
375
+ )
376
+
377
+ test(
378
+ 'zero-amount charge with testnet currency omission uses a proof credential',
379
+ { timeout: 120_000 },
380
+ async () => {
381
+ const isTestnet = true
382
+ const mainnetCurrency = '0x20C00000000000000000000b9537d11c60E8b50' as `0x${string}`
383
+
384
+ const server = Mppx_server.create({
385
+ methods: [
386
+ tempo.charge({
387
+ getClient: () => client,
388
+ ...(isTestnet ? {} : { currency: mainnetCurrency }),
389
+ testnet: isTestnet,
390
+ }),
391
+ ],
392
+ realm: 'localhost',
393
+ secretKey: 'cli-test-secret',
394
+ })
395
+ let authorization: string | undefined
396
+
397
+ const httpServer = await Http.createServer(async (req, res) => {
398
+ authorization = req.headers.authorization
399
+ const result = await toNodeListener(
400
+ server.charge({
401
+ amount: '0',
402
+ chainId: chain.id,
403
+ expires: new Date(Date.now() + 60_000).toISOString(),
404
+ recipient: accounts[0].address,
405
+ }),
406
+ )(req, res)
407
+ if (result.status === 402) return
408
+ res.end('zero-dollar-testnet-paid')
409
+ })
410
+
411
+ try {
412
+ const { output, exitCode } = await serve([httpServer.url, '--rpc-url', rpcUrl, '-s'], {
413
+ env: { MPPX_PRIVATE_KEY: testPrivateKey },
414
+ })
415
+ expect(exitCode).toBeUndefined()
416
+ expect(output).toContain('zero-dollar-testnet-paid')
417
+
418
+ const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(
419
+ authorization!,
420
+ )
421
+ expect(credential.challenge.request.amount).toBe('0')
422
+ expect(credential.challenge.request.currency).toBe(
423
+ '0x20c0000000000000000000000000000000000000',
424
+ )
425
+ expect(credential.payload.type).toBe('proof')
426
+ expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${testAccount.address}`)
427
+ } finally {
428
+ httpServer.close()
429
+ }
430
+ },
431
+ )
432
+
330
433
  test('error: no account found', { timeout: 60_000 }, async () => {
331
434
  const server = Mppx_server.create({
332
435
  methods: [tempo.charge({ getClient: () => client })],
@@ -1046,4 +1149,130 @@ describe('sign', () => {
1046
1149
  const parsed = JSON.parse(output.trim())
1047
1150
  expect(parsed.authorization).toMatch(/^Payment\s+\S+/)
1048
1151
  })
1152
+
1153
+ test(
1154
+ 'happy path: zero-amount challenge returns proof authorization accepted by live server',
1155
+ { timeout: 120_000 },
1156
+ async () => {
1157
+ const server = Mppx_server.create({
1158
+ methods: [tempo.charge({ getClient: () => client })],
1159
+ realm: 'cli-sign-zero',
1160
+ secretKey: 'cli-test-secret',
1161
+ })
1162
+
1163
+ const httpServer = await Http.createServer(async (req, res) => {
1164
+ const result = await toNodeListener(
1165
+ server.charge({
1166
+ amount: '0',
1167
+ currency: asset,
1168
+ expires: new Date(Date.now() + 60_000).toISOString(),
1169
+ recipient: accounts[0].address,
1170
+ }),
1171
+ )(req, res)
1172
+ if (result.status === 402) return
1173
+ res.end('zero-dollar-live-sign')
1174
+ })
1175
+
1176
+ try {
1177
+ const challengeResponse = await fetch(httpServer.url)
1178
+ expect(challengeResponse.status).toBe(402)
1179
+ const challenge = Challenge.fromResponse(challengeResponse)
1180
+
1181
+ const { output, exitCode } = await serve(
1182
+ ['sign', '--challenge', Challenge.serialize(challenge), '--rpc-url', rpcUrl],
1183
+ { env: { MPPX_PRIVATE_KEY: testPrivateKey } },
1184
+ )
1185
+
1186
+ expect(exitCode).toBeUndefined()
1187
+
1188
+ const authorization = output.trim()
1189
+ const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(
1190
+ authorization,
1191
+ )
1192
+ expect(credential.challenge.request.amount).toBe('0')
1193
+ expect(credential.payload.type).toBe('proof')
1194
+ expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${testAccount.address}`)
1195
+
1196
+ const response = await fetch(httpServer.url, {
1197
+ headers: { Authorization: authorization },
1198
+ })
1199
+ expect(response.status).toBe(200)
1200
+ expect(await response.text()).toBe('zero-dollar-live-sign')
1201
+
1202
+ const receipt = Receipt.fromResponse(response)
1203
+ expect(receipt.reference).toBe(credential.challenge.id)
1204
+ } finally {
1205
+ httpServer.close()
1206
+ }
1207
+ },
1208
+ )
1209
+
1210
+ test(
1211
+ 'happy path: zero-amount testnet challenge without explicit currency is accepted by live server',
1212
+ { timeout: 120_000 },
1213
+ async () => {
1214
+ const isTestnet = true
1215
+ const mainnetCurrency = '0x20C00000000000000000000b9537d11c60E8b50' as `0x${string}`
1216
+
1217
+ const server = Mppx_server.create({
1218
+ methods: [
1219
+ tempo.charge({
1220
+ getClient: () => client,
1221
+ ...(isTestnet ? {} : { currency: mainnetCurrency }),
1222
+ testnet: isTestnet,
1223
+ }),
1224
+ ],
1225
+ realm: 'cli-sign-zero-testnet',
1226
+ secretKey: 'cli-test-secret',
1227
+ })
1228
+
1229
+ const httpServer = await Http.createServer(async (req, res) => {
1230
+ const result = await toNodeListener(
1231
+ server.charge({
1232
+ amount: '0',
1233
+ chainId: chain.id,
1234
+ expires: new Date(Date.now() + 60_000).toISOString(),
1235
+ recipient: accounts[0].address,
1236
+ }),
1237
+ )(req, res)
1238
+ if (result.status === 402) return
1239
+ res.end('zero-dollar-live-sign-testnet')
1240
+ })
1241
+
1242
+ try {
1243
+ const challengeResponse = await fetch(httpServer.url)
1244
+ expect(challengeResponse.status).toBe(402)
1245
+ const challenge = Challenge.fromResponse(challengeResponse)
1246
+
1247
+ const { output, exitCode } = await serve(
1248
+ ['sign', '--challenge', Challenge.serialize(challenge), '--rpc-url', rpcUrl],
1249
+ { env: { MPPX_PRIVATE_KEY: testPrivateKey } },
1250
+ )
1251
+
1252
+ expect(exitCode).toBeUndefined()
1253
+
1254
+ const authorization = output.trim()
1255
+ const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(
1256
+ authorization,
1257
+ )
1258
+ expect(credential.challenge.request.amount).toBe('0')
1259
+ expect(credential.challenge.request.currency).toBe(
1260
+ '0x20c0000000000000000000000000000000000000',
1261
+ )
1262
+ expect(credential.payload.type).toBe('proof')
1263
+ expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${testAccount.address}`)
1264
+
1265
+ const response = await fetch(httpServer.url, {
1266
+ headers: { Authorization: authorization },
1267
+ })
1268
+ expect(response.status).toBe(200)
1269
+ expect(await response.text()).toBe('zero-dollar-live-sign-testnet')
1270
+
1271
+ const receipt = Receipt.fromResponse(response)
1272
+ expect(receipt.reference).toBe(credential.challenge.id)
1273
+ } finally {
1274
+ httpServer.close()
1275
+ }
1276
+ },
1277
+ )
1049
1278
  })
@@ -2,11 +2,14 @@ import * as http from 'node:http'
2
2
 
3
3
  import { Elysia } from 'elysia'
4
4
  import { Receipt } from 'mppx'
5
- import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
5
+ import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
6
6
  import { Mppx, discovery } from 'mppx/elysia'
7
7
  import { tempo as tempo_server } from 'mppx/server'
8
- import { describe, expect, test } from 'vp/test'
9
- import { accounts, asset, client } from '~test/tempo/viem.js'
8
+ import type { Address } from 'viem'
9
+ import { Addresses } from 'viem/tempo'
10
+ import { beforeAll, describe, expect, test } from 'vp/test'
11
+ import { deployEscrow } from '~test/tempo/session.js'
12
+ import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
10
13
 
11
14
  function createServer(app: Elysia<any, any, any, any, any, any, any>) {
12
15
  return new Promise<{ url: string; close: () => void }>((resolve) => {
@@ -34,13 +37,14 @@ function createServer(app: Elysia<any, any, any, any, any, any, any>) {
34
37
 
35
38
  const secretKey = 'test-secret-key'
36
39
 
37
- describe('charge', () => {
40
+ function createChargeHarness(feePayer: boolean) {
38
41
  const mppx = Mppx.create({
39
42
  methods: [
40
43
  tempo_server.charge({
41
44
  getClient: () => client,
42
45
  currency: asset,
43
46
  recipient: accounts[0].address,
47
+ ...(feePayer ? { feePayer: true } : {}),
44
48
  }),
45
49
  ],
46
50
  secretKey,
@@ -56,7 +60,13 @@ describe('charge', () => {
56
60
  ],
57
61
  })
58
62
 
63
+ return { fetch, mppx }
64
+ }
65
+
66
+ describe('charge', () => {
59
67
  test('returns 402 when no credential', async () => {
68
+ const { mppx } = createChargeHarness(false)
69
+
60
70
  const app = new Elysia().guard({ beforeHandle: mppx.charge({ amount: '1' }) }, (app) =>
61
71
  app.get('/', () => ({ fortune: 'You will be rich' })),
62
72
  )
@@ -70,6 +80,8 @@ describe('charge', () => {
70
80
  })
71
81
 
72
82
  test('returns 200 with receipt on valid payment', async () => {
83
+ const { fetch, mppx } = createChargeHarness(false)
84
+
73
85
  const app = new Elysia().guard({ beforeHandle: mppx.charge({ amount: '1' }) }, (app) =>
74
86
  app.get('/', () => ({ fortune: 'You will be rich' })),
75
87
  )
@@ -88,7 +100,24 @@ describe('charge', () => {
88
100
  server.close()
89
101
  })
90
102
 
103
+ test('fee payer: returns 200 with receipt on valid payment', async () => {
104
+ const { fetch, mppx } = createChargeHarness(true)
105
+
106
+ const app = new Elysia().guard({ beforeHandle: mppx.charge({ amount: '1' }) }, (app) =>
107
+ app.get('/', () => ({ fortune: 'You will be rich' })),
108
+ )
109
+
110
+ const server = await createServer(app)
111
+ const response = await fetch(server.url)
112
+ expect(response.status).toBe(200)
113
+ expect(Receipt.fromResponse(response).status).toBe('success')
114
+
115
+ server.close()
116
+ })
117
+
91
118
  test('serves /openapi.json from discovery plugin', async () => {
119
+ const { mppx } = createChargeHarness(false)
120
+
92
121
  const app = new Elysia().use(
93
122
  discovery(mppx, {
94
123
  info: { title: 'Elysia API', version: '1.0.0' },
@@ -113,3 +142,97 @@ describe('charge', () => {
113
142
  server.close()
114
143
  })
115
144
  })
145
+
146
+ describe('session', () => {
147
+ let escrowContract: Address
148
+
149
+ function createSessionHarness(feePayer: boolean) {
150
+ const mppx = Mppx.create({
151
+ methods: [
152
+ tempo_server.session({
153
+ getClient: () => client,
154
+ account: accounts[0],
155
+ currency: asset,
156
+ escrowContract,
157
+ ...(feePayer ? { feePayer: accounts[1] } : {}),
158
+ } as any),
159
+ ],
160
+ secretKey,
161
+ })
162
+
163
+ const { fetch } = Mppx_client.create({
164
+ polyfill: false,
165
+ methods: [
166
+ sessionIntent({
167
+ account: accounts[2],
168
+ deposit: '10',
169
+ getClient: () => client,
170
+ }),
171
+ ],
172
+ })
173
+
174
+ return { fetch, mppx }
175
+ }
176
+
177
+ beforeAll(async () => {
178
+ escrowContract = await deployEscrow()
179
+ await fundAccount({ address: accounts[1].address, token: Addresses.pathUsd })
180
+ await fundAccount({ address: accounts[1].address, token: asset })
181
+ await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
182
+ await fundAccount({ address: accounts[2].address, token: asset })
183
+ })
184
+
185
+ test('returns 402 when no credential', async () => {
186
+ const { mppx } = createSessionHarness(false)
187
+
188
+ const app = new Elysia().guard(
189
+ { beforeHandle: mppx.session({ amount: '1', currency: asset, unitType: 'token' }) },
190
+ (app) => app.get('/', () => ({ data: 'streamed' })),
191
+ )
192
+
193
+ const server = await createServer(app)
194
+ const response = await globalThis.fetch(server.url)
195
+ expect(response.status).toBe(402)
196
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
197
+
198
+ server.close()
199
+ })
200
+
201
+ test('returns 200 with receipt on valid payment', async () => {
202
+ const { fetch, mppx } = createSessionHarness(false)
203
+
204
+ const app = new Elysia().guard(
205
+ { beforeHandle: mppx.session({ amount: '1', currency: asset, unitType: 'token' }) },
206
+ (app) => app.get('/', () => ({ data: 'streamed' })),
207
+ )
208
+
209
+ const server = await createServer(app)
210
+ const response = await fetch(server.url)
211
+ expect(response.status).toBe(200)
212
+
213
+ const body = await response.json()
214
+ expect(body).toEqual({ data: 'streamed' })
215
+
216
+ const receipt = Receipt.fromResponse(response)
217
+ expect(receipt.status).toBe('success')
218
+ expect(receipt.method).toBe('tempo')
219
+
220
+ server.close()
221
+ })
222
+
223
+ test('fee payer: returns 200 with receipt on valid payment', async () => {
224
+ const { fetch, mppx } = createSessionHarness(true)
225
+
226
+ const app = new Elysia().guard(
227
+ { beforeHandle: mppx.session({ amount: '1', currency: asset, unitType: 'token' }) },
228
+ (app) => app.get('/', () => ({ data: 'streamed' })),
229
+ )
230
+
231
+ const server = await createServer(app)
232
+ const response = await fetch(server.url)
233
+ expect(response.status).toBe(200)
234
+ expect(Receipt.fromResponse(response).status).toBe('success')
235
+
236
+ server.close()
237
+ })
238
+ })