mppx 0.9.2 → 0.10.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 (78) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -0
  3. package/dist/Method.d.ts +2 -0
  4. package/dist/cli/Extension.d.ts +20 -0
  5. package/dist/cli/Extension.js +5 -0
  6. package/dist/cli/cli.js +137 -137
  7. package/dist/cli/config.d.ts +4 -0
  8. package/dist/cli/config.js +1 -0
  9. package/dist/cli/internal.d.ts +7 -1
  10. package/dist/cli/internal.js +51 -5
  11. package/dist/cli/plugins/index.js +12 -12
  12. package/dist/cli/plugins/plugin.d.ts +2 -2
  13. package/dist/cli/sessions/request.d.ts +1 -0
  14. package/dist/cli/sessions/request.js +1 -0
  15. package/dist/cli/validate/discovery.d.ts +3 -1
  16. package/dist/cli/validate/discovery.js +28 -1
  17. package/dist/cli/validate/helpers.d.ts +3 -2
  18. package/dist/cli/validate/helpers.js +3 -0
  19. package/dist/cli/validate/index.js +3 -1
  20. package/dist/cli/validate/payment.js +118 -59
  21. package/dist/client/Mppx.d.ts +32 -0
  22. package/dist/client/Mppx.js +180 -6
  23. package/dist/client/internal/Fetch.d.ts +6 -0
  24. package/dist/client/internal/Fetch.js +4 -2
  25. package/dist/evm/client/Charge.d.ts +2 -2
  26. package/dist/evm/client/Charge.js +1 -1
  27. package/dist/evm/server/Charge.d.ts +7 -2
  28. package/dist/evm/server/Charge.js +28 -2
  29. package/dist/evm/server/Methods.d.ts +1 -1
  30. package/dist/internal/version.d.ts +5 -0
  31. package/dist/internal/version.js +6 -0
  32. package/dist/server/Mppx.d.ts +7 -6
  33. package/dist/server/Mppx.js +84 -2
  34. package/dist/server/internal/html/config.js +1 -3
  35. package/dist/stripe/Methods.d.ts +4 -3
  36. package/dist/stripe/Methods.js +14 -11
  37. package/dist/stripe/client/Charge.d.ts +3 -3
  38. package/dist/stripe/client/Methods.d.ts +3 -3
  39. package/dist/stripe/internal/parse-units.d.ts +8 -0
  40. package/dist/stripe/internal/parse-units.js +20 -0
  41. package/dist/stripe/internal/payment-intent.d.ts +29 -0
  42. package/dist/stripe/internal/payment-intent.js +8 -0
  43. package/dist/stripe/server/Charge.d.ts +5 -3
  44. package/dist/stripe/server/Charge.js +17 -21
  45. package/dist/stripe/server/Methods.d.ts +6 -4
  46. package/dist/stripe/server/Methods.js +42 -14
  47. package/dist/stripe/server/internal/analytics.d.ts +6 -0
  48. package/dist/stripe/server/internal/analytics.js +16 -0
  49. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  50. package/dist/stripe/server/internal/html.gen.js +1 -1
  51. package/dist/stripe/server/internal/record-payment.d.ts +1 -0
  52. package/dist/stripe/server/internal/record-payment.js +7 -5
  53. package/dist/tempo/client/Charge.d.ts +8 -2
  54. package/dist/tempo/client/Charge.js +19 -11
  55. package/dist/tempo/client/Subscription.js +6 -2
  56. package/dist/tempo/internal/fee-payer.d.ts +2 -0
  57. package/dist/tempo/internal/fee-payer.js +4 -0
  58. package/dist/tempo/server/Subscription.js +1 -0
  59. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  60. package/dist/tempo/server/internal/html.gen.js +1 -1
  61. package/dist/tempo/session/client/CredentialState.d.ts +2 -0
  62. package/dist/tempo/session/client/CredentialState.js +9 -3
  63. package/dist/tempo/session/client/Session.d.ts +6 -0
  64. package/dist/tempo/session/client/Session.js +4 -1
  65. package/dist/tempo/session/client/SessionManager.d.ts +11 -0
  66. package/dist/tempo/session/client/SessionManager.js +23 -2
  67. package/dist/tempo/session/precompile/Chain.js +3 -0
  68. package/dist/tempo/subscription/KeyAuthorization.d.ts +7 -1
  69. package/dist/tempo/subscription/KeyAuthorization.js +21 -3
  70. package/dist/validation/core.d.ts +1 -0
  71. package/dist/validation/core.js +7 -2
  72. package/dist/viem/Client.d.ts +4 -0
  73. package/dist/viem/Client.js +13 -0
  74. package/dist/x402/client/Exact.d.ts +2 -2
  75. package/dist/x402/client/Exact.js +1 -1
  76. package/dist/x402/server/EvmCharge.d.ts +2 -0
  77. package/dist/x402/server/EvmCharge.js +34 -26
  78. package/package.json +1 -1
