diff --git a/.changelog/payment-outcomes.md b/.changelog/payment-outcomes.md new file mode 100644 index 0000000..c36ac45 --- /dev/null +++ b/.changelog/payment-outcomes.md @@ -0,0 +1,5 @@ +--- +wallet-cli: patch +--- + +Preserve final payment responses and HTTP error output, and report uncertain payment outcomes with safe recovery references. diff --git a/README.md b/README.md index 4b61fb8..e4df21b 100644 --- a/README.md +++ b/README.md @@ -200,3 +200,9 @@ Brief description of the change. Supported bump levels are `major`, `minor`, `patch`, and `none`. Run `pnpm check` before submitting changes. + +## Request failures and payment recovery + +HTTP errors return `E_HTTP` (exit 3) and preserve the response body through the usual output options, including `-o`. Transport failures return `E_NETWORK`. `--dump-header` and `--write-meta` describe the final response after any payment retry. A final HTTP error after submitting a payment credential does not by itself prove that no payment occurred. + +If a credential-bearing request or its response delivery fails, `E_PAYMENT_OUTCOME_UNKNOWN` (exit 4) means payment may have completed. Available challenge, transaction, or session references help the provider investigate; they do not prove settlement. Check with the provider before creating another payment. A transaction hash is only reported when the credential supplies one; signed pull transactions are never printed. The CLI cannot guarantee result recovery or a refund. diff --git a/src/commands/request.ts b/src/commands/request.ts index 9824e3b..327f50b 100644 --- a/src/commands/request.ts +++ b/src/commands/request.ts @@ -46,6 +46,8 @@ import { escrowAbi, version } from "../shared/constants.js"; import { authRefreshRequiredError, networkError, + httpError, + paymentOutcomeUnknownError, paymentError, usageError, } from "../shared/errors.js"; @@ -426,12 +428,13 @@ export async function executeRequest(options: RequestOptions, io: RequestRunOpti const started = Date.now(); let { request, response } = await fetchWithRetries(await buildFetchRequest(options), options); - if (options.dumpHeader) await writeHeadersFile(options.dumpHeader, response); - if (options.writeMeta) await writeMetaFile(options.writeMeta, response, started); + let paid = false; if (response.status === 402) { if (options.dryRun) { const { challenge } = preparePaymentChallenge(response, options, request.url); + if (options.dumpHeader) await writeHeadersFile(options.dumpHeader, response); + if (options.writeMeta) await writeMetaFile(options.writeMeta, response, started); await writeOutput( options.output, `${JSON.stringify( @@ -459,20 +462,42 @@ export async function executeRequest(options: RequestOptions, io: RequestRunOpti } response = await payAndRetryRequest(response, request, options); + paid = true; } - if (response.status >= 400) { - const body = await response.text().catch(() => ""); - if (options.sseJson) { - write( + try { + if (options.dumpHeader) await writeHeadersFile(options.dumpHeader, response); + if (options.writeMeta) await writeMetaFile(options.writeMeta, response, started); + if (response.status >= 400 && options.sseJson) { + const body = await response.text().catch(responseBodyNetworkError); + await writeOutput( + options.output ?? (options.remoteName ? remoteNamePath(options.url) : undefined), + `${JSON.stringify({ event: "error", status: response.status, message: `HTTP ${response.status}${body ? `: ${body}` : ""}`, ts: new Date().toISOString() })}\n`, stdout, - `${JSON.stringify({ event: "error", message: `HTTP ${response.status}${body ? `: ${body}` : ""}`, ts: new Date().toISOString() })}\n`, ); + } else { + await writeResponseBody(response, options, stdout); } - throw networkError(`HTTP ${response.status}${body ? `: ${body}` : ""}`); + if (paidResponseFailures.has(response)) + throw paymentOutcomeUnknownError( + `Paid response processing failed; payment may have completed.${paymentReferences.get(response) ?? ""} Check any saved response metadata and contact the provider before retrying.`, + ); + if (response.status >= 400) throw httpError(response.status); + } catch (error) { + if ( + paid && + !( + error && + typeof error === "object" && + "code" in error && + (error.code === "E_HTTP" || error.code === "E_PAYMENT_OUTCOME_UNKNOWN") + ) + ) + throw paymentOutcomeUnknownError( + `Paid response delivery failed; payment may have completed.${paymentReferences.get(response) ?? ""} Check any saved response metadata and contact the provider before retrying.`, + ); + throw error; } - - await writeResponseBody(response, options, stdout); } async function buildFetchRequest(options: RequestOptions) { @@ -576,10 +601,59 @@ async function fetchWithRetries( throw networkError(lastError instanceof Error ? lastError.message : String(lastError)); } +// Credentials may authorize payment even when the response never arrives. Never +// include the credential itself (or transport error text that might echo it). +export async function fetchPaidRequest( + request: FetchPlan, + options: RequestOptions, + credential: string, + fetchImpl: typeof fetch = undiciFetch as unknown as typeof fetch, +) { + const reference = paymentRecoveryReference(credential); + try { + const { response } = await fetchWithRedirects(request, options, fetchImpl, true); + paymentReferences.set(response, reference); + return response; + } catch { + throw paymentOutcomeUnknownError( + `A payment credential was sent or attempted, but no final response was received; payment may have completed.${reference} Check with the provider before creating another payment.`, + ); + } +} + +const paymentReferences = new WeakMap(); +const paidResponseFailures = new WeakSet(); + +function paymentRecoveryReference(credential: string) { + let reference = ""; + try { + const parsed = Credential.deserialize>(credential); + reference = ` Challenge ID: ${recoveryReferenceValue(parsed.challenge.id)}. Intent: ${recoveryReferenceValue(parsed.challenge.intent)}.`; + const hash = parsed.payload.hash; + if ( + parsed.payload.type === "hash" && + typeof hash === "string" && + /^0x[0-9a-fA-F]{64}$/.test(hash) + ) + reference += ` Transaction hash: ${hash}.`; + const channelId = parsed.payload.channelId; + if (typeof channelId === "string" && /^0x[0-9a-fA-F]{64}$/.test(channelId)) + reference += ` Channel ID: ${channelId}.`; + } catch { + // Recovery hints must never replace the original outcome classification. + } + return reference; +} + +function recoveryReferenceValue(value: unknown) { + return JSON.stringify(String(value).slice(0, 200)); +} + async function fetchWithRedirects( request: FetchPlan, options: RequestOptions, fetchImpl: typeof fetch, + sameOriginOnly = false, ) { let current = { init: cloneRequestInit(request.init), url: request.url }; const limit = options.followRedirects ? (options.maxRedirs ?? 10) : 0; @@ -593,7 +667,11 @@ async function fetchWithRedirects( if (!location) return { request: current, response }; if (redirects >= limit) throw networkError(`Too many redirects: exceeded ${limit}`); - current = redirectRequest(current, response.status, location); + const nextUrl = new URL(location, current.url); + if (sameOriginOnly && nextUrl.origin !== new URL(current.url).origin) + throw networkError("Refusing to forward a payment credential to another origin"); + + current = redirectRequest(current, response.status, nextUrl.toString()); } } @@ -646,20 +724,18 @@ async function payAndRetryRequest( paySessionAndRetryRequest(selectedResponse, request, options, sessionChallenge), ); } catch (error) { - if (isAuthRefreshRequiredError(error)) throw error; + if ( + isAuthRefreshRequiredError(error) || + (error && + typeof error === "object" && + "code" in error && + error.code === "E_PAYMENT_OUTCOME_UNKNOWN") + ) + throw error; if (options.paymentIntent === "auto") throw chargeFallbackError(header, sessionChallenge, options, error) ?? error; throw error; } - if (options.paymentIntent === "auto" && response.status === 402) { - const body = await response - .clone() - .text() - .catch(() => ""); - const reason = `HTTP ${response.status}${body ? `: ${body}` : ""}`; - const fallback = chargeFallbackError(header, sessionChallenge, options, reason); - if (fallback) throw fallback; - } return response; } @@ -693,7 +769,7 @@ async function payAndRetryRequest( const credential = await payment.createCredential(challengeResponse); const paidInit = payment.transport.setCredential(cloneRequestInit(request.init), credential); - return (await fetchWithRetries({ init: paidInit, url: request.url }, options)).response; + return await fetchPaidRequest({ init: paidInit, url: request.url }, options, credential); } catch (error) { if (error && typeof error === "object" && isActionablePaymentError(error)) throw error; const diagnostic = spendingLimitDiagnostic(error, header, options); @@ -762,8 +838,11 @@ async function paySessionAndRetryRequest( } const paidInit = payment.transport.setCredential(cloneRequestInit(request.init), credential); - const response = (await fetchWithRetries({ init: paidInit, url: request.url }, options)) - .response; + const response = await fetchPaidRequest( + { init: paidInit, url: request.url }, + options, + credential, + ); if (reusable && response.status === 402) { const recovered = await tryTopUpAndRetry({ credential, @@ -780,7 +859,12 @@ async function paySessionAndRetryRequest( if (recovered) return recovered; } if (reusable && (await isSessionInvalidationResponse(response))) { - await deleteSessionRecord(reusable.channel_id); + try { + await deleteSessionRecord(reusable.channel_id); + } catch { + paidResponseFailures.add(response); + return response; + } return paySessionAndRetryRequest( paymentRequiredResponse, request, @@ -789,7 +873,11 @@ async function paySessionAndRetryRequest( reusable.channel_id, ); } - await persistSessionReceipt(response, record.channel_id, signedCumulative); + try { + await persistSessionReceipt(response, record.channel_id, signedCumulative); + } catch { + paidResponseFailures.add(response); + } return response; } catch (error) { if (error && typeof error === "object" && isActionablePaymentError(error)) throw error; @@ -952,7 +1040,11 @@ export async function storedAccessKeyIdentity(walletState: WalletState, options: function isActionablePaymentError(error: unknown) { if (!error || typeof error !== "object") return false; const code = (error as Record).code; - return code === "E_PAYMENT" || code === "E_AUTH_REFRESH_REQUIRED"; + return ( + code === "E_PAYMENT_OUTCOME_UNKNOWN" || + code === "E_PAYMENT" || + code === "E_AUTH_REFRESH_REQUIRED" + ); } function isAuthRefreshRequiredError(error: unknown) { @@ -1090,7 +1182,10 @@ async function tryTopUpAndRetry(options: { response: Response; signedCumulative: bigint; }) { - const body = await options.response.text().catch(() => ""); + const body = await options.response + .clone() + .text() + .catch(() => ""); const additionalDeposit = topUpAmountFromProblem(body, options.record, options.signedCumulative); if (additionalDeposit <= 0n) return undefined; if (options.options.maxSpend) { @@ -1118,26 +1213,39 @@ async function tryTopUpAndRetry(options: { ); const topUpInit = topUpRequestInit(options.request.init); const authorizedTopUp = options.payment.transport.setCredential(topUpInit, topUpCredential); - const topUpResponse = ( - await fetchWithRetries({ init: authorizedTopUp, url: options.request.url }, options.options) - ).response; + const topUpResponse = await fetchPaidRequest( + { init: authorizedTopUp, url: options.request.url }, + options.options, + topUpCredential, + ); if (topUpResponse.status >= 400) return topUpResponse; - await upsertSessionRecord({ - ...options.record, - deposit: options.record.deposit + additionalDeposit, - last_used_at: nowSeconds(), - }); - await persistSessionReceipt(topUpResponse, options.record.channel_id, options.signedCumulative); + try { + await upsertSessionRecord({ + ...options.record, + deposit: options.record.deposit + additionalDeposit, + last_used_at: nowSeconds(), + }); + await persistSessionReceipt(topUpResponse, options.record.channel_id, options.signedCumulative); + } catch { + paidResponseFailures.add(topUpResponse); + return topUpResponse; + } const paidInit = options.payment.transport.setCredential( cloneRequestInit(options.request.init), options.credential, ); - const retried = ( - await fetchWithRetries({ init: paidInit, url: options.request.url }, options.options) - ).response; - await persistSessionReceipt(retried, options.record.channel_id, options.signedCumulative); + const retried = await fetchPaidRequest( + { init: paidInit, url: options.request.url }, + options.options, + options.credential, + ); + try { + await persistSessionReceipt(retried, options.record.channel_id, options.signedCumulative); + } catch { + paidResponseFailures.add(retried); + } return retried; } @@ -1554,6 +1662,30 @@ function offeredTempoCurrencies(header: string | null) { } } +function responseBodyNetworkError(error: unknown): never { + throw networkError(error instanceof Error ? error.message : String(error)); +} + +async function* readResponseBody(body: ReadableStream, signal?: AbortSignal) { + const reader = body.getReader(); + const cancel = () => { + void reader.cancel().catch(() => undefined); + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted) cancel(); + try { + while (true) { + const { done, value } = await reader.read().catch(responseBodyNetworkError); + if (done) return; + yield value; + } + } finally { + signal?.removeEventListener("abort", cancel); + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + async function writeResponseBody( response: Response, options: RequestOptions, @@ -1597,15 +1729,18 @@ async function writeResponseBody( if (outputPath) { await mkdir(dirname(outputPath), { recursive: true }); if (headerText) await writeFile(outputPath, headerText); - await pipeline(body, createWriteStream(outputPath, { flags: headerText ? "a" : "w" })); + await pipeline( + ({ signal } = {}) => readResponseBody(body, signal), + createWriteStream(outputPath, { flags: headerText ? "a" : "w" }), + ); } else { - write(stdout, headerText); - await pipeline(body, process.stdout); + await write(stdout, headerText); + await pipeline(({ signal } = {}) => readResponseBody(body, signal), process.stdout); } return; } - const body = Buffer.from(await response.arrayBuffer()); + const body = Buffer.from(await response.arrayBuffer().catch(responseBodyNetworkError)); await writeOutput( outputPath, headerText ? Buffer.concat([Buffer.from(headerText), body]) : body, @@ -1619,7 +1754,7 @@ async function writeOutput( stdout: Pick, ) { if (!path) { - write(stdout, text); + await write(stdout, text); return; } await mkdir(dirname(path), { recursive: true }); @@ -1990,5 +2125,24 @@ function parseSimpleToon(value: string) { } function write(stdout: Pick, text: string | Uint8Array) { - stdout.write(text); + const stream = stdout as Pick & { + off?: NodeJS.WriteStream["off"]; + once?: NodeJS.WriteStream["once"]; + }; + if (!stream.once || !stream.off) { + stream.write(text); + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + stream.once?.("error", onError); + stream.write(text, (error) => { + if (error) { + reject(error); + return; + } + stream.off?.("error", onError); + resolve(); + }); + }); } diff --git a/src/request-cli.ts b/src/request-cli.ts index 4725005..41c210f 100644 --- a/src/request-cli.ts +++ b/src/request-cli.ts @@ -123,10 +123,31 @@ async function main() { const argv = process.argv.slice(2); if (argv.includes("--describe")) { process.stdout.write(`${JSON.stringify(describeRequestCli())}\n`); - process.exit(0); + return; } - await cli.serve(normalizeIncurArgv(argv)); + const output: string[] = []; + let exitCode: number | undefined; + await cli.serve(normalizeIncurArgv(argv), { + exit(code) { + exitCode = code; + }, + stdout(text) { + output.push(text); + }, + }); + if (output.length > 0) + await writeProcessOutput( + exitCode === undefined ? process.stdout : process.stderr, + output.join(""), + ); + if (exitCode !== undefined) process.exitCode = exitCode; +} + +function writeProcessOutput(stream: NodeJS.WriteStream, text: string) { + return new Promise((resolve, reject) => { + stream.write(text, (error) => (error ? reject(error) : resolve())); + }); } type ParsedOptions = z.infer; diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 71f13a5..b24c6c8 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -23,3 +23,11 @@ export function authRefreshRequiredError(reason: "expired" | "missing" | "unusab exitCode: 4, }); } + +export function httpError(status: number) { + return new Errors.IncurError({ code: "E_HTTP", message: `HTTP ${status}`, exitCode: 3 }); +} + +export function paymentOutcomeUnknownError(message: string) { + return new Errors.IncurError({ code: "E_PAYMENT_OUTCOME_UNKNOWN", message, exitCode: 4 }); +} diff --git a/test/request.test.ts b/test/request.test.ts index 7b6ad33..81c6fcf 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -1,16 +1,24 @@ +import { execFile } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { Writable } from "node:stream"; import { Challenge, Credential, Method, z } from "mppx"; import { Mppx, session as tempoSession } from "mppx/client"; import { Keystore } from "accounts"; import { createClient, custom, decodeFunctionData } from "viem"; -import { Abis as TempoAbis, Channel as TempoChannel, KeyAuthorizationManager } from "viem/tempo"; -import { afterEach, describe, expect, it } from "vitest"; +import { + Abis as TempoAbis, + Actions as TempoActions, + Channel as TempoChannel, + KeyAuthorizationManager, +} from "viem/tempo"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildTopUpTransactionRequest, + fetchPaidRequest, chargeFallbackError, isSessionInvalidationResponse, parseRequestArgs, @@ -38,6 +46,16 @@ import { } from "./helpers.js"; import { loadWalletState } from "../src/wallet/store.js"; +vi.mock("mppx/client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Mppx: { ...actual.Mppx } }; +}); + +vi.mock("viem/tempo", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Actions: { ...actual.Actions, token: { ...actual.Actions.token } } }; +}); + type SeenRequest = { body: string; headers: Record; @@ -67,6 +85,416 @@ it("pins ephemeral clients without an offered chain to the selected network", as }); describe("request command", () => { + it("writes the paid response headers and status after a 402 retry", async () => { + const home = await useTempHome(); + const challenge = Challenge.from({ + id: "metadata-test", + intent: "charge", + method: "tempo", + realm: "example.com", + request: { + amount: "1", + currency: "0x20c000000000000000000000b9537d11c60e8b50", + recipient: testWallet, + }, + }); + const credential = Credential.serialize({ challenge, payload: { test: true } }); + const headers = join(home, "headers.txt"); + const meta = join(home, "meta.json"); + const server = await testServer((request, response) => { + if (!request.headers.authorization) { + response.statusCode = 402; + response.setHeader("www-authenticate", Challenge.serialize(challenge)); + response.end("payment required"); + } else { + response.setHeader("payment-receipt", "test-receipt"); + if (request.url === "/broken") { + response.flushHeaders(); + setTimeout(() => response.destroy(), 10); + } else response.end("paid result"); + } + }); + const create = vi.spyOn(Mppx, "create").mockReturnValue({ + onChallengeReceived: vi.fn(), + createCredential: vi.fn().mockResolvedValue(credential), + transport: { + setCredential: (init: RequestInit) => ({ ...init, headers: { authorization: credential } }), + }, + } as unknown as ReturnType); + const stdout = captureStdout(); + try { + await runRequest( + [ + "--private-key", + `0x${"1".repeat(64)}`, + "-D", + headers, + "--write-meta", + meta, + server.url("/paid"), + ], + { stdout }, + ); + await expect( + runRequest(["--private-key", `0x${"1".repeat(64)}`, server.url("/broken")], { + stdout: captureStdout(), + }), + ).rejects.toMatchObject({ + code: "E_PAYMENT_OUTCOME_UNKNOWN", + message: expect.stringContaining("metadata-test"), + }); + for (const flag of ["--stream", "--sse"]) { + await expect( + runRequest( + [ + "--private-key", + `0x${"1".repeat(64)}`, + flag, + "-o", + join(home, "stream.txt"), + server.url("/broken"), + ], + { stdout: captureStdout() }, + ), + ).rejects.toMatchObject({ + code: "E_PAYMENT_OUTCOME_UNKNOWN", + message: expect.stringContaining("metadata-test"), + }); + } + await expect( + runRequest(["--private-key", `0x${"1".repeat(64)}`, server.url("/paid")], { + stdout: new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error("output failed")); + }, + }), + }), + ).rejects.toMatchObject({ code: "E_PAYMENT_OUTCOME_UNKNOWN" }); + expect(stdout.text()).toBe("paid result"); + expect(await readFile(headers, "utf8")).toContain("HTTP 200"); + expect(await readFile(headers, "utf8")).not.toContain("www-authenticate"); + expect(JSON.parse(await readFile(meta, "utf8"))).toMatchObject({ + status: 200, + headers: { "payment-receipt": "test-receipt" }, + }); + } finally { + create.mockRestore(); + } + }); + + it("preserves an HTTP error body in the requested file with final metadata", async () => { + const home = await useTempHome(); + const output = join(home, "errors", "body.json"); + const meta = join(home, "meta.json"); + const server = await testServer((_request, response) => { + response.statusCode = 422; + response.end('{"error":"invalid input"}'); + }); + const stdout = captureStdout(); + await expect( + runRequest(["-o", output, "--write-meta", meta, server.url("/error")], { stdout }), + ).rejects.toMatchObject({ code: "E_HTTP", message: "HTTP 422", exitCode: 3 }); + expect(await readFile(output, "utf8")).toBe('{"error":"invalid input"}'); + expect(JSON.parse(await readFile(meta, "utf8"))).toMatchObject({ status: 422 }); + expect(stdout.text()).toBe(""); + }); + + it("preserves HTTP failures through the actual CLI entrypoint", async () => { + const home = await useTempHome(); + const meta = join(home, "cli", "meta.json"); + const body = `{"error":"${"x".repeat(256 * 1024)}"}`; + const server = await testServer((_request, response) => { + response.statusCode = 422; + response.end(body); + }); + const result = await new Promise<{ + code: number | string | null | undefined; + stdout: string; + stderr: string; + }>((resolve) => { + execFile( + process.execPath, + ["--import", "tsx", "src/request-cli.ts", "--write-meta", meta, server.url("/error")], + { cwd: join(import.meta.dirname, ".."), timeout: 15000 }, + (error, stdout, stderr) => resolve({ code: error?.code, stdout, stderr }), + ); + }); + expect(result.code).toBe(3); + expect(result.stdout).toBe(body); + expect(result.stderr).toContain("E_HTTP"); + expect(JSON.parse(await readFile(meta, "utf8"))).toMatchObject({ status: 422 }); + }); + + it.each([ + { flags: [], file: false }, + { flags: ["--sse-json"], file: false }, + { flags: ["--stream"], file: false }, + { flags: ["--sse"], file: false }, + { flags: ["--stream"], file: true }, + { flags: ["--sse"], file: true }, + ])( + "classifies interrupted unpaid error bodies as network failures through the CLI ($flags, file=$file)", + async ({ flags, file }) => { + const home = await useTempHome(); + const output = join(home, "partial.txt"); + const server = await testServer((_request, response) => { + response.writeHead(500, { "content-length": "100" }); + response.write("partial error"); + setTimeout(() => response.destroy(), 50); + }); + const result = await new Promise<{ + code: number | string | null | undefined; + stderr: string; + }>((resolve) => { + execFile( + process.execPath, + [ + "--import", + "tsx", + "src/request-cli.ts", + ...flags, + ...(file ? ["-o", output] : []), + server.url("/error"), + ], + { cwd: join(import.meta.dirname, ".."), timeout: 15000 }, + (error, _stdout, stderr) => resolve({ code: error?.code, stderr }), + ); + }); + expect(result.code).toBe(3); + expect(result.stderr).toContain("E_NETWORK"); + }, + ); + + it("preserves output errors and cancels an unfinished streaming body", async () => { + const home = await useTempHome(); + const server = await testServer((_request, response) => { + response.writeHead(200); + response.write("still streaming"); + }); + const result = await new Promise<{ + code: number | string | null | undefined; + stderr: string; + }>((resolve) => { + execFile( + process.execPath, + ["--import", "tsx", "src/request-cli.ts", "--stream", "-o", home, server.url("/stream")], + { cwd: join(import.meta.dirname, ".."), timeout: 5000 }, + (error, _stdout, stderr) => resolve({ code: error?.code, stderr }), + ); + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("EISDIR"); + expect(result.stderr).not.toContain("E_NETWORK"); + }); + + it("keeps actual CLI SSE error output valid NDJSON", async () => { + const server = await testServer((_request, response) => { + response.statusCode = 500; + response.end("broken"); + }); + const result = await new Promise<{ + code: number | string | null | undefined; + stdout: string; + }>((resolve) => { + execFile( + process.execPath, + ["--import", "tsx", "src/request-cli.ts", "--sse-json", server.url("/error")], + { cwd: join(import.meta.dirname, ".."), timeout: 15000 }, + (error, stdout) => resolve({ code: error?.code, stdout }), + ); + }); + expect(result.code).toBe(3); + expect(JSON.parse(result.stdout)).toMatchObject({ event: "error", status: 500 }); + expect(result.stdout.trim().split("\n")).toHaveLength(1); + }); + + it("preserves safe references after an ambiguous paid fetch without leaking credentials", async () => { + const challenge = Challenge.from({ + id: "recovery-test", + intent: "charge", + method: "tempo", + realm: "example.com", + request: { amount: "1", currency: "token", recipient: "recipient" }, + }); + const hash = `0x${"a".repeat(64)}`; + const credential = Credential.serialize({ + challenge, + payload: { type: "hash", hash, secret: "never-print" }, + }); + const fetchImpl = vi.fn().mockRejectedValue(new Error(`timeout ${credential}`)); + const options = parseRequestArgs(["https://example.com"]); + await expect( + fetchPaidRequest({ url: options.url, init: {} }, options, credential, fetchImpl), + ).rejects.toMatchObject({ + code: "E_PAYMENT_OUTCOME_UNKNOWN", + retryable: false, + message: expect.stringContaining(`Transaction hash: ${hash}`), + }); + const error = await fetchPaidRequest( + { url: options.url, init: {} }, + options, + credential, + fetchImpl, + ).catch((error) => error); + expect(error.message).toContain("recovery-test"); + expect(error.message).not.toContain(credential); + expect(error.message).not.toContain("never-print"); + }); + + it("never retries a credential-bearing request after an ambiguous failure", async () => { + const challenge = Challenge.from({ + id: "single-attempt", + intent: "charge", + method: "tempo", + realm: "example.com", + request: { amount: "1", currency: "token", recipient: "recipient" }, + }); + const credential = Credential.serialize({ challenge, payload: { test: true } }); + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new Error("connection lost")) + .mockResolvedValueOnce(new Response("replayed", { status: 402 })); + + await expect( + fetchPaidRequest( + { init: {}, url: "https://example.com/paid" }, + { ...requestOptions("https://example.com/paid"), retries: 1 }, + credential, + fetchImpl, + ), + ).rejects.toMatchObject({ code: "E_PAYMENT_OUTCOME_UNKNOWN" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not forward payment credentials across origins", async () => { + const challenge = Challenge.from({ + id: "redirect", + intent: "charge", + method: "tempo", + realm: "example.com", + request: { amount: "1", currency: "token", recipient: "recipient" }, + }); + const credential = Credential.serialize({ challenge, payload: { test: true } }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(null, { headers: { location: "https://other.example/result" }, status: 302 }), + ); + + await expect( + fetchPaidRequest( + { init: { headers: { authorization: credential } }, url: "https://example.com/paid" }, + { ...requestOptions("https://example.com/paid"), followRedirects: true }, + credential, + fetchImpl, + ), + ).rejects.toMatchObject({ code: "E_PAYMENT_OUTCOME_UNKNOWN" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("preserves a final session 402 instead of replacing it with charge advice", async () => { + const home = await useTempHome(); + const output = join(home, "session-402.txt"); + const headers = join(home, "session-402.headers"); + const meta = join(home, "session-402.json"); + const currency = "0x20c000000000000000000000b9537d11c60e8b50"; + const session = paymentChallenge({ + amount: "1", + id: "session-final-402", + intent: "session", + chainId: 4217, + currency, + sessionProtocol: "v2", + }); + const charge = paymentChallenge({ + amount: "1", + id: "charge-fallback", + intent: "charge", + chainId: 4217, + currency, + }); + mockSessionPayment(session, `0x${"6".repeat(64)}`); + const server = await testServer((request, response) => { + response.statusCode = 402; + if (!request.headers.authorization) + response.setHeader( + "www-authenticate", + [session, charge].map((challenge) => Challenge.serialize(challenge)).join(", "), + ); + response.end(request.headers.authorization ? "session payment rejected" : "payment required"); + }); + + const error = await runRequest( + [ + "--private-key", + `0x${"1".repeat(64)}`, + "-D", + headers, + "--write-meta", + meta, + "-o", + output, + server.url("/paid"), + ], + { stdout: captureStdout() }, + ).catch((error) => error); + + expect(error).toMatchObject({ code: "E_HTTP", exitCode: 3, message: "HTTP 402" }); + expect(error.message).not.toContain("one-time charge"); + expect(await readFile(output, "utf8")).toBe("session payment rejected"); + expect(await readFile(headers, "utf8")).toContain("HTTP 402"); + expect(JSON.parse(await readFile(meta, "utf8"))).toMatchObject({ status: 402 }); + }); + + it.each([200, 500])( + "preserves a session %s response before reporting receipt processing failure", + async (status) => { + const home = await useTempHome(); + const output = join(home, `session-${status}.txt`); + const meta = join(home, `session-${status}.json`); + const session = paymentChallenge({ + amount: "1", + id: `session-receipt-${status}`, + intent: "session", + chainId: 4217, + currency: "0x20c000000000000000000000b9537d11c60e8b50", + sessionProtocol: "v2", + }); + const channelId = `0x${"7".repeat(64)}`; + mockSessionPayment(session, channelId); + const server = await testServer((request, response) => { + if (!request.headers.authorization) { + response.statusCode = 402; + response.setHeader("www-authenticate", Challenge.serialize(session)); + response.end("payment required"); + return; + } + response.statusCode = status; + response.setHeader("payment-receipt", "not-a-valid-receipt"); + response.end(`paid response ${status}`); + }); + + const error = await runRequest( + [ + "--private-key", + `0x${"1".repeat(64)}`, + "--write-meta", + meta, + "-o", + output, + server.url("/paid"), + ], + { stdout: captureStdout() }, + ).catch((error) => error); + + expect(error).toMatchObject({ code: "E_PAYMENT_OUTCOME_UNKNOWN", exitCode: 4 }); + expect(error.message).toContain(channelId); + expect(error.message).not.toContain("one-time charge"); + expect(await readFile(output, "utf8")).toBe(`paid response ${status}`); + expect(JSON.parse(await readFile(meta, "utf8"))).toMatchObject({ status }); + }, + ); + it("performs a non-payment GET request", async () => { const server = await testServer((_request, response) => { response.end("hello world"); @@ -271,7 +699,7 @@ describe("request command", () => { await expect( runRequest(["--sse-json", server.url("/stream")], { stdout }), - ).rejects.toMatchObject({ code: "E_NETWORK" }); + ).rejects.toMatchObject({ code: "E_HTTP" }); const line = JSON.parse(stdout.text().trim()) as Record; expect(line).toMatchObject({ event: "error" }); @@ -1043,6 +1471,18 @@ function paymentChallenge(options: { }); } +function mockSessionPayment(challenge: Challenge.Challenge, channelId: string) { + const credential = Credential.serialize({ challenge, payload: { channelId } }); + vi.spyOn(TempoActions.token, "getBalance").mockResolvedValue({ amount: 1_000_000n } as never); + vi.spyOn(Mppx, "create").mockReturnValue({ + onChallengeReceived: vi.fn(), + createCredential: vi.fn().mockResolvedValue(credential), + transport: { + setCredential: (init: RequestInit) => ({ ...init, headers: { authorization: credential } }), + }, + } as unknown as ReturnType); +} + function sessionDescriptor() { return { authorizedSigner: testAccessKey,