mppx 0.3.4 → 0.3.5

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/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { type ZodMiniType, z } from 'zod/mini'
15
15
  import * as Challenge from './Challenge.js'
16
16
  import * as Credential from './Credential.js'
17
17
  import * as Mppx from './client/Mppx.js'
18
+ import { stripe } from './stripe/client/index.js'
18
19
  import { tempo } from './tempo/client/index.js'
19
20
  import type { SessionCredentialPayload } from './tempo/session/Types.js'
20
21
  import { signVoucher } from './tempo/session/Voucher.js'
@@ -41,9 +42,8 @@ cli
41
42
  .option('-H, --header <header>', 'Add header (repeatable)')
42
43
  .option('-L, --location', 'Follow redirects')
43
44
  .option('-X, --method <method>', 'HTTP method')
44
- .option('--channel <id>', 'Reuse existing session channel ID')
45
+ .option('-M, --method-opt <opt>', 'Method-specific option (key=value, repeatable)')
45
46
  .option('--confirm', 'Show confirmation prompts')
46
- .option('--deposit <amount>', 'Deposit amount for session payments (human-readable units)')
47
47
  .option('--json <json>', 'Send JSON body (sets Content-Type and Accept, implies POST)')
48
48
  .example(`${name} example.com/content`)
49
49
  .example(`${name} example.com/api --json '{"key":"value"}'`)
@@ -51,10 +51,8 @@ cli
51
51
  const options = parseOptions(
52
52
  z.object({
53
53
  account: z.optional(z.string()),
54
- channel: z.optional(z.coerce.string()),
55
54
  confirm: z.optional(z.boolean()),
56
55
  data: z.optional(z.string()),
57
- deposit: z.optional(z.union([z.string(), z.number()])),
58
56
  fail: z.optional(z.boolean()),
59
57
  header: z.optional(z.union([z.string(), z.array(z.string())])),
60
58
  include: z.optional(z.boolean()),
@@ -62,6 +60,7 @@ cli
62
60
  json: z.optional(z.string()),
63
61
  location: z.optional(z.boolean()),
64
62
  method: z.optional(z.string()),
63
+ methodOpt: z.optional(z.union([z.string(), z.array(z.string())])),
65
64
  rpcUrl: z.optional(z.string()),
66
65
  silent: z.optional(z.boolean()),
67
66
  userAgent: z.optional(z.string()),
@@ -69,6 +68,7 @@ cli
69
68
  }),
70
69
  rawOptions,
71
70
  )
71
+ const methodOpts = parseMethodOpts(options.methodOpt)
72
72
  if (!rawUrl) {
73
73
  cli.outputHelp()
74
74
  return
@@ -79,12 +79,6 @@ cli
79
79
  if (silent) options.confirm = false
80
80
 
81
81
  const accountName = resolveAccountName(options.account)
82
- const privateKey = process.env.MPPX_PRIVATE_KEY || (await createKeychain(accountName).get())
83
- if (!privateKey) {
84
- if (options.account) console.log(`Account "${accountName}" not found.`)
85
- else console.log(`No account found.`)
86
- process.exit(1)
87
- }
88
82
 
89
83
  const headers: Record<string, string> = {}
90
84
  if (options.header) {
@@ -157,24 +151,43 @@ cli
157
151
  return
158
152
  }
159
153
 
160
- const account = privateKeyToAccount(privateKey as `0x${string}`)
161
- const rpcUrl = options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined)
162
- const client = createClient({
163
- chain: await resolveChain({ ...options, rpcUrl }),
164
- transport: http(rpcUrl),
165
- })
166
-
167
154
  const challenge = Challenge.fromResponse(challengeResponse)
168
- const explorerUrl = client.chain?.blockExplorers?.default?.url
169
- const shownKeys = new Set<string>()
170
155
  const challengeRequest = challenge.request as Record<string, unknown>
171
156
  const currency = challengeRequest.currency as string | undefined
