mppx 0.5.11 → 0.5.13

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,m4tbAAm4tb,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,w91bAAw91b,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.5.11",
4
+ "version": "0.5.13",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -124,7 +124,7 @@
124
124
  },
125
125
  "dependencies": {
126
126
  "incur": "^0.3.23",
127
- "ox": "0.14.7",
127
+ "ox": "0.14.10",
128
128
  "zod": "^4.3.6"
129
129
  },
130
130
  "repository": {
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
2
2
  import * as fs from 'node:fs'
3
3
  import * as os from 'node:os'
4
4
  import * as path from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
5
6
 
6
7
  import { parseUnits } from 'viem'
7
8
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
@@ -14,6 +15,7 @@ import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js
14
15
 
15
16
  import * as Challenge from '../Challenge.js'
16
17
  import * as Credential from '../Credential.js'
18
+ import * as Method from '../Method.js'
17
19
  import * as Receipt from '../Receipt.js'
18
20
  import * as Mppx_server from '../server/Mppx.js'
19
21
  import { toNodeListener } from '../server/Mppx.js'
@@ -21,6 +23,7 @@ import * as Store from '../Store.js'
21
23
  import { stripe as stripe_server } from '../stripe/server/Methods.js'
22
24
  import { tempo } from '../tempo/server/Methods.js'
23
25
  import type { SessionCredentialPayload } from '../tempo/session/Types.js'
26
+ import * as z from '../zod.js'
24
27
  import cli from './cli.js'
25
28
 
26
29
  const testPrivateKey = generatePrivateKey()
@@ -78,6 +81,24 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
78
81
  return { output, stderr, exitCode }
79
82
  }
80
83
 
