mppx 0.3.4 → 0.3.6

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 (64) hide show
  1. package/README.md +0 -52
  2. package/dist/Challenge.d.ts +8 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +20 -4
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/cli.js +193 -66
  7. package/dist/cli.js.map +1 -1
  8. package/dist/internal/types.d.ts +10 -0
  9. package/dist/internal/types.d.ts.map +1 -1
  10. package/dist/proxy/internal/Headers.d.ts +2 -0
  11. package/dist/proxy/internal/Headers.d.ts.map +1 -1
  12. package/dist/proxy/internal/Headers.js +2 -0
  13. package/dist/proxy/internal/Headers.js.map +1 -1
  14. package/dist/proxy/internal/Route.d.ts +4 -0
  15. package/dist/proxy/internal/Route.d.ts.map +1 -1
  16. package/dist/proxy/internal/Route.js +4 -0
  17. package/dist/proxy/internal/Route.js.map +1 -1
  18. package/dist/server/Mppx.d.ts +2 -0
  19. package/dist/server/Mppx.d.ts.map +1 -1
  20. package/dist/server/Mppx.js +4 -3
  21. package/dist/server/Mppx.js.map +1 -1
  22. package/dist/server/NodeListener.d.ts +6 -0
  23. package/dist/server/NodeListener.d.ts.map +1 -1
  24. package/dist/server/NodeListener.js +6 -0
  25. package/dist/server/NodeListener.js.map +1 -1
  26. package/dist/server/Response.d.ts +17 -0
  27. package/dist/server/Response.d.ts.map +1 -1
  28. package/dist/server/Response.js +17 -0
  29. package/dist/server/Response.js.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js.map +1 -1
  31. package/dist/tempo/internal/defaults.d.ts +34 -8
  32. package/dist/tempo/internal/defaults.d.ts.map +1 -1
  33. package/dist/tempo/internal/defaults.js +30 -8
  34. package/dist/tempo/internal/defaults.js.map +1 -1
  35. package/dist/tempo/server/Charge.js +2 -2
  36. package/dist/tempo/server/Charge.js.map +1 -1
  37. package/dist/tempo/server/Session.d.ts.map +1 -1
  38. package/dist/tempo/server/Session.js +8 -3
  39. package/dist/tempo/server/Session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/Challenge.test.ts +201 -11
  42. package/src/Challenge.ts +34 -4
  43. package/src/Store.test.ts +93 -0
  44. package/src/cli.test.ts +233 -37
  45. package/src/cli.ts +229 -79
  46. package/src/client/Transport.test.ts +4 -4
  47. package/src/internal/env.test.ts +42 -0
  48. package/src/internal/types.ts +11 -0
  49. package/src/proxy/internal/Headers.ts +2 -0
  50. package/src/proxy/internal/Route.ts +4 -0
  51. package/src/server/Mppx.test.ts +173 -0
  52. package/src/server/Mppx.ts +6 -3
  53. package/src/server/NodeListener.ts +6 -0
  54. package/src/server/Response.ts +17 -0
  55. package/src/server/Transport.test.ts +5 -5
  56. package/src/tempo/client/ChannelOps.ts +1 -1
  57. package/src/tempo/internal/defaults.test.ts +94 -0
  58. package/src/tempo/internal/defaults.ts +41 -8
  59. package/src/tempo/server/Charge.test.ts +150 -0
  60. package/src/tempo/server/Charge.ts +2 -2
  61. package/src/tempo/server/Session.test.ts +241 -1
  62. package/src/tempo/server/Session.ts +8 -3
  63. package/src/tempo/server/internal/transport.test.ts +285 -0
  64. package/src/tempo/session/Voucher.test.ts +46 -0