172
- const tokenInfo = currency
173
- ? await fetchTokenInfo(client, currency as Address, account.address).catch(() => undefined)
174
- : undefined
175
- const tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
176
- const tokenDecimals =
177
- tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
157
+ const shownKeys = new Set<string>()
158
+
159
+ let tokenSymbol =
160
+ challenge.method === 'stripe' ? (currency?.toUpperCase() ?? '') : (currency ?? '')
161
+ let tokenDecimals =
162
+ (challengeRequest.decimals as number | undefined) ?? (challenge.method === 'stripe' ? 2 : 6)
163
+ let explorerUrl: string | undefined
164
+
165
+ // Tempo-specific setup (private key, viem account/client, token info)
166
+ let account: ReturnType<typeof privateKeyToAccount> | undefined
167
+ let client: ReturnType<typeof createClient> | undefined
168
+ if (challenge.method === 'tempo') {
169
+ const privateKey = process.env.MPPX_PRIVATE_KEY ?? (await createKeychain(accountName).get())
170
+ if (!privateKey) {
171
+ if (options.account) console.error(`Account "${accountName}" not found.`)
172
+ else console.error(`No account found.`)
173
+ process.exit(1)
174
+ }
175
+ account = privateKeyToAccount(privateKey as `0x${string}`)
176
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
177
+ client = createClient({
178
+ chain: await resolveChain({ ...options, rpcUrl }),
179
+ transport: http(rpcUrl),
180
+ })
181
+ explorerUrl = client.chain?.blockExplorers?.default?.url
182
+ const tokenInfo = currency
183
+ ? await fetchTokenInfo(client, currency as Address, account.address).catch(
184
+ () => undefined,
185
+ )
186
+ : undefined
187
+ tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
188
+ tokenDecimals =
189
+ tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
190
+ }
178
191
 
179
192
  {
180
193
  printResponseHeaders(challengeResponse)
@@ -276,42 +289,149 @@ cli
276
289
  }
277
290
  }
278
291
 