@@ -33,8 +33,8 @@ export interface Plugin {
33
33
  methods: Method.AnyClient[];
34
34
  /** Optional context to pass to createCredential */
35
35
  credentialContext?: unknown;
36
- /** Override credential creation entirely (e.g., delegating to an external CLI) */
37
- createCredential?: ((response: Response) => Promise<string>) | undefined;
36
+ /** Override credential creation entirely (e.g., delegating to an external CLI). */
37
+ createCredential?: ((response: Response, credentialContext?: unknown) => Promise<string>) | undefined;
38
38
  }>;
39
39
  /**
40
40
  * Modify the credential request before sending.
@@ -17,6 +17,7 @@ export type PersistentSessionRequestOptions = {
17
17
  export type PersistentSessionRequestParameters = {
18
18
  challenge: TempoSessionChallenge;
19
19
  challengeResponse: Response;
20
+ credentialContext?: unknown;
20
21
  endpoint: string;
21
22
  fetch: typeof globalThis.fetch;
22
23
  fetchInput: RequestInfo | URL;
@@ -198,6 +198,7 @@ export async function runPersistentSessionRequest(parameters) {
198
198
  bootstrap: false,
199
199
  client,
200
200
  channelStore,
201
+ credentialContext: parameters.credentialContext,
201
202
  decimals: sessionDecimals(parameters.challenge),
202
203
  maxDeposit: resolveSessionMaxDeposit(parameters.challenge, parameters.methodOptions, isTestnet(chain)),
203
204
  fetch: async (input, init) => {
@@ -1,10 +1,12 @@
1
- import type { EndpointSpec } from './helpers.js';
1
+ import type { CheckResult, EndpointSpec } from './helpers.js';
2
2
  export declare function fetchDiscoveryDoc(baseUrl: string): Promise<{
3
3
  doc: unknown;
4
4
  raw: string;
5
5
  } | {
6
6
  error: string;
7
7
  }>;
8
+ /** Checks that the server publishes nonempty text documentation at /llms.txt. */
9
+ export declare function validateLlmsDoc(baseUrl: string): Promise<CheckResult>;
8
10
  export declare function extractEndpointsFromDiscovery(doc: Record<string, unknown>): EndpointSpec[];
9
11
  export declare function extractRequestBodyFromDiscovery(doc: Record<string, unknown>, endpoint: EndpointSpec): string | undefined;
10
12
  export declare function buildUrl(baseUrl: string, endpoint: EndpointSpec, query?: string[]): string;
@@ -1,4 +1,4 @@
1
- import { fetchWithTimeout, HTTP_METHODS } from './helpers.js';
1
+ import { check, fetchWithTimeout, HTTP_METHODS } from './helpers.js';
2
2
  export async function fetchDiscoveryDoc(baseUrl) {
3
3
  // Trailing slash makes URL treat baseUrl as a directory, so relative resolution appends rather than replaces the last segment.
4
4
  const url = new URL('openapi.json', baseUrl.replace(/\/?$/, '/')).href;
@@ -18,6 +18,33 @@ export async function fetchDiscoveryDoc(baseUrl) {
18
18
  return { error: error.message };
19
19
  }
20
20
  }
