From 2d26acb483db07964ffe0503b80e24c4981ac5b5 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Sat, 3 Oct 2026 16:21:27 -0700 Subject: [PATCH] fix(mpp-seller): bind instrument and Stripe receipts --- .changeset/quiet-payment-receipts.md | 6 + .github/workflows/conformance.yml | 4 +- conformance/README.md | 20 ++- conformance/inflow-specs.lock.json | 2 +- conformance/mpp-shared-adapter.mjs | 32 ++++- package.json | 1 + packages/mpp-seller/src/methods.server.ts | 11 ++ .../test/unit/receipt-binding.test.ts | 126 ++++++++++++++++++ .../test/unit/stripe-method.test.ts | 3 +- scripts/conformance.mjs | 15 ++- scripts/mpp-shared-adapter.test.mjs | 10 ++ 11 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 .changeset/quiet-payment-receipts.md create mode 100644 packages/mpp-seller/test/unit/receipt-binding.test.ts diff --git a/.changeset/quiet-payment-receipts.md b/.changeset/quiet-payment-receipts.md new file mode 100644 index 0000000..43ca842 --- /dev/null +++ b/.changeset/quiet-payment-receipts.md @@ -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. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ab6b143..153f3df 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -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 @@ -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 diff --git a/conformance/README.md b/conformance/README.md index 87120dc..95952b8 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -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 @@ -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 diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json index e5d9c27..a841cb1 100644 --- a/conformance/inflow-specs.lock.json +++ b/conformance/inflow-specs.lock.json @@ -1,4 +1,4 @@ { "repository": "inflowpayai/inflow-specs", - "revision": "54689b7c93c07f259ed493897637fa33a7cfade2" + "revision": "0bf31dff396f139f574f07d9353415696760ec0c" } diff --git a/conformance/mpp-shared-adapter.mjs b/conformance/mpp-shared-adapter.mjs index 93d009a..8713111 100644 --- a/conformance/mpp-shared-adapter.mjs +++ b/conformance/mpp-shared-adapter.mjs @@ -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') ); @@ -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.'; @@ -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'; @@ -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], @@ -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 }); diff --git a/package.json b/package.json index f8059f8..714ba4e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/mpp-seller/src/methods.server.ts b/packages/mpp-seller/src/methods.server.ts index a3f1be3..ffe018d 100644 --- a/packages/mpp-seller/src/methods.server.ts +++ b/packages/mpp-seller/src/methods.server.ts @@ -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'])); } diff --git a/packages/mpp-seller/test/unit/receipt-binding.test.ts b/packages/mpp-seller/test/unit/receipt-binding.test.ts new file mode 100644 index 0000000..4b10b3d --- /dev/null +++ b/packages/mpp-seller/test/unit/receipt-binding.test.ts @@ -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>(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 = { ...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', + }); + } + }, + ); +}); diff --git a/packages/mpp-seller/test/unit/stripe-method.test.ts b/packages/mpp-seller/test/unit/stripe-method.test.ts index 1ab2ad1..6a906b8 100644 --- a/packages/mpp-seller/test/unit/stripe-method.test.ts +++ b/packages/mpp-seller/test/unit/stripe-method.test.ts @@ -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' }, diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index cf4b94c..f063920 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -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); @@ -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(); @@ -89,7 +90,11 @@ 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, @@ -97,7 +102,7 @@ async function main() { 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, diff --git a/scripts/mpp-shared-adapter.test.mjs b/scripts/mpp-shared-adapter.test.mjs index 89fcf08..a95658b 100644 --- a/scripts/mpp-shared-adapter.test.mjs +++ b/scripts/mpp-shared-adapter.test.mjs @@ -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', @@ -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],