diff --git a/CLAUDE.md b/CLAUDE.md index 2b01a5d..73fbbda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,10 @@ Key input field notes: ### mpp pay -- `mpp pay --spend-request-id [--method ] [--data ] [--header
]...` — completes the 402 flow: retrieves the spend request with `include: ['shared_payment_token']`, probes the URL, parses the `www-authenticate` stripe challenge, builds the `Authorization: Payment` credential, and retries. `--header` is repeatable and uses `"Name: Value"` format. `Content-Type: application/json` is auto-applied when `--data` is provided; user-provided headers take precedence. +- `mpp pay --spend-request-id [--method ] [--data ] [--header
]...` — completes the full payment flow, handling both bot-blocking and payment challenges automatically: + 1. Probes the URL. If 403 (bot-blocked), fetches Web Bot Auth headers via `WebBotAuthResource.getHeaders()` and retries the probe with `Signature` and `Signature-Input` headers attached. + 2. If the probe (or retried probe) returns 402, parses the `www-authenticate` stripe challenge, builds the `Authorization: Payment` credential, and retries with both the credential and any bot auth headers. +- `--header` is repeatable and uses `"Name: Value"` format. `Content-Type: application/json` is auto-applied when `--data` is provided; user-provided headers take precedence. - Requires an approved spend request with `credential_type: "shared_payment_token"`. The SPT is one-time-use — a failed payment requires a new spend request. - Implemented in `packages/cli/src/commands/mpp/` — pay.tsx (logic), schema.ts (input/output schema), index.tsx (incur registration). diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ad80a36..a64d67d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -82,7 +82,13 @@ cli.command( cli.command( createUserInfoCli(() => factory.createUserInfoResource(), authStorage), ); -cli.command(createMppCli(spendRequestRepo, authStorage)); +cli.command( + createMppCli( + spendRequestRepo, + factory.createWebBotAuthResource(), + authStorage, + ), +); cli.command( createWebBotAuthCli(() => factory.createWebBotAuthResource(), authStorage), ); @@ -91,6 +97,7 @@ cli.command( authRepo, spendRequestRepo, () => factory.createPaymentMethodsResource(), + () => factory.createWebBotAuthResource(), authStorage, ), ); @@ -99,6 +106,7 @@ cli.command( authRepo, spendRequestRepo, () => factory.createPaymentMethodsResource(), + () => factory.createWebBotAuthResource(), authStorage, ), ); diff --git a/packages/cli/src/commands/demo/demo-runner.tsx b/packages/cli/src/commands/demo/demo-runner.tsx index f9c2ed5..eb47022 100644 --- a/packages/cli/src/commands/demo/demo-runner.tsx +++ b/packages/cli/src/commands/demo/demo-runner.tsx @@ -2,6 +2,7 @@ import type { AuthStorage, IPaymentMethodsResource, ISpendRequestResource, + IWebBotAuthResource, } from '@stripe/link-sdk'; import { storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text, useApp, useInput } from 'ink'; @@ -29,6 +30,7 @@ interface DemoRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + webBotAuth: IWebBotAuthResource; authStorage?: AuthStorage; paymentMethodId?: string; onlyCard?: boolean; @@ -40,6 +42,7 @@ export const DemoRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + webBotAuth, authStorage = defaultStorage, paymentMethodId: preselectedPmId, onlyCard, @@ -189,6 +192,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 4900f4c..c9a3090 100644 --- a/packages/cli/src/commands/demo/index.tsx +++ b/packages/cli/src/commands/demo/index.tsx @@ -2,6 +2,7 @@ import type { AuthStorage, IPaymentMethodsResource, ISpendRequestResource, + IWebBotAuthResource, } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; @@ -24,6 +25,7 @@ export function createDemoCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + createWebBotAuthResource: () => IWebBotAuthResource, authStorage?: AuthStorage, ) { return Cli.create('demo', { @@ -40,12 +42,14 @@ export function createDemoCli( } const paymentMethodsResource = createPaymentMethodsResource(); + const webBotAuthResource = createWebBotAuthResource(); return renderInteractive( void; } @@ -44,6 +46,7 @@ interface SptFlowProps { export const SptFlow: React.FC = ({ spendRequestRepo, paymentMethodsResource, + webBotAuth, paymentMethodId: initialPaymentMethodId, onComplete, }) => { @@ -219,6 +222,7 @@ export const SptFlow: React.FC = ({ JSON.stringify({ amount: DEMO_SPT_AMOUNT }), undefined, spendRequestRepo, + webBotAuth, ); setPayResult(payResponse); setStep('done'); diff --git a/packages/cli/src/commands/mpp/__tests__/pay.test.ts b/packages/cli/src/commands/mpp/__tests__/pay.test.ts new file mode 100644 index 0000000..0968012 --- /dev/null +++ b/packages/cli/src/commands/mpp/__tests__/pay.test.ts @@ -0,0 +1,257 @@ +import type { + ISpendRequestResource, + IWebBotAuthResource, + WebBotAuthBlock, +} from '@stripe/link-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runMppPay } from '../pay'; + +const SPEND_REQUEST = { + id: 'sr_123', + status: 'approved', + credential_type: 'shared_payment_token', + shared_payment_token: { id: 'spt_abc' }, +}; + +const WWW_AUTHENTICATE_STRIPE = [ + 'Payment id="ch_001",', + 'realm="127.0.0.1",', + 'method="stripe",', + 'intent="charge",', + `request="${Buffer.from(JSON.stringify({ networkId: 'net_001', amount: '1000', currency: 'usd', decimals: 2, paymentMethodTypes: ['card'] })).toString('base64')}",`, + 'expires="2099-01-01T00:00:00Z"', +].join(' '); + +const WEB_BOT_AUTH_BLOCK: WebBotAuthBlock = { + signature: 'sig1=:stub_sig:', + signature_input: + 'sig1=("@authority" "signature-agent");created=1;keyid="k";alg="ed25519";expires=2;tag="web-bot-auth"', + signature_agent: + 'https://api.link.com/.well-known/http-message-signatures-directory', + authority: 'wine-merchant.com', + expires_at: '2099-12-31T23:59:59Z', +}; + +function makeRepository(sr = SPEND_REQUEST): ISpendRequestResource { + return { + getSpendRequest: vi.fn(async () => sr), + } as unknown as ISpendRequestResource; +} + +function makeWebBotAuth(block = WEB_BOT_AUTH_BLOCK): IWebBotAuthResource { + return { + getHeaders: vi.fn(async () => block), + } as unknown as IWebBotAuthResource; +} + +describe('runMppPay', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('includes WBA Signature headers on the initial probe', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const webBotAuth = makeWebBotAuth(); + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ); + + expect(result.status).toBe(200); + expect(webBotAuth.getHeaders).toHaveBeenCalledWith( + 'https://merchant.com/checkout', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, probeInit] = fetchMock.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(probeInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); + expect(probeInit.headers['Signature-Input']).toBe( + WEB_BOT_AUTH_BLOCK.signature_input, + ); + }); + + it('handles 402→SPT flow with WBA headers carried through to the SPT retry', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('payment required', { + status: 402, + headers: { 'www-authenticate': WWW_AUTHENTICATE_STRIPE }, + }), + ) + .mockResolvedValueOnce(new Response('payment accepted', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const webBotAuth = makeWebBotAuth(); + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Both probe and SPT retry must carry WBA headers + for (const call of fetchMock.mock.calls) { + const [, init] = call as [ + string, + RequestInit & { headers: Record }, + ]; + expect(init.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); + expect(init.headers['Signature-Input']).toBe( + WEB_BOT_AUTH_BLOCK.signature_input, + ); + } + + // SPT retry must also carry Authorization: Payment + const [, sptInit] = fetchMock.mock.calls[1] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(sptInit.headers.Authorization).toMatch(/^Payment /); + }); + + it('gracefully skips WBA headers when getHeaders throws', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const webBotAuth = { + getHeaders: vi.fn(async () => { + throw new Error('Not authenticated'); + }), + } as unknown as IWebBotAuthResource; + + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ); + + expect(result.status).toBe(200); + + const [, probeInit] = fetchMock.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(probeInit.headers.Signature).toBeUndefined(); + expect(probeInit.headers['Signature-Input']).toBeUndefined(); + }); + + it('gracefully skips WBA headers when getHeaders times out', async () => { + vi.useFakeTimers(); + + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const webBotAuth = { + getHeaders: vi.fn(() => new Promise(() => {})), + } as unknown as IWebBotAuthResource; + + const resultPromise = runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ); + + await vi.advanceTimersByTimeAsync(3001); + + const result = await resultPromise; + expect(result.status).toBe(200); + + const [, probeInit] = fetchMock.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(probeInit.headers.Signature).toBeUndefined(); + }); + + it('returns 403 as-is when merchant blocks even with WBA headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('forbidden', { status: 403 })), + ); + + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + makeWebBotAuth(), + ); + + expect(result.status).toBe(403); + }); + + it('throws when spend request is not found', async () => { + const repository = { + getSpendRequest: vi.fn(async () => null), + } as unknown as ISpendRequestResource; + + await expect( + runMppPay( + 'https://merchant.com/checkout', + 'sr_missing', + undefined, + undefined, + undefined, + repository, + makeWebBotAuth(), + ), + ).rejects.toThrow('sr_missing not found'); + }); + + it('throws when spend request is not approved', async () => { + const repository = makeRepository({ + ...SPEND_REQUEST, + status: 'pending', + }); + + await expect( + runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + repository, + makeWebBotAuth(), + ), + ).rejects.toThrow('approved'); + }); +}); diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index 50943fb..45a6b19 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -1,4 +1,8 @@ -import type { AuthStorage, ISpendRequestResource } from '@stripe/link-sdk'; +import type { + AuthStorage, + ISpendRequestResource, + IWebBotAuthResource, +} from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; import { renderInteractive } from '../../utils/render-interactive'; @@ -10,6 +14,7 @@ import { decodeOptions, payOptions } from './schema'; export function createMppCli( repository: ISpendRequestResource, + webBotAuth: IWebBotAuthResource, authStorage?: AuthStorage, ) { const cli = Cli.create('mpp', { @@ -43,6 +48,7 @@ export function createMppCli( data={data} headers={headers} repository={repository} + webBotAuth={webBotAuth} onComplete={(result) => { capturedResult = result; }} @@ -62,6 +68,7 @@ export function createMppCli( data, headers, repository, + webBotAuth, ); }, }); diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 47b3388..6ab790a 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -1,4 +1,7 @@ -import type { ISpendRequestResource } from '@stripe/link-sdk'; +import type { + ISpendRequestResource, + IWebBotAuthResource, +} from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import { Credential, Method } from 'mppx'; @@ -67,6 +70,35 @@ function createStripePaymentClient(spt: string) { }); } +const WBA_TIMEOUT_MS = 3_000; + +// Fetches Web Bot Auth headers with a timeout. Returns empty object on any +// failure so callers can proceed without bot-bypass rather than hard-failing. +async function tryGetBotAuthHeaders( + webBotAuth: IWebBotAuthResource, + url: string, +): Promise> { + try { + const block = await Promise.race([ + webBotAuth.getHeaders(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), WBA_TIMEOUT_MS), + ), + ]); + return { + Signature: block.signature, + 'Signature-Input': block.signature_input, + }; + } catch { + return {}; + } +} + +// NOTE: The multi-step payment flow (WBA prefetch → probe → SPT sign → retry) +// is implemented twice: once here for agent/format mode, and again inside the +// MppPay component below for interactive mode. They must be kept in sync. +// The right fix is to extract a shared flow that accepts progress callbacks, +// but that refactor belongs in a separate PR. export async function runMppPay( url: string, spendRequestId: string, @@ -74,6 +106,7 @@ export async function runMppPay( data: string | undefined, headers: string[] | undefined, repository: ISpendRequestResource, + webBotAuth: IWebBotAuthResource, ): Promise { // 1. Retrieve the approved spend request with SPT const spendRequest = await repository.getSpendRequest(spendRequestId, { @@ -103,28 +136,32 @@ export async function runMppPay( const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); const requestHeaders = buildHeaders(data, headers); - // 3. Make the initial request - const initialResponse = await fetch(url, { + // 3. Fetch Web Bot Auth headers proactively (gracefully skipped on failure/timeout) + const botAuthHeaders = await tryGetBotAuthHeaders(webBotAuth, url); + + // 4. Probe the URL with WBA headers included + const probeResponse = await fetch(url, { method: httpMethod, body: data, - headers: requestHeaders, + headers: { ...requestHeaders, ...botAuthHeaders }, }); - // 4. If not 402, return as-is - if (initialResponse.status !== 402) { - return readPayResult(initialResponse); + // 5. If not 402, return as-is + if (probeResponse.status !== 402) { + return readPayResult(probeResponse); } - // 5. Select the Stripe challenge and build the payment credential + // 6. Sign the 402 challenge with SPT const authHeader = - await createStripePaymentClient(spt).createCredential(initialResponse); + await createStripePaymentClient(spt).createCredential(probeResponse); - // 7. Retry with Authorization header + // 7. Retry with SPT credential (WBA headers carried through) const retryResponse = await fetch(url, { method: httpMethod, body: data, headers: { ...requestHeaders, + ...botAuthHeaders, Authorization: authHeader, }, }); @@ -141,6 +178,7 @@ export function MppPay({ data, headers, repository, + webBotAuth, onComplete, }: { url: string; @@ -149,6 +187,7 @@ export function MppPay({ data?: string; headers?: string[]; repository: ISpendRequestResource; + webBotAuth: IWebBotAuthResource; onComplete: (result: PayResult | null) => void; }) { const [step, setStep] = useState('retrieving'); @@ -186,14 +225,15 @@ export function MppPay({ const requestHeaders = buildHeaders(data, headers); setStep('probing'); - const initialResponse = await fetch(url, { + const botAuthHeaders = await tryGetBotAuthHeaders(webBotAuth, url); + const probeResponse = await fetch(url, { method: httpMethod, body: data, - headers: requestHeaders, + headers: { ...requestHeaders, ...botAuthHeaders }, }); - if (initialResponse.status !== 402) { - const payResult = await readPayResult(initialResponse); + if (probeResponse.status !== 402) { + const payResult = await readPayResult(probeResponse); setResult(payResult); setStep('done'); onComplete(payResult); @@ -202,9 +242,7 @@ export function MppPay({ setStep('signing'); const authHeader = - await createStripePaymentClient(spt).createCredential( - initialResponse, - ); + await createStripePaymentClient(spt).createCredential(probeResponse); setStep('submitting'); const retryResponse = await fetch(url, { @@ -212,6 +250,7 @@ export function MppPay({ body: data, headers: { ...requestHeaders, + ...botAuthHeaders, Authorization: authHeader, }, }); @@ -225,7 +264,16 @@ export function MppPay({ onComplete(null); } })(); - }, [url, spendRequestId, method, data, headers, repository, onComplete]); + }, [ + url, + spendRequestId, + method, + data, + headers, + repository, + webBotAuth, + onComplete, + ]); const stepLabels: Record = { retrieving: 'Retrieving spend request', diff --git a/packages/cli/src/commands/onboard/index.tsx b/packages/cli/src/commands/onboard/index.tsx index bebb87f..51b0c81 100644 --- a/packages/cli/src/commands/onboard/index.tsx +++ b/packages/cli/src/commands/onboard/index.tsx @@ -2,6 +2,7 @@ import type { AuthStorage, IPaymentMethodsResource, ISpendRequestResource, + IWebBotAuthResource, } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; @@ -13,6 +14,7 @@ export function createOnboardCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + createWebBotAuthResource: () => IWebBotAuthResource, authStorage?: AuthStorage, ) { return Cli.create('onboard', { @@ -28,12 +30,14 @@ export function createOnboardCli( } const paymentMethodsResource = createPaymentMethodsResource(); + const webBotAuthResource = createWebBotAuthResource(); return renderInteractive( {}} />, diff --git a/packages/cli/src/commands/onboard/onboard-runner.tsx b/packages/cli/src/commands/onboard/onboard-runner.tsx index e4c92c8..8226363 100644 --- a/packages/cli/src/commands/onboard/onboard-runner.tsx +++ b/packages/cli/src/commands/onboard/onboard-runner.tsx @@ -2,6 +2,7 @@ import type { AuthStorage, IPaymentMethodsResource, ISpendRequestResource, + IWebBotAuthResource, } from '@stripe/link-sdk'; import { storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text, useApp, useInput } from 'ink'; @@ -18,6 +19,7 @@ interface OnboardRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + webBotAuth: IWebBotAuthResource; authStorage?: AuthStorage; onComplete: () => void; } @@ -26,6 +28,7 @@ export const OnboardRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + webBotAuth, authStorage = defaultStorage, onComplete, }) => { @@ -170,6 +173,7 @@ export const OnboardRunner: React.FC = ({ authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + webBotAuth={webBotAuth} authStorage={storage} onComplete={onComplete} />