21
+ /** Checks that the server publishes nonempty text documentation at /llms.txt. */
22
+ export async function validateLlmsDoc(baseUrl) {
23
+ const url = new URL('/llms.txt', baseUrl).href;
24
+ const label = 'llms.txt found and nonempty';
25
+ try {
26
+ const response = await fetchWithTimeout(url, {});
27
+ if (!response.ok)
28
+ throw new Error(`HTTP ${response.status}`);
29
+ const body = (await response.text()).trim();
30
+ if (!body)
31
+ throw new Error('Empty document');
32
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
33
+ if ((contentType && !contentType.startsWith('text/')) ||
34
+ contentType === 'text/html' ||
35
+ /^<(?:!doctype html|html|head|body)\b/i.test(body))
36
+ throw new Error('Expected text documentation, received non-text or HTML content');
37
+ return check(label, url);
38
+ }
39
+ catch (error) {
40
+ return {
41
+ label,
42
+ detail: `${url}: ${error.message}`,
43
+ severity: 'suggested',
44
+ hint: 'Serve nonempty text documentation at /llms.txt.',
45
+ };
46
+ }
47
+ }
21
48
  // Extracts testable endpoints from an OpenAPI doc. Prefers endpoints with
22
49
  // explicit x-payment-info (the server declares them as paid). Falls back to
23
50
  // endpoints that list a 402 response (weaker signal, but still worth testing).
@@ -2,7 +2,7 @@ export type CheckResult = {
2
2
  label: string;
3
3
  detail?: string | undefined;
4
4
  hint?: string | undefined;
5
- severity: 'pass' | 'fail' | 'warn' | 'skip';
5
+ severity: 'pass' | 'fail' | 'warn' | 'suggested' | 'skip';
6
6
  };