package/src/Challenge.ts CHANGED
@@ -27,6 +27,8 @@ export const Schema = z.object({
27
27
  intent: z.string(),
28
28
  /** Payment method (e.g., "tempo", "stripe"). */
29
29
  method: z.string(),
30
+ /** Optional server-defined correlation data. Flat string-to-string map; clients MUST NOT modify. */
31
+ opaque: z.optional(z.record(z.string(), z.string())),
30
32
  /** Server realm (e.g., hostname). */
31
33
  realm: z.string(),
32
34
  /** Method-specific request data. */
@@ -111,11 +113,20 @@ export function from<
111
113
  const methods extends readonly Method.Method[] | undefined = undefined,
112
114
  >(parameters: parameters, options?: from.Options<methods>): from.ReturnType<parameters, methods> {
113
115
  void options
114
- const { description, digest, method: methodName, intent, realm, request, secretKey } = parameters
116
+ const {
117
+ description,
118
+ digest,
119
+ meta,
120
+ method: methodName,
121
+ intent,
122
+ realm,
123
+ request,
124
+ secretKey,
125
+ } = parameters
115
126
 
116
127
  const expires = (parameters.expires ?? request.expires) as string
117
128
  const id = secretKey
118
- ? computeId({ ...parameters, expires }, { secretKey })
129
+ ? computeId({ ...parameters, expires, ...(meta && { opaque: meta }) }, { secretKey })
119
130
  : (parameters as { id: string }).id
120
131
 
121
132
  return Schema.parse({
@@ -127,6 +138,7 @@ export function from<
127
138
  ...(description && { description }),
128
139
  ...(digest && { digest }),
129
140
  ...(expires && { expires }),
141
+ ...(meta && { opaque: meta }),
130
142
  }) as from.ReturnType<parameters, methods>
131
143
  }
132
144
 
@@ -153,6 +165,8 @@ export declare namespace from {
153
165
  expires?: string | undefined
154
166
  /** Intent type (e.g., "charge", "session"). */
155
167
  intent: string
168
+ /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
169
+ meta?: Record<string, string> | undefined
156
170
  /** Payment method (e.g., "tempo", "stripe"). */
157
171
  method: string
158
172
  /** Server realm (e.g., hostname). */
@@ -206,7 +220,7 @@ export function fromMethod<const method extends Method.Method>(
206
220
  parameters: fromMethod.Parameters<method>,
207
221
  ): fromMethod.ReturnType<method> {
208
222
  const { name: methodName, intent } = method
209
- const { description, digest, expires, id, realm, secretKey } = parameters
223
+ const { description, digest, expires, id, meta, realm, secretKey } = parameters
210
224
 
211
225
  const request = PaymentRequest.fromMethod(method, parameters.request)
212
226
 
@@ -219,6 +233,7 @@ export function fromMethod<const method extends Method.Method>(
219
233
  description,
220
234
  digest,
221
235
  expires,
236
+ meta,
222
237
  } as from.Parameters) as fromMethod.ReturnType<method>
223
238
  }
224
239
 
@@ -239,6 +254,8 @@ export declare namespace fromMethod {
239
254
  digest?: string | undefined
240
255
  /** Optional expiration timestamp (ISO 8601). */
241
256
  expires?: string | undefined
257
+ /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
258
+ meta?: Record<string, string> | undefined
242
259
  /** Server realm (e.g., hostname). */
243
260
  realm: string
244
261
  /** Method-specific request data. */
@@ -274,6 +291,8 @@ export function serialize(challenge: Challenge): string {
274
291
  if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
275
292
  if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
276
293
  if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
294
+ if (challenge.opaque !== undefined)
295
+ parts.push(`opaque="${PaymentRequest.serialize(challenge.opaque)}"`)
277
296
 
278
297
  return `Payment ${parts.join(', ')}`
279
298
  }
@@ -314,13 +333,14 @@ export function deserialize<const methods extends readonly Method.Method[] | und
314
333
  }
315
334
  }
316
335
 
317
- const { request, ...rest } = result
336
+ const { request, opaque, ...rest } = result
318
337
  if (!request) throw new Error('Missing request parameter.')
319
338
 
320
339
  return from(
321
340
  {
322
341
  ...rest,
323
342
  request: PaymentRequest.deserialize(request),
343
+ ...(opaque && { meta: PaymentRequest.deserialize(opaque) as Record<string, string> }),
324
344
  } as from.Parameters,
325
345
  options,
326
346
  )
@@ -404,8 +424,17 @@ export declare namespace verify {
404
424
  }
405
425
  }
