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/quiet-payment-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@inflowpayai/mpp-seller': patch
---

Reject Stripe and InFlow instrument payment receipts that omit the challenge identifier or refer to a different payment
method or challenge.
4 changes: 2 additions & 2 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
run: |
mkdir -p "$RUNNER_TEMP/conformance"
result=0
for suite in runtime mpp x402; do
for suite in runtime mpp stripe x402; do
node scripts/conformance.mjs --suite "$suite" --adapter-node "$ADAPTER_NODE" \
--contract-root ../contract-pinned \
--output "$RUNNER_TEMP/conformance/pinned-$suite.json" || result=1
Expand All @@ -108,7 +108,7 @@ jobs:
mkdir -p "$RUNNER_TEMP/conformance"
revision=$(git -C ../contract-current rev-parse HEAD)
result=0
for suite in runtime mpp x402; do
for suite in runtime mpp stripe x402; do
node scripts/conformance.mjs --suite "$suite" --adapter-node "$ADAPTER_NODE" \
--contract-root ../contract-current --contract-revision "$revision" \
--output "$RUNNER_TEMP/conformance/current-$suite.json" || result=1
Expand Down
20 changes: 17 additions & 3 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ platform are used.
`pnpm mpp:conformance` remains the separate upstream MPP protocol-vector command. It does not run these InFlow payment
workflows. Neither conformance runner is a dependency of the published SDKs.

## Stripe Seller

```sh
pnpm stripe:conformance:shared --contract-root ../inflow-specs --output /tmp/inflow-stripe-report.json
```

This runs the separate Stripe charge corpus through the public `stripe(...)` factory and the same MPP adapter. Offer
preparation uses the foundation's challenge generator, so decimal-dollar inputs pass through the SDK checks and become
integer cents on the wire. Route-binding cases use the real protected-route handler. Verification uses the
framework-composed validation and broadcast hooks; the adapter does not implement their sequence. The local platform
checks authenticated configuration, credential forwarding, rejection without settlement, receipt binding and retry keys.
These tests do not create Stripe tokens or perform live payments. The `card/charge` method is separate.

## x402 Core, Buyer, and Seller

