diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 2f1818f..6491c8f 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -803,6 +803,20 @@ describe('production mode', () => { 'expires="2099-01-01T00:00:00Z"', ].join(' '); + const WWW_AUTHENTICATE_MULTI = [ + 'Payment id="tempo_001",', + 'realm="127.0.0.1",', + 'method="tempo",', + 'intent="charge",', + 'request="e30=",', + '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(' '); + it('happy path: probes, gets 402, signs, retries, returns response', async () => { setNextResponse(200, APPROVED_SPT_REQUEST); setMerchantResponse(402, '{"error":"payment required"}', { @@ -853,6 +867,27 @@ describe('production mode', () => { expect(merchantRequests).toHaveLength(2); }); + it('selects the stripe challenge when the response advertises multiple methods', async () => { + setNextResponse(200, APPROVED_SPT_REQUEST); + setMerchantResponse(402, '{"error":"payment required"}', { + 'www-authenticate': WWW_AUTHENTICATE_MULTI, + }); + setMerchantResponse(200, '{"success":true}'); + + const result = await runProdCli( + 'mpp', + 'pay', + `http://127.0.0.1:${merchantPort}/api/charge`, + '--spend-request-id', + 'lsrq_spt_001', + '--output-json', + ); + + expect(result.exitCode).toBe(0); + expect(merchantRequests).toHaveLength(2); + expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /); + }); + it('passthrough: no 402 returns response without signing', async () => { setNextResponse(200, APPROVED_SPT_REQUEST); setMerchantResponse(200, '{"ok":true}'); diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts index 29ed939..3df89fb 100644 --- a/packages/cli/src/commands/mpp/decode.ts +++ b/packages/cli/src/commands/mpp/decode.ts @@ -1,5 +1,17 @@ import { Challenge } from 'mppx'; +type StripeChargeChallenge = Challenge.Challenge< + Record, + 'charge', + 'stripe' +>; + +type ResolvedStripeChallenge = { + challenge: StripeChargeChallenge; + networkId: string; + request: Record; +}; + export interface DecodedStripeChallenge { id: string; realm: string; @@ -46,11 +58,9 @@ function getMethodDetails( return methodDetails as Record; } -export function decodeStripeChallenge( - challengeHeader: string, -): DecodedStripeChallenge { - const challenges = Challenge.deserializeList(challengeHeader); - +function resolveStripeChallenge( + challenges: Challenge.Challenge[], +): ResolvedStripeChallenge { const stripeChallenge = challenges.find( (challenge) => challenge.method === 'stripe' && challenge.intent === 'charge', @@ -88,13 +98,33 @@ export function decodeStripeChallenge( } return { - id: stripeChallenge.id, - realm: stripeChallenge.realm, + challenge: stripeChallenge as StripeChargeChallenge, + networkId, + request, + }; +} + +export function getStripeChargeChallengeFromResponse( + response: Response, +): StripeChargeChallenge { + return resolveStripeChallenge(Challenge.fromResponseList(response)).challenge; +} + +export function decodeStripeChallenge( + challengeHeader: string, +): DecodedStripeChallenge { + const { challenge, networkId, request } = resolveStripeChallenge( + Challenge.deserializeList(challengeHeader), + ); + + return { + id: challenge.id, + realm: challenge.realm, method: 'stripe', intent: 'charge', - description: stripeChallenge.description, - digest: stripeChallenge.digest, - expires: stripeChallenge.expires, + description: challenge.description, + digest: challenge.digest, + expires: challenge.expires, network_id: networkId, request_json: request, }; diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index af0c5b9..f2bb1f2 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -1,10 +1,12 @@ import type { ISpendRequestResource } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; -import { Challenge, Credential } from 'mppx'; +import { Credential, Method } from 'mppx'; +import { Mppx, Transport } from 'mppx/client'; +import { Methods as StripeMethods } from 'mppx/stripe'; import React, { useEffect, useState } from 'react'; import { outputError } from '../../utils/execute-command'; -import { decodeStripeChallenge } from './decode'; +import { getStripeChargeChallengeFromResponse } from './decode'; export type PayResult = { status: number; @@ -46,6 +48,36 @@ async function readPayResult( return { status: response.status, headers: responseHeaders, body }; } +function createStripePaymentClient(spt: string) { + const stripeCharge = Method.toClient(StripeMethods.charge, { + async createCredential({ challenge }) { + return Credential.serialize({ + challenge, + payload: { spt }, + }); + }, + }); + + return Mppx.create({ + methods: [stripeCharge], + polyfill: false, + transport: Transport.from({ + name: 'stripe-http', + isPaymentRequired(response) { + return response.status === 402; + }, + getChallenge(response) { + return getStripeChargeChallengeFromResponse(response); + }, + setCredential(request, credential) { + const nextHeaders = new Headers(request.headers); + nextHeaders.set('Authorization', credential); + return { ...request, headers: nextHeaders }; + }, + }), + }); +} + export async function runMppPay( url: string, spendRequestId: string, @@ -103,27 +135,9 @@ export async function runMppPay( return readPayResult(initialResponse); } - // 5. Parse challenges, find stripe - const decoded = decodeStripeChallenge( - initialResponse.headers.get('www-authenticate') ?? '', - ); - const stripeChallenge = Challenge.from({ - id: decoded.id, - realm: decoded.realm, - method: decoded.method, - intent: decoded.intent, - request: decoded.request_json, - ...(decoded.description ? { description: decoded.description } : {}), - ...(decoded.digest ? { digest: decoded.digest } : {}), - ...(decoded.expires ? { expires: decoded.expires } : {}), - }); - - // 6. Build and serialize credential - const credential = Credential.from({ - challenge: stripeChallenge, - payload: { spt }, - }); - const authHeader = Credential.serialize(credential); + // 5. Select the Stripe challenge and build the payment credential + const authHeader = + await createStripePaymentClient(spt).createCredential(initialResponse); // 7. Retry with Authorization header const retryResponse = await fetch(url, { @@ -206,25 +220,10 @@ export function MppPay({ } setStep('signing'); - const decoded = decodeStripeChallenge( - initialResponse.headers.get('www-authenticate') ?? '', - ); - const stripeChallenge = Challenge.from({ - id: decoded.id, - realm: decoded.realm, - method: decoded.method, - intent: decoded.intent, - request: decoded.request_json, - ...(decoded.description ? { description: decoded.description } : {}), - ...(decoded.digest ? { digest: decoded.digest } : {}), - ...(decoded.expires ? { expires: decoded.expires } : {}), - }); - - const credential = Credential.from({ - challenge: stripeChallenge, - payload: { spt }, - }); - const authHeader = Credential.serialize(credential); + const authHeader = + await createStripePaymentClient(spt).createCredential( + initialResponse, + ); setStep('submitting'); const retryResponse = await fetch(url, {