406
426
 
427
+ /** Alias for `challenge.opaque`. Extracts server-defined correlation data from a challenge. */
428
+ export function meta(challenge: Challenge): Record<string, string> | undefined {
429
+ return challenge.opaque
430
+ }
431
+
407
432
  /** @internal Computes HMAC-SHA256 challenge ID from parameters. */
408
433
  function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: string }): string {
434
+ // Each field occupies a fixed positional slot joined by '|'. Optional fields
435
+ // use an empty string when absent so the slot count is stable — this avoids
436
+ // ambiguity between e.g. (expires set, no digest) vs (no expires, digest set)
437
+ // and means adding a new optional field changes all HMACs exactly once.
409
438
  const input = [
410
439
  challenge.realm,
411
440
  challenge.method,
@@ -413,6 +442,7 @@ function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: strin
413
442
  PaymentRequest.serialize(challenge.request),
414
443
  challenge.expires ?? '',
415
444
  challenge.digest ?? '',
445
+ challenge.opaque ? PaymentRequest.serialize(challenge.opaque) : '',
416
446
  ].join('|')
417
447
 
418
448
  const key = Bytes.fromString(options.secretKey)
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import * as Store from './Store.js'
3
+
4
+ const nested = {
5
+ name: 'alice',
6
+ scores: [1, 2, 3],
7
+ meta: { active: true, tags: ['a', 'b'] },
8
+ }
9
+
10
+ function fakeKv(): Store.cloudflare.Parameters {
11
+ const map = new Map<string, string>()
12
+ return {
13
+ async get(key) {
14
+ return map.get(key) ?? null
15
+ },
16
+ async put(key, value) {
17
+ map.set(key, value)
18
+ },
19
+ async delete(key) {
20
+ map.delete(key)
21
+ },
22
+ }
23
+ }
24
+
25
+ describe.each([
26
+ { label: 'memory', create: () => Store.memory() },
27
+ { label: 'cloudflare', create: () => Store.cloudflare(fakeKv()) },
28
+ {
29
+ label: 'upstash',
30
+ create: () => {
31
+ const kv = fakeKv()
32
+ return Store.upstash({
33
+ get: kv.get,
34
+ set: (key, value) => kv.put(key, value as string),
35
+ del: (key) => kv.delete(key),
36
+ })
37
+ },
38
+ },
39
+ ])('$label', ({ create }) => {
40
+ test('roundtrip', async () => {
41
+ const store = create()
42
+ await store.put('k', nested)
43
+ expect(await store.get('k')).toEqual(nested)
44
+ })
45
+
46
+ test('get missing key returns null', async () => {
47
+ const store = create()
48
+ expect(await store.get('missing')).toBeNull()
49
+ })
50
+
51
+ test('delete removes key', async () => {
52
+ const store = create()
53
+ await store.put('k', 'value')
54
+ await store.delete('k')
55
+ expect(await store.get('k')).toBeNull()
56
+ })
57
+
58
+ test('put overwrites existing value', async () => {
59
+ const store = create()
60
+ await store.put('k', 'first')
61
+ await store.put('k', 'second')
62
+ expect(await store.get('k')).toBe('second')
63
+ })
64
+ })
65
+
66
+ describe('json roundtrip behavior', () => {
67
+ test('memory json-roundtrips nested objects', async () => {
68
+ const store = Store.memory()
69
+ const value = { a: [1, { b: 'c' }], d: null }
70
+ await store.put('k', value)
71
+ expect(await store.get('k')).toEqual(value)
72
+ })
73
+
74
+ test('cloudflare json-roundtrips nested objects', async () => {
75
+ const store = Store.cloudflare(fakeKv())
76
+ const value = { a: [1, { b: 'c' }], d: null }
77
+ await store.put('k', value)
78
+ expect(await store.get('k')).toEqual(value)
79
+ })
80
+
81
+ test('upstash passes values through without json serialization', async () => {
82
+ const kv = fakeKv()
83
+ const store = Store.upstash({
84
+ get: kv.get,
85
+ set: (key, value) => kv.put(key, value as string),
86
+ del: (key) => kv.delete(key),
87
+ })
88
+ const value = { a: 1 }
89
+ await store.put('k', value)
90
+ // upstash store does not JSON-serialize; the fake map holds the original reference
91
+ expect(await kv.get('k')).toBe(value)
92
+ })
93
+ })
package/src/cli.test.ts CHANGED
@@ -11,6 +11,7 @@ import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
11
11
  import * as Store from './Store.js'
12
12
  import * as Mppx_server from './server/Mppx.js'
13
13
  import { toNodeListener } from './server/Mppx.js'
14
+ import { stripe as stripe_server } from './stripe/server/Methods.js'
14
15
  import { tempo } from './tempo/server/Methods.js'
15
16
 
16
17
  const cliPath = path.resolve(import.meta.dirname, 'cli.ts')
@@ -44,12 +45,12 @@ function runRaw(
44
45
 
45
46
  function runAsync(
46
47
  args: string[],
47
- options?: { input?: string },
48
+ options?: { input?: string; env?: NodeJS.ProcessEnv },
48
49
  ): Promise<{ stdout: string; stderr: string }> {
49
50
  return new Promise((resolve, reject) => {
50
51
  const child = spawn('node', ['--import', 'tsx', cliPath, ...args], {
51
52
  cwd,
52
- env,
53
+ env: options?.env ?? env,
53
54
  stdio: ['pipe', 'pipe', 'pipe'],
54
55
  })
55
56
 
@@ -126,19 +127,36 @@ describe('basic charge (examples/basic)', () => {
126
127
  }
127
128
  })
128
129
 
129
- test('error: no account found', { timeout: 60_000 }, () => {
130
- const result = spawnSync(
131
- 'node',
132
- ['--import', 'tsx', cliPath, 'http://localhost:1', '--account', 'nonexistent-account'],
133
- {
134
- encoding: 'utf8',
135
- cwd,
136
- timeout: 60_000,
130
+ test('error: no account found', { timeout: 60_000 }, async () => {
131
+ const server = Mppx_server.create({
132
+ methods: [tempo.charge({ getClient: () => client })],
133
+ realm: 'cli-test-no-account',
134
+ secretKey: 'cli-test-secret',
135
+ })
136
+
137
+ const httpServer = await Http.createServer(async (req, res) => {
138
+ const result = await toNodeListener(
139
+ server.charge({
140
+ amount: '1',
141
+ currency: asset,
142
+ expires: new Date(Date.now() + 60_000).toISOString(),
143
+ recipient: accounts[0].address,
144
+ }),
145
+ )(req, res)
146
+ if (result.status === 402) return
147
+ res.end('paid')
148
+ })
149
+
150
+ try {
151
+ const result = await runAsync([httpServer.url, '--account', 'nonexistent-account'], {
152
+ input: '',
137
153
  env: { ...process.env, NODE_NO_WARNINGS: '1' },
138
- },
139
- )
140
- expect(result.status).not.toBe(0)
141
- expect(result.stdout).toContain('Account "nonexistent-account" not found')
154
+ }).catch((err) => err as Error)
155
+ expect(result).toBeInstanceOf(Error)
156
+ expect((result as Error).message).toContain('Account "nonexistent-account" not found')
157
+ } finally {
158
+ httpServer.close()
159
+ }
142
160
  })
143
161
  })
144
162
 
@@ -179,7 +197,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
179
197
 
180
198
  try {
181
199
  const { stdout } = await runAsync(
182
- [httpServer.url, '--rpc-url', rpcUrl, '-s', '--deposit', '10'],
200
+ [httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'],
183
201
  { input: '' },
184
202
  )
185
203
  expect(stdout).toContain('scraped-content')
@@ -228,7 +246,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
228
246
  try {
229
247
  // First request: open a channel, answer "y" to proceed, "n" to close channel
230
248
  const first = await runAsync(
231
- [httpServer.url, '--rpc-url', rpcUrl, '--confirm', '--deposit', '10'],
249
+ [httpServer.url, '--rpc-url', rpcUrl, '--confirm', '-M', 'deposit=10'],
232
250
  { input: 'y\nn\n' },
233
251
  )
234
252
  expect(first.stdout).toContain('scraped-content')
@@ -238,9 +256,18 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
238
256
  expect(match).toBeTruthy()
239
257
  const channelId = match![1]!
240
258
 
241
- // Second request: reuse the channel via --channel
259
+ // Second request: reuse the channel via -M channel=<id>
242
260
  const second = await runAsync(
243
- [httpServer.url, '--rpc-url', rpcUrl, '-s', '--channel', channelId, '--deposit', '10'],
261
+ [
262
+ httpServer.url,
263
+ '--rpc-url',
264
+ rpcUrl,
265
+ '-s',
266
+ '-M',
267
+ `channel=${channelId}`,
268
+ '-M',
269
+ 'deposit=10',
270
+ ],
244
271
  { input: '' },
245
272
  )
246
273
  expect(second.stdout).toContain('scraped-content')
@@ -266,6 +293,53 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
266
293
  })
267
294
  })
268
295
 
296
+ describe.skipIf(!process.env.VITE_STRIPE_SECRET_KEY)('stripe charge (integration)', () => {
297
+ test('happy path: makes Stripe payment via real API', { timeout: 120_000 }, async () => {
298
+ const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY!
299
+
300
+ const server = Mppx_server.create({
301
+ methods: [
302
+ stripe_server.charge({
303
+ secretKey: stripeSecretKey,
304
+ networkId: 'internal',
305
+ paymentMethodTypes: ['card'],
306
+ }),
307
+ ],
308
+ realm: 'cli-test-stripe',
309
+ secretKey: 'cli-test-secret',
310
+ })
311
+
312
+ const httpServer = await Http.createServer(async (req, res) => {
313
+ const result = await toNodeListener(
314
+ server.charge({
315
+ amount: '1',
316
+ currency: 'usd',
317
+ decimals: 2,
318
+ }),
319
+ )(req, res)
320
+ if (result.status === 402) return
321
+ res.end('paid')
322
+ })
323
+
324
+ try {
325
+ const { stdout } = await runAsync(
326
+ [httpServer.url, '-M', 'paymentMethod=pm_card_visa', '-s'],
327
+ {
328
+ input: '',
329
+ env: {
330
+ ...env,
331
+ MPPX_STRIPE_SECRET_KEY: stripeSecretKey,
332
+ MPPX_PRIVATE_KEY: undefined as unknown as string,
333
+ },
334
+ },
335
+ )
336
+ expect(stdout).toContain('paid')
337
+ } finally {
338
+ httpServer.close()
339
+ }
340
+ })
341
+ })
342
+
269
343
  describe('session sse (examples/session/sse)', () => {
270
344
  test('streams SSE tokens to stdout', { timeout: 120_000 }, async () => {
271
345
  await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
@@ -313,7 +387,7 @@ describe('session sse (examples/session/sse)', () => {
313
387
  })
314
388
 
315
389
  try {
316
- const { stdout } = await runAsync([httpServer.url, '--rpc-url', rpcUrl, '--deposit', '10'], {
390
+ const { stdout } = await runAsync([httpServer.url, '--rpc-url', rpcUrl, '-M', 'deposit=10'], {
317
391
  input: '',
318
392
  })
319
393
  expect(stdout.trim()).toBe('Hello world!')
@@ -337,6 +411,129 @@ describe('session sse (examples/session/sse)', () => {
337
411
  })
338
412
  })
339
413
 
414
+ describe('stripe charge', () => {
415
+ test('happy path: makes Stripe payment and receives response', { timeout: 60_000 }, async () => {
416
+ const mockStripeClient = {
417
+ paymentIntents: { create: async () => ({ id: 'pi_mock_cli_123', status: 'succeeded' }) },
418
+ }
419
+
420
+ const server = Mppx_server.create({
421
+ methods: [
422
+ stripe_server.charge({
423
+ client: mockStripeClient,
424
+ networkId: 'internal',
425
+ paymentMethodTypes: ['card'],
426
+ }),
427
+ ],
428
+ realm: 'cli-test-stripe',
429
+ secretKey: 'cli-test-secret',
430
+ })
431
+
432
+ const sptServer = await Http.createServer(async (_req, res) => {
433
+ res.writeHead(200, { 'Content-Type': 'application/json' })
434
+ res.end(JSON.stringify({ id: 'spt_mock_cli_test' }))
435
+ })
436
+
437
+ const appServer = await Http.createServer(async (req, res) => {
438
+ const result = await Mppx_server.toNodeListener(
439
+ server.charge({ amount: '1', currency: 'usd', decimals: 2 }),
440
+ )(req, res)
441
+ if (result.status === 402) return
442
+ res.end('paid')
443
+ })
444
+
445
+ try {
446
+ const { stdout } = await runAsync([appServer.url, '-s', '-M', 'paymentMethod=pm_card_visa'], {
447
+ input: '',
448
+ env: {
449
+ ...process.env,
450
+ NODE_NO_WARNINGS: '1',
451
+ MPPX_STRIPE_SECRET_KEY: 'sk_test_mock',
452
+ MPPX_STRIPE_SPT_URL: sptServer.url,
453
+ },
454
+ })
455
+ expect(stdout).toContain('paid')
456
+ } finally {
457
+ appServer.close()
458
+ sptServer.close()
459
+ }
460
+ })
461
+
462
+ test('error: missing MPPX_STRIPE_SECRET_KEY', { timeout: 60_000 }, async () => {
463
+ const server = Mppx_server.create({
464
+ methods: [
465
+ stripe_server.charge({
466
+ secretKey: 'sk_test_mock',
467
+ networkId: 'internal',
468
+ paymentMethodTypes: ['card'],
469
+ }),
470
+ ],
471
+ realm: 'cli-test-stripe-nokey',
472
+ secretKey: 'cli-test-secret',
473
+ })
474
+
475
+ const appServer = await Http.createServer(async (req, res) => {
476
+ const result = await Mppx_server.toNodeListener(
477
+ server.charge({ amount: '1', currency: 'usd', decimals: 2 }),
478
+ )(req, res)
479
+ if (result.status === 402) return
480
+ res.end('paid')
481
+ })
482
+
483
+ try {
484
+ const result = await runAsync([appServer.url, '-s', '-M', 'paymentMethod=pm_card_visa'], {
485
+ input: '',
486
+ env: {
487
+ ...process.env,
488
+ NODE_NO_WARNINGS: '1',
489
+ MPPX_STRIPE_SECRET_KEY: '',
490
+ },
491
+ }).catch((err) => err as Error)
492
+ expect(result).toBeInstanceOf(Error)
493
+ expect((result as Error).message).toContain('MPPX_STRIPE_SECRET_KEY')
494
+ } finally {
495
+ appServer.close()
496
+ }
497
+ })
498
+
499
+ test('error: production key rejected', { timeout: 60_000 }, async () => {
500
+ const server = Mppx_server.create({
501
+ methods: [
502
+ stripe_server.charge({
503
+ secretKey: 'sk_test_mock',
504
+ networkId: 'internal',
505
+ paymentMethodTypes: ['card'],
506
+ }),
507
+ ],
508
+ realm: 'cli-test-stripe-live',
509
+ secretKey: 'cli-test-secret',
510
+ })
511
+
512
+ const appServer = await Http.createServer(async (req, res) => {
513
+ const result = await Mppx_server.toNodeListener(
514
+ server.charge({ amount: '1', currency: 'usd', decimals: 2 }),
515
+ )(req, res)
516
+ if (result.status === 402) return
517
+ res.end('paid')
518
+ })
519
+
520
+ try {
521
+ const result = await runAsync([appServer.url, '-s', '-M', 'paymentMethod=pm_card_visa'], {
522
+ input: '',
523
+ env: {
524
+ ...process.env,
525
+ NODE_NO_WARNINGS: '1',
526
+ MPPX_STRIPE_SECRET_KEY: 'sk_live_fake',
527
+ },
528
+ }).catch((err) => err as Error)
529
+ expect(result).toBeInstanceOf(Error)
530
+ expect((result as Error).message).toContain('test mode')
531
+ } finally {
532
+ appServer.close()
533
+ }
534
+ })
535
+ })
536
+
340
537
  // ---------------------------------------------------------------------------
341
538
  // account [action]
342
539
  // TODO: investigate account tests timing out in CI (secret-tool/gnome-keyring hangs)
@@ -505,24 +702,23 @@ test('mppx --help', () => {
505
702
  view View account address
506
703
 
507
704
  Options:
508
- -a, --account <name> Account name (env: MPPX_ACCOUNT)
509
- -d, --data <data> Send request body (implies POST unless -X is set)
510
- -f, --fail Fail silently on HTTP errors (exit 22)
511
- -i, --include Include response headers in output
512
- -k, --insecure Skip TLS certificate verification (true for localhost/.local)
513
- -r, --rpc-url <url> RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)
514
- -s, --silent Silent mode (suppress progress and info)
515
- -v, --verbose Show request/response headers
516
- -A, --user-agent <ua> Set User-Agent header
517
- -H, --header <header> Add header (repeatable)
518
- -L, --location Follow redirects
519
- -X, --method <method> HTTP method
520
- --channel <id> Reuse existing session channel ID
521
- --confirm Show confirmation prompts
522
- --deposit <amount> Deposit amount for session payments (human-readable units)
523
- --json <json> Send JSON body (sets Content-Type and Accept, implies POST)
524
- -V, --version Display version number
525
- -h, --help Display this message
705
+ -a, --account <name> Account name (env: MPPX_ACCOUNT)
706
+ -d, --data <data> Send request body (implies POST unless -X is set)
707
+ -f, --fail Fail silently on HTTP errors (exit 22)
708
+ -i, --include Include response headers in output
709
+ -k, --insecure Skip TLS certificate verification (true for localhost/.local)
710
+ -r, --rpc-url <url> RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)
711
+ -s, --silent Silent mode (suppress progress and info)
712
+ -v, --verbose Show request/response headers
713
+ -A, --user-agent <ua> Set User-Agent header
714
+ -H, --header <header> Add header (repeatable)
715
+ -L, --location Follow redirects
716
+ -X, --method <method> HTTP method
717
+ -M, --method-opt <opt> Method-specific option (key=value, repeatable)
718
+ --confirm Show confirmation prompts
719
+ --json <json> Send JSON body (sets Content-Type and Accept, implies POST)
720
+ -V, --version Display version number
721
+ -h, --help Display this message
526
722
 
527
723
  Examples:
528
724
  mppx example.com/content