7
7
  export type EndpointSpec = {
8
8
  method: string;
@@ -33,10 +33,11 @@ export type Counts = {
33
33
  passed: number;
34
34
  failed: number;
35
35
  warnings: number;
36
+ suggested: number;
36
37
  skipped: number;
37
38
  };
38
39
  export declare function printResults(results: CheckResult[], counts: Counts): void;
39
- export declare function fetchWithTimeout(url: string, init: RequestInit, timeoutMs?: number): Promise<Response>;
40
+ export declare function fetchWithTimeout(url: RequestInfo | URL, init: RequestInit, timeoutMs?: number): Promise<Response>;
40
41
  export declare function formatBytes(bytes: number): string;
41
42
  export declare const HTTP_METHODS: Set<string>;
42
43
  export declare function isValidAddress(addr: unknown): boolean;
@@ -28,6 +28,7 @@ const SEVERITY_ICONS = {
28
28
  pass: pc.green('✓'),
29
29
  fail: pc.red('✗'),
30
30
  warn: pc.yellow('⚠'),
31
+ suggested: pc.cyan('◇'),
31
32
  skip: pc.dim('○'),
32
33
  };
33
34
  export function printCheck(result) {
@@ -50,6 +51,8 @@ export function printResults(results, counts) {
50
51
  counts.failed++;
51
52
  else if (result.severity === 'warn')
52
53
  counts.warnings++;
54
+ else if (result.severity === 'suggested')
55
+ counts.suggested++;
53
56
  else if (result.severity === 'skip')
54
57
  counts.skipped++;
55
58
  }
@@ -53,7 +53,7 @@ const validate = Cli.create('validate', {
53
53
  // Streaming human-readable output
54
54
  const baseUrl = c.args.url.replace(/\/$/, '').replace(/\/openapi\.json$/i, '');
55
55
  console.log(`\n${pc.bold('mppx validate')} ${pc.dim(baseUrl)}\n`);
56
- const counts = { passed: 0, failed: 0, warnings: 0, skipped: 0 };
56
+ const counts = { passed: 0, failed: 0, warnings: 0, suggested: 0, skipped: 0 };
57
57
  let sawMppEndpoint = false;
58
58
  let sawNonMppPaymentEndpoint = false;
59
59
  let sawMalformedChallenge = false;
@@ -165,6 +165,8 @@ function printSummary(counts, flags, endpointsLength) {
165
165
  parts.push(pc.red(`${counts.failed} failed`));
166
166
  if (counts.warnings > 0)
167
167
  parts.push(pc.yellow(`${counts.warnings} warning(s)`));
168
+ if (counts.suggested > 0)
169
+ parts.push(pc.cyan(`${counts.suggested} suggested`));
168
170
  if (counts.skipped > 0)
169
171
  parts.push(pc.yellow(`${counts.skipped} skipped`));
170
172
  console.log(`${pc.bold('Summary:')} ${parts.join(', ')}`);
@@ -11,8 +11,9 @@ import * as Constants from '../../Constants.js';
11
11
  import * as Receipt from '../../Receipt.js';
12
12
  import { tempo as tempoMethods } from '../../tempo/client/index.js';
13
13
  import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js';
14
+ import { isTempoSessionChallenge } from '../../tempo/session/client/Transports.js';
14
15
  import { resolveAccount, resolveAccountName } from '../account.js';
15
- import { loadConfig, resolvePlugin } from '../internal.js';
16
+ import { assertSamePaymentRequest, flattenConfigMethods, loadConfig, preparePayment, resolvePlugin, } from '../internal.js';
16
17
  import { fetchTokenInfo, confirm, pc } from '../utils.js';
17
18
  import { buildUrl } from './discovery.js';
18
19
  import { check, fail, fetchWithTimeout, formatBytes, isValidIntegerAmount, parseHeaders, skip, warn, } from './helpers.js';
@@ -151,10 +152,13 @@ export async function validatePaymentFlow(baseUrl, endpoint, verbose, options) {
151
152
  results.push(fail('Payment: parse challenge', 'No Payment challenges in response'));
152
153
  return results;
153
154
  }
155
+ const loaded = await loadConfig().catch(() => undefined);
154
156
  // Testnet Tempo: always use ephemeral wallet (zero-setup, free money)
155
157
  const tempoTestnetChallenge = challenges.find((ch) => {
156
158
  if (ch.method !== Constants.Methods.tempo)
157
159
  return false;
160
+ if (flattenConfigMethods(loaded?.config)?.some((method) => method.name === ch.method && method.intent === ch.intent))
161
+ return false;
158
162
  const req = ch.request;
159
163
  const md = req.methodDetails;
160
164
  return typeof md?.chainId === 'number' && md.chainId !== tempoChainIds.mainnet;
@@ -170,9 +174,10 @@ export async function validatePaymentFlow(baseUrl, endpoint, verbose, options) {
170
174
  });
171
175
  try {
172
176
  const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false });
173
- const cred = await mppx.createCredential(fakeResp);
177
+ const credentialContext = await preparePayment(tempoTestnetChallenge, loaded?.config.extensions);
178
+ const cred = await mppx.createCredential(fakeResp, credentialContext);
174
179
  results.push(check('Payment: submitted', 'ephemeral testnet wallet'));
175
- await sendAndValidateResponse(results, url, endpoint, cred, fetchHeaders, fetchBody, verbose, tempoModerato);
180
+ await sendAndValidateResponse(results, url, endpoint, cred, fetchHeaders, fetchBody, verbose, tempoModerato, Challenge.credentialHeader(tempoTestnetChallenge));
176
181
  }
177
182
  catch (error) {
178
183
  results.push(fail('Payment: create credential', error.message));
@@ -185,7 +190,6 @@ export async function validatePaymentFlow(baseUrl, endpoint, verbose, options) {
185
190
  }
186
191
  // Try each remaining challenge method (tempo testnet already handled above),
187
192
  // so validate exercises every payment method the server offers, same as mainnet.
188
- const loaded = await loadConfig().catch(() => undefined);
189
193
  const isInteractive = options.interactive;
190
194
  const stripeKey = process.env.MPPX_STRIPE_SECRET_KEY ?? resolveStripeKey(verbose);
191
195
  const isStripeTestKey = stripeKey?.startsWith('sk_test_') || stripeKey?.startsWith('rk_test_');
@@ -255,31 +259,42 @@ async function attemptCryptoPayment(challenge, tag, ctx) {
255
259
  : undefined;
256
260
  const decimals = methodDetails?.decimals ?? request.decimals ?? 6;
257
261
  const currency = request.currency;
258
- // Resolve wallet
259
- let walletAddress;
260
- try {
261
- walletAddress = await resolveWalletAddress();
262
- }
263
- catch { }
264
- if (!walletAddress) {
265
- results.push(skip(tag, 'no wallet configured. Run "mppx account create" to create one.'));
262
+ const { plugin, method: directMethod } = resolvePlugin(challenge, loaded?.config);
263
+ if (!plugin && !directMethod) {
264
+ results.push(skip(tag, methodSetupHint(challenge)));
266
265
  return;
267
266
  }
267
+ // Direct methods own their payer; the CLI wallet may be unrelated or absent.
268
+ let walletAddress;
269
+ if (!directMethod) {
270
+ try {
271
+ walletAddress = await resolveWalletAddress();
272
+ }
273
+ catch { }
274
+ if (!walletAddress) {
275
+ results.push(skip(tag, 'no wallet configured. Run "mppx account create" to create one.'));
276
+ return;
277
+ }
278
+ }
268
279
  // Pre-flight balance check and chain resolution
269
280
  let paymentChain;
270
- if (challenge.method === Constants.Methods.tempo)
271
- paymentChain = tempoMainnetChain;
281
+ if (challenge.method === Constants.Methods.tempo) {
282
+ const chainId = methodDetails?.chainId ??
283
+ (directMethod ? undefined : tempoMainnetChain.id);
284
+ if (chainId !== undefined)
285
+ paymentChain = chainId === tempoModerato.id ? tempoModerato : resolveEvmChain(chainId);
286
+ }
272
287
  else if (challenge.method === Constants.Methods.evm) {
273
288
  const chainId = methodDetails?.chainId;
274
289
  if (chainId)
275
290
  paymentChain = resolveEvmChain(chainId);
276
291
  }
277
292
  let tokenSymbol;
278
- if (requiredAmount && currency && paymentChain) {
293
+ if (walletAddress && requiredAmount && currency && paymentChain) {
279
294
  try {
280
295
  let balance;
281
296
  if (challenge.method === Constants.Methods.tempo) {
282
- const client = createClient({ chain: tempoMainnetChain, transport: http() });
297
+ const client = createClient({ chain: paymentChain, transport: http() });
283
298
  const info = await fetchTokenInfo(client, currency, walletAddress);
284
299
  balance = info.balance;
285
300
  tokenSymbol = info.symbol;
@@ -308,8 +323,10 @@ async function attemptCryptoPayment(challenge, tag, ctx) {
308
323
  const chainName = paymentChain?.name;
309
324
  const paymentDesc = `${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''}${chainName ? ` on ${chainName}` : ''}`;
310
325
  if (!options.silent)
311
- console.log(pc.dim(` Attempting payment with wallet ${walletAddress}`));
312
- if (paymentChain?.testnet) {
326
+ console.log(pc.dim(walletAddress
327
+ ? ` Attempting payment with wallet ${walletAddress}`
328
+ : ' Attempting payment with configured method'));
329
+ if (!directMethod && paymentChain?.testnet) {
313
330
  if (!options.silent)
314
331
  console.log(pc.dim(` Auto-approved: ${paymentDesc} (testnet)`));
315
332
  }
@@ -328,16 +345,9 @@ async function attemptCryptoPayment(challenge, tag, ctx) {
328
345
  if (!options.silent)
329
346
  console.log(pc.dim(` Auto-approved: ${paymentDesc}`));
330
347
  }
331
- // Resolve plugin and pay
332
- const resolved = resolvePlugin(challenge, loaded?.config);
333
- const plugin = resolved.plugin;
334
- const directMethod = resolved.method;
335
- if (!plugin && !directMethod) {
336
- results.push(skip(tag, methodSetupHint(challenge)));
337
- return;
338
- }
339
348
  let methods;
340
349
  let createCredentialFn;
350
+ let credentialContext;
341
351
  if (plugin) {
342
352
  try {
343
353
  const pluginResult = await plugin.setup({
@@ -347,6 +357,7 @@ async function attemptCryptoPayment(challenge, tag, ctx) {
347
357
  });
348
358
  methods = pluginResult.methods;
349
359
  createCredentialFn = pluginResult.createCredential;
360
+ credentialContext = pluginResult.credentialContext;
350
361
  }
351
362
  catch (error) {
352
363
  results.push(skip(tag, error.message));
@@ -356,14 +367,58 @@ async function attemptCryptoPayment(challenge, tag, ctx) {
356
367
  else {
357
368
  methods = [directMethod];
358
369
  }
359
- const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results);
370
+ if (directMethod && isTempoSessionChallenge(challenge)) {
371
+ let initial = true;
372
+ const mppx = Mppx.create({
373
+ methods: [directMethod],
374
+ polyfill: false,
375
+ async onChallenge(retry, { createCredential }) {
376
+ const context = await preparePayment(retry, loaded?.config.extensions);
377
+ assertSamePaymentRequest(challenge, retry);
378
+ const credential = await createCredential(context);
379
+ results.push(check(`${tag}: submitted`));
380
+ return credential;
381
+ },
382
+ fetch: async (input, init) => {
383
+ if (initial) {
384
+ initial = false;
385
+ return new Response(null, {
386
+ status: 402,
387
+ headers: {
388
+ [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
389
+ },
390
+ });
391
+ }
392
+ return fetchWithTimeout(input, init ?? {}, 30_000);
393
+ },
394
+ });
395
+ try {
396
+ const response = await mppx.fetch(url, {
397
+ method: endpoint.method,
398
+ headers: fetchHeaders,
399
+ body: fetchBody ?? null,
400
+ });
401
+ await validatePaymentResponse(results, response, verbose, paymentChain);
402
+ }
403
+ catch (error) {
404
+ results.push(fail(tag, error.message));
405
+ }
406
+ return;
407
+ }
408
+ const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results, loaded?.config.extensions, credentialContext);
360
409
  if (!credential)
361
410
  return;
362
411
  plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders });
363
- await sendAndValidateResponse(results, url, endpoint, credential, fetchHeaders, fetchBody, verbose, paymentChain);
412
+ await sendAndValidateResponse(results, url, endpoint, credential, fetchHeaders, fetchBody, verbose, paymentChain, Challenge.credentialHeader(challenge));
364
413
  }
365
414
  async function attemptStripePayment(challenge, tag, ctx) {
366
- const { results, url, endpoint, fetchHeaders, fetchBody, verbose, loaded, options, isStripeTestKey, stripeKey, } = ctx;
415
+ const { results, url, endpoint, fetchHeaders, fetchBody, verbose, loaded, options, stripeKey } = ctx;
416
+ const { plugin, method: directMethod } = resolvePlugin(challenge, loaded?.config);
417
+ if (!plugin && !directMethod) {
418
+ results.push(skip(tag, 'no Stripe payment method available'));
419
+ return;
420
+ }
421
+ const isStripeTestKey = Boolean(plugin && !loaded?.config.plugins?.includes(plugin) && ctx.isStripeTestKey);
367
422
  const request = challenge.request;
368
423
  const requiredAmount = isValidIntegerAmount(request.amount)
369
424
  ? BigInt(request.amount)
@@ -392,40 +447,40 @@ async function attemptStripePayment(challenge, tag, ctx) {
392
447
  if (!options.silent)
393
448
  console.log(pc.dim(` Auto-approved: ${paymentDesc}`));
394
449
  }
395
- // Resolve plugin
396
- const resolved = resolvePlugin(challenge, loaded?.config);
397
- const plugin = resolved.plugin;
398
- if (!plugin) {
399
- results.push(skip(tag, 'no Stripe plugin available'));
400
- return;
401
- }
402
450
  let methods;
403
451
  let createCredentialFn;
404
- try {
405
- const methodOpts = { paymentMethod: 'pm_card_visa' };
406
- if (stripeKey)
407
- methodOpts.secretKey = stripeKey;
408
- const pluginResult = await plugin.setup({
409
- challenge,
410
- options: {},
411
- methodOpts,
412
- });
413
- methods = pluginResult.methods;
414
- createCredentialFn = pluginResult.createCredential;
452
+ let credentialContext;
453
+ if (plugin) {
454
+ try {
455
+ const methodOpts = { paymentMethod: 'pm_card_visa' };
456
+ if (stripeKey)
457
+ methodOpts.secretKey = stripeKey;
458
+ const pluginResult = await plugin.setup({
459
+ challenge,
460
+ options: {},
461
+ methodOpts,
462
+ });
463
+ methods = pluginResult.methods;
464
+ createCredentialFn = pluginResult.createCredential;
465
+ credentialContext = pluginResult.credentialContext;
466
+ }
467
+ catch (error) {
468
+ results.push(skip(tag, error.message));
469
+ return;
470
+ }
415
471
  }
416
- catch (error) {
417
- results.push(skip(tag, error.message));
418
- return;
472
+ else {
473
+ methods = [directMethod];
419
474
  }
420
- const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results);
475
+ const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results, loaded?.config.extensions, credentialContext);
421
476
  if (!credential)
422
477
  return;
423
- plugin.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders });
478
+ plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders });
424
479
  // Stripe testmode: detect livemode rejection gracefully
425
480
  if (isStripeTestKey) {
426
481
  const resp = await fetchWithTimeout(url, {
427
482
  method: endpoint.method,
428
- headers: { ...fetchHeaders, [Constants.Headers.authorization]: credential },
483
+ headers: { ...fetchHeaders, [Challenge.credentialHeader(challenge)]: credential },
429
484
  body: fetchBody ?? null,
430
485
  }, 30_000);
431
486
  if (resp.status >= 200 && resp.status < 300) {
@@ -436,21 +491,22 @@ async function attemptStripePayment(challenge, tag, ctx) {
436
491
  }
437
492
  return;
438
493
  }
439
- await sendAndValidateResponse(results, url, endpoint, credential, fetchHeaders, fetchBody, verbose, undefined);
494
+ await sendAndValidateResponse(results, url, endpoint, credential, fetchHeaders, fetchBody, verbose, undefined, Challenge.credentialHeader(challenge));
440
495
  }
441
- async function createAndSend(challenge, methods, createCredentialFn, tag, results) {
496
+ async function createAndSend(challenge, methods, createCredentialFn, tag, results, extensions, initialCredentialContext) {
442
497
  const fakeResponse = new Response(null, {
443
498
  status: 402,
444
499
  headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
445
500
  });
446
501
  try {
502
+ const credentialContext = await preparePayment(challenge, extensions, initialCredentialContext);
447
503
  let credential;
448
504
  if (createCredentialFn) {
449
- credential = await createCredentialFn(fakeResponse);
505
+ credential = await createCredentialFn(fakeResponse, credentialContext);
450
506
  }
451
507
  else {
452
508
  const mppx = Mppx.create({ methods, polyfill: false });
453
- credential = await mppx.createCredential(fakeResponse);
509
+ credential = await mppx.createCredential(fakeResponse, credentialContext);
454
510
  }
455
511
  results.push(check(`${tag}: submitted`));
456
512
  return credential;
@@ -466,12 +522,12 @@ async function createAndSend(challenge, methods, createCredentialFn, tag, result
466
522
  return undefined;
467
523
  }
468
524
  }
469
- async function sendAndValidateResponse(results, url, endpoint, credential, baseHeaders, fetchBody, verbose, explorerChain) {
525
+ async function sendAndValidateResponse(results, url, endpoint, credential, baseHeaders, fetchBody, verbose, explorerChain, credentialHeader = Constants.Headers.authorization) {
470
526
  let paymentResponse;
471
527
  try {
472
528
  paymentResponse = await fetchWithTimeout(url, {
473
529
  method: endpoint.method,
474
- headers: { ...baseHeaders, [Constants.Headers.authorization]: credential },
530
+ headers: { ...baseHeaders, [credentialHeader]: credential },
475
531
  body: fetchBody ?? null,
476
532
  }, 30_000);
477
533
  }
@@ -479,6 +535,9 @@ async function sendAndValidateResponse(results, url, endpoint, credential, baseH
479
535
  results.push(fail('Payment: send credential', error.message));
480
536
  return results;
481
537
  }
538
+ return validatePaymentResponse(results, paymentResponse, verbose, explorerChain);
539
+ }
540
+ async function validatePaymentResponse(results, paymentResponse, verbose, explorerChain) {
482
541
  if (paymentResponse.status === 402) {
483
542
  const body = await paymentResponse.text().catch(() => '');
484
543
  let detail = 'Payment rejected';
@@ -24,6 +24,25 @@ export type PreparedPayment<methods extends readonly Method.AnyClient[] = readon
24
24
  /** Attaches a credential using the protocol that produced the selected challenge. */
25
25
  setCredential: (request: Transport.RequestOf<transport>, credential: string) => Transport.RequestOf<transport>;
26
26
  }>;
27
+ /** A payment challenge prepared together with the exact HTTP request that produced it. */
28
+ export type PreparedRequest<methods extends readonly Method.AnyClient[]> = Readonly<PreparedPayment<methods, Transport.Transport<RequestInit, Response>> & {
29
+ /** Exact request that returned the selected payment challenge. */
30
+ request: Request;
31
+ /** Payment-required response returned for {@link request}. */
32
+ response: Response;
33
+ /** Redirects followed before receiving the payment challenge. */
34
+ redirects: readonly PreparedRequest.Redirect[];
35
+ /** Creates and sends a credential to the prepared request without following redirects. */
36
+ pay: (context?: AnyContextFor<methods> | undefined) => Promise<Response>;
37
+ }>;
38
+ export declare namespace PreparedRequest {
39
+ /** A redirect followed while discovering a payment challenge. */
40
+ type Redirect = Readonly<{
41
+ from: string;
42
+ status: number;
43
+ to: string;
44
+ }>;
45
+ }
27
46
  /**
28
47
  * Client-side payment handler.
29
48
  */
@@ -50,6 +69,12 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
50
69
  * ```
51
70
  */
52
71
  preparePayment: (response: Transport.ResponseOf<transport>, options?: preparePayment.Options<FlattenMethods<methods>, transport> | undefined) => Promise<PreparedPayment<FlattenMethods<methods>, transport>>;
72
+ /**
73
+ * Follows safe pre-payment redirects and prepares the challenge with the exact request that
74
+ * produced it. Credential-bearing requests never follow redirects. Requires a runtime that
75
+ * exposes manual redirect responses; browsers return opaque redirects and are not supported.
76
+ */
77
+ prepareRequest: transport extends Transport.Transport<RequestInit, Response> ? (input: RequestInfo | URL, init?: RequestInit | undefined, options?: prepareRequest.Options<FlattenMethods<methods>> | undefined) => Promise<PreparedRequest<FlattenMethods<methods>>> : never;
53
78
  /** Creates a credential from a payment-required response by routing to the correct method. */
54
79
  createCredential: (response: Transport.ResponseOf<transport>, context?: AnyContextFor<FlattenMethods<methods>> | undefined, options?: createCredential.Options<FlattenMethods<methods>, transport> | undefined) => Promise<string>;
55
80
  /** Register a client event handler by canonical event name. */
@@ -89,6 +114,13 @@ export declare namespace preparePayment {
89
114
  /** Options for selecting a payment without creating its credential. */
90
115
  type Options<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[], transport extends Transport.AnyTransport = Transport.Transport> = createCredential.Options<methods, transport>;
91
116
  }
117
+ export declare namespace prepareRequest {
118
+ /** Options for preparing a request-bound payment. */
119
+ type Options<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = Omit<preparePayment.Options<methods, Transport.Transport<RequestInit, Response>>, 'request'> & {
120
+ /** Maximum redirects followed before rejecting the request. @default 20 */
121
+ maxRedirects?: number | undefined;
122
+ };
123
+ }
92
124
  export declare namespace createCredential {
93
125
  type Options<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[], transport extends Transport.AnyTransport = Transport.Transport> = {
94
126
  /** Request-local Accept-Payment override for manual rawFetch + createCredential flows. */