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
6 changes: 6 additions & 0 deletions .changeset/calm-dodos-pay.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
};
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 () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ cli.command(
);
cli.command(
createMppCli(
factory.createMppResource(),
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
authStorage,
envAccessToken,
),
Expand Down Expand Up @@ -191,6 +191,7 @@ cli.command(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
factory.createMppResource(),
authStorage,
),
);
Expand All @@ -199,6 +200,7 @@ cli.command(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
factory.createMppResource(),
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
@@ -1,4 +1,5 @@
import type {
IMppResource,
IPaymentMethodsResource,
ISpendRequestResource,
} from '@stripe/link-sdk';
Expand Down Expand Up @@ -31,6 +32,7 @@ interface DemoRunnerProps {
authRepo: IAuthResource;
spendRequestRepo: ISpendRequestResource;
paymentMethodsResource: IPaymentMethodsResource;
mpp: IMppResource;
authStorage?: CliAuthStorage;
paymentMethodId?: string;
onlyCard?: boolean;
Expand All @@ -42,6 +44,7 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
authRepo,
spendRequestRepo,
paymentMethodsResource,
mpp,
authStorage = defaultStorage,
paymentMethodId: preselectedPmId,
onlyCard,
Expand Down Expand Up @@ -191,6 +194,7 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
<SptFlow
spendRequestRepo={spendRequestRepo}
paymentMethodsResource={paymentMethodsResource}
mpp={mpp}
paymentMethodId={paymentMethodId || undefined}
onComplete={onSptComplete}
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
IMppResource,
IPaymentMethodsResource,
ISpendRequestResource,
} from '@stripe/link-sdk';
Expand All @@ -23,6 +24,7 @@ export function createDemoCli(
authRepo: IAuthResource,
spendRequestRepo: ISpendRequestResource,
createPaymentMethodsResource: () => IPaymentMethodsResource,
mpp: IMppResource,
authStorage?: CliAuthStorage,
) {
return Cli.create('demo', {
Expand All @@ -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}
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/demo/spt-flow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
IMppResource,
IPaymentMethodsResource,
ISpendRequestResource,
PaymentMethod,
Expand All @@ -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,
Expand All @@ -37,13 +37,15 @@ type Step =
interface SptFlowProps {
spendRequestRepo: ISpendRequestResource;
paymentMethodsResource: IPaymentMethodsResource;
mpp: IMppResource;
paymentMethodId?: string;
onComplete: (success: boolean) => void;
}

export const SptFlow: React.FC<SptFlowProps> = ({
spendRequestRepo,
paymentMethodsResource,
mpp,
paymentMethodId: initialPaymentMethodId,
onComplete,
}) => {
Expand Down Expand Up @@ -162,7 +164,10 @@ export const SptFlow: React.FC<SptFlowProps> = ({
}

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');
Expand Down Expand Up @@ -218,7 +223,7 @@ export const SptFlow: React.FC<SptFlowProps> = ({
'POST',
JSON.stringify({ amount: DEMO_SPT_AMOUNT }),
undefined,
spendRequestRepo,
mpp,
);
setPayResult(payResponse);
setStep('done');
Expand Down
39 changes: 15 additions & 24 deletions packages/cli/src/commands/mpp/decode-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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(<DecodeChallengeView decoded={decoded} />);

const frame = lastFrame() ?? '';
Expand Down
44 changes: 28 additions & 16 deletions packages/cli/src/commands/mpp/decode-view.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column">
<Text color="green">✓ Stripe challenge decoded</Text>
<Box flexDirection="column" marginTop={1} paddingX={2}>
<Text>
ID: <Text bold>{decoded.id}</Text>
</Text>
<Text>
Realm: <Text bold>{decoded.realm}</Text>
</Text>
<Text>
Network ID: <Text bold>{decoded.network_id}</Text>
</Text>
<Text>Request JSON:</Text>
<Text>{JSON.stringify(decoded.request_json, null, 2)}</Text>
</Box>
<Text color="green">
✓ {decoded.length} supported challenge(s) decoded
</Text>
{decoded.map((challenge) => (
<Box
key={`${challenge.method}:${challenge.id}`}
flexDirection="column"
marginTop={1}
paddingX={2}
>
<Text>
Method: <Text bold>{challenge.method}</Text>
</Text>
<Text>
ID: <Text bold>{challenge.id}</Text>
</Text>
<Text>
Realm: <Text bold>{challenge.realm}</Text>
</Text>
<Text>
Network ID: <Text bold>{challenge.network_id}</Text>
</Text>
<Text>Request JSON:</Text>
<Text>{JSON.stringify(challenge.request_json, null, 2)}</Text>
</Box>
))}
</Box>
);
}
Loading
Loading