```sh
Expand Down Expand Up @@ -89,14 +102,15 @@ middleware, or sponsorship execution.
## Hosted reports and contract drift

The **shared conformance** workflow runs on pull requests, pushes to `main`, and manual dispatch. Each Node 22/24 and
locked/latest foundation combination runs all three suites against both the pinned contract and the current
locked/latest foundation combination runs all four suites against both the pinned contract and the current
`inflow-specs` main commit. A failure in one suite does not prevent the other suites from producing reports; any failure
still fails the job. The current-contract step runs even if the pinned cases fail.

Open the workflow run's **Artifacts** section and download `conformance-node22-locked`, `conformance-node22-latest`,
`conformance-node24-locked`, or `conformance-node24-latest`. Each artifact contains `pinned-*.json` and `current-*.json`
reports for runtime, MPP, and x402, retained for 14 days. Failed runs also upload available reports. Check `completed`
and `passed`; an empty or incomplete report is not passing evidence. Installation/build failures may prevent reports.
reports for runtime, MPP, Stripe, and x402, retained for 14 days. Failed runs also upload available reports. Check
`completed` and `passed`; an empty or incomplete report is not passing evidence. Installation/build failures may prevent
reports.

The contract runner uses Node 24; `--adapter-node` selects the executable that runs the SDK adapter. Reported
`implementation.runtime` is that adapter's actual Node version. The latest-dependency jobs intentionally modify
Expand Down
2 changes: 1 addition & 1 deletion conformance/inflow-specs.lock.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"repository": "inflowpayai/inflow-specs",
"revision": "54689b7c93c07f259ed493897637fa33a7cfade2"
"revision": "0bf31dff396f139f574f07d9353415696760ec0c"
}
32 changes: 28 additions & 4 deletions conformance/mpp-shared-adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as core from '../packages/mpp/dist/index.js';
import * as buyer from '../packages/mpp-buyer/dist/index.js';
import * as seller from '../packages/mpp-seller/dist/index.js';

const { Credential } = await import(
const { Credential, Errors } = await import(
createRequire(new URL('../packages/mpp-seller/package.json', import.meta.url)).resolve('mppx')
);

Expand Down Expand Up @@ -42,6 +42,12 @@ export function classify(error, operation, input = {}) {
} else if (error instanceof core.MppCodecError) {
code = operation === 'mpp.core.decode-credential' ? 'invalid-credential' : 'invalid-input';
message = code === 'invalid-credential' ? 'Invalid credential.' : 'Invalid input.';
} else if (error instanceof seller.MppStripeAmountError || error instanceof seller.MppStripeRequestError) {
code = 'invalid-input';
message = 'Invalid input.';
} else if (error instanceof Errors.InvalidChallengeError) {
code = 'invalid-credential';
message = 'Invalid credential.';
} else if (error instanceof seller.MppCredentialProblemError) {
code = 'payment-failed';
message = 'Payment failed.';
Expand All @@ -52,6 +58,7 @@ export function classify(error, operation, input = {}) {
seller.MppUnsupportedRailError,
seller.MppAmbiguousRailError,
seller.MppInstrumentRequiredError,
seller.MppStripeUnavailableError,
].some((type) => error instanceof type)
) {
code = 'unsupported-capability';
Expand Down Expand Up @@ -138,10 +145,17 @@ async function executeSeller(operation, input, baseUrl) {
? seller.inflow.subscription
: name === 'tempo' && intent === 'charge'
? seller.tempo
: undefined;
: name === 'stripe' && intent === 'charge'
? seller.stripe
: undefined;
if (!factory) throw new Error('Unsupported MPP method and intent');
const request = challenge ? core.decode(challenge.request) : input.request;
const method = factory({ apiKey: input.api_key, baseUrl, currency: request.currency, recipient: request.recipient });
const method = await factory({
apiKey: input.api_key,
baseUrl,
currency: request.currency,
recipient: request.recipient,
});
if (operation === 'mpp.seller.route-binding') {
const framework = seller.Mppx.create({
methods: [method],
Expand All @@ -159,7 +173,17 @@ async function executeSeller(operation, input, baseUrl) {
);
return { status: response.status };
}
if (operation === 'mpp.seller.prepare') return method.request({ request });
if (operation === 'mpp.seller.prepare') {
if (name === 'stripe') {
const framework = seller.Mppx.create({
methods: [method],
secretKey: 'test-only-binding-secret-at-least-32-bytes',
realm: 'seller.example',
});
return (await framework.challenge.stripe.charge(request)).request;
}
return method.request({ request });
}
const credential = { ...input.credential, challenge: { ...challenge, request } };
if (operation === 'mpp.seller.verify') return method.verify({ credential, request });
const value = await method.validate({ credential, request });
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"conformance:update": "pnpm mpp:conformance:update",
"runtime:conformance": "pnpm --filter @inflowpayai/mpp... --filter @inflowpayai/x402 build && node scripts/conformance.mjs",
"mpp:conformance:shared": "pnpm --filter @inflowpayai/mpp-buyer... --filter @inflowpayai/mpp-seller... build && node scripts/conformance.mjs --suite mpp",
"stripe:conformance:shared": "pnpm --filter @inflowpayai/mpp-buyer... --filter @inflowpayai/mpp-seller... build && node scripts/conformance.mjs --suite stripe",
"x402:conformance:shared": "pnpm --filter @inflowpayai/x402-buyer... --filter @inflowpayai/x402-seller... build && node scripts/conformance.mjs --suite x402",
"mpp:conformance": "node scripts/mpp-conformance.mjs",
"mpp:conformance:update": "node scripts/mpp-conformance.mjs --update",
Expand Down
11 changes: 11 additions & 0 deletions packages/mpp-seller/src/methods.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,17 @@ async function broadcast(
const problem = isRecord(result) ? result['problem'] : undefined;
throw new MppCredentialProblemError(problem ?? fallbackProblem('broadcast'));
}
const methodDetails = credential.challenge.request['methodDetails'];
const requireBoundReceipt =
credential.challenge.method === 'stripe' ||
(credential.challenge.method === 'inflow' && isRecord(methodDetails) && methodDetails['rail'] === 'instrument');
if (
requireBoundReceipt &&
(result['receipt'].method !== credential.challenge.method ||
result['receipt'].challengeId !== credential.challenge.id)
) {
throw new MppCredentialProblemError(fallbackProblem('broadcast'));
}
return Receipt.from(toMppxReceipt(result['receipt']));
}

Expand Down
126 changes: 126 additions & 0 deletions packages/mpp-seller/test/unit/receipt-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { decode, decodeReceipt } from '@inflowpayai/mpp';
import { Challenge, Credential } from 'mppx';
import { Mppx } from 'mppx/server';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';

import { inflow, stripe } from '../../src/methods.server.js';

const BASE = 'https://receipt-binding.test';
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe.each(['instrument', 'stripe'] as const)('%s receipt binding', (kind) => {
it.each(['matching', 'wrong-method', 'wrong-challenge', 'missing-challenge'] as const)(
'handles a %s receipt through the protected route',
async (scenario) => {
const name = kind === 'instrument' ? 'inflow' : 'stripe';
const calls: string[] = [];
server.use(
http.get(`${BASE}/v1/mpp/config`, () => {
calls.push('config');
return HttpResponse.json({
sellerId: '22222222-2222-4222-8222-222222222222',
featureFlags: { idempotencyKeyEnabled: true },
supportedMethods: [
{
id: name,
supportedCurrencies: ['USD'],
supportedIntents: ['charge'],
methodDetails:
kind === 'instrument'
? { intentCurrencyRails: { charge: { USD: [{ rail: 'instrument', instrumentId: 'optional' }] } } }
: { networkId: 'profile_test', paymentMethodTypes: ['card', 'link'] },
},
],
});
}),
http.post(`${BASE}/v1/mpp/validate`, async ({ request }) => {
calls.push('validate');
// The local HTTP fixture receives the SDK's encoded wire credential.
const body = (await request.json()) as {
credential: {
challenge: { method: string; intent: string; request: string };
payload: unknown;
source: string;
};
};
return HttpResponse.json({
success: true,
...body,
challenge: body.credential.challenge,
details: {},
request: decode<Record<string, unknown>>(body.credential.challenge.request),
method: name,
intent: 'charge',
source: body.credential.source,
});
}),
);
const method =
kind === 'instrument'
? inflow({ apiKey: 'test-only', baseUrl: BASE })
: await stripe({ apiKey: 'test-only', baseUrl: BASE });
const framework = Mppx.create({
methods: [method],
realm: 'seller.example',
secretKey: 'test-only-binding-secret-at-least-32-bytes',
});
const options = { amount: '1.00', currency: 'USD' };
const unpaid = await framework.charge(options)(new Request('https://seller.example/item'));
expect(unpaid.status).toBe(402);
if (unpaid.status !== 402) throw new Error('Expected payment challenge');
// Parse the actual challenge returned by the protected route, including its signature.
const issued = Challenge.fromResponse(unpaid.challenge);
const expectedReceipt = {
method: name,
challengeId: issued.id,
reference: 'test-payment',
status: 'success',
timestamp: '2026-10-03T12:00:00Z',
settlement: { amount: '1.00', currency: 'USD' },
};
const receipt: Record<string, unknown> = { ...expectedReceipt };
if (scenario === 'wrong-method') receipt['method'] = 'card';
if (scenario === 'wrong-challenge') receipt['challengeId'] = 'other-challenge';
if (scenario === 'missing-challenge') delete receipt['challengeId'];
server.use(
http.post(`${BASE}/v1/mpp/broadcast`, () => {
calls.push('broadcast');
return HttpResponse.json({ receipt });
}),
);
const authorization = Credential.serialize({
challenge: issued,
payload:
kind === 'instrument'
? { type: 'instrument', transactionId: 'test-transaction', approvalId: 'test-approval' }
: { spt: 'spt_test_only' },
source: 'did:example:buyer',
});
const result = await framework.charge(options)(
new Request('https://seller.example/item', { headers: { Authorization: authorization } }),
);
expect(calls).toEqual(['config', 'validate', 'broadcast']);
if (scenario === 'matching') {
expect(result.status).toBe(200);
if (result.status !== 200) throw new Error('Expected successful payment');
const response = result.withReceipt(new Response('protected content'));
const header = response.headers.get('Payment-Receipt');
if (header === null) throw new Error('Expected receipt');
expect(decodeReceipt(header)).toEqual(expectedReceipt);
expect(await response.text()).toBe('protected content');
} else {
expect(result.status).toBe(402);
if (result.status !== 402) throw new Error('Unexpected payment success');
expect(result.challenge.headers.has('Payment-Receipt')).toBe(false);
expect(await result.challenge.json()).toMatchObject({
type: 'https://paymentauth.org/problems/verification-failed',
});
}
},
);
});
3 changes: 2 additions & 1 deletion packages/mpp-seller/test/unit/stripe-method.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ function mockLifecycle(): {
order.push('broadcast');
body = await request.json();
idempotencyKey = request.headers.get('Idempotency-Key');
// The fixture echoes the challenge identifier from the SDK's encoded wire credential.
return HttpResponse.json({
receipt: {
challengeId: 'stripe-challenge',
challengeId: (body as { credential: { challenge: { id: string } } }).credential.challenge.id,
method: 'stripe',
reference: 'pi_test_123',
settlement: { amount: '1.00', currency: 'USD' },
Expand Down
15 changes: 10 additions & 5 deletions scripts/conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ async function main() {
});
if (!values['contract-root'] || !values.output) {
throw new Error(
'Usage: node scripts/conformance.mjs --suite runtime|mpp|x402 --contract-root PATH --output NEW_REPORT.json',
'Usage: node scripts/conformance.mjs --suite runtime|mpp|stripe|x402 --contract-root PATH --output NEW_REPORT.json',
);
}
if (!['runtime', 'mpp', 'x402'].includes(values.suite)) throw new Error('Unknown conformance suite');
if (!['runtime', 'mpp', 'stripe', 'x402'].includes(values.suite)) throw new Error('Unknown conformance suite');
const contractRoot = resolve(values['contract-root']);
const lock = JSON.parse(await readFile(new URL('../conformance/inflow-specs.lock.json', import.meta.url), 'utf8'));
verifyContract(contractRoot, values['contract-revision'] ?? lock.revision);
Expand All @@ -78,9 +78,10 @@ async function main() {
const suites = {
runtime: ['runtime'],
mpp: ['mpp-core', 'mpp-buyer', 'mpp-seller'],
stripe: ['mpp-seller'],
x402: ['x402-core', 'x402-buyer', 'x402-seller'],
}[values.suite];
const metadata = await implementation(values.suite, adapterNode);
const metadata = await implementation(values.suite === 'stripe' ? 'mpp' : values.suite, adapterNode);
const output = await open(resolve(values.output), 'wx', 0o600);
const controller = new AbortController();
const abort = () => controller.abort();
Expand All @@ -89,15 +90,19 @@ async function main() {
try {
const report = await run({
index,
capabilities: { suites, supported_features: [], unsupported_features: [] },
capabilities: {
suites,
supported_features: values.suite === 'mpp' ? ['mpp-seller-subscriptions'] : [],
unsupported_features: [],
},
implementation: metadata,
command: [
adapterNode,
resolve(
root,
values.suite === 'runtime'
? 'conformance/runtime-adapter.mjs'
: `conformance/${values.suite}-shared-adapter.mjs`,
: `conformance/${values.suite === 'stripe' ? 'mpp' : values.suite}-shared-adapter.mjs`,
),
],
contractRoot,
Expand Down
10 changes: 10 additions & 0 deletions scripts/mpp-shared-adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ test('Seller classification preserves real problems and projects only recognized
new seller.MppUnsupportedRailError('USD', 'charge', 'instrument'),
new seller.MppAmbiguousRailError('USD', 'charge'),
new seller.MppInstrumentRequiredError('USD', 'charge'),
new seller.MppStripeUnavailableError(),
])
assert.deepEqual(classify(capability, 'mpp.seller.prepare'), {
code: 'unsupported-capability',
Expand All @@ -121,6 +122,15 @@ test('Seller classification preserves real problems and projects only recognized
);
});

test('Stripe input failures retain their classification without masking unknown exceptions', () => {
for (const error of [
new seller.MppStripeAmountError('invalid amount'),
new seller.MppStripeRequestError('invalid metadata'),
]) {
assert.deepEqual(classify(error, 'mpp.seller.prepare'), { code: 'invalid-input', message: 'Invalid input.' });
}
});

for (const [operation, accepted] of [
['verify', true],
['verify', false],
Expand Down
Loading