From cb641b6fe2d399ca6b37c84310a4c9693009fc7b Mon Sep 17 00:00:00 2001 From: ayomustap Date: Mon, 31 Aug 2026 01:24:55 +0000 Subject: [PATCH] Handle empty appraisal API responses deterministically --- services/appraisal-api/package.json | 2 +- services/appraisal-api/src/client.test.ts | 142 ++++++++++++++++++++++ services/appraisal-api/src/client.ts | 72 +++++++++-- services/appraisal-api/src/index.ts | 1 + 4 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 services/appraisal-api/src/client.test.ts diff --git a/services/appraisal-api/package.json b/services/appraisal-api/package.json index 39568304..146c931b 100644 --- a/services/appraisal-api/package.json +++ b/services/appraisal-api/package.json @@ -7,7 +7,7 @@ "main": "src/index.ts", "scripts": { "start": "node --import tsx src/run.ts", - "test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts", + "test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts src/client.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/services/appraisal-api/src/client.test.ts b/services/appraisal-api/src/client.test.ts new file mode 100644 index 00000000..757f4d6c --- /dev/null +++ b/services/appraisal-api/src/client.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { x402HTTPClient } from "@x402/core/client"; +import { Keypair } from "@stellar/stellar-sdk"; +import { describe, test } from "node:test"; + +import { createPaidFetch, X402PaymentError, AppraisalResponseParseError } from "./client.js"; + +const TEST_SECRET = Keypair.random().secret(); +const VALID_402_BODY = { + x402Version: 2, + resource: "https://example.com/appraise", + accepts: [ + { + scheme: "exact", + network: "stellar:testnet", + payTo: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + price: "0.10", + asset: "USDC", + maxTimeoutSeconds: 60, + extra: {}, + }, + ], + error: "Payment required", + metadata: {}, +}; + +function buildResponse(status: number, body: string | undefined, headers?: Record) { + const headersMap = new Headers(headers ?? {}); + return new Response(body ?? "", { + status, + headers: headersMap, + }); +} + +describe("createPaidFetch response parsing", () => { + test("throws a typed parse error for an unpaid empty response body", async () => { + const paidFetch = createPaidFetch({ secret: TEST_SECRET }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => buildResponse(200, ""); + + try { + await assert.rejects( + () => paidFetch("https://example.com/appraise"), + (err: unknown) => { + assert.ok(err instanceof AppraisalResponseParseError); + assert.equal(err.status, 200); + assert.equal(err.name, "AppraisalResponseParseError"); + assert.match(err.message, /invalid JSON body/i); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("throws a typed parse error for a paid non-JSON response body without exposing raw content", async () => { + const paidFetch = createPaidFetch({ secret: TEST_SECRET }); + const originalFetch = globalThis.fetch; + const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse; + const originalCreatePaymentPayload = x402HTTPClient.prototype.createPaymentPayload; + const originalEncodePaymentSignatureHeader = + x402HTTPClient.prototype.encodePaymentSignatureHeader; + const originalGetPaymentSettleResponse = x402HTTPClient.prototype.getPaymentSettleResponse; + + x402HTTPClient.prototype.getPaymentRequiredResponse = () => VALID_402_BODY as never; + x402HTTPClient.prototype.createPaymentPayload = async () => ({ + x402Version: 2, + payload: "stub-payload", + resource: "https://example.com/appraise", + accepted: VALID_402_BODY.accepts[0], + extensions: {}, + }) as never; + x402HTTPClient.prototype.encodePaymentSignatureHeader = () => ({ + "X-PAYMENT": "stub-signature", + }); + x402HTTPClient.prototype.getPaymentSettleResponse = () => ({ + transaction: "stub-tx", + network: "stellar:testnet", + payer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }) as never; + + let calls = 0; + globalThis.fetch = async (_url, _init) => { + calls += 1; + if (calls === 1) { + return buildResponse(402, JSON.stringify(VALID_402_BODY), { + "x402-version": "2", + "x402-payment-required": "1", + }); + } + if (calls === 2) { + return buildResponse(200, "not-json"); + } + throw new Error("unexpected extra fetch call"); + }; + + try { + await assert.rejects( + () => paidFetch("https://example.com/appraise"), + (err: unknown) => { + assert.ok(err instanceof AppraisalResponseParseError); + assert.equal(err.status, 200); + assert.equal(err.name, "AppraisalResponseParseError"); + assert.doesNotMatch(err.message, /not-json|raw|payload/i); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse; + x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload; + x402HTTPClient.prototype.encodePaymentSignatureHeader = originalEncodePaymentSignatureHeader; + x402HTTPClient.prototype.getPaymentSettleResponse = originalGetPaymentSettleResponse; + } + }); + + test("preserves x402 payment errors for non-JSON 402 responses", async () => { + const paidFetch = createPaidFetch({ secret: TEST_SECRET }); + const originalFetch = globalThis.fetch; + const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse; + x402HTTPClient.prototype.getPaymentRequiredResponse = () => { + throw new Error("invalid x402 payment required response"); + }; + + globalThis.fetch = async () => buildResponse(402, "not-json"); + + try { + await assert.rejects( + () => paidFetch("https://example.com/appraise"), + (err: unknown) => { + assert.ok(err instanceof X402PaymentError); + assert.match(err.message, /payment|402/i); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse; + } + }); +}); diff --git a/services/appraisal-api/src/client.ts b/services/appraisal-api/src/client.ts index cb2cf257..f2f6c4d0 100644 --- a/services/appraisal-api/src/client.ts +++ b/services/appraisal-api/src/client.ts @@ -8,7 +8,7 @@ // pay the appraisal API per call. import { x402Client, x402HTTPClient } from "@x402/core/client"; -import type { Network, SettleResponse } from "@x402/core/types"; +import type { Network, PaymentRequired, SettleResponse } from "@x402/core/types"; import { createEd25519Signer } from "@x402/stellar"; import { ExactStellarScheme as ClientStellarScheme } from "@x402/stellar/exact/client"; @@ -28,7 +28,38 @@ export interface PaidResult { settlement?: SettleResponse; } -export class X402PaymentError extends Error {} +export class AppraisalResponseParseError extends Error { + readonly name = "AppraisalResponseParseError"; + readonly status: number; + + constructor(status: number, options?: ErrorOptions) { + super(`appraisal api returned ${status} with invalid JSON body`, options); + this.status = status; + } +} + +export class X402PaymentError extends Error { + readonly name = "X402PaymentError"; + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.status = status; + } +} + +async function parseJsonResponse(res: Response): Promise { + const text = await res.text(); + if (!text.trim()) { + throw new AppraisalResponseParseError(res.status); + } + + try { + return JSON.parse(text) as T; + } catch (cause) { + throw new AppraisalResponseParseError(res.status, { cause }); + } +} /** Build a paid-fetch function bound to a payer wallet. */ export function createPaidFetch(config: PaidClientConfig) { @@ -47,15 +78,34 @@ export function createPaidFetch(config: PaidClientConfig) { ): Promise> { const first = await fetch(url, init); if (first.status !== 402) { - return { status: first.status, body: (await first.json()) as T }; + return { status: first.status, body: await parseJsonResponse(first) }; } // 402 → build the signed payment and retry. - const bodyForParse = await first.clone().json().catch(() => undefined); - const paymentRequired = http.getPaymentRequiredResponse( - (name) => first.headers.get(name), - bodyForParse, - ); + let bodyForParse: unknown; + try { + bodyForParse = await parseJsonResponse(first.clone()); + } catch (error) { + if (error instanceof AppraisalResponseParseError) { + bodyForParse = undefined; + } else { + throw error; + } + } + + let paymentRequired: PaymentRequired; + try { + paymentRequired = http.getPaymentRequiredResponse( + (name) => first.headers.get(name), + bodyForParse, + ); + } catch { + throw new X402PaymentError( + `x402 payment required response was invalid (${first.status})`, + first.status, + ); + } + const payload = await http.createPaymentPayload(paymentRequired); const payHeaders = http.encodePaymentSignatureHeader(payload); @@ -63,12 +113,10 @@ export function createPaidFetch(config: PaidClientConfig) { ...init, headers: { ...(init.headers ?? {}), ...payHeaders }, }); - const body = (await paid.json()) as T; + const body = await parseJsonResponse(paid); if (paid.status !== 200) { - throw new X402PaymentError( - `paid request failed (${paid.status}): ${JSON.stringify(body)}`, - ); + throw new X402PaymentError(`paid request failed (${paid.status})`, paid.status); } const settlement = http.getPaymentSettleResponse((name) => paid.headers.get(name)); return { status: paid.status, body, settlement }; diff --git a/services/appraisal-api/src/index.ts b/services/appraisal-api/src/index.ts index 9cdda462..0a811f44 100644 --- a/services/appraisal-api/src/index.ts +++ b/services/appraisal-api/src/index.ts @@ -17,6 +17,7 @@ export { } from "./config.js"; export { createPaidFetch, + AppraisalResponseParseError, X402PaymentError, type PaidClientConfig, type PaidResult,