279
- const mppx = Mppx.create({
280
- methods: tempo({
281
- account,
282
- getClient: () => client,
283
- deposit: (() => {
284
- if (challenge.intent !== 'session') return undefined
285
- const suggestedDeposit = (challenge.request as Record<string, unknown>)
286
- .suggestedDeposit as string | undefined
287
- const cliDeposit = options.deposit !== undefined ? String(options.deposit) : undefined
288
- const resolved =
289
- suggestedDeposit ?? cliDeposit ?? (isTestnet(client.chain!) ? '10' : undefined)
290
- if (!resolved) {
291
- console.error(
292
- 'Session payment requires a deposit. Use --deposit <amount> or connect to testnet.',
293
- )
294
- process.exit(1)
292
+ let credential: string
293
+ if (challenge.method === 'tempo') {
294
+ if (!account || !client) {
295
+ console.error('Tempo requires a configured account.')
296
+ process.exit(1)
297
+ }
298
+ const tempoOpts = parseOptions(
299
+ z.object({
300
+ channel: z.optional(z.coerce.string()),
301
+ deposit: z.optional(z.union([z.string(), z.number()])),
302
+ }),
303
+ methodOpts,
304
+ )
305
+ const mppx = Mppx.create({
306
+ methods: tempo({
307
+ account,
308
+ getClient: () => client!,
309
+ deposit: (() => {
310
+ if (challenge.intent !== 'session') return undefined
311
+ const suggestedDeposit = (challenge.request as Record<string, unknown>)
312
+ .suggestedDeposit as string | undefined
313
+ const cliDeposit =
314
+ tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
315
+ const resolved =
316
+ suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
317
+ if (!resolved) {
318
+ console.error(
319
+ 'Session payment requires a deposit. Use -M deposit=<amount> or connect to testnet.',
320
+ )
321
+ process.exit(1)
322
+ }
323
+ return resolved
324
+ })(),
325
+ }),
326
+ polyfill: false,
327
+ })
328
+ credential = await mppx.createCredential(
329
+ challengeResponse,
330
+ (() => {
331
+ if (!tempoOpts.channel) return undefined
332
+ const channelId = tempoOpts.channel
333
+ const saved = readChannelCumulative(channelId)
334
+ return {
335
+ channelId,
336
+ ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
295
337
  }
296
- return resolved
297
338
  })(),
298
- }),
299
- polyfill: false,
300
- })
301
-
302
- const credential = await mppx.createCredential(
303
- challengeResponse,
304
- (() => {
305
- if (!options.channel) return undefined
306
- const idx = process.argv.indexOf('--channel')
307
- const channelId = idx !== -1 ? process.argv[idx + 1]! : String(options.channel)
308
- const saved = readChannelCumulative(channelId)
309
- return {
310
- channelId,
311
- ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
312
- }
313
- })(),
314
- )
339
+ )
340
+ } else if (challenge.method === 'stripe') {
341
+ const stripeOpts = parseOptions(
342
+ z.object({
343
+ paymentMethod: z.string(),
344
+ }),
345
+ methodOpts,
346
+ )
347
+ const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
348
+ if (!stripeSecretKey) {
349
+ console.error(
350
+ '\nMPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
351
+ )
352
+ process.exit(1)
353
+ }
354
+ if (!stripeSecretKey.startsWith('sk_test_')) {
355
+ console.error(
356
+ '\nStripe CLI payments are currently only supported in test mode (sk_test_... keys).',
357
+ )
358
+ process.exit(1)
359
+ }
360
+ const mppx = Mppx.create({
361
+ methods: [
362
+ stripe.charge({
363
+ paymentMethod: stripeOpts.paymentMethod,
364
+ createToken: async ({
365
+ paymentMethod,
366
+ amount,
367
+ currency,
368
+ networkId,
369
+ expiresAt,
370
+ metadata,
371
+ }) => {
372
+ const body = new URLSearchParams({
373
+ payment_method: paymentMethod!,
374
+ 'usage_limits[currency]': currency,
375
+ 'usage_limits[max_amount]': amount,
376
+ 'usage_limits[expires_at]': expiresAt.toString(),
377
+ })
378
+ if (networkId) body.set('seller_details[network_id]', networkId)
379
+ if (metadata) {
380
+ for (const [key, value] of Object.entries(metadata)) {
381
+ body.set(`metadata[${key}]`, value)
382
+ }
383
+ }
384
+ const sptUrl =
385
+ process.env.MPPX_STRIPE_SPT_URL ??
386
+ 'https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens'
387
+ const sptHeaders = {
388
+ Authorization: `Basic ${btoa(`${stripeSecretKey}:`)}`,
389
+ 'Content-Type': 'application/x-www-form-urlencoded',
390
+ }
391
+ let response = await globalThis.fetch(sptUrl, {
392
+ method: 'POST',
393
+ headers: sptHeaders,
394
+ body,
395
+ })
396
+ if (!response.ok) {
397
+ const errorBody = (await response.json()) as { error: { message: string } }
398
+ if (
399
+ (metadata || networkId) &&
400
+ errorBody.error.message.includes('Received unknown parameter')
401
+ ) {
402
+ const fallbackBody = new URLSearchParams({
403
+ payment_method: paymentMethod!,
404
+ 'usage_limits[currency]': currency,
405
+ 'usage_limits[max_amount]': amount,
406
+ 'usage_limits[expires_at]': expiresAt.toString(),
407
+ })
408
+ response = await globalThis.fetch(sptUrl, {
409
+ method: 'POST',
410
+ headers: sptHeaders,
411
+ body: fallbackBody,
412
+ })
413
+ if (!response.ok) {
414
+ const fallbackError = (await response.json()) as {
415
+ error: { message: string }
416
+ }
417
+ throw new Error(`Failed to create SPT: ${fallbackError.error.message}`)
418
+ }
419
+ } else {
420
+ throw new Error(`Failed to create SPT: ${errorBody.error.message}`)
421
+ }
422
+ }
423
+ const { id } = (await response.json()) as { id: string }
424
+ return id
425
+ },
426
+ }),
427
+ ],
428
+ polyfill: false,
429
+ })
430
+ credential = await mppx.createCredential(challengeResponse)
431
+ } else {
432
+ console.error(`Unsupported payment method: ${challenge.method}`)
433
+ process.exit(1)
434
+ }
315
435
 
316
436
  const sessionMd = challenge.request.methodDetails as
317
437
  | { escrowContract?: string; chainId?: number }
@@ -324,18 +444,23 @@ cli
324
444
  if (challenge.intent === 'session') {
325
445
  const parsed = Credential.deserialize<SessionCredentialPayload>(credential)
326
446
  sessionChannelId = parsed.payload.channelId
327
- sessionChainId = sessionMd?.chainId ?? client.chain?.id ?? 0
447
+ sessionChainId = sessionMd?.chainId ?? client?.chain?.id ?? 0
328
448
  sessionEscrowContract = sessionMd?.escrowContract as Address | undefined
329
449
  if ('cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount)
330
450
  sessionCumulativeAmount = BigInt(parsed.payload.cumulativeAmount)
331
451
 
332
452
  if (parsed.payload.action === 'open') {
333
- const depositRaw =
334
- (challengeRequest.suggestedDeposit as string | undefined) ?? options.deposit
453
+ const depositRaw = challengeRequest.suggestedDeposit as string | undefined
335
454
  const depositDisplay = depositRaw
336
455
  ? ` ${pc.dim(`(deposit ${depositRaw} ${tokenSymbol})`)}`
337
456
  : ''
338
- info(`\n${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`)
457
+ const prefix = options.confirm ? '' : '\n'
458
+ info(
459
+ `${prefix}${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`,
460
+ )
461
+ } else {
462
+ const prefix = options.confirm ? '' : '\n'
463
+ info(`${prefix}${pc.dim(`Channel reused ${parsed.payload.channelId}`)}\n`)
339
464
  }
340
465
  }
341
466
 
@@ -403,6 +528,15 @@ cli
403
528
  explorerUrl
404
529
  ) {
405
530
  rows.push([key, pc.link(`${explorerUrl}/tx/${value}`, value)])
531
+ } else if (
532
+ key === 'reference' &&
533
+ typeof value === 'string' &&
534
+ challenge.method === 'stripe' &&
535
+ value.startsWith('pi_')
536
+ ) {
537
+ const isTest = process.env.MPPX_STRIPE_SECRET_KEY?.startsWith('sk_test_')
538
+ const dashboardUrl = `https://dashboard.stripe.com${isTest ? '/test' : ''}/payments/${value}`
539
+ rows.push([key, pc.link(dashboardUrl, value)])
406
540
  } else rows.push([key, String(value)])
407
541
  }
408
542
  rows.sort(([a], [b]) => a.localeCompare(b))
@@ -430,7 +564,7 @@ cli
430
564
  const md = challenge.request.methodDetails as
431
565
  | { escrowContract?: string; chainId?: number }
432
566
  | undefined
433
- const sessionChainId = md?.chainId ?? client.chain?.id ?? 0
567
+ const sessionChainId = md?.chainId ?? client?.chain?.id ?? 0
434
568
  const escrowContract = md?.escrowContract as Address | undefined
435
569
  let cumulativeAmount =
436
570
  sessionCred?.payload &&
@@ -493,8 +627,8 @@ cli
493
627
  cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
494
628
 
495
629
  const signature = await signVoucher(
496
- client,
497
- account,
630
+ client!,
631
+ account!,
498
632
  { channelId, cumulativeAmount },
499
633
  escrowContract,
500
634
  sessionChainId,
@@ -507,7 +641,7 @@ cli
507
641
  cumulativeAmount: cumulativeAmount.toString(),
508
642
  signature,
509
643
  },
510
- source: `did:pkh:eip155:${sessionChainId}:${account.address}`,
644
+ source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
511
645
  })