84
+ function createMockChargeMethod(name: string) {
85
+ return Method.from({
86
+ name,
87
+ intent: 'charge',
88
+ schema: {
89
+ credential: {
90
+ payload: z.object({ token: z.string() }),
91
+ },
92
+ request: z.object({
93
+ amount: z.string(),
94
+ currency: z.string(),
95
+ decimals: z.number(),
96
+ recipient: z.string(),
97
+ }),
98
+ },
99
+ })
100
+ }
101
+
81
102
  describe('discover validate', () => {
82
103
  test('validates a local discovery document', async () => {
83
104
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
@@ -329,6 +350,198 @@ describe('basic charge (examples/basic)', () => {
329
350
  }
330
351
  })
331
352
 
353
+ test('selects a later supported challenge when the first offer is unsupported', async () => {
354
+ const unsupportedMethod = Method.toServer(createMockChargeMethod('unknown'), {
355
+ async verify() {
356
+ return {
357
+ method: 'unknown',
358
+ reference: 'unknown-ref',
359
+ status: 'success' as const,
360
+ timestamp: new Date().toISOString(),
361
+ }
362
+ },
363
+ })
364
+ const tempoMethod = tempo.charge({ getClient: () => client })
365
+
366
+ const server = Mppx_server.create({
367
+ methods: [unsupportedMethod, tempoMethod],
368
+ realm: 'cli-test-multi-offer',
369
+ secretKey: 'cli-test-secret',
370
+ })
371
+
372
+ const httpServer = await Http.createServer(async (req, res) => {
373
+ const result = await toNodeListener(
374
+ server.compose(
375
+ [
376
+ unsupportedMethod,
377
+ {
378
+ amount: '1',
379
+ currency: asset,
380
+ decimals: 6,
381
+ expires: new Date(Date.now() + 60_000).toISOString(),
382
+ recipient: accounts[0].address,
383
+ },
384
+ ],
385
+ [
386
+ tempoMethod,
387
+ {
388
+ amount: '1',
389
+ currency: asset,
390
+ decimals: 6,
391
+ expires: new Date(Date.now() + 60_000).toISOString(),
392
+ recipient: accounts[0].address,
393
+ },
394
+ ],
395
+ ),
396
+ )(req, res)
397
+ if (result.status === 402) return
398
+ res.end('paid-from-second-offer')
399
+ })
400
+
401
+ try {
402
+ const { output, exitCode } = await serve([httpServer.url, '--rpc-url', rpcUrl, '-s'], {
403
+ env: { MPPX_PRIVATE_KEY: testPrivateKey },
404
+ })
405
+
406
+ expect(exitCode).toBeUndefined()
407
+ expect(output).toContain('paid-from-second-offer')
408
+ } finally {
409
+ httpServer.close()
410
+ }
411
+ })
412
+
413
+ test('config methods emit Accept-Payment and select the preferred challenge', async () => {
414
+ const alphaMethod = Method.toServer(createMockChargeMethod('alpha'), {
415
+ async verify({ envelope }) {
416
+ if (!envelope) throw new Error('expected envelope')
417
+ if ((envelope.credential.payload as { token: string }).token !== 'alpha-token') {
418
+ throw new Error('expected alpha credential')
419
+ }
420
+
421
+ return {
422
+ method: 'alpha',
423
+ reference: 'alpha-ref',
424
+ status: 'success' as const,
425
+ timestamp: new Date().toISOString(),
426
+ }
427
+ },
428
+ })
429
+ const betaMethod = Method.toServer(createMockChargeMethod('beta'), {
430
+ async verify({ envelope }) {
431
+ if (!envelope) throw new Error('expected envelope')
432
+ if ((envelope.credential.payload as { token: string }).token !== 'beta-token') {
433
+ throw new Error('expected beta credential')
434
+ }
435
+
436
+ return {
437
+ method: 'beta',
438
+ reference: 'beta-ref',
439
+ status: 'success' as const,
440
+ timestamp: new Date().toISOString(),
441
+ }
442
+ },
443
+ })
444
+
445
+ const server = Mppx_server.create({
446
+ methods: [betaMethod, alphaMethod],
447
+ realm: 'cli-test-config-offers',
448
+ secretKey: 'cli-test-secret',
449
+ })
450
+
451
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-config-'))
452
+ const configPath = path.join(configDir, 'mppx.config.mjs')
453
+ const mppxModuleUrl = pathToFileURL(path.join(process.cwd(), 'src/index.ts')).href
454
+ const cliModuleUrl = pathToFileURL(path.join(process.cwd(), 'src/cli/config.ts')).href
455
+ fs.writeFileSync(
456
+ configPath,
457
+ `
458
+ import { Credential, Method, z } from '${mppxModuleUrl}'
459
+ import { defineConfig } from '${cliModuleUrl}'
460
+
461
+ const alpha = Method.toClient(Method.from({
462
+ name: 'alpha',
463
+ intent: 'charge',
464
+ schema: {
465
+ credential: { payload: z.object({ token: z.string() }) },
466
+ request: z.object({ amount: z.string(), currency: z.string(), decimals: z.number(), recipient: z.string() }),
467
+ },
468
+ }), {
469
+ async createCredential({ challenge }) {
470
+ return Credential.serialize({ challenge, payload: { token: 'alpha-token' } })
471
+ },
472
+ })
473
+
474
+ const beta = Method.toClient(Method.from({
475
+ name: 'beta',
476
+ intent: 'charge',
477
+ schema: {
478
+ credential: { payload: z.object({ token: z.string() }) },
479
+ request: z.object({ amount: z.string(), currency: z.string(), decimals: z.number(), recipient: z.string() }),
480
+ },
481
+ }), {
482
+ async createCredential({ challenge }) {
483
+ return Credential.serialize({ challenge, payload: { token: 'beta-token' } })
484
+ },
485
+ })
486
+
487
+ export default defineConfig({
488
+ methods: [beta, alpha],
489
+ paymentPreferences: ({ alpha, beta }) => ({
490
+ [alpha.charge]: 1,
491
+ [beta.charge]: 0.2,
492
+ }),
493
+ })
494
+ `.trim(),
495
+ )
496
+
497
+ let acceptPaymentHeader: string | undefined
498
+ let authorization: string | undefined
499
+ const httpServer = await Http.createServer(async (req, res) => {
500
+ if (!req.headers.authorization)
501
+ acceptPaymentHeader = req.headers['accept-payment'] as string | undefined
502
+ else authorization = req.headers.authorization
503
+
504
+ const result = await toNodeListener(
505
+ server.compose(
506
+ [
507
+ betaMethod,
508
+ {
509
+ amount: '1',
510
+ currency: asset,
511
+ decimals: 6,
512
+ expires: new Date(Date.now() + 60_000).toISOString(),
513
+ recipient: accounts[0].address,
514
+ },
515
+ ],
516
+ [
517
+ alphaMethod,
518
+ {
519
+ amount: '1',
520
+ currency: asset,
521
+ decimals: 6,
522
+ expires: new Date(Date.now() + 60_000).toISOString(),
523
+ recipient: accounts[0].address,
524
+ },
525
+ ],
526
+ ),
527
+ )(req, res)
528
+ if (result.status === 402) return
529
+ res.end('paid-from-config-preference')
530
+ })
531
+
532
+ try {
533
+ const { output, exitCode } = await serve([httpServer.url, '--config', configPath, '-s'])
534
+
535
+ expect(exitCode).toBeUndefined()
536
+ expect(output).toContain('paid-from-config-preference')
537
+ expect(acceptPaymentHeader).toBe('beta/charge;q=0.2, alpha/charge')
538
+ expect(Credential.deserialize(authorization!).payload).toEqual({ token: 'alpha-token' })
539
+ } finally {
540
+ httpServer.close()
541
+ fs.rmSync(configDir, { recursive: true, force: true })
542
+ }
543
+ })
544
+
332
545
  test(
333
546
  'zero-amount charge uses a proof credential and receives response',
334
547
  { timeout: 120_000 },
@@ -1120,6 +1333,71 @@ describe('sign', () => {
1120
1333
  expect(output).toContain('Unsupported payment method')
1121
1334
  })
1122
1335
 
1336
+ test('paymentPreferences opt-out is not bypassed by CLI fallback selection', async () => {
1337
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-sign-config-'))
1338
+ const configPath = path.join(configDir, 'mppx.config.mjs')
1339
+ const mppxModuleUrl = pathToFileURL(path.join(process.cwd(), 'src/index.ts')).href
1340
+ const cliModuleUrl = pathToFileURL(path.join(process.cwd(), 'src/cli/config.ts')).href
1341
+ fs.writeFileSync(
1342
+ configPath,
1343
+ `
1344
+ import { Credential, Method, z } from '${mppxModuleUrl}'
1345
+ import { defineConfig } from '${cliModuleUrl}'
1346
+
1347
+ const alpha = Method.toClient(Method.from({
1348
+ name: 'alpha',
1349
+ intent: 'charge',
1350
+ schema: {
1351
+ credential: { payload: z.object({ token: z.string() }) },
1352
+ request: z.object({ amount: z.string() }),
1353
+ },
1354
+ }), {
1355
+ async createCredential({ challenge }) {
1356
+ return Credential.serialize({ challenge, payload: { token: 'alpha-token' } })
1357
+ },
1358
+ })
1359
+
1360
+ export default defineConfig({
1361
+ methods: [alpha],
1362
+ paymentPreferences: ({ alpha }) => ({
1363
+ [alpha.charge]: 0,
1364
+ }),
1365
+ })
1366
+ `.trim(),
1367
+ )
1368
+
1369
+ const challenge =
1370
+ 'Payment id="x", realm="x", method="alpha", intent="charge", request="eyJhbW91bnQiOiIxIn0"'
1371
+
1372
+ try {
1373
+ const { exitCode, output } = await serve([
1374
+ 'sign',
1375
+ '--challenge',
1376
+ challenge,
1377
+ '--config',
1378
+ configPath,
1379
+ ])
1380
+ expect(exitCode).toBe(2)
1381
+ expect(output).toContain('Unsupported payment method')
1382
+ } finally {
1383
+ fs.rmSync(configDir, { recursive: true, force: true })
1384
+ }
1385
+ })
1386
+
1387
+ test('selects a later supported challenge from a merged challenge value', async () => {
1388
+ const merged = [
1389
+ 'Payment id="x", realm="x", method="unknown", intent="charge", request="e30"',
1390
+ validChallenge,
1391
+ ].join(', ')
1392
+
1393
+ const { output, exitCode } = await serve(['sign', '--challenge', merged, '--rpc-url', rpcUrl], {
1394
+ env: { MPPX_PRIVATE_KEY: testPrivateKey },
1395
+ })
1396
+
1397
+ expect(exitCode).toBeUndefined()
1398
+ expect(output.trim()).toMatch(/^Payment\s+\S+/)
1399
+ })
1400
+
1123
1401
  test('error: no account for tempo', async () => {
1124
1402
  const { exitCode, output } = await serve(
1125
1403
  ['sign', '--challenge', validChallenge, '--account', 'nonexistent-sign-test'],
package/src/cli/cli.ts CHANGED
@@ -13,7 +13,7 @@ import { normalizeHeaders } from '../client/internal/Fetch.js'
13
13
  import * as Mppx from '../client/Mppx.js'
14
14
  import { validate as validateDiscovery } from '../discovery/Validate.js'
15
15
  import { createDefaultStore, createKeychain, resolveAccountName } from './account.js'
16
- import { loadConfig, resolvePlugin } from './internal.js'
16
+ import { loadConfig, resolveAcceptPayment, selectChallenge } from './internal.js'
17
17
  import type { Plugin } from './plugins/plugin.js'
18
18
  import { readTempoKeystore, resolveTempoAccount } from './plugins/tempo.js'
19
19
  import {
@@ -128,6 +128,13 @@ const cli = Cli.create('mppx', {
128
128
  headers[header.slice(0, index).trim()] = header.slice(index + 1).trim()
129
129
  }
130
130
  }
131
+ const acceptPayment = resolveAcceptPayment(loaded?.config)
132
+ if (
133
+ acceptPayment &&
134
+ !Object.keys(headers).some((key) => key.toLowerCase() === 'accept-payment')
135
+ ) {
136
+ headers['Accept-Payment'] = acceptPayment
137
+ }
131
138
 
132
139
  const url = (() => {
133
140
  const hasProtocol = /^https?:\/\//.test(c.args.url)
@@ -200,8 +207,26 @@ const cli = Cli.create('mppx', {
200
207
  return
201
208
  }
202
209
 
203
- const challenge = Challenge.fromResponse(challengeResponse)
204
- const { plugin, method: configMethod } = resolvePlugin(challenge, loaded?.config)
210
+ const selected = selectChallenge(
211
+ Challenge.fromResponseList(challengeResponse),
212
+ loaded?.config,
213
+ )
214
+ if (!selected) {
215
+ const offers = Challenge.fromResponseList(challengeResponse)
216
+ .map((challenge) => `${challenge.method}/${challenge.intent}`)
217
+ .join(', ')
218
+ return c.error({
219
+ code: 'UNSUPPORTED_METHOD',
220
+ message: `Unsupported payment method. Server offers: ${offers}. Add it to mppx.config.ts using defineConfig().`,
221
+ exitCode: 2,
222
+ })
223
+ }
224
+
225
+ const { challenge, plugin, method: configMethod } = selected
226
+ const selectedChallengeResponse = new Response(null, {
227
+ status: 402,
228
+ headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
229
+ })
205
230
 
206
231
  let tokenSymbol = (challenge.request.currency as string | undefined) ?? ''
207
232
  let tokenDecimals = (challenge.request.decimals as number | undefined) ?? 6
@@ -297,23 +322,17 @@ const cli = Cli.create('mppx', {
297
322
  // Create credential
298
323
  let credential: string
299
324
  if (pluginResult?.createCredential)
300
- credential = await pluginResult.createCredential(challengeResponse)
325
+ credential = await pluginResult.createCredential(selectedChallengeResponse)
301
326
  else if (pluginResult) {
302
327
  const mppx = Mppx.create({ methods: pluginResult.methods, polyfill: false })
303
328
  credential = await mppx.createCredential(
304
- challengeResponse,
329
+ selectedChallengeResponse,
305
330
  pluginResult.credentialContext as undefined,
306
331
  )
307
332
  } else if (configMethod) {
308
333
  const mppx = Mppx.create({ methods: [configMethod], polyfill: false })
309
- credential = await mppx.createCredential(challengeResponse)
310
- } else {
311
- return c.error({
312
- code: 'UNSUPPORTED_METHOD',
313
- message: `Unsupported payment method: ${challenge.method}/${challenge.intent}. Add it to mppx.config.ts using defineConfig().`,
314
- exitCode: 2,
315
- })
316
- }
334
+ credential = await mppx.createCredential(selectedChallengeResponse)
335
+ } else throw new Error('unreachable')
317
336
 
318
337
  // Send credential and get response
319
338
  const credentialHeaders = {
@@ -815,9 +834,9 @@ const sign = Cli.create('sign', {
815
834
  })
816
835
  }
817
836
 
818
- let challenge: Challenge.Challenge
837
+ let challenges: Challenge.Challenge[]
819
838
  try {
820
- challenge = Challenge.deserialize(raw)
839
+ challenges = Challenge.deserializeList(raw)
821
840
  } catch (err) {
822
841
  return c.error({
823
842
  code: 'INVALID_CHALLENGE',
@@ -832,7 +851,19 @@ const sign = Cli.create('sign', {
832
851
  }
833
852
 
834
853
  const loaded = await loadConfig(c.options.config)
835
- const { plugin, method: configMethod } = resolvePlugin(challenge, loaded?.config)
854
+ const selected = selectChallenge(challenges, loaded?.config)
855
+ if (!selected) {
856
+ const offers = challenges
857
+ .map((challenge) => `${challenge.method}/${challenge.intent}`)
858
+ .join(', ')
859
+ return c.error({
860
+ code: 'UNSUPPORTED_METHOD',
861
+ message: `Unsupported payment method. Server offers: ${offers}. Add it to mppx.config.ts using defineConfig().`,
862
+ exitCode: 2,
863
+ })
864
+ }
865
+
866
+ const { challenge, plugin, method: configMethod } = selected
836
867
  const methodOpts = parseMethodOpts(c.options.methodOpt)
837
868
 
838
869
  const wwwAuth = Challenge.serialize(challenge)
package/src/cli/config.ts CHANGED
@@ -28,17 +28,23 @@ import type { Plugin } from './plugins/plugin.js'
28
28
  * })
29
29
  * ```
30
30
  */
31
- export function defineConfig(config: defineConfig.Config): defineConfig.Config {
31
+ export function defineConfig<const methods extends Mppx.Methods | undefined = undefined>(
32
+ config: defineConfig.Config<methods>,
33
+ ): defineConfig.Config<methods> {
32
34
  return config
33
35
  }
34
36
 
35
37
  export declare namespace defineConfig {
36
- type Config = {
38
+ type Config<methods extends Mppx.Methods | undefined = undefined> = {
37
39
  /** Array of methods to use. */
38
- methods?: Mppx.create.Config['methods'] | undefined
40
+ methods?: methods
41
+ /** Optional payment preferences for configured client methods. */
42
+ paymentPreferences?: methods extends Mppx.Methods
43
+ ? Mppx.create.Config<methods>['paymentPreferences']
44
+ : undefined
39
45
  /** Array of plugins to use. */
40
46
  plugins?: Plugin[] | undefined
41
47
  }
42
48
  }
43
49
 
44
- export type Config = defineConfig.Config
50
+ export type Config = defineConfig.Config<Mppx.Methods>
@@ -2,6 +2,7 @@ import * as fs from 'node:fs'
2
2
  import * as path from 'node:path'
3
3
 
4
4
  import type * as Challenge from '../Challenge.js'
5
+ import * as AcceptPayment from '../internal/AcceptPayment.js'
5
6
  import type * as Method from '../Method.js'
6
7
  import type { Config } from './config.js'
7
8
  import { stripe as stripePlugin, tempo as tempoPlugin } from './plugins/index.js'
@@ -13,13 +14,13 @@ export function resolvePlugin(
13
14
  challenge: Challenge.Challenge,
14
15
  config?: { plugins?: Plugin[] | undefined; methods?: any },
15
16
  ): { plugin?: Plugin | undefined; method?: Method.AnyClient | undefined } {
16
- const configPlugin = config?.plugins?.find((p) => p.method === challenge.method)
17
+ const configPlugin = config?.plugins?.find((p) => supportsPlugin(p, challenge))
17
18
  if (configPlugin) return { plugin: configPlugin }
18
19
 
19
- const builtin = builtinPlugins.find((p) => p.method === challenge.method)
20
+ const builtin = builtinPlugins.find((p) => supportsPlugin(p, challenge))
20
21
  if (builtin) return { plugin: builtin }
21
22
 
22
- const configMethods = config?.methods?.flat() as Method.AnyClient[] | undefined
23
+ const configMethods = flattenConfigMethods(config)
23
24
  const matched = configMethods?.find(
24
25
  (m) => m.name === challenge.method && m.intent === challenge.intent,
25
26
  )
@@ -28,6 +29,61 @@ export function resolvePlugin(
28
29
  return {}
29
30
  }
30
31
 
32
+ export function selectChallenge(
33
+ challenges: readonly Challenge.Challenge[],
34
+ config?: Config | undefined,
35
+ ):
36
+ | ({ challenge: Challenge.Challenge } & {
37
+ plugin?: Plugin | undefined
38
+ method?: Method.AnyClient | undefined
39
+ })
40
+ | undefined {
41
+ const configMethods = flattenConfigMethods(config)
42
+ if (configMethods?.length) {
43
+ const resolvedPreferences = AcceptPayment.resolve(
44
+ configMethods,
45
+ config?.paymentPreferences as AcceptPayment.Config<typeof configMethods> | undefined,
46
+ )
47
+ const selected = AcceptPayment.selectChallenge(
48
+ challenges,
49
+ configMethods,
50
+ resolvedPreferences.entries,
51
+ )
52
+ if (selected) {
53
+ return { challenge: selected.challenge, ...resolvePlugin(selected.challenge, config) }
54
+ }
55
+
56
+ return undefined
57
+ }
58
+
59
+ for (const challenge of challenges) {
60
+ const resolved = resolvePlugin(challenge, config)
61
+ if (resolved.plugin || resolved.method) return { challenge, ...resolved }
62
+ }
63
+
64
+ return undefined
65
+ }
66
+
67
+ export function resolveAcceptPayment(config?: Config | undefined): string | undefined {
68
+ const methods = flattenConfigMethods(config)
69
+ if (!methods?.length) return undefined
70
+
71
+ return AcceptPayment.resolve(
72
+ methods,
73
+ config?.paymentPreferences as AcceptPayment.Config<typeof methods> | undefined,
74
+ ).header
75
+ }
76
+
77
+ export function flattenConfigMethods(
78
+ config?: Pick<Config, 'methods'> | undefined,
79
+ ): Method.AnyClient[] | undefined {
80
+ return Array.isArray(config?.methods) ? (config.methods.flat() as Method.AnyClient[]) : undefined
81
+ }
82
+
83
+ function supportsPlugin(plugin: Plugin, challenge: Challenge.Challenge): boolean {
84
+ return plugin.supports ? plugin.supports(challenge) : plugin.method === challenge.method
85
+ }
86
+
31
87
  const CONFIG_NAMES = ['mppx.config.ts', 'mppx.config.js', 'mppx.config.mjs'] as const
32
88
 
33
89
  export async function loadConfig(
@@ -9,6 +9,9 @@ export interface Plugin {
9
9
  /** Payment method name (e.g., 'tempo', 'stripe') */
10
10
  method: string
11
11
 
12
+ /** Optional predicate for challenge support when a plugin does not support every intent for its method. */
13
+ supports?(challenge: Challenge.Challenge): boolean
14
+
12
15
  /**
13
16
  * Resolve account, client, and display info for a challenge.
14
17
  * Returns methods for credential creation.
@@ -7,6 +7,9 @@ import { createPlugin } from './plugin.js'
7
7
  export function stripe() {
8
8
  return createPlugin({
9
9
  method: 'stripe',
10
+ supports(challenge) {
11
+ return challenge.method === 'stripe' && challenge.intent === 'charge'
12
+ },
10
13
 
11
14
  async setup({ challenge, methodOpts }) {
12
15
  const challengeRequest = challenge.request as Record<string, unknown>
@@ -46,6 +46,9 @@ export function tempo() {
46
46
 
47
47
  return createPlugin({
48
48
  method: 'tempo',
49
+ supports(challenge) {
50
+ return challenge.method === 'tempo' && ['charge', 'session'].includes(challenge.intent)
51
+ },
49
52
 
50
53
  async setup({ challenge, options, methodOpts }) {
51
54
  const accountName = resolveAccountName(options.account)
@@ -46,6 +46,23 @@ describe('create.Config', () => {
46
46
 
47
47
  expectTypeOf<Config>().toHaveProperty('methods')
48
48
  })
49
+
50
+ test('paymentPreferences callback exposes typed method keys', () => {
51
+ const mppx = Mppx.create({
52
+ methods: [tempo({ account: {} as Account })],
53
+ paymentPreferences: ({ tempo }) => {
54
+ expectTypeOf(tempo.charge).toEqualTypeOf<'tempo/charge'>()
55
+ expectTypeOf(tempo.session).toEqualTypeOf<'tempo/session'>()
56
+
57
+ return {
58
+ [tempo.charge]: 0.5,
59
+ [tempo.session]: 0,
60
+ }
61
+ },
62
+ })
63
+
64
+ expectTypeOf(mppx.fetch).toBeFunction()
65
+ })
49
66
  })
50
67
 
51
68
  describe('Method.toClient', () => {
@@ -99,6 +116,22 @@ describe('Mppx with context', () => {
99
116
  expectTypeOf(mppx.createCredential).toBeFunction()
100
117
  expectTypeOf(mppx.createCredential).returns.toMatchTypeOf<Promise<string>>()
101
118
  })
119
+
120
+ test('createCredential accepts an optional Accept-Payment override', () => {
121
+ const method = charge({
122
+ account: {} as Account,
123
+ })
124
+
125
+ const mppx = Mppx.create({ methods: [method] })
126
+
127
+ const createCredential: (
128
+ response: Response,
129
+ context?: Parameters<typeof mppx.createCredential>[1],
130
+ options?: Parameters<typeof mppx.createCredential>[2],
131
+ ) => Promise<string> = mppx.createCredential
132
+
133
+ expectTypeOf(createCredential).toBeFunction()
134
+ })
102
135
  })
103
136
 
104
137
  describe('fetch context', () => {