Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ Key input field notes:

### mpp pay

- `mpp pay <url> --spend-request-id <id> [--method <method>] [--data <body>] [--header <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 <url> --spend-request-id <id> [--method <method>] [--data <body>] [--header <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).

Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand All @@ -91,6 +97,7 @@ cli.command(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
() => factory.createWebBotAuthResource(),
authStorage,
),
);
Expand All @@ -99,6 +106,7 @@ cli.command(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
() => factory.createWebBotAuthResource(),
authStorage,
),
);
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/demo/demo-runner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -29,6 +30,7 @@ interface DemoRunnerProps {
authRepo: IAuthResource;
spendRequestRepo: ISpendRequestResource;
paymentMethodsResource: IPaymentMethodsResource;
webBotAuth: IWebBotAuthResource;
authStorage?: AuthStorage;
paymentMethodId?: string;
onlyCard?: boolean;
Expand All @@ -40,6 +42,7 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
authRepo,
spendRequestRepo,
paymentMethodsResource,
webBotAuth,
authStorage = defaultStorage,
paymentMethodId: preselectedPmId,
onlyCard,
Expand Down Expand Up @@ -189,6 +192,7 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
<SptFlow
spendRequestRepo={spendRequestRepo}
paymentMethodsResource={paymentMethodsResource}
webBotAuth={webBotAuth}
paymentMethodId={paymentMethodId || undefined}
onComplete={onSptComplete}
/>
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AuthStorage,
IPaymentMethodsResource,
ISpendRequestResource,
IWebBotAuthResource,
} from '@stripe/link-sdk';
import { Cli, z } from 'incur';
import React from 'react';
Expand All @@ -24,6 +25,7 @@ export function createDemoCli(
authRepo: IAuthResource,
spendRequestRepo: ISpendRequestResource,
createPaymentMethodsResource: () => IPaymentMethodsResource,
createWebBotAuthResource: () => IWebBotAuthResource,
authStorage?: AuthStorage,
) {
return Cli.create('demo', {
Expand All @@ -40,12 +42,14 @@ export function createDemoCli(
}

const paymentMethodsResource = createPaymentMethodsResource();
const webBotAuthResource = createWebBotAuthResource();

return renderInteractive(
<DemoRunner
authRepo={authRepo}
spendRequestRepo={spendRequestRepo}
paymentMethodsResource={paymentMethodsResource}
webBotAuth={webBotAuthResource}
authStorage={authStorage}
onlyCard={c.options.onlyCard}
onlySpt={c.options.onlySpt}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/demo/spt-flow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
IPaymentMethodsResource,
ISpendRequestResource,
IWebBotAuthResource,
PaymentMethod,
SpendRequest,
} from '@stripe/link-sdk';
Expand Down Expand Up @@ -37,13 +38,15 @@ type Step =
interface SptFlowProps {
spendRequestRepo: ISpendRequestResource;
paymentMethodsResource: IPaymentMethodsResource;
webBotAuth: IWebBotAuthResource;
paymentMethodId?: string;
onComplete: (success: boolean) => void;
}

export const SptFlow: React.FC<SptFlowProps> = ({
spendRequestRepo,
paymentMethodsResource,
webBotAuth,
paymentMethodId: initialPaymentMethodId,
onComplete,
}) => {
Expand Down Expand Up @@ -219,6 +222,7 @@ export const SptFlow: React.FC<SptFlowProps> = ({
JSON.stringify({ amount: DEMO_SPT_AMOUNT }),
undefined,
spendRequestRepo,
webBotAuth,
);
setPayResult(payResponse);
setStep('done');
Expand Down
257 changes: 257 additions & 0 deletions packages/cli/src/commands/mpp/__tests__/pay.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> },
];
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<string, string> },
];
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<string, string> },
];
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<string, string> },
];
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<string, string> },
];
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');
});
});
Loading
Loading