diff --git a/.changeset/calm-dodos-pay.md b/.changeset/calm-dodos-pay.md new file mode 100644 index 00000000..9da0ba0b --- /dev/null +++ b/.changeset/calm-dodos-pay.md @@ -0,0 +1,6 @@ +--- +'@stripe/link-sdk': minor +'@stripe/link-cli': patch +--- + +Expose Machine Payment Protocol helpers through `link.mpp` in the TypeScript SDK. The SDK can decode supported challenges into an extensible array and safely submit payment from an approved spend request ID; spend-request creation and approval remain on the existing `spendRequests` resource. diff --git a/README.md b/README.md index 52e76949..74603785 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,7 @@ By default, a spend request provisions a virtual card. Link can also provide a s For merchants that support the [Machine Payments Protocol](https://mpp.dev) (HTTP 402) and the Stripe payment method, instead pass `--credential-type "shared_payment_token"` when creating the spend request. The SPT is one-time-use — if payment fails, create a new spend request. -Use `mpp decode` to validate a raw `WWW-Authenticate` header and extract the `network_id` needed for `shared_payment_token` spend requests: +Use `mpp decode` to validate a raw `WWW-Authenticate` header. It returns an array of supported challenges; select the Stripe entry to get the `network_id` needed for `shared_payment_token` spend requests: ```bash link-cli mpp decode \ @@ -557,7 +557,7 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \ In agent mode (`--format json`), the full flow returns the payment continuation twice: as `_next.pay_argv` (`{ "command": "mpp", "args": [...] }`) and as `_next.pay_command`. Prefer `pay_argv` and invoke it directly, passing each `args` entry as its own process argument. The URL, body and headers can carry merchant-controlled text, so `pay_command` is shell-quoted for callers that must go through a shell — pass it to the shell verbatim, without unquoting or re-splitting it. -Use `mpp decode` to validate a raw `WWW-Authenticate` header and extract the `network_id` needed for `shared_payment_token` spend requests: +Use `mpp decode` to validate a raw `WWW-Authenticate` header. It returns an array of supported challenges; select the Stripe entry to get the `network_id` needed for `shared_payment_token` spend requests: ```bash link-cli mpp decode \ diff --git a/packages/cli/package.json b/packages/cli/package.json index 339d3ea2..2d7c9d78 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,6 @@ "incur": "^0.5.1", "ink": "^7.1.1", "ink-spinner": "^5.0.0", - "mppx": "0.10.1", "qrcode": "^1.5.4", "react": "^19.2.8", "strip-ansi": "^7.2.0", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 916a1185..53b550e4 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -3619,16 +3619,17 @@ describe('production mode', () => { ); expect(result.exitCode).toBe(0); - const parsed = parseJson(result.stdout) as { + const parsed = parseJson(result.stdout) as Array<{ method: string; intent: string; network_id: string; request_json: Record; - }; - expect(parsed.method).toBe('stripe'); - expect(parsed.intent).toBe('charge'); - expect(parsed.network_id).toBe('net_001'); - expect(parsed.request_json.networkId).toBe('net_001'); + }>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.method).toBe('stripe'); + expect(parsed[0]?.intent).toBe('charge'); + expect(parsed[0]?.network_id).toBe('net_001'); + expect(parsed[0]?.request_json.networkId).toBe('net_001'); }); it('fails when the stripe challenge payload is invalid', async () => { diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 400b5c7a..ccb1daa4 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -145,8 +145,8 @@ cli.command( ); cli.command( createMppCli( + factory.createMppResource(), spendRequestRepo, - () => factory.createPaymentMethodsResource(), authStorage, envAccessToken, ), @@ -191,6 +191,7 @@ cli.command( authRepo, spendRequestRepo, () => factory.createPaymentMethodsResource(), + factory.createMppResource(), authStorage, ), ); @@ -199,6 +200,7 @@ cli.command( authRepo, spendRequestRepo, () => factory.createPaymentMethodsResource(), + factory.createMppResource(), authStorage, ), ); diff --git a/packages/cli/src/commands/demo/demo-runner.tsx b/packages/cli/src/commands/demo/demo-runner.tsx index 83ce9371..293f8216 100644 --- a/packages/cli/src/commands/demo/demo-runner.tsx +++ b/packages/cli/src/commands/demo/demo-runner.tsx @@ -1,4 +1,5 @@ import type { + IMppResource, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -31,6 +32,7 @@ interface DemoRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + mpp: IMppResource; authStorage?: CliAuthStorage; paymentMethodId?: string; onlyCard?: boolean; @@ -42,6 +44,7 @@ export const DemoRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + mpp, authStorage = defaultStorage, paymentMethodId: preselectedPmId, onlyCard, @@ -191,6 +194,7 @@ export const DemoRunner: React.FC = ({ diff --git a/packages/cli/src/commands/demo/index.tsx b/packages/cli/src/commands/demo/index.tsx index aefcea5f..7e43d315 100644 --- a/packages/cli/src/commands/demo/index.tsx +++ b/packages/cli/src/commands/demo/index.tsx @@ -1,4 +1,5 @@ import type { + IMppResource, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -23,6 +24,7 @@ export function createDemoCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + mpp: IMppResource, authStorage?: CliAuthStorage, ) { return Cli.create('demo', { @@ -45,6 +47,7 @@ export function createDemoCli( authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + mpp={mpp} authStorage={authStorage} onlyCard={c.options.onlyCard} onlySpt={c.options.onlySpt} diff --git a/packages/cli/src/commands/demo/spt-flow.tsx b/packages/cli/src/commands/demo/spt-flow.tsx index fc7a8ea0..249a50c6 100644 --- a/packages/cli/src/commands/demo/spt-flow.tsx +++ b/packages/cli/src/commands/demo/spt-flow.tsx @@ -1,4 +1,5 @@ import type { + IMppResource, IPaymentMethodsResource, ISpendRequestResource, PaymentMethod, @@ -10,7 +11,6 @@ import { useEffect, useRef, useState } from 'react'; import { MarkdownText } from '../../utils/markdown-text'; import { openUrl } from '../../utils/open-url'; import { pollUntilApproved } from '../../utils/poll-until-approved'; -import { decodeStripeChallenge } from '../mpp/decode'; import { type PayResult, runMppPayWithSpendRequest } from '../mpp/pay'; import { DEMO_CLIMATE_API_URL, @@ -37,6 +37,7 @@ type Step = interface SptFlowProps { spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + mpp: IMppResource; paymentMethodId?: string; onComplete: (success: boolean) => void; } @@ -44,6 +45,7 @@ interface SptFlowProps { export const SptFlow: React.FC = ({ spendRequestRepo, paymentMethodsResource, + mpp, paymentMethodId: initialPaymentMethodId, onComplete, }) => { @@ -162,7 +164,10 @@ export const SptFlow: React.FC = ({ } const wwwAuth = probeResponse.headers.get('www-authenticate') ?? ''; - const decoded = decodeStripeChallenge(wwwAuth); + const decoded = mpp + .decodeChallenge(wwwAuth) + .find((challenge) => challenge.method === 'stripe'); + if (!decoded) throw new Error('No supported Stripe challenge found'); setNetworkId(decoded.network_id); setStep('explain-402'); @@ -218,7 +223,7 @@ export const SptFlow: React.FC = ({ 'POST', JSON.stringify({ amount: DEMO_SPT_AMOUNT }), undefined, - spendRequestRepo, + mpp, ); setPayResult(payResponse); setStep('done'); diff --git a/packages/cli/src/commands/mpp/decode-view.test.tsx b/packages/cli/src/commands/mpp/decode-view.test.tsx index b39711eb..7a1cee3e 100644 --- a/packages/cli/src/commands/mpp/decode-view.test.tsx +++ b/packages/cli/src/commands/mpp/decode-view.test.tsx @@ -1,37 +1,28 @@ import { render } from 'ink-testing-library'; import { describe, expect, it } from 'vitest'; -import { decodeStripeChallenge } from './decode'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { DecodeChallengeView } from './decode-view'; const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden'; const CLEAN_TEXT = 'EvilHidden'; -function encodeRequest(request: Record): string { - return Buffer.from(JSON.stringify(request)).toString('base64'); -} - describe('DecodeChallengeView', () => { it('renders no raw ANSI escapes for an attacker-controlled challenge', () => { - // The challenge string is fully attacker-controlled. Sanitization happens - // at the decode.ts boundary, so render the real decoded output rather than - // a hand-built object. - const header = [ - `Payment id="${ESCAPE_PAYLOAD}",`, - `realm="${ESCAPE_PAYLOAD}",`, - 'method="stripe",', - 'intent="charge",', - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - merchantName: ESCAPE_PAYLOAD, - methodDetails: { - networkId: 'net_001', - paymentMethodTypes: ['card'], + const decoded = sanitizeDeep([ + { + id: ESCAPE_PAYLOAD, + realm: ESCAPE_PAYLOAD, + method: 'stripe' as const, + intent: 'charge' as const, + description: ESCAPE_PAYLOAD, + network_id: 'net_001', + request_json: { + amount: '1000', + currency: 'usd', + merchantName: ESCAPE_PAYLOAD, }, - })}"`, - ].join(' '); - - const decoded = decodeStripeChallenge(header); + }, + ]); const { lastFrame } = render(); const frame = lastFrame() ?? ''; diff --git a/packages/cli/src/commands/mpp/decode-view.tsx b/packages/cli/src/commands/mpp/decode-view.tsx index 93ed4b5c..94d7b5eb 100644 --- a/packages/cli/src/commands/mpp/decode-view.tsx +++ b/packages/cli/src/commands/mpp/decode-view.tsx @@ -1,28 +1,40 @@ +import type { DecodedMppChallenge } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import type React from 'react'; -import type { DecodedStripeChallenge } from './decode'; export function DecodeChallengeView({ decoded, }: { - decoded: DecodedStripeChallenge; + decoded: DecodedMppChallenge[]; }): React.ReactElement { return ( - ✓ Stripe challenge decoded - - - ID: {decoded.id} - - - Realm: {decoded.realm} - - - Network ID: {decoded.network_id} - - Request JSON: - {JSON.stringify(decoded.request_json, null, 2)} - + + ✓ {decoded.length} supported challenge(s) decoded + + {decoded.map((challenge) => ( + + + Method: {challenge.method} + + + ID: {challenge.id} + + + Realm: {challenge.realm} + + + Network ID: {challenge.network_id} + + Request JSON: + {JSON.stringify(challenge.request_json, null, 2)} + + ))} ); } diff --git a/packages/cli/src/commands/mpp/decode.test.ts b/packages/cli/src/commands/mpp/decode.test.ts deleted file mode 100644 index ca5a102d..00000000 --- a/packages/cli/src/commands/mpp/decode.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { decodeStripeChallenge } from './decode'; - -function encodeRequest(request: Record): string { - return Buffer.from(JSON.stringify(request)).toString('base64'); -} - -describe('decodeStripeChallenge', () => { - it('decodes a stripe charge challenge from a mixed WWW-Authenticate header', () => { - const header = [ - 'Bearer realm="merchant.example",', - 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge", request="e30=",', - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - methodDetails: { - networkId: 'net_001', - paymentMethodTypes: ['card'], - }, - })}"`, - ].join(' '); - - expect(decodeStripeChallenge(header)).toMatchObject({ - id: 'ch_001', - realm: 'merchant.example', - method: 'stripe', - intent: 'charge', - network_id: 'net_001', - request_json: { - methodDetails: { - networkId: 'net_001', - }, - }, - }); - }); - - it('handles escaped quoted-string values in challenge parameters', () => { - const header = [ - 'Payment id="ch_001",', - 'realm="merchant.example",', - 'method="stripe",', - 'intent="charge",', - 'description="Plan \\"Pro\\", monthly",', - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - methodDetails: { - networkId: 'net_001', - paymentMethodTypes: ['card'], - }, - })}"`, - ].join(' '); - - expect(decodeStripeChallenge(header)).toMatchObject({ - description: 'Plan "Pro", monthly', - network_id: 'net_001', - }); - }); - - it('decodes a stripe session challenge', () => { - const header = [ - 'Payment id="sess_001", realm="merchant.example", method="stripe", intent="session",', - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - methodDetails: { - networkId: 'net_001', - paymentMethodTypes: ['card'], - }, - })}"`, - ].join(' '); - - expect(decodeStripeChallenge(header)).toMatchObject({ - id: 'sess_001', - realm: 'merchant.example', - method: 'stripe', - intent: 'session', - network_id: 'net_001', - }); - }); - - it('rejects headers without a stripe charge or session challenge', () => { - const header = - 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge", request="e30="'; - - expect(() => decodeStripeChallenge(header)).toThrow(/stripe charge/i); - }); - - it('rejects invalid stripe request payloads with a readable error', () => { - const header = [ - 'Payment id="ch_001",', - 'realm="merchant.example",', - 'method="stripe",', - 'intent="charge",', - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - methodDetails: { - paymentMethodTypes: ['card'], - }, - })}"`, - ].join(' '); - - expect(() => decodeStripeChallenge(header)).toThrow( - /Invalid stripe challenge request: methodDetails\.networkId: missing/, - ); - }); - - it('accepts the live climate.stripe.dev stripe challenge shape', () => { - const header = [ - 'Payment id="gc2RV-_QuqyahPkrJPAmrVA4Iqam240ab6gGU4UmMXc", realm="climatestripe-d6929lvln.vercelapp.stripe.dev", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMGI5NTM3ZDExYzYwZThiNTAiLCJtZXRob2REZXRhaWxzIjp7ImNoYWluSWQiOjQyMTd9LCJyZWNpcGllbnQiOiIweDdiOWNhZTNjNmYzMzlkODY0YzdjNGNlZWVjOTcwM2M4NjRhNjhjOGIifQ", expires="2026-04-20T23:27:21.154Z",', - 'Payment id="ZcLbITjkJRIuYj1PPaHLi9b8OHqFS3DLyzRWRLupZGY", realm="climatestripe-d6929lvln.vercelapp.stripe.dev", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxMDAiLCJjdXJyZW5jeSI6InVzZCIsIm1ldGhvZERldGFpbHMiOnsibmV0d29ya0lkIjoicHJvZmlsZV82MVUxV25hT1JRNWRBQThaekE2VTFXbmExWlNRVDJXQWVCYXd3cVVwYzBQMiIsInBheW1lbnRNZXRob2RUeXBlcyI6WyJjYXJkIiwibGluayJdfX0", description="Climate contribution", expires="2026-04-20T23:27:19.863Z"', - ].join(' '); - - expect(decodeStripeChallenge(header)).toMatchObject({ - id: 'ZcLbITjkJRIuYj1PPaHLi9b8OHqFS3DLyzRWRLupZGY', - network_id: 'profile_61U1WnaORQ5dAA8ZzA6U1Wna1ZSQT2WAeBawwqUpc0P2', - request_json: { - amount: '100', - currency: 'usd', - methodDetails: { - networkId: 'profile_61U1WnaORQ5dAA8ZzA6U1Wna1ZSQT2WAeBawwqUpc0P2', - paymentMethodTypes: ['card', 'link'], - }, - }, - }); - }); - - it('strips ANSI escape and control characters from decoded fields', () => { - const payload = '\x1b[2JEvil\rHidden'; - const clean = 'EvilHidden'; - const header = [ - `Payment id="${payload}",`, - `realm="${payload}",`, - 'method="stripe",', - 'intent="charge",', - `description="${payload}",`, - `request="${encodeRequest({ - amount: '1000', - currency: 'usd', - merchantName: payload, - methodDetails: { - networkId: 'net_001', - paymentMethodTypes: ['card'], - }, - })}"`, - ].join(' '); - - const decoded = decodeStripeChallenge(header); - expect(decoded).toMatchObject({ - id: clean, - realm: clean, - description: clean, - network_id: 'net_001', - request_json: { - amount: '1000', - currency: 'usd', - merchantName: clean, - }, - }); - const serialized = JSON.stringify(decoded); - expect(serialized).not.toContain('\x1b[2J'); - expect(serialized).not.toContain('\r'); - }); -}); diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts deleted file mode 100644 index 584ac2fd..00000000 --- a/packages/cli/src/commands/mpp/decode.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Challenge } from 'mppx'; -import { sanitizeDeep } from '../../utils/sanitize-text'; - -type StripeChargeChallenge = Challenge.Challenge< - Record, - 'charge' | 'session', - 'stripe' ->; - -type ResolvedStripeChallenge = { - challenge: StripeChargeChallenge; - networkId: string; - request: Record; -}; - -export interface DecodedStripeChallenge { - id: string; - realm: string; - method: 'stripe'; - intent: 'charge' | 'session'; - description?: string; - digest?: string; - expires?: string; - network_id: string; - request_json: Record; -} - -function getString( - value: unknown, - path: string, - required = true, -): string | undefined { - if (value == null) { - if (required) { - throw new Error(`Invalid stripe challenge request: ${path}: missing`); - } - return undefined; - } - if (typeof value !== 'string') { - throw new Error( - `Invalid stripe challenge request: ${path}: expected string, received ${typeof value}`, - ); - } - return value; -} - -function getMethodDetails( - request: Record, -): Record | undefined { - const methodDetails = request.methodDetails; - if (methodDetails == null) { - return undefined; - } - if (typeof methodDetails !== 'object' || Array.isArray(methodDetails)) { - throw new Error( - 'Invalid stripe challenge request: methodDetails: expected object', - ); - } - return methodDetails as Record; -} - -function resolveStripeChallenge( - challenges: Challenge.Challenge[], -): ResolvedStripeChallenge { - const stripeChallenge = challenges.find( - (challenge) => - challenge.method === 'stripe' && - (challenge.intent === 'charge' || challenge.intent === 'session'), - ); - - if (!stripeChallenge) { - throw new Error( - 'WWW-Authenticate header does not include a stripe charge or session challenge', - ); - } - - if ( - typeof stripeChallenge.request !== 'object' || - stripeChallenge.request == null || - Array.isArray(stripeChallenge.request) - ) { - throw new Error( - 'Invalid stripe challenge request: request: expected object', - ); - } - - const request = stripeChallenge.request as Record; - getString(request.amount, 'amount'); - getString(request.currency, 'currency'); - - const methodDetails = getMethodDetails(request); - const networkId = - getString(methodDetails?.networkId, 'methodDetails.networkId', false) ?? - getString(request.networkId, 'networkId', false); - - if (!networkId) { - throw new Error( - 'Invalid stripe challenge request: methodDetails.networkId: missing', - ); - } - - return { - challenge: stripeChallenge as StripeChargeChallenge, - networkId, - request, - }; -} - -export function getStripeChargeChallengeFromResponse( - response: Response, -): StripeChargeChallenge { - return resolveStripeChallenge(Challenge.fromResponseList(response)).challenge; -} - -export function getStripeChargeChallengeFromHeader( - header: string, -): StripeChargeChallenge { - return resolveStripeChallenge(Challenge.deserializeList(header)).challenge; -} - -export function decodeStripeChallenge( - challengeHeader: string, -): DecodedStripeChallenge { - const { challenge, networkId, request } = resolveStripeChallenge( - Challenge.deserializeList(challengeHeader), - ); - - return sanitizeDeep({ - id: challenge.id, - realm: challenge.realm, - method: 'stripe', - intent: challenge.intent, - description: challenge.description, - digest: challenge.digest, - expires: challenge.expires, - network_id: networkId, - request_json: request, - }); -} diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index a075f578..ad63b76e 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -1,23 +1,18 @@ -import type { - IPaymentMethodsResource, - ISpendRequestResource, -} from '@stripe/link-sdk'; +import type { IMppResource, ISpendRequestResource } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; +import { sanitizeDeep, sanitizeText } from '../../utils/sanitize-text'; import { shellCommand, shellQuote } from '../../utils/shell-quote'; -import { decodeStripeChallenge } from './decode'; import { DecodeChallengeView } from './decode-view'; import { buildHeaders, + type CliMppResource, MppPay, type PayResult, - probeMppRequest, - readPayResult, runMppPayWithSpendRequest, } from './pay'; -import { createMppRequest } from './request'; import { decodeOptions, payOptions } from './schema'; export function resolveInteractivePayResult( @@ -34,11 +29,12 @@ export function resolveInteractivePayResult( } export function createMppCli( - repository: ISpendRequestResource, - paymentMethodsFactory: () => IPaymentMethodsResource, + mpp: IMppResource, + spendRequests: ISpendRequestResource, authStorage?: CliAuthStorage, envAccessToken?: string, ) { + const cliMpp = mpp as CliMppResource; const cli = Cli.create('mpp', { description: 'Machine payment protocol (MPP) commands', }); @@ -73,8 +69,8 @@ export function createMppCli( amountOverride={opts.amount} paymentMethodId={opts.paymentMethodId} test={opts.test} - repository={repository} - paymentMethodsFactory={paymentMethodsFactory} + mpp={cliMpp} + spendRequests={spendRequests} onComplete={(result) => { capturedResult = result; }} @@ -90,64 +86,14 @@ export function createMppCli( method, data, headers, - repository, + mpp, opts.approvedChallenge, ); return; } - // Full flow in agent mode: yield approval URL mid-flow so the agent - // can present it to the user while we poll for approval inline. - const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); + // Agent mode returns a continuation so approval can span multiple runs. const requestHeaders = buildHeaders(data, headers); - - const probe = await probeMppRequest( - createMppRequest(url, httpMethod, data, requestHeaders), - ); - const probeResponse = probe.response; - - if (probeResponse.status !== 402) { - yield await readPayResult(probeResponse); - return; - } - - const wwwAuth = probeResponse.headers.get('www-authenticate'); - if (!wwwAuth) { - return c.error({ - code: 'INVALID_RESPONSE', - message: 'URL returned 402 but no WWW-Authenticate header', - }); - } - - const decoded = decodeStripeChallenge(wwwAuth); - await probeResponse.body?.cancel(); - const networkId = decoded.network_id; - const challengeAmount = decoded.request_json.amount - ? Number(decoded.request_json.amount) - : undefined; - const challengeCurrency = - (decoded.request_json.currency as string) ?? 'usd'; - const amount = opts.amount ?? challengeAmount; - - if ( - opts.amount !== undefined && - challengeAmount !== undefined && - opts.amount !== challengeAmount - ) { - return c.error({ - code: 'INVALID_INPUT', - message: `--amount must match the MPP challenge amount (${challengeAmount})`, - }); - } - - if (!amount) { - return c.error({ - code: 'INVALID_INPUT', - message: - 'Could not determine amount from 402 challenge. Pass --amount explicitly.', - }); - } - if (!opts.context) { return c.error({ code: 'INVALID_INPUT', @@ -156,30 +102,25 @@ export function createMppCli( }); } - let pmId = opts.paymentMethodId; - if (!pmId) { - const pmResource = paymentMethodsFactory(); - const methods = await pmResource.list(); - if (!methods.length) { - return c.error({ - code: 'NO_PAYMENT_METHOD', - message: - 'No payment methods found. Add one with `link-cli payment-methods add`.', - }); - } - pmId = methods[0].id; - } - - const spendRequest = await repository.create({ - payment_details: pmId, - credential_type: 'shared_payment_token', - network_id: networkId, - amount, - currency: challengeCurrency, + const prepared = await cliMpp.createSpendRequest({ + url, + ...(method !== undefined && { method }), + ...(data !== undefined && { body: data }), + headers: requestHeaders, context: opts.context, - request_approval: true, - test: opts.test || undefined, + ...(opts.amount !== undefined && { amount: opts.amount }), + ...(opts.paymentMethodId !== undefined && { + paymentMethodId: opts.paymentMethodId, + }), + test: opts.test, }); + if (!('spendRequest' in prepared)) { + yield sanitizeDeep(prepared); + return; + } + const spendRequest = sanitizeDeep(prepared.spendRequest); + const probe = prepared.request; + const wwwAuth = sanitizeText(prepared.approvedChallenge); // Continue from the request that actually returned the challenge. Redirects // may have changed its URL, method, body, or safe-to-forward headers. @@ -195,7 +136,7 @@ export function createMppCli( probe.method, ]; if (probe.body !== undefined) nextArgs.push('-d', probe.body); - for (const [name, value] of probe.headers) { + for (const [name, value] of Object.entries(probe.headers)) { nextArgs.push('-H', `${name}: ${value}`); } const nextCommand = `mpp ${shellCommand(nextArgs)}`; @@ -217,11 +158,11 @@ export function createMppCli( cli.command('decode', { description: - 'Decode a stripe WWW-Authenticate challenge and extract network_id', + 'Decode supported MPP challenges from a WWW-Authenticate header', options: decodeOptions, outputPolicy: 'agent-only' as const, async run(c) { - const decoded = decodeStripeChallenge(c.options.challenge); + const decoded = sanitizeDeep(mpp.decodeChallenge(c.options.challenge)); if (!c.agent && !c.formatExplicit) { return renderInteractive( diff --git a/packages/cli/src/commands/mpp/pay.test.ts b/packages/cli/src/commands/mpp/pay.test.ts deleted file mode 100644 index 9e078213..00000000 --- a/packages/cli/src/commands/mpp/pay.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import type { ISpendRequestResource } from '@stripe/link-sdk'; -import { Challenge, Credential } from 'mppx'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - payWithSpt, - runMppPayFullFlow, - runMppPayWithSpendRequest, -} from './pay'; - -const STRIPE_REQUEST = { - amount: '1000', - currency: 'usd', - decimals: 2, - paymentMethodTypes: ['card'], - networkId: 'net_001', -}; - -const STRIPE_CHALLENGE: Challenge.Challenge = { - id: 'ch_001', - realm: 'merchant.example', - method: 'stripe', - intent: 'charge', - request: STRIPE_REQUEST, - expires: '2099-01-01T00:00:00Z', -}; - -const WWW_AUTHENTICATE_STRIPE = Challenge.serialize(STRIPE_CHALLENGE); - -function challengeWith(overrides: Partial = {}): string { - return Challenge.serialize({ - ...STRIPE_CHALLENGE, - ...overrides, - request: overrides.request ?? STRIPE_REQUEST, - }); -} - -function challengeResponse( - challengeHeader = WWW_AUTHENTICATE_STRIPE, -): Response { - return new Response('{"error":"payment required"}', { - status: 402, - headers: { 'www-authenticate': challengeHeader }, - }); -} - -function challengeResponseWithCredentialHeader(header: string): Response { - return challengeResponse( - WWW_AUTHENTICATE_STRIPE.replace( - 'intent="charge",', - `intent="charge", header="${header}",`, - ), - ); -} - -beforeEach(() => { - vi.stubGlobal('__CLI_VERSION__', 'test'); -}); - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe('payWithSpt', () => { - it('rejects a redirect before using an approved credential', async () => { - const fetcher = vi.fn().mockResolvedValue( - new Response(null, { - status: 307, - headers: { location: 'https://other.example/challenge' }, - }), - ); - vi.stubGlobal('fetch', fetcher); - - await expect( - payWithSpt( - 'https://merchant.example/challenge', - 'spt_test_123', - undefined, - undefined, - undefined, - ), - ).rejects.toThrow(/redirected with status 307 after approval/); - - expect(fetcher).toHaveBeenCalledTimes(1); - expect(fetcher.mock.calls[0][0]).toBe('https://merchant.example/challenge'); - expect( - new Headers(fetcher.mock.calls[0][1]?.headers).has('authorization'), - ).toBe(false); - }); - - it('replaces caller authorization and refuses a redirect after payment', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce( - new Response(null, { - status: 307, - headers: { location: 'https://other.example/payment' }, - }), - ); - vi.stubGlobal('fetch', fetcher); - - await expect( - payWithSpt( - 'https://merchant.example/challenge', - 'spt_test_123', - 'POST', - '{"item":"book"}', - ['authorization: Bearer caller-value'], - ), - ).rejects.toThrow('redirect 307'); - - const paidHeaders = new Headers(fetcher.mock.calls[1][1]?.headers); - expect( - [...paidHeaders].filter(([name]) => name === 'authorization'), - ).toEqual([['authorization', expect.stringMatching(/^Payment /)]]); - expect(fetcher).toHaveBeenCalledTimes(2); - expect(fetcher.mock.calls[1][1]?.redirect).toBe('manual'); - expect(fetcher.mock.calls[1][1]?.body).toBe('{"item":"book"}'); - }); - - it('uses the credential header selected by the challenge', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce( - challengeResponseWithCredentialHeader('Payment-Credential'), - ) - .mockResolvedValueOnce(new Response('paid')); - vi.stubGlobal('fetch', fetcher); - - await payWithSpt( - 'https://merchant.example/challenge', - 'spt_test_123', - undefined, - undefined, - undefined, - ); - - const paidHeaders = new Headers(fetcher.mock.calls[1][1]?.headers); - expect(paidHeaders.get('payment-credential')).toMatch(/^Payment /); - expect(paidHeaders.has('authorization')).toBe(false); - }); - - it('refreshes an approved challenge at the pinned destination without following redirects', async () => { - const repository = { - create: vi.fn().mockResolvedValue({ - id: 'lsrq_123', - status: 'pending_approval', - }), - retrieve: vi - .fn() - .mockResolvedValueOnce({ id: 'lsrq_123', status: 'approved' }) - .mockResolvedValueOnce({ - id: 'lsrq_123', - status: 'approved', - shared_payment_token: { id: 'spt_test_123' }, - }), - } as unknown as ISpendRequestResource; - const fetcher = vi - .fn() - .mockResolvedValueOnce( - new Response(null, { - status: 302, - headers: { location: 'https://merchant.example/challenge' }, - }), - ) - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce( - new Response(null, { - status: 307, - headers: { location: 'https://other.example/challenge' }, - }), - ); - vi.stubGlobal('fetch', fetcher); - - await expect( - runMppPayFullFlow({ - url: 'https://redirector.example/start', - method: 'GET', - data: undefined, - headers: undefined, - context: - 'Buy a test item from the merchant after explicit Link approval for this machine payment request.', - amountOverride: 1000, - paymentMethodId: 'pd_test_123', - test: true, - repository, - paymentMethodsFactory: vi.fn(), - }), - ).rejects.toThrow(/redirected with status 307 after approval/); - expect( - fetcher.mock.calls.map(([input]) => - input instanceof Request ? input.url : input, - ), - ).toEqual([ - 'https://redirector.example/start', - 'https://merchant.example/challenge', - 'https://merchant.example/challenge', - ]); - }); - - it('accepts a refreshed challenge with a new id and no expiration when its approved terms match', async () => { - const repository = approvedRepository(); - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce( - challengeResponse( - challengeWith({ - id: 'ch_002', - expires: undefined, - }), - ), - ) - .mockResolvedValueOnce(new Response('paid')); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(repository)).resolves.toMatchObject({ - status: 200, - }); - expect(fetcher).toHaveBeenCalledTimes(3); - const credential = Credential.deserialize( - new Headers(fetcher.mock.calls[2][1]?.headers).get('authorization') ?? '', - ); - expect(credential.challenge.id).toBe('ch_002'); - }); - - it('accepts semantically identical request objects regardless of key order', async () => { - const approvedRequest = { - amount: '1000', - currency: 'usd', - methodDetails: { networkId: 'net_001', captureMethod: 'automatic' }, - paymentMethodTypes: ['card'], - decimals: 2, - }; - const refreshedRequest = { - decimals: 2, - paymentMethodTypes: ['card'], - methodDetails: { captureMethod: 'automatic', networkId: 'net_001' }, - currency: 'usd', - amount: '1000', - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce( - challengeResponse(challengeWith({ request: approvedRequest })), - ) - .mockResolvedValueOnce( - challengeResponse( - challengeWith({ id: 'ch_002', request: refreshedRequest }), - ), - ) - .mockResolvedValueOnce(new Response('paid')); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(approvedRepository())).resolves.toMatchObject({ - status: 200, - body: 'paid', - }); - }); - - it('returns a refreshed non-payment response without submitting a credential', async () => { - const refreshedResponse = new Response('already complete'); - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce(refreshedResponse); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(approvedRepository())).resolves.toEqual({ - status: 200, - headers: { 'content-type': 'text/plain;charset=UTF-8' }, - body: 'already complete', - }); - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it.each([ - ['amount', { request: { ...STRIPE_REQUEST, amount: '2000' } }], - ['currency', { request: { ...STRIPE_REQUEST, currency: 'eur' } }], - ['decimals', { request: { ...STRIPE_REQUEST, decimals: 6 } }], - [ - 'payment method type', - { request: { ...STRIPE_REQUEST, paymentMethodTypes: ['bank_account'] } }, - ], - [ - 'payment method list', - { - request: { - ...STRIPE_REQUEST, - paymentMethodTypes: ['bank_account', 'card'], - }, - }, - ], - ['network', { request: { ...STRIPE_REQUEST, networkId: 'net_002' } }], - [ - 'an added request field', - { request: { ...STRIPE_REQUEST, merchant: 'new' } }, - ], - [ - 'a removed request field', - { - request: { - amount: '1000', - currency: 'usd', - paymentMethodTypes: ['card'], - networkId: 'net_001', - }, - }, - ], - ['intent', { intent: 'session' }], - ['realm', { realm: 'other.example' }], - ['description', { description: 'Different purchase' }], - ['request digest', { digest: 'sha-256=ZGlmZmVyZW50' }], - ['credential header', { header: 'Payment-Credential' }], - ['opaque metadata', { opaque: 'bWV0YWRhdGE' }], - ])( - 'rejects a refreshed challenge with changed %s', - async (_field, change) => { - const repository = approvedRepository(); - const refreshedResponse = challengeResponse(challengeWith(change)); - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce(refreshedResponse); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(repository)).rejects.toThrow( - /challenge changed after approval/, - ); - expect(fetcher).toHaveBeenCalledTimes(2); - expect(refreshedResponse.bodyUsed).toBe(true); - for (const [, init] of fetcher.mock.calls) { - expect(new Headers(init?.headers).has('authorization')).toBe(false); - } - }, - ); - - it('rejects a changed field nested inside the payment request', async () => { - const approvedRequest = { - ...STRIPE_REQUEST, - methodDetails: { captureMethod: 'automatic', networkId: 'net_001' }, - }; - const refreshedRequest = { - ...STRIPE_REQUEST, - methodDetails: { captureMethod: 'manual', networkId: 'net_001' }, - }; - const refreshedResponse = challengeResponse( - challengeWith({ request: refreshedRequest }), - ); - const fetcher = vi - .fn() - .mockResolvedValueOnce( - challengeResponse(challengeWith({ request: approvedRequest })), - ) - .mockResolvedValueOnce(refreshedResponse); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(approvedRepository())).rejects.toThrow( - /challenge changed after approval/, - ); - expect(fetcher).toHaveBeenCalledTimes(2); - expect(refreshedResponse.bodyUsed).toBe(true); - }); - - it('rejects a changed challenge when continuing an approved spend request', async () => { - const repository = { - retrieve: vi.fn().mockResolvedValue({ - id: 'lsrq_123', - status: 'approved', - credential_type: 'shared_payment_token', - shared_payment_token: { id: 'spt_test_123' }, - }), - } as unknown as ISpendRequestResource; - const refreshedResponse = challengeResponse( - challengeWith({ request: { ...STRIPE_REQUEST, amount: '2000' } }), - ); - const fetcher = vi.fn().mockResolvedValueOnce(refreshedResponse); - vi.stubGlobal('fetch', fetcher); - - await expect( - runMppPayWithSpendRequest( - 'https://merchant.example/challenge', - 'lsrq_123', - 'GET', - undefined, - undefined, - repository, - WWW_AUTHENTICATE_STRIPE, - ), - ).rejects.toThrow(/challenge changed after approval/); - expect(fetcher).toHaveBeenCalledTimes(1); - expect( - new Headers(fetcher.mock.calls[0][1]?.headers).has('authorization'), - ).toBe(false); - expect(refreshedResponse.bodyUsed).toBe(true); - }); - - it('waits for SPT propagation when continuing an approved spend request', async () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const retrievedAt: number[] = []; - const repository = { - retrieve: vi.fn().mockImplementation(async () => { - retrievedAt.push(Date.now()); - return { - id: 'lsrq_123', - status: 'approved', - credential_type: 'shared_payment_token', - ...(retrievedAt.length === 8 - ? { shared_payment_token: { id: 'spt_test_123' } } - : {}), - }; - }), - } as unknown as ISpendRequestResource; - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce(new Response('paid')); - vi.stubGlobal('fetch', fetcher); - - const resultPromise = runMppPayWithSpendRequest( - 'https://merchant.example/challenge', - 'lsrq_123', - 'GET', - undefined, - undefined, - repository, - ); - await vi.runAllTimersAsync(); - - await expect(resultPromise).resolves.toMatchObject({ - status: 200, - body: 'paid', - }); - expect(retrievedAt).toEqual([0, 1000, 2000, 3000, 5000, 7000, 9000, 11000]); - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it('rejects an explicit amount that conflicts with the challenge', async () => { - const repository = approvedRepository(); - const fetcher = vi.fn().mockResolvedValueOnce(challengeResponse()); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(repository, 2000)).rejects.toThrow( - '--amount must match the MPP challenge amount (1000)', - ); - expect(repository.create).not.toHaveBeenCalled(); - expect(fetcher).toHaveBeenCalledTimes(1); - }); - - it.each([ - ['no authentication challenge', undefined], - ['a non-Payment challenge', 'Basic realm="merchant.example"'], - ['a malformed Payment challenge', 'Payment id="ch_002"'], - ])( - 'rejects a refreshed 402 with %s without submitting a credential', - async (_case, authenticate) => { - const refreshedResponse = new Response('payment required', { - status: 402, - headers: authenticate - ? { 'www-authenticate': authenticate } - : undefined, - }); - const fetcher = vi - .fn() - .mockResolvedValueOnce(challengeResponse()) - .mockResolvedValueOnce(refreshedResponse); - vi.stubGlobal('fetch', fetcher); - - await expect(runFullFlow(approvedRepository())).rejects.toThrow(); - expect(fetcher).toHaveBeenCalledTimes(2); - expect(refreshedResponse.bodyUsed).toBe(true); - }, - ); -}); - -function approvedRepository() { - return { - create: vi.fn().mockResolvedValue({ - id: 'lsrq_123', - status: 'pending_approval', - }), - retrieve: vi - .fn() - .mockResolvedValueOnce({ id: 'lsrq_123', status: 'approved' }) - .mockResolvedValueOnce({ - id: 'lsrq_123', - status: 'approved', - shared_payment_token: { id: 'spt_test_123' }, - }), - } as unknown as ISpendRequestResource; -} - -function runFullFlow(repository: ISpendRequestResource, amountOverride = 1000) { - return runMppPayFullFlow({ - url: 'https://merchant.example/challenge', - method: 'GET', - data: undefined, - headers: undefined, - context: - 'Buy a test item from the merchant after explicit Link approval for this machine payment request.', - amountOverride, - paymentMethodId: 'pd_test_123', - test: true, - repository, - paymentMethodsFactory: vi.fn(), - }); -} diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 75c45d6a..c92553e5 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -1,34 +1,55 @@ import type { - IPaymentMethodsResource, + IMppResource, ISpendRequestResource, + MppPaymentResult, + SpendRequest, } from '@stripe/link-sdk'; import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; -import { Challenge, Credential, Method } from 'mppx'; -import { Mppx } from 'mppx/client'; -import { Methods as StripeMethods } from 'mppx/stripe'; import { useEffect, useState } from 'react'; import { openUrl } from '../../utils/open-url'; import { pollUntilApproved } from '../../utils/poll-until-approved'; import { sanitizeDeep } from '../../utils/sanitize-text'; -import { - decodeStripeChallenge, - getStripeChargeChallengeFromHeader, - getStripeChargeChallengeFromResponse, -} from './decode'; -import { - createMppRequest, - createSafeMppFetch, - fetchMppRequest, - isRedirectResponse, - type MppRequest, -} from './request'; -export type PayResult = { - status: number; - headers: Record; - body: string; -}; +export type PayResult = MppPaymentResult; +export type Step = + | 'probing' + | 'creating' + | 'approving' + | 'signing' + | 'submitting' + | 'done'; + +interface CliMppRequestOptions { + url: string; + method?: string; + body?: string; + headers?: HeadersInit; +} + +export interface CliMppResource extends IMppResource { + createSpendRequest( + options: CliMppRequestOptions & { + context: string; + amount?: number; + paymentMethodId?: string; + test?: boolean; + onStep?: (step: Step) => void; + }, + ): Promise< + | MppPaymentResult + | { + spendRequest: SpendRequest; + request: { + url: string; + method: string; + headers: Record; + body?: string; + }; + approvedChallenge: string; + } + >; +} declare const __CLI_VERSION__: string; @@ -37,9 +58,7 @@ export function buildHeaders( headers: string[] | undefined, ): Record { const result: Record = {}; - if (data !== undefined) { - result['Content-Type'] = 'application/json'; - } + if (data !== undefined) result['Content-Type'] = 'application/json'; for (const line of headers ?? []) { const idx = line.indexOf(':'); if (idx === -1) continue; @@ -53,95 +72,25 @@ export function buildHeaders( return result; } -export async function readPayResult(response: Response): Promise { - const responseHeaders = Object.fromEntries(response.headers.entries()); - const body = await response.text(); - // Response body and headers are fully attacker-controlled. Strip ANSI escape - // sequences and control characters so they cannot spoof the terminal UI or - // inject content into the agent's context. See CLAUDE.md security note. - return sanitizeDeep({ - status: response.status, - headers: responseHeaders, - body, - }); -} - -function createStripePaymentClient( - spt?: string, - fetcher: typeof fetch = fetch, -) { - const stripeCharge = Method.toClient(StripeMethods.charge, { - async createCredential({ challenge }) { - if (!spt) throw new Error('A shared payment token is required to pay'); - return Credential.serialize({ - challenge, - payload: { spt }, - }); - }, - }); - - const stripeSession = Method.toClient( - { ...StripeMethods.charge, intent: 'session' as const }, - { - async createCredential({ challenge }) { - if (!spt) throw new Error('A shared payment token is required to pay'); - return Credential.serialize({ - challenge, - payload: { action: 'open', grantedToken: spt }, - }); - }, - }, - ); - - return Mppx.create({ - fetch: createSafeMppFetch(fetcher), - methods: [stripeCharge, stripeSession], - polyfill: false, - }); -} - -export interface MppProbe extends MppRequest { - response: Response; -} - -const SPT_RETRIEVAL_DELAYS_MS = [ - 0, 1000, 1000, 1000, 2000, 2000, 2000, 2000, -] as const; - -export async function probeMppRequest( - initial: MppRequest, - fetcher: typeof fetch = fetch, -): Promise { - const prepared = await createStripePaymentClient( - undefined, - fetcher, - ).prepareRequest( - initial.url, - { - body: initial.body, - headers: initial.headers, - method: initial.method, - }, - { maxRedirects: 10 }, +export async function runMppPayWithSpendRequest( + url: string, + spendRequestId: string, + method: string | undefined, + data: string | undefined, + headers: string[] | undefined, + mpp: IMppResource, + approvedChallenge?: string, +): Promise { + return sanitizeDeep( + await mpp.pay({ + url, + spendRequestId, + ...(method !== undefined && { method }), + ...(data !== undefined && { body: data }), + headers: buildHeaders(data, headers), + ...(approvedChallenge !== undefined && { challenge: approvedChallenge }), + }), ); - const response = prepared.payment - ? new Response(null, { - headers: prepared.response.headers, - status: prepared.response.status, - statusText: prepared.response.statusText, - }) - : prepared.response; - if (prepared.payment) { - void prepared.response.body?.cancel().catch(() => undefined); - } - const method = prepared.request.method; - return { - body: method === 'GET' || method === 'HEAD' ? undefined : initial.body, - headers: new Headers(prepared.request.headers), - method, - response, - url: prepared.request.url, - }; } export interface MppPayFullFlowOptions { @@ -153,306 +102,61 @@ export interface MppPayFullFlowOptions { amountOverride: number | undefined; paymentMethodId: string | undefined; test: boolean; - repository: ISpendRequestResource; - paymentMethodsFactory: () => IPaymentMethodsResource; + mpp: CliMppResource; + spendRequests: ISpendRequestResource; onStep?: (step: Step) => void; onApprovalUrl?: (url: string) => void; } -export async function runMppPayWithSpendRequest( - url: string, - spendRequestId: string, - method: string | undefined, - data: string | undefined, - headers: string[] | undefined, - repository: ISpendRequestResource, - approvedChallengeHeader?: string, -): Promise { - let spendRequest = await repository.retrieve(spendRequestId, { - include: ['shared_payment_token'], - }); - - if (!spendRequest) { - throw new Error(`Spend request ${spendRequestId} not found`); - } - if (spendRequest.credential_type !== 'shared_payment_token') { - const type = spendRequest.credential_type ?? 'card'; - throw new Error( - `Spend request ${spendRequestId} must have credential_type 'shared_payment_token' (current: '${type}')`, - ); - } - if (spendRequest.status !== 'approved') { - throw new Error( - `Spend request must be approved (current status: ${spendRequest.status})`, - ); - } - for (const delayMs of SPT_RETRIEVAL_DELAYS_MS.slice(1)) { - if (spendRequest.shared_payment_token) break; - await new Promise((resolve) => setTimeout(resolve, delayMs)); - const retrieved = await repository.retrieve(spendRequestId, { - include: ['shared_payment_token'], - }); - if (!retrieved) { - throw new Error(`Spend request ${spendRequestId} not found`); - } - spendRequest = retrieved; - } - if (!spendRequest.shared_payment_token) { - throw new Error('Failed to retrieve shared payment token'); - } - - return payWithSpt( - url, - spendRequest.shared_payment_token.id, - method, - data, - headers, - approvedChallengeHeader, - ); -} - -export async function payWithSpt( - url: string, - spt: string, - method: string | undefined, - data: string | undefined, - headers: string[] | undefined, - approvedChallengeHeader?: string, -): Promise { - const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); - const requestHeaders = buildHeaders(data, headers); - const approvedChallenge = approvedChallengeHeader - ? getStripeChargeChallengeFromHeader(approvedChallengeHeader) - : undefined; - return payPinnedChallengeWithSpt( - createMppRequest(url, httpMethod, data, requestHeaders), - spt, - approvedChallenge, - ); -} - -async function submitMppPayment( - challenge: MppProbe, - spt: string, -): Promise { - // Credential creation needs only the challenge status and headers. Keep the - // untrusted response body out of signing and release its stream separately. - const credentialResponse = new Response(null, { - status: challenge.response.status, - statusText: challenge.response.statusText, - headers: challenge.response.headers, - }); - const payment = - await createStripePaymentClient(spt).preparePayment(credentialResponse); - const credential = await payment.createCredential(); - await challenge.response.body?.cancel(); - - const response = await fetch(challenge.url, { - ...payment.setCredential( - { - method: challenge.method, - headers: challenge.headers, - body: challenge.body, - }, - credential, - ), - redirect: 'manual', - }); - if (isRedirectResponse(response)) { - await response.body?.cancel(); - throw new Error( - `Paid MPP request returned redirect ${response.status}; refusing to forward the payment credential`, - ); - } - return readPayResult(response); -} - -async function payPinnedChallengeWithSpt( - request: MppRequest, - spt: string, - approvedChallenge?: Challenge.Challenge, -): Promise { - // Approved credentials may be used minutes later. Refresh the challenge at - // the pinned destination, but never let that destination move afterward. - const response = await fetchMppRequest(request); - if (isRedirectResponse(response)) { - await response.body?.cancel(); - throw new Error( - `MPP challenge destination redirected with status ${response.status} after approval`, - ); - } - const refreshed = { ...request, response }; - if (response.status !== 402) return readPayResult(response); - if (approvedChallenge) { - let refreshedChallenge: Challenge.Challenge; - try { - refreshedChallenge = getStripeChargeChallengeFromResponse(response); - } catch (error) { - await response.body?.cancel(); - throw error; - } - if ( - comparableChallenge(refreshedChallenge) !== - comparableChallenge(approvedChallenge) - ) { - await response.body?.cancel(); - throw new Error( - 'MPP challenge changed after approval; refusing to use the approved payment credential', - ); - } - } - return submitMppPayment(refreshed, spt); -} - -function comparableChallenge(challenge: Challenge.Challenge): string { - return Challenge.serialize({ - ...challenge, - id: 'approval-comparison', - expires: undefined, - }); -} - export async function runMppPayFullFlow( - opts: MppPayFullFlowOptions, + options: MppPayFullFlowOptions, ): Promise { - const { - url, - method, - data, - headers, - context, - amountOverride, - paymentMethodId, - test, - repository, - paymentMethodsFactory, - onStep, - onApprovalUrl, - } = opts; - - const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); - const requestHeaders = buildHeaders(data, headers); - - // 1. Probe URL - onStep?.('probing'); - const probe = await probeMppRequest( - createMppRequest(url, httpMethod, data, requestHeaders), - ); - const probeResponse = probe.response; - - if (probeResponse.status !== 402) { - return readPayResult(probeResponse); - } - - // 2. Parse challenge - const wwwAuth = probeResponse.headers.get('www-authenticate'); - if (!wwwAuth) { - throw new Error('URL returned 402 but no WWW-Authenticate header'); - } - - const decoded = decodeStripeChallenge(wwwAuth); - const approvedChallenge = getStripeChargeChallengeFromResponse(probeResponse); - await probeResponse.body?.cancel(); - const networkId = decoded.network_id; - const challengeAmount = decoded.request_json.amount - ? Number(decoded.request_json.amount) - : undefined; - const challengeCurrency = (decoded.request_json.currency as string) ?? 'usd'; - - const amount = amountOverride ?? challengeAmount; - if ( - amountOverride !== undefined && - challengeAmount !== undefined && - amountOverride !== challengeAmount - ) { - throw new Error( - `--amount must match the MPP challenge amount (${challengeAmount})`, - ); - } - if (!amount) { - throw new Error( - 'Could not determine amount from 402 challenge. Pass --amount explicitly.', - ); - } - - // 3. Get payment method - let pmId = paymentMethodId; - if (!pmId) { - onStep?.('creating'); - const pmResource = paymentMethodsFactory(); - const methods = await pmResource.list(); - if (!methods.length) { - throw new Error( - 'No payment methods found. Add one with `link-cli payment-methods add`.', - ); - } - pmId = methods[0].id; - } - - // 4. Create spend request - onStep?.('creating'); - const spendRequest = await repository.create({ - payment_details: pmId, - credential_type: 'shared_payment_token', - network_id: networkId, - amount, - currency: challengeCurrency, - context, - request_approval: true, - test: test || undefined, + const prepared = await options.mpp.createSpendRequest({ + url: options.url, + ...(options.method !== undefined && { method: options.method }), + ...(options.data !== undefined && { body: options.data }), + headers: buildHeaders(options.data, options.headers), + context: options.context, + ...(options.amountOverride !== undefined && { + amount: options.amountOverride, + }), + ...(options.paymentMethodId !== undefined && { + paymentMethodId: options.paymentMethodId, + }), + test: options.test, + ...(options.onStep !== undefined && { onStep: options.onStep }), }); + if (!('spendRequest' in prepared)) return sanitizeDeep(prepared); - // 5. Poll for approval - onStep?.('approving'); - if (spendRequest.approval_url) { - onApprovalUrl?.(spendRequest.approval_url); + options.onStep?.('approving'); + if (prepared.spendRequest.approval_url) { + options.onApprovalUrl?.(prepared.spendRequest.approval_url); } - - const approved = await pollUntilApproved(repository, spendRequest.id); + const approved = await pollUntilApproved( + options.spendRequests, + prepared.spendRequest.id, + ); if (approved.status !== 'approved') { throw new Error( `Spend request was not approved (status: ${approved.status})`, ); } - // 6. Retrieve with SPT (retry briefly in case of propagation delay) - onStep?.('signing'); - let withSpt = null; - for (const delayMs of SPT_RETRIEVAL_DELAYS_MS) { - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - withSpt = await repository.retrieve(spendRequest.id, { - include: ['shared_payment_token'], - }); - if (withSpt?.shared_payment_token) break; - } - if (!withSpt?.shared_payment_token) { - throw new Error('Failed to retrieve shared payment token'); - } - - // 7. Pay - onStep?.('submitting'); - return payPinnedChallengeWithSpt( - probe, - withSpt.shared_payment_token.id, - approvedChallenge, - ); + options.onStep?.('signing'); + options.onStep?.('submitting'); + const result = await options.mpp.pay({ + ...prepared.request, + spendRequestId: prepared.spendRequest.id, + challenge: prepared.approvedChallenge, + }); + options.onStep?.('done'); + return sanitizeDeep(result); } -export type Step = - | 'probing' - | 'creating' - | 'approving' - | 'signing' - | 'submitting' - | 'done'; - export function MppApprovalPrompt({ approvalUrl }: { approvalUrl: string }) { useInput((_input, key) => { if (key.return) openUrl(approvalUrl); }); - return ( IPaymentMethodsResource; + mpp: CliMppResource; + spendRequests: ISpendRequestResource; onComplete: (result: PayResult | null) => void; }) { const [step, setStep] = useState( @@ -511,7 +215,6 @@ export function MppPay({ (async () => { try { let payResult: PayResult; - if (spendRequestId) { setStep('signing'); payResult = await runMppPayWithSpendRequest( @@ -520,7 +223,7 @@ export function MppPay({ method, data, headers, - repository, + mpp, ); } else { if (!context) { @@ -537,13 +240,12 @@ export function MppPay({ amountOverride, paymentMethodId, test: test ?? false, - repository, - paymentMethodsFactory, + mpp, + spendRequests, onStep: setStep, - onApprovalUrl: (u) => setApprovalUrl(u), + onApprovalUrl: setApprovalUrl, }); } - setResult(payResult); setStep('done'); onComplete(payResult); @@ -562,8 +264,8 @@ export function MppPay({ amountOverride, paymentMethodId, test, - repository, - paymentMethodsFactory, + mpp, + spendRequests, onComplete, ]); @@ -575,11 +277,7 @@ export function MppPay({ submitting: 'Submitting payment', done: 'Done', }; - - if (error) { - return Error: {error}; - } - + if (error) return Error: {error}; return ( {step !== 'done' && ( diff --git a/packages/cli/src/commands/mpp/request.test.ts b/packages/cli/src/commands/mpp/request.test.ts deleted file mode 100644 index 05961b52..00000000 --- a/packages/cli/src/commands/mpp/request.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { probeMppRequest } from './pay'; -import { createMppRequest, createSafeMppFetch } from './request'; - -function response(status: number, location?: string): Response { - return new Response('response body', { - status, - headers: location ? { location } : undefined, - }); -} - -describe('probeMppRequest', () => { - it('preserves method and body across 307 while stripping cross-origin credentials', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(response(307, 'https://merchant.example/pay')) - .mockResolvedValueOnce(response(200)); - const request = createMppRequest( - 'https://redirector.example/start', - 'PUT', - 'payload', - { - Authorization: 'Bearer secret', - 'Content-Type': 'text/plain', - }, - ); - - const result = await probeMppRequest(request, fetcher); - - expect(result.method).toBe('PUT'); - expect(result.body).toBe('payload'); - expect(result.headers.get('authorization')).toBeNull(); - expect(result.headers.get('content-type')).toBe('text/plain'); - }); - - it('turns PUT into GET on a same-origin 303 and drops body headers', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(response(303, '/challenge')) - .mockResolvedValueOnce(response(200)); - const request = createMppRequest( - 'https://merchant.example/start', - 'PUT', - 'payload', - { - Authorization: 'Bearer caller-value', - 'Content-Type': 'text/plain', - }, - ); - - const result = await probeMppRequest(request, fetcher); - - expect(result.url).toBe('https://merchant.example/challenge'); - expect(result.method).toBe('GET'); - expect(result.body).toBeUndefined(); - expect(result.headers.get('content-type')).toBeNull(); - expect(result.headers.get('authorization')).toBe('Bearer caller-value'); - }); - - it('rejects remote HTTP and HTTPS downgrade redirects', async () => { - expect(() => - createMppRequest('http://merchant.example/pay', 'GET', undefined, {}), - ).toThrow(/require HTTPS/); - - const redirected = response(302, 'http://127.0.0.1:8080/pay'); - const fetcher = vi.fn().mockResolvedValue(redirected); - const request = createMppRequest( - 'https://merchant.example/start', - 'GET', - undefined, - {}, - ); - - await expect(probeMppRequest(request, fetcher)).rejects.toThrow( - /HTTPS downgrade/, - ); - expect(redirected.bodyUsed).toBe(true); - }); - - it('rejects an HTTP redirect from loopback to a remote host', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(response(302, 'http://merchant.example/pay')); - const request = createMppRequest( - 'http://localhost:8080/start', - 'GET', - undefined, - {}, - ); - - await expect(probeMppRequest(request, fetcher)).rejects.toThrow( - /require HTTPS/, - ); - expect(fetcher).toHaveBeenCalledOnce(); - }); -}); - -describe('createSafeMppFetch', () => { - it('rejects remote HTTP before sending the request', async () => { - const fetcher = vi.fn(); - const safeFetch = createSafeMppFetch(fetcher); - - await expect(safeFetch('http://merchant.example/pay')).rejects.toThrow( - /require HTTPS/, - ); - expect(fetcher).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/commands/mpp/request.ts b/packages/cli/src/commands/mpp/request.ts deleted file mode 100644 index 76820320..00000000 --- a/packages/cli/src/commands/mpp/request.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface MppRequest { - url: string; - method: string; - headers: Headers; - body: string | undefined; -} - -function isHttpLoopback(url: URL): boolean { - return ( - url.protocol === 'http:' && - (url.hostname === '127.0.0.1' || - url.hostname === 'localhost' || - url.hostname === '[::1]') - ); -} - -function assertSafeMppUrl(url: URL): void { - if (url.protocol === 'https:' || isHttpLoopback(url)) return; - throw new Error( - `MPP requests require HTTPS (HTTP is allowed only for localhost development): ${url.href}`, - ); -} - -export function createMppRequest( - url: string, - method: string, - body: string | undefined, - headers: HeadersInit, -): MppRequest { - const parsed = new URL(url); - assertSafeMppUrl(parsed); - return { - url: parsed.href, - method: method.toUpperCase(), - headers: new Headers(headers), - body, - }; -} - -export function isRedirectResponse(response: Response): boolean { - return response.status >= 300 && response.status < 400; -} - -export function createSafeMppFetch( - fetcher: typeof fetch = fetch, -): typeof fetch { - return async (input, init) => { - const url = new URL(input instanceof Request ? input.url : input); - assertSafeMppUrl(url); - return fetcher(input, init); - }; -} - -export async function fetchMppRequest( - request: MppRequest, - fetcher: typeof fetch = fetch, -): Promise { - // Keep redirects visible so probing can follow them safely and approved - // payment flows can reject them. - return fetcher(request.url, { - method: request.method, - headers: request.headers, - body: request.body, - redirect: 'manual', - }); -} diff --git a/packages/cli/src/commands/onboard/index.tsx b/packages/cli/src/commands/onboard/index.tsx index 6605318e..0a678658 100644 --- a/packages/cli/src/commands/onboard/index.tsx +++ b/packages/cli/src/commands/onboard/index.tsx @@ -1,4 +1,5 @@ import type { + IMppResource, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -12,6 +13,7 @@ export function createOnboardCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + mpp: IMppResource, authStorage?: CliAuthStorage, ) { return Cli.create('onboard', { @@ -33,6 +35,7 @@ export function createOnboardCli( authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + mpp={mpp} authStorage={authStorage} onComplete={() => {}} />, diff --git a/packages/cli/src/commands/onboard/onboard-runner.tsx b/packages/cli/src/commands/onboard/onboard-runner.tsx index 05869844..4f2b596d 100644 --- a/packages/cli/src/commands/onboard/onboard-runner.tsx +++ b/packages/cli/src/commands/onboard/onboard-runner.tsx @@ -1,4 +1,5 @@ import type { + IMppResource, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -20,6 +21,7 @@ interface OnboardRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + mpp: IMppResource; authStorage?: CliAuthStorage; onComplete: () => void; } @@ -28,6 +30,7 @@ export const OnboardRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + mpp, authStorage = defaultStorage, onComplete, }) => { @@ -172,6 +175,7 @@ export const OnboardRunner: React.FC = ({ authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + mpp={mpp} authStorage={storage} onComplete={onComplete} /> diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index ff9dcabe..ace1c7ac 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -4,6 +4,7 @@ import { type IAttestationsResource, type IBalancesResource, type IIdentityCredentialsResource, + type IMppResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -125,6 +126,7 @@ export class ResourceFactory { private webBotAuthResource?: IWebBotAuthResource; private reportResource?: IReportResource; private ucpResource?: IUcpResource; + private mppResource?: IMppResource; constructor(options: ResourceFactoryOptions = {}) { this.verbose = options.verbose ?? false; @@ -359,4 +361,11 @@ export class ResourceFactory { this.ucpResource = resource; return resource; } + + createMppResource(): IMppResource { + if (this.mppResource) return this.mppResource; + const resource = this.createSdkClient().mpp; + this.mppResource = resource; + return resource; + } } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 60e78bb9..d9c977ab 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -162,6 +162,60 @@ polled automatically. For any other resolution, surface the action to the user and follow its instructions. Keep returned card or shared-payment-token credentials out of model context, logs, and user-visible messages. +## Machine payments (MPP) + +The [Machine Payments Protocol](https://mpp.dev) uses HTTP 402 challenges to +describe a payment required by an API. `link.mpp` exposes two operations: +`decodeChallenge` for inspecting the supported challenges in a header and +`pay` for paying a Stripe challenge with an approved Link spend request. + +`decodeChallenge` returns an array because one header can advertise several +payment methods. The current SDK recognizes Stripe charge and session +challenges; future methods will be added as new members of the +`DecodedMppChallenge` union. + +Decode the merchant's `WWW-Authenticate` header, then use the regular +`spendRequests` resource to create and approve a shared-payment-token request: + +```ts +const challengeHeader = response.headers.get('www-authenticate')!; +const challenges = link.mpp.decodeChallenge(challengeHeader); +const challenge = challenges.find((candidate) => candidate.method === 'stripe'); +if (!challenge) throw new Error('No supported Stripe challenge found'); +const paymentMethods = await link.paymentMethods.list(); + +const spendRequest = await link.spendRequests.create({ + payment_details: paymentMethods[0].id, + credential_type: 'shared_payment_token', + network_id: challenge.network_id, + amount: Number(challenge.request_json.amount), + currency: String(challenge.request_json.currency), + context: + 'The user asked the agent to buy the selected item from Merchant after reviewing the Link approval.', + request_approval: true, +}); + +await sendToUser(spendRequest.approval_url!); +``` + +After confirming that the spend request is approved, pass its ID to `pay`. +Supplying the original challenge pins the approved payment details and causes +the SDK to reject a changed challenge: + +```ts +const paid = await link.mpp.pay({ + url: 'https://merchant.example/api/purchase', + method: 'POST', + body: JSON.stringify({ sku: 'sku_123' }), + headers: { 'Content-Type': 'application/json' }, + spendRequestId: spendRequest.id, + challenge: challengeHeader, +}); +``` + +Remote URLs must use HTTPS; plain HTTP is allowed only for loopback +development. + ## Configuration ```ts @@ -219,3 +273,4 @@ try { - `balances` — list balances - `webBotAuth` — sign URLs for Web Bot Auth - `reports` — report agent outcomes +- `mpp` — probe and pay Machine Payment Protocol endpoints with Link approval diff --git a/packages/sdk/package.json b/packages/sdk/package.json index dc1cf486..931f4874 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -49,6 +49,7 @@ "test": "vitest run" }, "dependencies": { + "mppx": "0.10.1", "zod": "^4.5.4" }, "devDependencies": { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8c17f68c..318b0fa6 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -8,6 +8,7 @@ import type { IAttestationsResource, IBalancesResource, IIdentityCredentialsResource, + IMppResource, IPaymentMethodsResource, IReportResource, IShippingAddressResource, @@ -18,6 +19,7 @@ import type { IUserInfoResource, IWebBotAuthResource, } from '@/resources/interfaces'; +import { MppResource } from '@/resources/mpp'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ReportResource } from '@/resources/report'; import { ShippingAddressResource } from '@/resources/shipping-address'; @@ -42,6 +44,7 @@ export class Link { readonly webBotAuth: IWebBotAuthResource; readonly reports: IReportResource; readonly ucp: IUcpResource; + readonly mpp: IMppResource; constructor(options: LinkOptions) { this.attestations = new AttestationsResource(options); @@ -57,6 +60,10 @@ export class Link { this.webBotAuth = new WebBotAuthResource(options); this.reports = new ReportResource(options); this.ucp = new UcpResource(options); + this.mpp = new MppResource(options, { + spendRequests: this.spendRequests, + paymentMethods: this.paymentMethods, + }); } } diff --git a/packages/sdk/src/resources/__tests__/factory.test.ts b/packages/sdk/src/resources/__tests__/factory.test.ts index eac98ec9..369dfb92 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import Link from '@/client'; import { AttestationsResource } from '@/resources/attestations'; import { IdentityCredentialsResource } from '@/resources/identity-credentials'; +import { MppResource } from '@/resources/mpp'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ReportResource } from '@/resources/report'; import { SpendRequestResource } from '@/resources/spend-request'; @@ -25,6 +26,7 @@ describe('Link', () => { expect(client.transactions).toBeInstanceOf(TransactionsResource); expect(client.webBotAuth).toBeInstanceOf(WebBotAuthResource); expect(client.reports).toBeInstanceOf(ReportResource); + expect(client.mpp).toBeInstanceOf(MppResource); expect(client.spendRequests.create).toBeTypeOf('function'); expect(client.spendRequests.update).toBeTypeOf('function'); expect(client.spendRequests.retrieve).toBeTypeOf('function'); @@ -32,5 +34,6 @@ describe('Link', () => { expect(client.identityCredentials.issue).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); + expect(client.mpp.pay).toBeTypeOf('function'); }); }); diff --git a/packages/sdk/src/resources/__tests__/mpp.test.ts b/packages/sdk/src/resources/__tests__/mpp.test.ts new file mode 100644 index 00000000..c5b7234a --- /dev/null +++ b/packages/sdk/src/resources/__tests__/mpp.test.ts @@ -0,0 +1,386 @@ +import { Challenge, Credential } from 'mppx'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + IPaymentMethodsResource, + ISpendRequestResource, + MppPaymentResult, +} from '@/resources/interfaces'; +import { decodeMppChallenges, MppResource } from '@/resources/mpp'; + +const REQUEST = { + amount: '1000', + currency: 'usd', + decimals: 2, + paymentMethodTypes: ['card'], + networkId: 'net_001', +}; +const CHALLENGE: Challenge.Challenge = { + id: 'ch_001', + realm: 'merchant.example', + method: 'stripe', + intent: 'charge', + request: REQUEST, + expires: '2099-01-01T00:00:00Z', +}; +const HEADER = Challenge.serialize(CHALLENGE); + +function challengeResponse(header = HEADER): Response { + return new Response('payment required', { + status: 402, + headers: { 'www-authenticate': header }, + }); +} + +function resource( + fetch: typeof globalThis.fetch, + spendRequests: ISpendRequestResource = {} as ISpendRequestResource, + paymentMethods: IPaymentMethodsResource = {} as IPaymentMethodsResource, +) { + return new MppResource( + { accessToken: 'test_token', fetch }, + { spendRequests, paymentMethods }, + ); +} + +function payWithSharedPaymentToken( + mpp: MppResource, + options: { + url: string; + method?: string; + body?: string; + headers?: HeadersInit; + sharedPaymentToken: string; + approvedChallenge?: string; + }, +): Promise { + const internal = mpp as unknown as { + payWithSharedPaymentToken(value: typeof options): Promise; + }; + return internal.payWithSharedPaymentToken(options); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('MppResource', () => { + it('decodes stripe charge and session challenges for SDK consumers', () => { + expect(decodeMppChallenges(HEADER)).toMatchObject([ + { + id: 'ch_001', + method: 'stripe', + network_id: 'net_001', + request_json: { amount: '1000', currency: 'usd' }, + }, + ]); + }); + + it('decodes methodDetails.networkId and rejects malformed challenges', () => { + const header = Challenge.serialize({ + ...CHALLENGE, + intent: 'session', + request: { + ...REQUEST, + networkId: undefined, + methodDetails: { networkId: 'net_nested' }, + }, + }); + expect(decodeMppChallenges(header)).toMatchObject([ + { + intent: 'session', + network_id: 'net_nested', + }, + ]); + expect(() => + decodeMppChallenges( + 'Payment id="x", realm="r", method="tempo", intent="charge", request="e30="', + ), + ).toThrow(/stripe charge or session/); + }); + + it('returns every supported challenge and ignores unsupported methods', () => { + const secondStripe = Challenge.serialize({ + ...CHALLENGE, + id: 'ch_002', + intent: 'session', + }); + const tempo = Challenge.serialize({ + id: 'tempo_001', + realm: 'merchant.example', + method: 'tempo', + intent: 'charge', + request: { amount: '1000000', currency: '0x01' }, + }); + + expect( + decodeMppChallenges([tempo, HEADER, secondStripe].join(', ')), + ).toMatchObject([ + { id: 'ch_001', method: 'stripe', intent: 'charge' }, + { id: 'ch_002', method: 'stripe', intent: 'session' }, + ]); + }); + + it('rejects non-loopback HTTP before making a request', async () => { + const fetch = vi.fn(); + await expect( + resource(fetch).probe({ url: 'http://merchant.example/pay' }), + ).rejects.toThrow(/require HTTPS/); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('follows safe probe redirects and strips cross-origin credentials', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { location: 'https://merchant.example/pay' }, + }), + ) + .mockResolvedValueOnce(new Response('ok')); + const probe = await resource(fetch).probe({ + url: 'https://redirector.example/start', + method: 'PUT', + body: 'payload', + headers: { Authorization: 'Bearer secret', 'Content-Type': 'text/plain' }, + }); + expect(probe.url).toBe('https://merchant.example/pay'); + expect(probe.method).toBe('PUT'); + expect(probe.body).toBe('payload'); + expect(probe.headers.get('authorization')).toBeNull(); + }); + + it('turns a same-origin 303 into GET and drops body headers', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 303, + headers: { location: '/challenge' }, + }), + ) + .mockResolvedValueOnce(new Response('ok')); + const probe = await resource(fetch).probe({ + url: 'https://merchant.example/start', + method: 'PUT', + body: 'payload', + headers: { 'Content-Type': 'text/plain' }, + }); + expect(probe).toMatchObject({ + url: 'https://merchant.example/challenge', + method: 'GET', + }); + expect(probe.body).toBeUndefined(); + expect(probe.headers.has('content-type')).toBe(false); + }); + + it('rejects HTTPS downgrade redirects', async () => { + const redirected = new Response('redirect', { + status: 302, + headers: { location: 'http://127.0.0.1:8080/pay' }, + }); + const fetch = vi.fn().mockResolvedValue(redirected); + await expect( + resource(fetch).probe({ url: 'https://merchant.example/start' }), + ).rejects.toThrow(/HTTPS downgrade/); + expect(redirected.bodyUsed).toBe(true); + }); + + it('signs and submits a stripe charge with a shared payment token', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(new Response('paid')); + const result = await payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/pay', + sharedPaymentToken: 'spt_test_123', + }); + expect(result).toMatchObject({ status: 200, body: 'paid' }); + const headers = new Headers(fetch.mock.calls[1]![1]?.headers); + const credential = Credential.deserialize(headers.get('authorization')!); + expect(credential.payload).toEqual({ spt: 'spt_test_123' }); + }); + + it('replaces caller authorization and refuses paid redirects', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { location: 'https://other.example/pay' }, + }), + ); + await expect( + payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/pay', + sharedPaymentToken: 'spt_test_123', + headers: { Authorization: 'Bearer caller-value' }, + }), + ).rejects.toThrow(/redirect 307/); + const headers = new Headers(fetch.mock.calls[1]![1]?.headers); + expect(headers.get('authorization')).toMatch(/^Payment /); + expect(fetch.mock.calls[1]![1]?.redirect).toBe('manual'); + }); + + it('supports stripe session challenges', async () => { + const sessionHeader = Challenge.serialize({ + ...CHALLENGE, + intent: 'session', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(challengeResponse(sessionHeader)) + .mockResolvedValueOnce(new Response('opened')); + await payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/session', + sharedPaymentToken: 'spt_test_123', + }); + const headers = new Headers(fetch.mock.calls[1]![1]?.headers); + const credential = Credential.deserialize(headers.get('authorization')!); + expect(credential.payload).toEqual({ + action: 'open', + grantedToken: 'spt_test_123', + }); + }); + + it('refuses a changed challenge after approval', async () => { + const changed = Challenge.serialize({ + ...CHALLENGE, + request: { ...REQUEST, amount: '2000' }, + }); + const response = challengeResponse(changed); + const fetch = vi.fn().mockResolvedValueOnce(response); + await expect( + payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/pay', + sharedPaymentToken: 'spt_test_123', + approvedChallenge: HEADER, + }), + ).rejects.toThrow(/challenge changed after approval/); + expect(fetch).toHaveBeenCalledOnce(); + expect(response.bodyUsed).toBe(true); + }); + + it('accepts refreshed challenge IDs and expiration changes', async () => { + const refreshed = Challenge.serialize({ + ...CHALLENGE, + id: 'ch_002', + expires: undefined, + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(challengeResponse(refreshed)) + .mockResolvedValueOnce(new Response('paid')); + await expect( + payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/pay', + sharedPaymentToken: 'spt_test_123', + approvedChallenge: HEADER, + }), + ).resolves.toMatchObject({ body: 'paid' }); + const headers = new Headers(fetch.mock.calls[1]![1]?.headers); + expect( + Credential.deserialize(headers.get('authorization')!).challenge.id, + ).toBe('ch_002'); + }); + + it.each([ + ['currency', { request: { ...REQUEST, currency: 'eur' } }], + ['network', { request: { ...REQUEST, networkId: 'net_002' } }], + ['intent', { intent: 'session' }], + ['realm', { realm: 'other.example' }], + ['description', { description: 'Different purchase' }], + ['credential header', { header: 'Payment-Credential' }], + ])('refuses a changed approved %s', async (_name, change) => { + const changed = Challenge.serialize({ ...CHALLENGE, ...change }); + const response = challengeResponse(changed); + const fetch = vi.fn().mockResolvedValueOnce(response); + await expect( + payWithSharedPaymentToken(resource(fetch), { + url: 'https://merchant.example/pay', + sharedPaymentToken: 'spt_test_123', + approvedChallenge: HEADER, + }), + ).rejects.toThrow(/challenge changed after approval/); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('validates the spend request before retrieving its credential', async () => { + const retrieve = vi.fn().mockResolvedValue({ + id: 'lsrq_123', + status: 'pending_approval', + credential_type: 'shared_payment_token', + }); + await expect( + resource(vi.fn(), { + retrieve, + } as unknown as ISpendRequestResource).pay({ + url: 'https://merchant.example/pay', + spendRequestId: 'lsrq_123', + }), + ).rejects.toThrow(/must be approved/); + }); + + it('retrieves an approved spend request and waits for SPT propagation', async () => { + vi.useFakeTimers(); + const retrieve = vi + .fn() + .mockResolvedValueOnce({ + id: 'lsrq_123', + status: 'approved', + credential_type: 'shared_payment_token', + }) + .mockResolvedValueOnce({ + id: 'lsrq_123', + status: 'approved', + credential_type: 'shared_payment_token', + }) + .mockResolvedValueOnce({ + id: 'lsrq_123', + status: 'approved', + credential_type: 'shared_payment_token', + shared_payment_token: { id: 'spt_test_123' }, + }); + const spendRequests = { retrieve } as unknown as ISpendRequestResource; + const fetch = vi + .fn() + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(new Response('paid')); + const promise = resource(fetch, spendRequests).pay({ + url: 'https://merchant.example/pay', + spendRequestId: 'lsrq_123', + challenge: HEADER, + }); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toMatchObject({ body: 'paid' }); + expect(retrieve).toHaveBeenCalledTimes(3); + }); + + it('returns a serializable continuation when creating a spend request', async () => { + const spendRequests = { + create: vi.fn().mockResolvedValue({ + id: 'lsrq_123', + status: 'pending_approval', + approval_url: 'https://link.com/approve/lsrq_123', + }), + } as unknown as ISpendRequestResource; + const fetch = vi.fn().mockResolvedValueOnce(challengeResponse()); + const prepared = await resource(fetch, spendRequests).createSpendRequest({ + url: 'https://merchant.example/pay', + context: + 'Buy the selected item from the merchant after the user reviews and approves this exact request.', + paymentMethodId: 'pm_123', + }); + expect(prepared).toMatchObject({ + request: { + url: 'https://merchant.example/pay', + method: 'GET', + headers: {}, + }, + approvedChallenge: HEADER, + spendRequest: { id: 'lsrq_123' }, + }); + expect(JSON.parse(JSON.stringify(prepared))).toEqual(prepared); + }); +}); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 748e738a..abe0ff6a 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -103,6 +103,59 @@ export interface ISpendRequestResource { ): Promise; } +export interface MppPayOptions { + url: string; + method?: string; + body?: string; + headers?: HeadersInit; + spendRequestId: string; + challenge?: string; +} + +export interface MppPaymentResult { + status: number; + headers: Record; + body: string; +} + +export interface DecodedMppChallengeBase { + id: string; + realm: string; + description?: string; + digest?: string; + expires?: string; + header?: string; + meta?: Record; + opaque?: string; +} + +export interface DecodedStripeChallengeRequest extends Record { + amount: string; + currency: string; + networkId?: string; + methodDetails?: { + networkId: string; + paymentMethodTypes?: string[]; + metadata?: Record; + [key: string]: unknown; + }; +} + +export interface DecodedStripeChallenge extends DecodedMppChallengeBase { + method: 'stripe'; + intent: 'charge' | 'session'; + network_id: string; + request_json: DecodedStripeChallengeRequest; +} + +/** Supported decoded MPP challenges. Add new method variants to this union. */ +export type DecodedMppChallenge = DecodedStripeChallenge; + +export interface IMppResource { + decodeChallenge(challengeHeader: string): DecodedMppChallenge[]; + pay(options: MppPayOptions): Promise; +} + export interface IPaymentMethodsResource { list(): Promise; retrieve(id: string): Promise; diff --git a/packages/sdk/src/resources/mpp.ts b/packages/sdk/src/resources/mpp.ts new file mode 100644 index 00000000..45a271f8 --- /dev/null +++ b/packages/sdk/src/resources/mpp.ts @@ -0,0 +1,655 @@ +import { Challenge, Credential, Method } from 'mppx'; +import { Mppx } from 'mppx/client'; +import { Methods as StripeMethods } from 'mppx/stripe'; +import { + type LinkOptions, + requireFetchImplementation, + resolveLinkSdkConfig, +} from '@/config'; +import type { + DecodedMppChallenge, + DecodedStripeChallenge, + DecodedStripeChallengeRequest, + IMppResource, + IPaymentMethodsResource, + ISpendRequestResource, + MppPaymentResult, + MppPayOptions, +} from '@/resources/interfaces'; +import { PaymentMethodsResource } from '@/resources/payment-methods'; +import { SpendRequestResource } from '@/resources/spend-request'; + +type StripeChallenge = Challenge.Challenge< + Record, + 'charge' | 'session', + 'stripe' +>; + +interface MppResourceDependencies { + spendRequests?: ISpendRequestResource; + paymentMethods?: IPaymentMethodsResource; +} + +type MppPaymentStep = 'probing' | 'creating'; + +interface MppRequestOptions { + url: string; + method?: string; + body?: string; + headers?: HeadersInit; +} + +interface MppProbeResult { + url: string; + method: string; + headers: Headers; + body?: string; + response: Response; +} + +interface PreparedMppPayment { + probe: MppProbeResult; + challenge: StripeChallenge; + challengeHeader: string; + decoded: DecodedStripeChallenge; +} + +interface NormalizedMppRequest { + url: string; + method: string; + headers: Headers; + body: string | undefined; +} + +interface MppCreateSpendRequestOptions extends MppRequestOptions { + context: string; + amount?: number; + paymentMethodId?: string; + test?: boolean; + onStep?: (step: MppPaymentStep) => void; +} + +interface MppSpendRequestResult { + spendRequest: import('@/types').SpendRequest; + request: { + url: string; + method: string; + headers: Record; + body?: string; + }; + approvedChallenge: string; +} + +interface MppPayWithSharedPaymentTokenOptions extends MppRequestOptions { + sharedPaymentToken: string; + approvedChallenge?: string; +} + +const SPT_RETRIEVAL_DELAYS_MS = [ + 0, 1000, 1000, 1000, 2000, 2000, 2000, 2000, +] as const; + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function isHttpLoopback(url: URL): boolean { + return ( + url.protocol === 'http:' && + (url.hostname === '127.0.0.1' || + url.hostname === 'localhost' || + url.hostname === '[::1]') + ); +} + +function assertSafeMppUrl(url: URL): void { + if (url.protocol === 'https:' || isHttpLoopback(url)) return; + throw new Error( + `MPP requests require HTTPS (HTTP is allowed only for localhost development): ${url.href}`, + ); +} + +function isRedirectResponse(response: Response): boolean { + return response.status >= 300 && response.status < 400; +} + +function normalizeRequest(options: MppRequestOptions): NormalizedMppRequest { + const url = new URL(options.url); + assertSafeMppUrl(url); + return { + url: url.href, + method: ( + options.method ?? (options.body !== undefined ? 'POST' : 'GET') + ).toUpperCase(), + headers: new Headers(options.headers), + body: options.body, + }; +} + +function getString( + value: unknown, + path: string, + required = true, +): string | undefined { + if (value == null) { + if (required) { + throw new Error(`Invalid stripe challenge request: ${path}: missing`); + } + return undefined; + } + if (typeof value !== 'string') { + throw new Error( + `Invalid stripe challenge request: ${path}: expected string, received ${typeof value}`, + ); + } + return value; +} + +function getRequiredString(value: unknown, path: string): string { + const result = getString(value, path); + if (result === undefined) { + throw new Error(`Invalid stripe challenge request: ${path}: missing`); + } + return result; +} + +function getOptionalStringArray( + value: unknown, + path: string, +): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error( + `Invalid stripe challenge request: ${path}: expected string array`, + ); + } + return value; +} + +function getOptionalStringRecord( + value: unknown, + path: string, +): Record | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== 'object' || + value == null || + Array.isArray(value) || + Object.values(value).some((item) => typeof item !== 'string') + ) { + throw new Error( + `Invalid stripe challenge request: ${path}: expected string record`, + ); + } + return value as Record; +} + +function isSupportedStripeChallenge( + challenge: Challenge.Challenge, +): challenge is StripeChallenge { + return ( + challenge.method === 'stripe' && + (challenge.intent === 'charge' || challenge.intent === 'session') + ); +} + +function parseStripeChallenge(challenge: StripeChallenge): { + challenge: StripeChallenge; + decoded: DecodedStripeChallenge; +} { + if ( + typeof challenge.request !== 'object' || + challenge.request == null || + Array.isArray(challenge.request) + ) { + throw new Error( + 'Invalid stripe challenge request: request: expected object', + ); + } + + const request = challenge.request as Record; + const amount = getRequiredString(request.amount, 'amount'); + const currency = getRequiredString(request.currency, 'currency'); + const methodDetails = request.methodDetails; + if ( + methodDetails != null && + (typeof methodDetails !== 'object' || Array.isArray(methodDetails)) + ) { + throw new Error( + 'Invalid stripe challenge request: methodDetails: expected object', + ); + } + const details = methodDetails as Record | undefined; + const networkId = + getString(details?.networkId, 'methodDetails.networkId', false) ?? + getString(request.networkId, 'networkId', false); + if (!networkId) { + throw new Error( + 'Invalid stripe challenge request: methodDetails.networkId: missing', + ); + } + const paymentMethodTypes = getOptionalStringArray( + details?.paymentMethodTypes, + 'methodDetails.paymentMethodTypes', + ); + const metadata = getOptionalStringRecord( + details?.metadata, + 'methodDetails.metadata', + ); + + const requestJson: DecodedStripeChallengeRequest = { + ...request, + amount, + currency, + ...(details && { + methodDetails: { + ...details, + networkId, + ...(paymentMethodTypes && { paymentMethodTypes }), + ...(metadata && { metadata }), + }, + }), + }; + return { + challenge, + decoded: { + id: challenge.id, + realm: challenge.realm, + method: 'stripe', + intent: challenge.intent, + ...(challenge.description !== undefined && { + description: challenge.description, + }), + ...(challenge.digest !== undefined && { + digest: challenge.digest, + }), + ...(challenge.expires !== undefined && { + expires: challenge.expires, + }), + ...(challenge.header !== undefined && { header: challenge.header }), + ...(challenge.meta !== undefined && { meta: challenge.meta }), + ...(challenge.opaque !== undefined && { opaque: challenge.opaque }), + network_id: networkId, + request_json: requestJson, + }, + }; +} + +function resolveStripeChallenge(challenges: Challenge.Challenge[]): { + challenge: StripeChallenge; + decoded: DecodedStripeChallenge; +} { + const challenge = challenges.find(isSupportedStripeChallenge); + if (!challenge) { + throw new Error( + 'WWW-Authenticate header does not include a supported stripe charge or session challenge', + ); + } + return parseStripeChallenge(challenge); +} + +export function decodeMppChallenges( + challengeHeader: string, +): DecodedMppChallenge[] { + const challenges = Challenge.deserializeList(challengeHeader).filter( + isSupportedStripeChallenge, + ); + if (challenges.length === 0) { + throw new Error( + 'WWW-Authenticate header does not include a supported stripe charge or session challenge', + ); + } + return challenges.map((challenge) => parseStripeChallenge(challenge).decoded); +} + +function challengeFromResponse(response: Response): StripeChallenge { + return resolveStripeChallenge(Challenge.fromResponseList(response)).challenge; +} + +function challengeFromHeader(header: string): StripeChallenge { + return resolveStripeChallenge(Challenge.deserializeList(header)).challenge; +} + +function comparableChallenge(challenge: Challenge.Challenge): string { + return Challenge.serialize({ + ...challenge, + id: 'approval-comparison', + expires: undefined, + }); +} + +/** Machine Payment Protocol flows backed by Link shared payment tokens. */ +export class MppResource implements IMppResource { + private readonly fetchImpl: typeof globalThis.fetch; + private readonly spendRequests: ISpendRequestResource; + private readonly paymentMethods: IPaymentMethodsResource; + + constructor( + options: LinkOptions, + dependencies: MppResourceDependencies = {}, + ) { + this.fetchImpl = requireFetchImplementation(resolveLinkSdkConfig(options)); + this.spendRequests = + dependencies.spendRequests ?? new SpendRequestResource(options); + this.paymentMethods = + dependencies.paymentMethods ?? new PaymentMethodsResource(options); + } + + decodeChallenge(challengeHeader: string): DecodedMppChallenge[] { + return decodeMppChallenges(challengeHeader); + } + + async probe(options: MppRequestOptions): Promise { + const initial = normalizeRequest(options); + const prepared = await this.createPaymentClient().prepareRequest( + initial.url, + { + ...(initial.body !== undefined && { body: initial.body }), + headers: initial.headers, + method: initial.method, + }, + { maxRedirects: 10 }, + ); + const response = prepared.payment + ? new Response(null, { + headers: prepared.response.headers, + status: prepared.response.status, + statusText: prepared.response.statusText, + }) + : prepared.response; + if (prepared.payment) { + void prepared.response.body?.cancel().catch(() => undefined); + } + const method = prepared.request.method; + return { + url: prepared.request.url, + method, + headers: new Headers(prepared.request.headers), + ...(method !== 'GET' && method !== 'HEAD' && initial.body !== undefined + ? { body: initial.body } + : {}), + response, + }; + } + + /** @internal Used by the CLI to orchestrate its interactive approval flow. */ + async createSpendRequest( + options: MppCreateSpendRequestOptions, + ): Promise { + options.onStep?.('probing'); + const probe = await this.probe(options); + if (probe.response.status !== 402) { + return this.readResult(probe.response); + } + const prepared = this.parsePreparedPayment(probe); + try { + const spendRequest = await this.createLinkSpendRequest( + options, + prepared.decoded, + ); + const request = { + url: prepared.probe.url, + method: prepared.probe.method, + headers: Object.fromEntries(prepared.probe.headers.entries()), + ...(prepared.probe.body !== undefined && { + body: prepared.probe.body, + }), + }; + return { + spendRequest, + request, + approvedChallenge: prepared.challengeHeader, + }; + } finally { + await prepared.probe.response.body?.cancel().catch(() => undefined); + } + } + + async pay(options: MppPayOptions): Promise { + const spendRequest = await this.spendRequests.retrieve( + options.spendRequestId, + { include: ['shared_payment_token'] }, + ); + if (!spendRequest) { + throw new Error(`Spend request ${options.spendRequestId} not found`); + } + if (spendRequest.credential_type !== 'shared_payment_token') { + const type = spendRequest.credential_type ?? 'card'; + throw new Error( + `Spend request ${options.spendRequestId} must have credential_type 'shared_payment_token' (current: '${type}')`, + ); + } + if (spendRequest.status !== 'approved') { + throw new Error( + `Spend request must be approved (current status: ${spendRequest.status})`, + ); + } + const sharedPaymentToken = + spendRequest.shared_payment_token?.id ?? + (await this.retrieveSharedPaymentToken(options.spendRequestId, false)); + return this.payWithSharedPaymentToken({ + url: options.url, + ...(options.method !== undefined && { method: options.method }), + ...(options.body !== undefined && { body: options.body }), + ...(options.headers !== undefined && { headers: options.headers }), + sharedPaymentToken, + ...(options.challenge !== undefined && { + approvedChallenge: options.challenge, + }), + }); + } + + private async payWithSharedPaymentToken( + options: MppPayWithSharedPaymentTokenOptions, + ): Promise { + const request = normalizeRequest(options); + const response = await this.fetchRequest(request); + if (isRedirectResponse(response)) { + await response.body?.cancel(); + throw new Error( + `MPP challenge destination redirected with status ${response.status} after approval`, + ); + } + if (response.status !== 402) return this.readResult(response); + const probe: MppProbeResult = { + url: request.url, + method: request.method, + headers: request.headers, + ...(request.body !== undefined && { body: request.body }), + response, + }; + if (options.approvedChallenge) { + const approvedChallenge = challengeFromHeader(options.approvedChallenge); + let refreshedChallenge: StripeChallenge; + try { + refreshedChallenge = challengeFromResponse(response); + } catch (error) { + await response.body?.cancel(); + throw error; + } + if ( + comparableChallenge(refreshedChallenge) !== + comparableChallenge(approvedChallenge) + ) { + await response.body?.cancel(); + throw new Error( + 'MPP challenge changed after approval; refusing to use the approved payment credential', + ); + } + } + return this.submitPayment(probe, options.sharedPaymentToken); + } + + private createPaymentClient(sharedPaymentToken?: string) { + const stripeCharge = Method.toClient(StripeMethods.charge, { + async createCredential({ challenge }) { + if (!sharedPaymentToken) { + throw new Error('A shared payment token is required to pay'); + } + return Credential.serialize({ + challenge, + payload: { spt: sharedPaymentToken }, + }); + }, + }); + const stripeSession = Method.toClient( + { ...StripeMethods.charge, intent: 'session' as const }, + { + async createCredential({ challenge }) { + if (!sharedPaymentToken) { + throw new Error('A shared payment token is required to pay'); + } + return Credential.serialize({ + challenge, + payload: { action: 'open', grantedToken: sharedPaymentToken }, + }); + }, + }, + ); + return Mppx.create({ + fetch: this.createSafeFetch(), + methods: [stripeCharge, stripeSession], + polyfill: false, + }); + } + + private createSafeFetch(): typeof globalThis.fetch { + return async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + assertSafeMppUrl(url); + return this.fetchImpl(input, init); + }; + } + + private async fetchRequest( + request: NormalizedMppRequest | Omit, + ): Promise { + return this.fetchImpl(request.url, { + method: request.method, + headers: request.headers, + ...(request.body !== undefined && { body: request.body }), + redirect: 'manual', + }); + } + + private parsePreparedPayment(probe: MppProbeResult): PreparedMppPayment { + const challengeHeader = probe.response.headers.get('www-authenticate'); + if (!challengeHeader) { + throw new Error('URL returned 402 but no WWW-Authenticate header'); + } + const resolved = resolveStripeChallenge( + Challenge.fromResponseList(probe.response), + ); + return { probe, challengeHeader, ...resolved }; + } + + private async createLinkSpendRequest( + options: MppCreateSpendRequestOptions, + decoded: DecodedStripeChallenge, + ) { + const challengeAmount = decoded.request_json.amount + ? Number(decoded.request_json.amount) + : undefined; + const amount = options.amount ?? challengeAmount; + if ( + options.amount !== undefined && + challengeAmount !== undefined && + options.amount !== challengeAmount + ) { + throw new Error( + `--amount must match the MPP challenge amount (${challengeAmount})`, + ); + } + if (!amount) { + throw new Error( + 'Could not determine amount from 402 challenge. Pass an amount explicitly.', + ); + } + let paymentMethodId = options.paymentMethodId; + if (!paymentMethodId) { + options.onStep?.('creating'); + const methods = await this.paymentMethods.list(); + const paymentMethod = methods[0]; + if (!paymentMethod) { + throw new Error('No Link payment methods found.'); + } + paymentMethodId = paymentMethod.id; + } + options.onStep?.('creating'); + return this.spendRequests.create({ + payment_details: paymentMethodId, + credential_type: 'shared_payment_token', + network_id: decoded.network_id, + amount, + currency: (decoded.request_json.currency as string) ?? 'usd', + context: options.context, + request_approval: true, + ...(options.test && { test: true }), + }); + } + + private async retrieveSharedPaymentToken( + spendRequestId: string, + includeInitialDelay: boolean, + ): Promise { + const delays = includeInitialDelay + ? SPT_RETRIEVAL_DELAYS_MS + : SPT_RETRIEVAL_DELAYS_MS.slice(1); + for (const delayMs of delays) { + if (delayMs > 0) await sleep(delayMs); + const request = await this.spendRequests.retrieve(spendRequestId, { + include: ['shared_payment_token'], + }); + if (!request) { + throw new Error(`Spend request ${spendRequestId} not found`); + } + if (request.shared_payment_token?.id) { + return request.shared_payment_token.id; + } + } + throw new Error('Failed to retrieve shared payment token'); + } + + private async submitPayment( + challenge: MppProbeResult, + sharedPaymentToken: string, + ): Promise { + const credentialResponse = new Response(null, { + status: challenge.response.status, + statusText: challenge.response.statusText, + headers: challenge.response.headers, + }); + const payment = + await this.createPaymentClient(sharedPaymentToken).preparePayment( + credentialResponse, + ); + const credential = await payment.createCredential(); + await challenge.response.body?.cancel(); + const response = await this.fetchImpl(challenge.url, { + ...payment.setCredential( + { + method: challenge.method, + headers: challenge.headers, + ...(challenge.body !== undefined && { body: challenge.body }), + }, + credential, + ), + redirect: 'manual', + }); + if (isRedirectResponse(response)) { + await response.body?.cancel(); + throw new Error( + `Paid MPP request returned redirect ${response.status}; refusing to forward the payment credential`, + ); + } + return this.readResult(response); + } + + private async readResult(response: Response): Promise { + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: await response.text(), + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e3fc777..4eb2e4e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: ink-spinner: specifier: ^5.0.0 version: 5.0.0(ink@7.1.1(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) - mppx: - specifier: 0.10.1 - version: 0.10.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(express@5.2.1)(hono@4.13.7)(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(viem@2.56.3(typescript@7.0.2)(zod@4.5.4)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -223,6 +220,9 @@ importers: packages/sdk: dependencies: + mppx: + specifier: 0.10.1 + version: 0.10.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(express@5.2.1)(hono@4.13.7)(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(viem@2.56.3(typescript@7.0.2)(zod@4.5.4)) zod: specifier: ^4.5.4 version: 4.5.4