512
646
  await globalThis.fetch(url, {
513
647
  method: 'POST',
@@ -586,8 +720,8 @@ cli
586
720
 
587
721
  if (channelId && escrowContract && sessionChainId) {
588
722
  const signature = await signVoucher(
589
- client,
590
- account,
723
+ client!,
724
+ account!,
591
725
  { channelId, cumulativeAmount },
592
726
  escrowContract,
593
727
  sessionChainId,
@@ -601,7 +735,7 @@ cli
601
735
  const closeCred = Credential.serialize({
602
736
  challenge,
603
737
  payload: closePayload,
604
- source: `did:pkh:eip155:${sessionChainId}:${account.address}`,
738
+ source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
605
739
  })
606
740
  const closeRes = await globalThis.fetch(url, {
607
741
  method: 'POST',
@@ -649,8 +783,8 @@ cli
649
783
  info(`${pc.dim('Kept channel open.')}\n`)
650
784
  } else if (shouldClose) {
651
785
  const signature = await signVoucher(
652
- client,
653
- account,
786
+ client!,
787
+ account!,
654
788
  { channelId: sessionChannelId!, cumulativeAmount: sessionCumulativeAmount },
655
789
  sessionEscrowContract!,
656
790
  sessionChainId,
@@ -664,7 +798,7 @@ cli
664
798
  const closeCred = Credential.serialize({
665
799
  challenge,
666
800
  payload: closePayload,
667
- source: `did:pkh:eip155:${sessionChainId}:${account.address}`,
801
+ source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
668
802
  })
669
803
  const closeRes = await globalThis.fetch(url, {
670
804
  ...fetchInit,
@@ -690,20 +824,21 @@ cli
690
824
  closeTxHash && explorerUrl
691
825
  ? ` ${pc.dim(pc.link(`${explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
692
826
  : ''
827
+ const closePrefix = options.confirm ? '' : '\n'
693
828
  info(
694
- `\n${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(sessionCumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
829
+ `${closePrefix}${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(sessionCumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
695
830
  )
696
831
  } else {
697
832
  const closeBody = await closeRes.text().catch(() => '')
698
833
  info(
699
- `${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`,
834
+ `\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`,
700
835
  )
701
836
  info(
702
837
  `${pc.dim(` channelId: ${sessionChannelId}`)}\n` +
703
838
  `${pc.dim(` cumulativeAmount: ${sessionCumulativeAmount}`)}\n` +
704
839
  `${pc.dim(` escrowContract: ${sessionEscrowContract}`)}\n` +
705
840
  `${pc.dim(` chainId: ${sessionChainId}`)}\n` +
706
- `${pc.dim(` account: ${account.address}`)}\n` +
841
+ `${pc.dim(` account: ${account?.address}`)}\n` +
707
842
  `${pc.dim(` response: ${closeBody || '(empty)'}`)}\n`,
708
843
  )
709
844
  }
@@ -988,6 +1123,21 @@ try {
988
1123
 
989
1124
  /////////////////////////////////////////////////////////////////////////////////////////////////
990
1125
 
1126
+ function parseMethodOpts(raw: string | string[] | undefined): Record<string, string> {
1127
+ if (!raw) return {}
1128
+ const list = Array.isArray(raw) ? raw : [raw]
1129
+ const result: Record<string, string> = {}
1130
+ for (const item of list) {
1131
+ const idx = item.indexOf('=')
1132
+ if (idx === -1) {
1133
+ console.error(`Invalid method option format: ${item} (expected key=value)`)
1134
+ process.exit(1)
1135
+ }
1136
+ result[item.slice(0, idx)] = item.slice(idx + 1)
1137
+ }
1138
+ return result
1139
+ }
1140
+
991
1141
  function parseOptions<const schema extends ZodMiniType>(
992
1142
  schema: schema,
993
1143
  rawOptions: unknown,
@@ -1285,8 +1435,8 @@ function chainName(chain: { id: number; name: string }) {
1285
1435
  }
1286
1436
 
1287
1437
  const pathUsd = '0x20c0000000000000000000000000000000000000' as Address
1288
- const usdcE = '0x20C000000000000000000000b9537d11c60E8b50' as Address
1289
- const mainnetTokens = [pathUsd, usdcE] as const
1438
+ const usdc = '0x20C000000000000000000000b9537d11c60E8b50' as Address
1439
+ const mainnetTokens = [pathUsd, usdc] as const
1290
1440
  const testnetTokens = [
1291
1441
  '0x20c0000000000000000000000000000000000000',
1292
1442
  '0x20c0000000000000000000000000000000000001',
@@ -1326,7 +1476,7 @@ async function fetchTokenInfo(
1326
1476
  ])
1327
1477
  const knownSymbols: Record<string, string> = {
1328
1478
  [pathUsd]: 'PathUSD',
1329
- [usdcE]: 'USDC.e',
1479
+ [usdc]: 'USDC',
1330
1480
  }
1331
1481
  const symbol = knownSymbols[token] ?? metadata.symbol
1332
1482
  const decimals = 'decimals' in metadata ? metadata.decimals : 6
@@ -60,7 +60,7 @@ describe('http', () => {
60
60
  expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
61
61
  {
62
62
  "expires": "2025-01-01T00:00:00.000Z",
63
- "id": "i1474pQ7BtfAx76cLch6u8_AQkcp3akMkerEYrL5Rwo",
63
+ "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
64
64
  "intent": "charge",
65
65
  "method": "tempo",
66
66
  "realm": "api.example.com",
@@ -91,7 +91,7 @@ describe('http', () => {
91
91
  const headers = result.headers as Headers
92
92
 
93
93
  expect(headers.get('Authorization')).toMatchInlineSnapshot(
94
- `"Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiIsImlkIjoiaTE0NzRwUTdCdGZBeDc2Y0xjaDZ1OF9BUWtjcDNha01rZXJFWXJMNVJ3byIsImludGVudCI6ImNoYXJnZSIsIm1ldGhvZCI6InRlbXBvIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJyZXF1ZXN0IjoiZXlKaGJXOTFiblFpT2lJeE1EQXdJaXdpWTNWeWNtVnVZM2tpT2lJd2VESXdZekF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKbGVIQnBjbVZ6SWpvaU1qQXlOUzB3TVMwd01WUXdNRG93TURvd01DNHdNREJhSWl3aWNtVmphWEJwWlc1MElqb2lNSGczTkRKa016VkRZelkyTXpSRE1EVXpNamt5TldFellqZzBORUpqT1dVM05UazFaamhtUlRBd0luMCJ9LCJwYXlsb2FkIjp7InNpZ25hdHVyZSI6IjB4YWJjMTIzIiwidHlwZSI6InRyYW5zYWN0aW9uIn19"`,
94
+ `"Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiIsImlkIjoiejhkVWk2MWxWaU9qNmN3aF9JU2JfNVg4bkJKRjJPalR5ZGNFYXA4d1gwbyIsImludGVudCI6ImNoYXJnZSIsIm1ldGhvZCI6InRlbXBvIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJyZXF1ZXN0IjoiZXlKaGJXOTFiblFpT2lJeE1EQXdJaXdpWTNWeWNtVnVZM2tpT2lJd2VESXdZekF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKbGVIQnBjbVZ6SWpvaU1qQXlOUzB3TVMwd01WUXdNRG93TURvd01DNHdNREJhSWl3aWNtVmphWEJwWlc1MElqb2lNSGczTkRKa016VkRZelkyTXpSRE1EVXpNamt5TldFellqZzBORUpqT1dVM05UazFaamhtUlRBd0luMCJ9LCJwYXlsb2FkIjp7InNpZ25hdHVyZSI6IjB4YWJjMTIzIiwidHlwZSI6InRyYW5zYWN0aW9uIn19"`,
95
95
  )
96
96
  })
97
97
 
@@ -182,7 +182,7 @@ describe('mcp', () => {
182
182
  expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
183
183
  {
184
184
  "expires": "2025-01-01T00:00:00.000Z",
185
- "id": "i1474pQ7BtfAx76cLch6u8_AQkcp3akMkerEYrL5Rwo",
185
+ "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
186
186
  "intent": "charge",
187
187
  "method": "tempo",
188
188
  "realm": "api.example.com",
@@ -239,7 +239,7 @@ describe('mcp', () => {
239
239
  "org.paymentauth/credential": {
240
240
  "challenge": {
241
241
  "expires": "2025-01-01T00:00:00.000Z",
242
- "id": "i1474pQ7BtfAx76cLch6u8_AQkcp3akMkerEYrL5Rwo",
242
+ "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
243
243
  "intent": "charge",
244
244
  "method": "tempo",
245
245
  "realm": "api.example.com",
@@ -0,0 +1,42 @@
1
+ import * as Env from './env.js'
2
+
3
+ afterEach(() => {
4
+ vi.unstubAllEnvs()
5
+ })
6
+
7
+ describe('Env.get', () => {
8
+ test('returns default realm when no env vars are set', () => {
9
+ expect(Env.get('realm')).toBe('MPP Payment')
10
+ })
11
+
12
+ test('returns default secretKey when MPP_SECRET_KEY is not set', () => {
13
+ expect(Env.get('secretKey')).toBe('tmp')
14
+ })
15
+
16
+ test('returns MPP_SECRET_KEY when set', () => {
17
+ vi.stubEnv('MPP_SECRET_KEY', 'sk_live_abc123')
18
+ expect(Env.get('secretKey')).toBe('sk_live_abc123')
19
+ })
20
+
21
+ test('returns FLY_APP_NAME when set', () => {
22
+ vi.stubEnv('FLY_APP_NAME', 'my-fly-app')
23
+ expect(Env.get('realm')).toBe('my-fly-app')
24
+ })
25
+
26
+ test('FLY_APP_NAME takes precedence over HOST', () => {
27
+ vi.stubEnv('FLY_APP_NAME', 'fly-app')
28
+ vi.stubEnv('HOST', 'my-host')
29
+ expect(Env.get('realm')).toBe('fly-app')
30
+ })
31
+
32
+ test('HOST takes precedence over MPP_REALM', () => {
33
+ vi.stubEnv('HOST', 'my-host')
34
+ vi.stubEnv('MPP_REALM', 'custom-realm')
35
+ expect(Env.get('realm')).toBe('my-host')
36
+ })
37
+
38
+ test('falls through to later vars when earlier ones are unset', () => {
39
+ vi.stubEnv('MPP_REALM', 'fallback-realm')
40
+ expect(Env.get('realm')).toBe('fallback-realm')
41
+ })
42
+ })