From 69b069556186e4b7582d534519cff74716a6222f Mon Sep 17 00:00:00 2001 From: nvp Date: Thu, 21 May 2026 01:07:38 +0000 Subject: [PATCH 1/3] feat(mpp): auto-sign requests when merchant returns 403 (Web Bot Auth bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `mpp pay` receives a 403, it now calls `webBotAuth.getHeaders()`, attaches `Signature` + `Signature-Input` headers, and retries the probe. If the retry returns 402, the SPT flow continues with both header sets carried through to the final payment request. - `runMppPay` and `MppPay` component both gain the `webBotAuth` param - New `bypassing` step label for interactive progress display - 7-test unit suite for `runMppPay` including the full 403→402→SPT flow - CLAUDE.md updated with the 2-step auto-retry behavior Committed-By-Agent: claude --- CLAUDE.md | 5 +- packages/cli/src/cli.tsx | 10 +- .../cli/src/commands/demo/demo-runner.tsx | 4 + packages/cli/src/commands/demo/index.tsx | 4 + packages/cli/src/commands/demo/spt-flow.tsx | 4 + .../src/commands/mpp/__tests__/pay.test.ts | 247 ++++++++++++++++++ packages/cli/src/commands/mpp/index.tsx | 9 +- packages/cli/src/commands/mpp/pay.tsx | 84 ++++-- packages/cli/src/commands/onboard/index.tsx | 4 + .../src/commands/onboard/onboard-runner.tsx | 4 + 10 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/commands/mpp/__tests__/pay.test.ts 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..175ea1a --- /dev/null +++ b/packages/cli/src/commands/mpp/__tests__/pay.test.ts @@ -0,0 +1,247 @@ +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(); + }); + + it('returns result directly when merchant responds 200', 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(result.body).toBe('ok'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(webBotAuth.getHeaders).not.toHaveBeenCalled(); + }); + + it('does not call webBotAuth for non-403 error responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('not found', { status: 404 })), + ); + + const webBotAuth = makeWebBotAuth(); + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ); + + expect(result.status).toBe(404); + expect(webBotAuth.getHeaders).not.toHaveBeenCalled(); + }); + + it('retries with Signature headers when merchant returns 403', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) + .mockResolvedValueOnce(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(2); + + const [, retryInit] = fetchMock.mock.calls[1] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(retryInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); + expect(retryInit.headers['Signature-Input']).toBe( + WEB_BOT_AUTH_BLOCK.signature_input, + ); + }); + + it('handles full 403→402→SPT flow: bot auth headers carry through to the SPT retry', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) + .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(3); + expect(webBotAuth.getHeaders).toHaveBeenCalledWith( + 'https://merchant.com/checkout', + ); + + // Second call (bot auth retry) must have Signature headers + const [, botAuthInit] = fetchMock.mock.calls[1] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(botAuthInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); + expect(botAuthInit.headers['Signature-Input']).toBe( + WEB_BOT_AUTH_BLOCK.signature_input, + ); + + // Third call (SPT retry) must carry BOTH bot auth headers AND Authorization: Payment + const [, sptInit] = fetchMock.mock.calls[2] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(sptInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); + expect(sptInit.headers['Signature-Input']).toBe( + WEB_BOT_AUTH_BLOCK.signature_input, + ); + expect(sptInit.headers.Authorization).toMatch(/^Payment /); + }); + + it('propagates error when webBotAuth.getHeaders throws', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('bot blocked', { status: 403 })), + ); + + const webBotAuth = { + getHeaders: vi.fn(async () => { + throw new Error('Not authenticated'); + }), + } as unknown as IWebBotAuthResource; + + await expect( + runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + webBotAuth, + ), + ).rejects.toThrow('Not authenticated'); + }); + + 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..cabe46a 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'; @@ -74,6 +77,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 +107,45 @@ export async function runMppPay( const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); const requestHeaders = buildHeaders(data, headers); - // 3. Make the initial request + // 3. Make the initial probe const initialResponse = await fetch(url, { method: httpMethod, body: data, headers: requestHeaders, }); - // 4. If not 402, return as-is - if (initialResponse.status !== 402) { - return readPayResult(initialResponse); + // 4. If 403, fetch Web Bot Auth headers and retry the probe + let probeResponse = initialResponse; + let botAuthHeaders: Record = {}; + if (initialResponse.status === 403) { + const block = await webBotAuth.getHeaders(url); + botAuthHeaders = { + Signature: block.signature, + 'Signature-Input': block.signature_input, + }; + probeResponse = await fetch(url, { + method: httpMethod, + body: data, + headers: { ...requestHeaders, ...botAuthHeaders }, + }); } - // 5. Select the Stripe challenge and build the payment credential + // 5. If not 402, return as-is + if (probeResponse.status !== 402) { + return readPayResult(probeResponse); + } + + // 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 (and bot auth headers if applicable) const retryResponse = await fetch(url, { method: httpMethod, body: data, headers: { ...requestHeaders, + ...botAuthHeaders, Authorization: authHeader, }, }); @@ -132,7 +153,13 @@ export async function runMppPay( return readPayResult(retryResponse); } -type Step = 'retrieving' | 'probing' | 'signing' | 'submitting' | 'done'; +type Step = + | 'retrieving' + | 'probing' + | 'bypassing' + | 'signing' + | 'submitting' + | 'done'; export function MppPay({ url, @@ -141,6 +168,7 @@ export function MppPay({ data, headers, repository, + webBotAuth, onComplete, }: { url: string; @@ -149,6 +177,7 @@ export function MppPay({ data?: string; headers?: string[]; repository: ISpendRequestResource; + webBotAuth: IWebBotAuthResource; onComplete: (result: PayResult | null) => void; }) { const [step, setStep] = useState('retrieving'); @@ -192,8 +221,24 @@ export function MppPay({ headers: requestHeaders, }); - if (initialResponse.status !== 402) { - const payResult = await readPayResult(initialResponse); + let probeResponse = initialResponse; + let botAuthHeaders: Record = {}; + if (initialResponse.status === 403) { + setStep('bypassing'); + const block = await webBotAuth.getHeaders(url); + botAuthHeaders = { + Signature: block.signature, + 'Signature-Input': block.signature_input, + }; + probeResponse = await fetch(url, { + method: httpMethod, + body: data, + headers: { ...requestHeaders, ...botAuthHeaders }, + }); + } + + if (probeResponse.status !== 402) { + const payResult = await readPayResult(probeResponse); setResult(payResult); setStep('done'); onComplete(payResult); @@ -202,9 +247,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 +255,7 @@ export function MppPay({ body: data, headers: { ...requestHeaders, + ...botAuthHeaders, Authorization: authHeader, }, }); @@ -225,11 +269,21 @@ 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', probing: 'Probing URL', + bypassing: 'Bypassing bot protection', signing: 'Signing credential', submitting: 'Submitting payment', done: 'Done', 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} /> From d0c062ddcb343a00b3a20d1618947bc244deb500 Mon Sep 17 00:00:00 2001 From: nvp Date: Thu, 21 May 2026 18:29:15 +0000 Subject: [PATCH 2/3] address 403 + test --- .../src/commands/mpp/__tests__/pay.test.ts | 22 +++++++++++++++++++ packages/cli/src/commands/mpp/pay.tsx | 17 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/cli/src/commands/mpp/__tests__/pay.test.ts b/packages/cli/src/commands/mpp/__tests__/pay.test.ts index 175ea1a..0f67484 100644 --- a/packages/cli/src/commands/mpp/__tests__/pay.test.ts +++ b/packages/cli/src/commands/mpp/__tests__/pay.test.ts @@ -183,6 +183,28 @@ describe('runMppPay', () => { expect(sptInit.headers.Authorization).toMatch(/^Payment /); }); + it('throws when WBA retry still returns 403', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) + .mockResolvedValueOnce(new Response('still blocked', { status: 403 })), + ); + + await expect( + runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + makeWebBotAuth(), + ), + ).rejects.toThrow('unrelated to bot protection'); + }); + it('propagates error when webBotAuth.getHeaders throws', async () => { vi.stubGlobal( 'fetch', diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index cabe46a..7778f44 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -70,6 +70,11 @@ function createStripePaymentClient(spt: string) { }); } +// NOTE: The multi-step payment flow (probe → WBA bypass → 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, @@ -128,6 +133,12 @@ export async function runMppPay( body: data, headers: { ...requestHeaders, ...botAuthHeaders }, }); + if (probeResponse.status === 403) { + throw new Error( + 'Received 403 before and after Web Bot Auth retry. ' + + 'The merchant is returning 403 for a reason unrelated to bot protection.', + ); + } } // 5. If not 402, return as-is @@ -235,6 +246,12 @@ export function MppPay({ body: data, headers: { ...requestHeaders, ...botAuthHeaders }, }); + if (probeResponse.status === 403) { + throw new Error( + 'Received 403 before and after Web Bot Auth retry. ' + + 'The merchant is returning 403 for a reason unrelated to bot protection.', + ); + } } if (probeResponse.status !== 402) { From c9dc5a1e1a9a6cce406389c43b64bb04b1047b2d Mon Sep 17 00:00:00 2001 From: nvp Date: Fri, 22 May 2026 01:36:04 +0000 Subject: [PATCH 3/3] code review changes --- .../src/commands/mpp/__tests__/pay.test.ts | 176 ++++++++---------- packages/cli/src/commands/mpp/pay.tsx | 97 ++++------ 2 files changed, 119 insertions(+), 154 deletions(-) diff --git a/packages/cli/src/commands/mpp/__tests__/pay.test.ts b/packages/cli/src/commands/mpp/__tests__/pay.test.ts index 0f67484..0968012 100644 --- a/packages/cli/src/commands/mpp/__tests__/pay.test.ts +++ b/packages/cli/src/commands/mpp/__tests__/pay.test.ts @@ -51,9 +51,10 @@ describe('runMppPay', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); - it('returns result directly when merchant responds 200', async () => { + it('includes WBA Signature headers on the initial probe', async () => { const fetchMock = vi .fn() .mockResolvedValue(new Response('ok', { status: 200 })); @@ -71,16 +72,32 @@ describe('runMppPay', () => { ); expect(result.status).toBe(200); - expect(result.body).toBe('ok'); + expect(webBotAuth.getHeaders).toHaveBeenCalledWith( + 'https://merchant.com/checkout', + ); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(webBotAuth.getHeaders).not.toHaveBeenCalled(); - }); - it('does not call webBotAuth for non-403 error responses', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(new Response('not found', { status: 404 })), + 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( @@ -93,18 +110,41 @@ describe('runMppPay', () => { webBotAuth, ); - expect(result.status).toBe(404); - expect(webBotAuth.getHeaders).not.toHaveBeenCalled(); + 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('retries with Signature headers when merchant returns 403', async () => { + it('gracefully skips WBA headers when getHeaders throws', async () => { const fetchMock = vi .fn() - .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('ok', { status: 200 })); + .mockResolvedValue(new Response('ok', { status: 200 })); vi.stubGlobal('fetch', fetchMock); - const webBotAuth = makeWebBotAuth(); + 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', @@ -116,36 +156,28 @@ describe('runMppPay', () => { ); expect(result.status).toBe(200); - expect(webBotAuth.getHeaders).toHaveBeenCalledWith( - 'https://merchant.com/checkout', - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [, retryInit] = fetchMock.mock.calls[1] as [ + const [, probeInit] = fetchMock.mock.calls[0] as [ string, RequestInit & { headers: Record }, ]; - expect(retryInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); - expect(retryInit.headers['Signature-Input']).toBe( - WEB_BOT_AUTH_BLOCK.signature_input, - ); + expect(probeInit.headers.Signature).toBeUndefined(); + expect(probeInit.headers['Signature-Input']).toBeUndefined(); }); - it('handles full 403→402→SPT flow: bot auth headers carry through to the SPT retry', async () => { + it('gracefully skips WBA headers when getHeaders times out', async () => { + vi.useFakeTimers(); + const fetchMock = vi .fn() - .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response('payment required', { - status: 402, - headers: { 'www-authenticate': WWW_AUTHENTICATE_STRIPE }, - }), - ) - .mockResolvedValueOnce(new Response('payment accepted', { status: 200 })); + .mockResolvedValue(new Response('ok', { status: 200 })); vi.stubGlobal('fetch', fetchMock); - const webBotAuth = makeWebBotAuth(); - const result = await runMppPay( + const webBotAuth = { + getHeaders: vi.fn(() => new Promise(() => {})), + } as unknown as IWebBotAuthResource; + + const resultPromise = runMppPay( 'https://merchant.com/checkout', 'sr_123', undefined, @@ -155,79 +187,35 @@ describe('runMppPay', () => { webBotAuth, ); - expect(result.status).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(webBotAuth.getHeaders).toHaveBeenCalledWith( - 'https://merchant.com/checkout', - ); + await vi.advanceTimersByTimeAsync(3001); - // Second call (bot auth retry) must have Signature headers - const [, botAuthInit] = fetchMock.mock.calls[1] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(botAuthInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); - expect(botAuthInit.headers['Signature-Input']).toBe( - WEB_BOT_AUTH_BLOCK.signature_input, - ); + const result = await resultPromise; + expect(result.status).toBe(200); - // Third call (SPT retry) must carry BOTH bot auth headers AND Authorization: Payment - const [, sptInit] = fetchMock.mock.calls[2] as [ + const [, probeInit] = fetchMock.mock.calls[0] as [ string, RequestInit & { headers: Record }, ]; - expect(sptInit.headers.Signature).toBe(WEB_BOT_AUTH_BLOCK.signature); - expect(sptInit.headers['Signature-Input']).toBe( - WEB_BOT_AUTH_BLOCK.signature_input, - ); - expect(sptInit.headers.Authorization).toMatch(/^Payment /); + expect(probeInit.headers.Signature).toBeUndefined(); }); - it('throws when WBA retry still returns 403', async () => { + it('returns 403 as-is when merchant blocks even with WBA headers', async () => { vi.stubGlobal( 'fetch', - vi - .fn() - .mockResolvedValueOnce(new Response('bot blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('still blocked', { status: 403 })), + vi.fn().mockResolvedValue(new Response('forbidden', { status: 403 })), ); - await expect( - runMppPay( - 'https://merchant.com/checkout', - 'sr_123', - undefined, - undefined, - undefined, - makeRepository(), - makeWebBotAuth(), - ), - ).rejects.toThrow('unrelated to bot protection'); - }); - - it('propagates error when webBotAuth.getHeaders throws', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(new Response('bot blocked', { status: 403 })), + const result = await runMppPay( + 'https://merchant.com/checkout', + 'sr_123', + undefined, + undefined, + undefined, + makeRepository(), + makeWebBotAuth(), ); - const webBotAuth = { - getHeaders: vi.fn(async () => { - throw new Error('Not authenticated'); - }), - } as unknown as IWebBotAuthResource; - - await expect( - runMppPay( - 'https://merchant.com/checkout', - 'sr_123', - undefined, - undefined, - undefined, - makeRepository(), - webBotAuth, - ), - ).rejects.toThrow('Not authenticated'); + expect(result.status).toBe(403); }); it('throws when spend request is not found', async () => { diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 7778f44..6ab790a 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -70,8 +70,32 @@ function createStripePaymentClient(spt: string) { }); } -// NOTE: The multi-step payment flow (probe → WBA bypass → SPT sign → retry) is -// implemented twice: once here for agent/format mode, and again inside the +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. @@ -112,35 +136,16 @@ export async function runMppPay( const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); const requestHeaders = buildHeaders(data, headers); - // 3. Make the initial probe - 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 403, fetch Web Bot Auth headers and retry the probe - let probeResponse = initialResponse; - let botAuthHeaders: Record = {}; - if (initialResponse.status === 403) { - const block = await webBotAuth.getHeaders(url); - botAuthHeaders = { - Signature: block.signature, - 'Signature-Input': block.signature_input, - }; - probeResponse = await fetch(url, { - method: httpMethod, - body: data, - headers: { ...requestHeaders, ...botAuthHeaders }, - }); - if (probeResponse.status === 403) { - throw new Error( - 'Received 403 before and after Web Bot Auth retry. ' + - 'The merchant is returning 403 for a reason unrelated to bot protection.', - ); - } - } - // 5. If not 402, return as-is if (probeResponse.status !== 402) { return readPayResult(probeResponse); @@ -150,7 +155,7 @@ export async function runMppPay( const authHeader = await createStripePaymentClient(spt).createCredential(probeResponse); - // 7. Retry with SPT credential (and bot auth headers if applicable) + // 7. Retry with SPT credential (WBA headers carried through) const retryResponse = await fetch(url, { method: httpMethod, body: data, @@ -164,13 +169,7 @@ export async function runMppPay( return readPayResult(retryResponse); } -type Step = - | 'retrieving' - | 'probing' - | 'bypassing' - | 'signing' - | 'submitting' - | 'done'; +type Step = 'retrieving' | 'probing' | 'signing' | 'submitting' | 'done'; export function MppPay({ url, @@ -226,34 +225,13 @@ 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 }, }); - let probeResponse = initialResponse; - let botAuthHeaders: Record = {}; - if (initialResponse.status === 403) { - setStep('bypassing'); - const block = await webBotAuth.getHeaders(url); - botAuthHeaders = { - Signature: block.signature, - 'Signature-Input': block.signature_input, - }; - probeResponse = await fetch(url, { - method: httpMethod, - body: data, - headers: { ...requestHeaders, ...botAuthHeaders }, - }); - if (probeResponse.status === 403) { - throw new Error( - 'Received 403 before and after Web Bot Auth retry. ' + - 'The merchant is returning 403 for a reason unrelated to bot protection.', - ); - } - } - if (probeResponse.status !== 402) { const payResult = await readPayResult(probeResponse); setResult(payResult); @@ -300,7 +278,6 @@ export function MppPay({ const stepLabels: Record = { retrieving: 'Retrieving spend request', probing: 'Probing URL', - bypassing: 'Bypassing bot protection', signing: 'Signing credential', submitting: 'Submitting payment', done: 'Done',