diff --git a/README.md b/README.md index 492b3f7..5408192 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,11 @@ installWebSocketGateway(adapter, connector, { }); ``` -Payment headers, hop-by-hop headers, `host`, `content-length`, and lease tokens are never forwarded -upstream. Dynamic route params use `[name]` segments and upstream placeholders must come from the -matched public path. +Client-supplied payment headers (including all `x-x402-*` names), hop-by-hop headers, `host`, +`content-length`, and lease tokens are never forwarded upstream. The proxy itself injects trusted +`x-x402-*` payment-metadata headers on paid upstream requests (see +[Payment metadata forwarding](#payment-metadata-forwarding)). Dynamic route params use `[name]` +segments and upstream placeholders must come from the matched public path. ## Header forwarding @@ -254,6 +256,56 @@ Responses always forward a safe default set (`content-type`, `content-dispositio the proxy re-frames the body (`fetch` transparently decompresses upstream responses). Upstream `3xx` responses are relayed verbatim (`Location` included) rather than followed. +## Payment metadata forwarding + +For every paid upstream request the proxy injects trusted `x-x402-*` headers describing the verified +payment, so upstreams (e.g. a host API mounting this SDK in-process) can do payer/transaction-level +accounting. Values come from the verified accepted payment requirements and the settle result — never +from client-controlled payload fields. + +| Header | Value | +| --- | --- | +| `x-x402-payment-id` | SDK-generated UUID for this verified payment; correlates with `onPaymentSettled` | +| `x-x402-resource-id` | Resource id, **always `encodeURIComponent`-encoded** (ids may contain `/`, spaces, `%`, ...) — decode before comparing | +| `x-x402-scheme` | Accepted payment scheme (e.g. `exact`) | +| `x-x402-network` | Accepted CAIP-2 network | +| `x-x402-amount` | Accepted on-chain amount (base units) | +| `x-x402-asset` | Accepted asset address | +| `x-x402-pay-to` | Accepted payout address | +| `x-x402-payer` | Settled payer address (settle result) — only when settlement precedes the upstream request | +| `x-x402-transaction` | Settlement transaction hash — only when settlement precedes the upstream request | + +Per-kind availability: + +| Kind | Headers on the upstream request | +| --- | --- | +| `http` | All except `x-x402-payer`/`x-x402-transaction` (settles after the upstream accepts) — correlate via `x-x402-payment-id` + `onPaymentSettled` | +| `http-stream-direct` | Same as `http` | +| `http-stream` (lease relay) | Full set including `x-x402-payer` and `x-x402-transaction` (settlement happened at lease issuance; the details ride inside the signed lease token, so leases minted before 0.2.1 relay without metadata headers) | + +To be notified of every successful settlement — including `http`/`http-stream-direct`, whose +`payer`/`transaction` are only known post-request — supply `onPaymentSettled`: + +```ts +const sdk = createX402ProxySdk({ + // ... + onPaymentSettled: async (event) => { + // event: { paymentId, resourceId, kind, transaction, network, payer?, requirements, settledAt } + await accounting.recordSettlement(event.paymentId, event.payer, event.transaction, event.requirements); + }, +}); +``` + +The callback fires for all resource kinds and is fire-and-forget: a throwing or rejecting callback +never affects the payment or the proxied response. Set `forwardPaymentMetadata: false` to disable the +upstream headers; the `onPaymentSettled` callback fires regardless. + +**Security.** The nine `x-x402-*` metadata names are internal payment headers: client-supplied values +for them are always stripped and can never reach the upstream — not even when a resource's +`forwardRequestHeaders` explicitly lists them — so any `x-x402-*` value an upstream sees was injected +by the proxy. Metadata values are additionally guarded (printable ASCII only, resource ids +URI-encoded); an invalid value is skipped rather than breaking the paid request. + ## Request bodies The proxy forwards `POST`, `PUT`, `PATCH`, and `DELETE` bodies. When no body parser has consumed the diff --git a/package.json b/package.json index b358d22..678d843 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/x402-proxy", - "version": "0.2.0", + "version": "0.2.1", "description": "TypeScript SDK for x402-protected HTTP and WebSocket proxy endpoints.", "repository": { "type": "git", diff --git a/src/headerPolicy.ts b/src/headerPolicy.ts index cf1277c..504d616 100644 --- a/src/headerPolicy.ts +++ b/src/headerPolicy.ts @@ -1,6 +1,6 @@ import type { Request, Response } from "express"; -import type { X402HeaderPolicy, X402HeaderPreset, X402ResourceAccess } from "./types"; +import type { X402HeaderPolicy, X402HeaderPreset, X402PaymentMetadata, X402ResourceAccess } from "./types"; const HOP_BY_HOP_HEADERS = new Set([ "connection", @@ -13,13 +13,33 @@ const HOP_BY_HOP_HEADERS = new Set([ "upgrade", ]); -const INTERNAL_PAYMENT_HEADERS = new Set([ +/** + * SDK-minted payment-metadata header names, keyed by `X402PaymentMetadata` field. All + * nine are registered as internal payment headers, so client-supplied values can never + * reach the upstream (not even via a resource's `forwardRequestHeaders` allowlist) — + * an upstream can therefore trust that any value it sees was injected by the proxy via + * `applyPaymentMetadataHeaders`. + */ +export const PAYMENT_METADATA_HEADERS = { + paymentId: "x-x402-payment-id", + resourceId: "x-x402-resource-id", + scheme: "x-x402-scheme", + network: "x-x402-network", + amount: "x-x402-amount", + asset: "x-x402-asset", + payTo: "x-x402-pay-to", + payer: "x-x402-payer", + transaction: "x-x402-transaction", +} as const; + +const INTERNAL_PAYMENT_HEADERS = new Set([ "payment-signature", "payment-required", "payment-response", "x-payment", "x-payment-response", "x-x402-lease", + ...Object.values(PAYMENT_METADATA_HEADERS), ]); /** @@ -225,3 +245,67 @@ export function applyServiceTokenAccess(headers: Headers, access?: X402ResourceA } headers.set(serviceTokenHeader, serviceTokenValue); } + +/** + * Conservative payment-metadata value guard: printable ASCII only (0x20-0x7E) with no + * leading/trailing whitespace and at least one visible character. Stricter than + * `isValidHttpHeaderValue` on purpose — metadata values come from facilitator/resource + * data and there is no legitimate reason for them to contain non-ASCII or padding. + */ +function isSafePaymentMetadataValue(value: string): boolean { + return /^[\x21-\x7E](?:[\x20-\x7E]*[\x21-\x7E])?$/.test(value); +} + +function setPaymentMetadataHeader(headers: Headers, name: string, value: string | undefined): void { + if (value === undefined || !isSafePaymentMetadataValue(value)) { + return; + } + headers.set(name, value); +} + +/** + * `encodeURIComponent` that never throws: a lone surrogate (e.g. `"\uD800"`) in the input + * makes the built-in throw `URIError`. Resource ids are operator-controlled but only + * validated as non-empty strings (and custom stores may skip validation entirely), so a + * mid-proxy throw here would 500 an otherwise-valid paid request. Returns null when the id + * cannot be encoded, so the caller omits the header rather than failing the request. + */ +function tryEncodeResourceId(resourceId: string): string | null { + try { + return encodeURIComponent(resourceId); + } catch { + return null; + } +} + +/** + * Inject the trusted `x-x402-*` payment-metadata headers on an outbound upstream + * request. This is the single trusted injection point for these names: it writes via + * `headers.set` directly and deliberately does NOT consult `shouldDropProxyHeader`, + * because all nine names are internal payment headers (which exist precisely to strip + * client-supplied values before this runs). + * + * Value handling: + * - `resourceId` is written `encodeURIComponent`-encoded — resource ids may contain + * path-hostile characters (`/`, spaces, `%`, non-ASCII, ...); upstreams must decode + * `x-x402-resource-id` before comparing. An id that cannot be encoded (lone surrogate) + * is skipped rather than allowed to throw. + * - `paymentId` is SDK-generated (`randomUUID`) and written as-is. + * - Every other value is written raw only if it passes a conservative printable-ASCII + * guard; invalid values are skipped silently (a mid-proxy throw must never break a + * paid request), and absent optionals (`payer`, `transaction`) are simply omitted. + */ +export function applyPaymentMetadataHeaders(headers: Headers, metadata: X402PaymentMetadata): void { + headers.set(PAYMENT_METADATA_HEADERS.paymentId, metadata.paymentId); + const encodedResourceId = tryEncodeResourceId(metadata.resourceId); + if (encodedResourceId !== null) { + headers.set(PAYMENT_METADATA_HEADERS.resourceId, encodedResourceId); + } + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.scheme, metadata.scheme); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.network, metadata.network); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.amount, metadata.amount); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.asset, metadata.asset); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.payTo, metadata.payTo); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.payer, metadata.payer); + setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.transaction, metadata.transaction); +} diff --git a/src/httpProxy.ts b/src/httpProxy.ts index c05f51d..b370d3c 100644 --- a/src/httpProxy.ts +++ b/src/httpProxy.ts @@ -9,13 +9,19 @@ import { UpstreamRequestError, UpstreamTimeoutError, } from "./errors"; -import { applyServiceTokenAccess, applyUpstreamResponseHeaders, createForwardHeaders } from "./headerPolicy"; +import { + applyPaymentMetadataHeaders, + applyServiceTokenAccess, + applyUpstreamResponseHeaders, + createForwardHeaders, +} from "./headerPolicy"; import { interpolateUpstreamUrl } from "./routePattern"; import type { HttpMethod, HttpProxyEndpointConfig, SecurityConfig, X402HeaderPolicy, + X402PaymentMetadata, X402ResourceAccess, } from "./types"; @@ -176,6 +182,12 @@ export type ProxyHttpRequestInput = { securityConfig?: SecurityConfig; params?: Record; excludeQueryParams?: string[]; + /** + * Trusted payment metadata to inject as `x-x402-*` headers on the upstream request + * (after the header policy and service-token injection, so it always wins). Client + * values for these names are stripped by the header policy regardless. + */ + paymentMetadata?: X402PaymentMetadata; }; export type BufferedProxyResponse = { @@ -295,6 +307,9 @@ export async function proxyBufferedHttpRequest(input: ProxyHttpRequestInput): Pr const headers = createForwardHeaders(input.req, input.target.headers); applyServiceTokenAccess(headers, input.target.access); + if (input.paymentMetadata) { + applyPaymentMetadataHeaders(headers, input.paymentMetadata); + } const requestInit: RequestInit = { method: input.req.method, headers, @@ -382,6 +397,9 @@ export async function openStreamingUpstream(input: ProxyHttpRequestInput): Promi const headers = createForwardHeaders(input.req, input.target.headers); applyServiceTokenAccess(headers, input.target.access); + if (input.paymentMetadata) { + applyPaymentMetadataHeaders(headers, input.paymentMetadata); + } const requestInit: RequestInit = { method: input.req.method, headers, diff --git a/src/index.ts b/src/index.ts index 733420f..cf177c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ export type { X402HeaderPolicy, X402HeaderPreset, X402LoadedResource, + X402PaymentMetadata, + X402PaymentSettledEvent, X402ProxyDiagnostics, X402ProxySdk, X402ProxySdkConfig, @@ -53,11 +55,13 @@ export { webSocketGatewayEndpointsFromResources, } from "./wsGateway"; export { + applyPaymentMetadataHeaders, applyServiceTokenAccess, applyUpstreamResponseHeaders, createForwardHeaders, isValidHttpHeaderName, isValidHttpHeaderValue, + PAYMENT_METADATA_HEADERS, shouldDropProxyHeader, } from "./headerPolicy"; export { @@ -99,6 +103,7 @@ export { verifyHttpStreamLeaseToken, type HttpStreamLeaseIssueResult, type HttpStreamLeasePayload, + type HttpStreamLeasePaymentInfo, type X402LeaseUseStore, } from "./streamLease"; export { X402ResourceRuntime, endpointToResource } from "./resourceRuntime"; diff --git a/src/install.ts b/src/install.ts index 6a671c1..229868a 100644 --- a/src/install.ts +++ b/src/install.ts @@ -176,11 +176,15 @@ export function createX402ProxySdk(config: X402ProxySdkConfig): X402ProxySdk { leaseTokenSecret: config.leaseTokenSecret, syncFacilitatorOnStart: config.syncFacilitatorOnStart ?? true, requireProtectedResources: config.requireProtectedResources ?? true, + forwardPaymentMetadata: config.forwardPaymentMetadata ?? true, paywallConfig, }; if (config.paywall) { runtimeInput.paywall = config.paywall; } + if (config.onPaymentSettled) { + runtimeInput.onPaymentSettled = config.onPaymentSettled; + } if (config.security) { runtimeInput.security = config.security; } diff --git a/src/resourceRuntime.ts b/src/resourceRuntime.ts index 76336dd..c690777 100644 --- a/src/resourceRuntime.ts +++ b/src/resourceRuntime.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { ExpressAdapter } from "@x402/express"; import { @@ -6,6 +6,7 @@ import { type HTTPProcessResult, type PaywallConfig, type PaywallProvider, + type ProcessSettleSuccessResponse, type RouteConfig, } from "@x402/core/server"; import type { PaymentRequirements, PaymentPayload, Network } from "@x402/core/types"; @@ -42,6 +43,8 @@ import { InMemoryX402LeaseUseStore, issueHttpStreamLease, verifyHttpStreamLeaseToken, + type HttpStreamLeasePayload, + type HttpStreamLeasePaymentInfo, type X402LeaseUseStore, } from "./streamLease"; import { issueLease } from "./wsLease"; @@ -52,6 +55,8 @@ import type { X402AccessEvent, X402AccessEventStore, X402LoadedResource, + X402PaymentMetadata, + X402PaymentSettledEvent, X402ProxyDiagnostics, X402Resource, X402ResourceMatch, @@ -83,13 +88,19 @@ export type X402ResourceRuntimeOptions = { initialResources?: X402Resource[]; paywall?: PaywallProvider; paywallConfig?: PaywallConfig; + /** Inject trusted `x-x402-*` payment-metadata headers on upstream requests. Defaults to true. */ + forwardPaymentMetadata?: boolean; + /** Fire-and-forget settlement notification; errors never affect the paid request. */ + onPaymentSettled?: (event: X402PaymentSettledEvent) => void | Promise; }; type PaymentVerifiedResult = Extract; /** * Outcome of the payment phase for a matched paid resource: - * - "verified": a valid payment was presented; the caller settles, then serves. + * - "verified": a valid payment was presented; the caller settles, then serves. Each + * verified payment gets a fresh SDK-generated `paymentId` that correlates the + * upstream metadata headers with the later `onPaymentSettled` event. * - "responded": a response (402 challenge, payment error, 503) was already sent. * * There is deliberately no "granted" (serve-without-payment) outcome: this SDK never @@ -98,7 +109,7 @@ type PaymentVerifiedResult = Extract void | Promise) | undefined; + private loaded: LoadedResourceInternal[] = []; private invalid: X402ResourceValidationIssue[] = []; @@ -442,6 +548,8 @@ export class X402ResourceRuntime { // freshly created HTTP server. this.paywall = options.paywall; this.paywallConfig = options.paywallConfig; + this.forwardPaymentMetadata = options.forwardPaymentMetadata ?? true; + this.onPaymentSettled = options.onPaymentSettled; if (options.initialResources) { this.loadResources(options.initialResources, Date.now()); } @@ -740,18 +848,25 @@ export class X402ResourceRuntime { verifiedEvent.payer = payer; } await this.recordEvent(verifiedEvent); - return { kind: "verified", payment: result }; + return { kind: "verified", payment: result, paymentId: randomUUID() }; } + /** + * Settle a verified payment. Returns the full settle success result (payer, + * transaction, network, ...) so callers can notify the host and enrich lease tokens; + * returns null on failure, in which case the 402 response has already been sent (or + * there was no HTTP server generation to settle against). + */ private async settlePayment( req: Request, res: Response, resource: X402Resource, payment: PaymentVerifiedResult, - ): Promise { + paymentId: string, + ): Promise { const httpServer = this.httpServer; if (!httpServer) { - return false; + return null; } const settleResult = await httpServer.processSettlement( @@ -774,7 +889,7 @@ export class X402ResourceRuntime { }), ); res.status(402).json({ error: "Settlement failed", details: settleResult.errorReason }); - return false; + return null; } applySettlementHeaders(res, settleResult); @@ -797,7 +912,54 @@ export class X402ResourceRuntime { settledEvent.transaction = transaction; } await this.recordEvent(settledEvent); - return true; + this.notifyPaymentSettled(resource, payment, paymentId, settleResult); + return settleResult; + } + + /** + * Fire the host's `onPaymentSettled` callback for a successful settlement, + * fire-and-forget: like audit-store writes, a throwing or rejecting callback must + * never affect the user-facing result of a paid request, so both synchronous throws + * and rejections are swallowed. + */ + private notifyPaymentSettled( + resource: X402Resource, + payment: PaymentVerifiedResult, + paymentId: string, + settleResult: ProcessSettleSuccessResponse, + ): void { + const onPaymentSettled = this.onPaymentSettled; + if (!onPaymentSettled) { + return; + } + // Server-matched requirement, not the client-submitted `paymentPayload.accepted` + // (see toPaymentMetadata) — the event reports what was actually charged. + const requirements = payment.paymentRequirements; + const event: X402PaymentSettledEvent = { + paymentId, + resourceId: resource.id, + kind: resource.kind, + transaction: settleResult.transaction, + network: settleResult.network, + requirements: { + scheme: requirements.scheme, + network: requirements.network, + amount: requirements.amount, + asset: requirements.asset, + payTo: requirements.payTo, + }, + settledAt: Date.now(), + }; + if (typeof settleResult.payer === "string") { + event.payer = settleResult.payer; + } + try { + Promise.resolve(onPaymentSettled(event)).catch(() => { + // Settlement notifications are best-effort; the payment is already settled. + }); + } catch { + // A synchronously throwing callback is equally non-fatal. + } } private findPublicResource(req: Request, method: string, path: string): { resource: X402Resource; match: RouteMatch } | null { @@ -850,10 +1012,13 @@ export class X402ResourceRuntime { if (this.security) { proxyInput.securityConfig = this.security; } + if (this.forwardPaymentMetadata) { + proxyInput.paymentMetadata = toPaymentMetadata(resource, outcome.payment, outcome.paymentId); + } const upstreamResult = await proxyBufferedHttpRequest(proxyInput); if (upstreamResult.status < 400) { - const settled = await this.settlePayment(req, res, resource, outcome.payment); - if (!settled) { + const settleResult = await this.settlePayment(req, res, resource, outcome.payment, outcome.paymentId); + if (!settleResult) { return; } } @@ -888,6 +1053,9 @@ export class X402ResourceRuntime { if (this.security) { proxyInput.securityConfig = this.security; } + if (this.forwardPaymentMetadata) { + proxyInput.paymentMetadata = toPaymentMetadata(resource, outcome.payment, outcome.paymentId); + } const connection = await openStreamingUpstream(proxyInput); if (!connection) { // Client disconnected during connection establishment: nothing was settled and @@ -895,8 +1063,8 @@ export class X402ResourceRuntime { return; } if (connection.response.status < 400) { - const settled = await this.settlePayment(req, res, resource, outcome.payment); - if (!settled) { + const settleResult = await this.settlePayment(req, res, resource, outcome.payment, outcome.paymentId); + if (!settleResult) { // 402 already sent by settlePayment; stop the upstream run. connection.abort(); return; @@ -911,8 +1079,8 @@ export class X402ResourceRuntime { return; } - const settled = await this.settlePayment(req, res, resource, outcome.payment); - if (!settled) { + const settleResult = await this.settlePayment(req, res, resource, outcome.payment, outcome.paymentId); + if (!settleResult) { return; } const baseUrl = inferRequestBaseUrl(req, this.discovery?.publicBaseUrl); @@ -943,7 +1111,13 @@ export class X402ResourceRuntime { return; } - const lease = issueHttpStreamLease(resource, this.leaseTokenSecret, baseUrl, match.params); + const lease = issueHttpStreamLease( + resource, + this.leaseTokenSecret, + baseUrl, + match.params, + toLeasePaymentInfo(outcome.payment, outcome.paymentId, settleResult), + ); await this.recordEvent( createAccessEvent({ resourceId: resource.id, @@ -1025,6 +1199,14 @@ export class X402ResourceRuntime { if (this.security) { proxyInput.securityConfig = this.security; } + if (this.forwardPaymentMetadata) { + // Leases minted before 0.2.1 carry no payment fields; relay them without + // metadata headers rather than failing. + const metadata = leasePaymentMetadata(resource, payload); + if (metadata) { + proxyInput.paymentMetadata = metadata; + } + } await proxyStreamingHttpRequest(proxyInput); } diff --git a/src/streamLease.ts b/src/streamLease.ts index f4f73fb..e388b0a 100644 --- a/src/streamLease.ts +++ b/src/streamLease.ts @@ -3,6 +3,24 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { LeaseTokenError } from "./errors"; import type { HttpMethod, X402Resource } from "./types"; +/** + * Optional settled-payment details embedded in a lease token at issuance (0.2.1+), so + * the later relay request can forward trusted payment metadata — including + * `payer`/`transaction`, which are known here because lease issuance settles before + * the stream request happens. Tokens minted by older versions simply lack these + * fields; verification and relay treat them as absent. + */ +export type HttpStreamLeasePaymentInfo = { + paymentId?: string; + payer?: string; + transaction?: string; + scheme?: string; + network?: string; + amount?: string; + asset?: string; + payTo?: string; +}; + export type HttpStreamLeasePayload = { resourceId: string; exp: number; @@ -10,7 +28,7 @@ export type HttpStreamLeasePayload = { method: HttpMethod; publicPath: string; upstreamUrl: string; -}; +} & HttpStreamLeasePaymentInfo; export type HttpStreamLeaseIssueResult = { token: string; @@ -110,6 +128,7 @@ export function issueHttpStreamLease( secret: string, requestBaseUrl: URL, params: Record, + payment?: HttpStreamLeasePaymentInfo, ): HttpStreamLeaseIssueResult { if (!resource.stream) { throw new LeaseTokenError("Stream resource is missing lease config", { resourceId: resource.id }); @@ -118,6 +137,7 @@ export function issueHttpStreamLease( const exp = now + resource.stream.leaseSeconds; const token = createHttpStreamLeaseToken( { + ...payment, resourceId: resource.id, exp, jti: randomUUID(), diff --git a/src/types.ts b/src/types.ts index 67ff178..29917f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,51 @@ export type X402AccessEvent = { createdAt: number; }; +/** + * Trusted, SDK-minted payment metadata injected as `x-x402-*` request headers on the + * upstream call (see `applyPaymentMetadataHeaders`). Values come from the VERIFIED + * accepted payment requirements and the SDK itself — never from client-controlled + * scheme payload fields. `payer`/`transaction` are present only when settlement + * completed before the upstream request (the `http-stream` lease relay); `http` and + * `http-stream-direct` settle after the upstream accepts, so those kinds correlate + * settlement via `paymentId` and the `onPaymentSettled` event instead. + */ +export type X402PaymentMetadata = { + paymentId: string; + resourceId: string; + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + payer?: string; + transaction?: string; +}; + +/** + * Host notification emitted after every successful settlement, for all resource kinds. + * Delivery is fire-and-forget: a throwing or rejecting callback never affects the paid + * request. Correlate with the upstream request via `paymentId` (`x-x402-payment-id`). + * `transaction`/`network`/`payer` come from the facilitator settle result; + * `requirements` echoes the verified accepted payment requirements. + */ +export type X402PaymentSettledEvent = { + paymentId: string; + resourceId: string; + kind: X402Resource["kind"]; + transaction: string; + network: string; + payer?: string; + requirements: { + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + }; + settledAt: number; +}; + export interface X402ResourceStore { listEnabledResources(): Promise; getResourceById(id: string): Promise; @@ -235,6 +280,20 @@ export type X402ProxySdkConfig = { discovery?: DiscoveryConfig; security?: SecurityConfig; syncFacilitatorOnStart?: boolean; + /** + * Inject trusted `x-x402-*` payment-metadata headers on upstream requests for paid + * traffic (default true). Controls the upstream headers only — `onPaymentSettled` + * fires regardless of this flag. Client-supplied `x-x402-*` headers are always + * stripped whether this is enabled or not. + */ + forwardPaymentMetadata?: boolean; + /** + * Called after every successful settlement (all resource kinds), fire-and-forget: + * rejections and thrown errors are swallowed and never affect the payment or the + * proxied response. Use it for payer/transaction-level accounting, correlating with + * the upstream request via `event.paymentId` (`x-x402-payment-id`). + */ + onPaymentSettled?: (event: X402PaymentSettledEvent) => void | Promise; /** * Optional paywall provider that renders the full wallet-connect payment UI for * browser requests hitting protected endpoints (e.g. from @x402/paywall: diff --git a/src/validation.ts b/src/validation.ts index 0ebc4ad..8a067ef 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -158,6 +158,14 @@ export function validateProxySdkConfig(config: X402ProxySdkConfig): void { errors.push("defaultPayTo must be a non-empty string"); } + if (config.forwardPaymentMetadata !== undefined && typeof config.forwardPaymentMetadata !== "boolean") { + errors.push("forwardPaymentMetadata must be a boolean when provided"); + } + + if (config.onPaymentSettled !== undefined && typeof config.onPaymentSettled !== "function") { + errors.push("onPaymentSettled must be a function when provided"); + } + const seenIds = new Set(); const seenHttpRoutes = new Set(); const seenLeasePaths = new Set(); diff --git a/tests/helpers/x402.ts b/tests/helpers/x402.ts index 8849e38..22b3133 100644 --- a/tests/helpers/x402.ts +++ b/tests/helpers/x402.ts @@ -56,7 +56,12 @@ export function createFacilitatorApp(controls: FacilitatorControls): Express { res.json({ success: false, errorReason: "insufficient_funds" }); return; } - res.json({ success: true, transaction: "0xsettled", network }); + res.json({ + success: true, + transaction: "0xsettled", + network, + payer: "0xPayerAddress00000000000000000000000000001", + }); }); return app; } diff --git a/tests/integration/payment-metadata.integration.test.ts b/tests/integration/payment-metadata.integration.test.ts new file mode 100644 index 0000000..b180b37 --- /dev/null +++ b/tests/integration/payment-metadata.integration.test.ts @@ -0,0 +1,465 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import express, { type Request, type Response as ExpressResponse } from "express"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHttpStreamLeaseToken, createX402ProxySdk, InMemoryX402ResourceStore } from "../../src"; +import type { X402PaymentSettledEvent, X402Resource } from "../../src/types"; +import { + buildPaymentHeader, + createFacilitatorApp, + readPaymentRequirement, + startExpressServer, + type FacilitatorControls, + type RunningServer, +} from "../helpers/x402"; + +const NETWORK = "eip155:8453"; +const SETTLE_PAYER = "0xPayerAddress00000000000000000000000000001"; +const SETTLE_TRANSACTION = "0xsettled"; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Metadata header names asserted throughout (mirrors PAYMENT_METADATA_HEADERS). */ +const METADATA_HEADER_NAMES = [ + "x-x402-payment-id", + "x-x402-resource-id", + "x-x402-scheme", + "x-x402-network", + "x-x402-amount", + "x-x402-asset", + "x-x402-pay-to", + "x-x402-payer", + "x-x402-transaction", +] as const; + +type UpstreamHit = { path: string; headers: Record }; + +describe("payment metadata forwarding integration", () => { + const controls: FacilitatorControls = { settleSucceeds: true, networks: [NETWORK] }; + const order: string[] = []; + const upstreamHits: UpstreamHit[] = []; + const settledEvents: X402PaymentSettledEvent[] = []; + const callbackBehavior: { mode: "none" | "sync-throw" | "reject" } = { mode: "none" }; + + let facilitatorServer: RunningServer; + let upstreamServer: RunningServer; + let proxyServer: RunningServer; + + function resource(overrides: Partial & Pick): X402Resource { + const now = Date.now(); + return { + enabled: true, + method: "POST", + pricing: { amount: "0.01", network: NETWORK, payTo: "0xPayee0000000000000000000000000000000001" }, + createdAt: now, + updatedAt: now, + ...overrides, + }; + } + + beforeAll(async () => { + // Wrap the shared facilitator helper so settle calls land in the order log. + const facilitatorApp = express(); + facilitatorApp.use((req, _res, next) => { + if (req.method === "POST" && req.path === "/settle") { + order.push("settle"); + } + next(); + }); + facilitatorApp.use(createFacilitatorApp(controls)); + facilitatorServer = await startExpressServer(facilitatorApp); + + const upstreamApp = express(); + const capture = (req: Request, res: ExpressResponse): void => { + order.push("upstream"); + upstreamHits.push({ path: req.path, headers: { ...req.headers } }); + res.json({ ok: true, path: req.path }); + }; + upstreamApp.post("/alpha", capture); + upstreamApp.post("/direct", capture); + upstreamApp.post("/spoof", capture); + upstreamApp.post("/chat", (req, res) => { + order.push("upstream"); + upstreamHits.push({ path: req.path, headers: { ...req.headers } }); + res.setHeader("content-type", "text/event-stream"); + res.write("data: one\n\n"); + res.end(); + }); + upstreamServer = await startExpressServer(upstreamApp); + + const sdk = createX402ProxySdk({ + defaultNetwork: NETWORK, + defaultPayTo: "0xPayee0000000000000000000000000000000001", + leaseTokenSecret: "lease-token-secret-with-32-characters", + facilitator: { url: facilitatorServer.url }, + syncFacilitatorOnStart: true, + security: { allowInsecureHttpUpstream: true, allowPrivateIpUpstreams: true }, + onPaymentSettled: (event) => { + settledEvents.push(event); + if (callbackBehavior.mode === "sync-throw") { + throw new Error("host callback exploded synchronously"); + } + if (callbackBehavior.mode === "reject") { + return Promise.reject(new Error("host callback rejected")); + } + return undefined; + }, + resourceStore: new InMemoryX402ResourceStore([ + // Path-hostile id proves the encodeURIComponent contract for x-x402-resource-id. + resource({ + id: "agents/alpha beta", + kind: "http", + publicPath: "/paid/alpha", + upstreamUrl: `${upstreamServer.url}/alpha`, + }), + resource({ + id: "direct-agent", + kind: "http-stream-direct", + publicPath: "/paid/direct", + upstreamUrl: `${upstreamServer.url}/direct`, + }), + resource({ + id: "stream-agent", + kind: "http-stream", + publicPath: "/paid/chat", + upstreamUrl: `${upstreamServer.url}/chat`, + stream: { leasePath: "/paid/chat/lease", leaseSeconds: 60, allowRenewal: false, renewalWindowSeconds: 0 }, + }), + // Explicitly allow-lists the metadata names: spoofed client values must STILL + // never reach the upstream. + resource({ + id: "spoof-agent", + kind: "http", + publicPath: "/paid/spoof", + upstreamUrl: `${upstreamServer.url}/spoof`, + headers: { + forwardRequestHeaders: ["x-x402-payment-id", "x-x402-payer", "x-x402-transaction"], + }, + }), + // WebSocket resource: settlement (and therefore onPaymentSettled) happens on the + // lease POST, before any socket exists. The upstream ws URL is only embedded in the + // lease token, never dialed, so no upstream route is needed. + resource({ + id: "ws-agent", + kind: "websocket", + method: "GET", + publicPath: "/ws/feed", + upstreamUrl: "wss://upstream.test/feed", + stream: { leasePath: "/paid/feed/lease", leaseSeconds: 60, allowRenewal: false, renewalWindowSeconds: 0 }, + }), + ]), + }); + await sdk.refreshResources(); + const app = express(); + sdk.install(app); + proxyServer = await startExpressServer(app); + }); + + afterAll(async () => { + await Promise.all([proxyServer.close(), facilitatorServer.close(), upstreamServer.close()]); + }); + + async function pay(url: string, extraHeaders: Record = {}): Promise { + const { paymentRequired, accepted } = await readPaymentRequirement(url, "POST"); + return fetch(url, { + method: "POST", + headers: { "PAYMENT-SIGNATURE": buildPaymentHeader(paymentRequired, accepted), ...extraHeaders }, + }); + } + + function resetLogs(): void { + order.length = 0; + upstreamHits.length = 0; + settledEvents.length = 0; + } + + it("forwards trusted metadata (no payer/tx) for kind http and notifies the host on settle", async () => { + resetLogs(); + const url = `${proxyServer.url}/paid/alpha`; + const { accepted } = await readPaymentRequirement(url, "POST"); + const response = await pay(url); + expect(response.status).toBe(200); + + const hit = upstreamHits.at(-1); + expect(hit?.path).toBe("/alpha"); + const headers = hit?.headers ?? {}; + expect(headers["x-x402-payment-id"]).toMatch(UUID_REGEX); + expect(headers["x-x402-resource-id"]).toBe("agents%2Falpha%20beta"); + expect(headers["x-x402-scheme"]).toBe(accepted.scheme); + expect(headers["x-x402-network"]).toBe(NETWORK); + expect(headers["x-x402-amount"]).toBe(accepted.amount); + expect(headers["x-x402-asset"]).toBe(accepted.asset); + expect(headers["x-x402-pay-to"]).toBe(accepted.payTo); + // Settlement is post-request for kind http: never present on the upstream call. + expect(headers["x-x402-payer"]).toBeUndefined(); + expect(headers["x-x402-transaction"]).toBeUndefined(); + + expect(settledEvents).toHaveLength(1); + const event = settledEvents[0]; + expect(event?.paymentId).toBe(headers["x-x402-payment-id"]); + expect(event?.resourceId).toBe("agents/alpha beta"); + expect(event?.kind).toBe("http"); + expect(event?.transaction).toBe(SETTLE_TRANSACTION); + expect(event?.network).toBe(NETWORK); + expect(event?.payer).toBe(SETTLE_PAYER); + expect(event?.requirements).toEqual({ + scheme: accepted.scheme, + network: accepted.network, + amount: accepted.amount, + asset: accepted.asset, + payTo: accepted.payTo, + }); + expect(event?.settledAt).toBeGreaterThan(0); + }); + + it("forwards the SERVER requirement, not a tampered x402 v1 client `accepted`, on kind http", async () => { + // F1 anti-spoof: an x402 v1 payment is matched by scheme+network ONLY (never + // deep-equal-checked against the server's accepts), so a client can echo back an + // `accepted` that keeps the real scheme+network but forges amount/asset/payTo. The + // mocked facilitator /verify returns isValid:true without inspecting the payload, so + // the forged payment verifies — the SDK must still source upstream metadata (and the + // settled event) from `payment.paymentRequirements` (the server-matched requirement). + resetLogs(); + const url = `${proxyServer.url}/paid/alpha`; + const { paymentRequired, accepted } = await readPaymentRequirement(url, "POST"); + + const tampered: PaymentRequirements = { + ...accepted, + amount: "0", + asset: "0xATTACKERasset000000000000000000000000dEaD", + payTo: "0xATTACKERpayout00000000000000000000000dEaD", + }; + const response = await fetch(url, { + method: "POST", + headers: { + "PAYMENT-SIGNATURE": buildPaymentHeader({ ...paymentRequired, x402Version: 1 }, tampered), + }, + }); + expect(response.status).toBe(200); + + const hit = upstreamHits.at(-1); + expect(hit?.path).toBe("/alpha"); + const headers = hit?.headers ?? {}; + // Server values win: the forged amount/asset/payTo never reach the upstream. + expect(headers["x-x402-scheme"]).toBe(accepted.scheme); + expect(headers["x-x402-network"]).toBe(NETWORK); + expect(headers["x-x402-amount"]).toBe(accepted.amount); + expect(headers["x-x402-amount"]).not.toBe("0"); + expect(headers["x-x402-asset"]).toBe(accepted.asset); + expect(headers["x-x402-asset"]).not.toBe(tampered.asset); + expect(headers["x-x402-pay-to"]).toBe(accepted.payTo); + expect(headers["x-x402-pay-to"]).not.toBe(tampered.payTo); + + // The settled-event requirements mirror the server values too, never the forgery. + expect(settledEvents).toHaveLength(1); + expect(settledEvents[0]?.requirements).toEqual({ + scheme: accepted.scheme, + network: accepted.network, + amount: accepted.amount, + asset: accepted.asset, + payTo: accepted.payTo, + }); + }); + + it("forwards trusted metadata for http-stream-direct and preserves upstream-then-settle ordering", async () => { + resetLogs(); + const url = `${proxyServer.url}/paid/direct`; + const { accepted } = await readPaymentRequirement(url, "POST"); + const response = await pay(url); + expect(response.status).toBe(200); + await response.text(); + + // Existing contract: connect upstream first, settle only after it accepts. + expect(order).toEqual(["upstream", "settle"]); + + const headers = upstreamHits.at(-1)?.headers ?? {}; + expect(headers["x-x402-payment-id"]).toMatch(UUID_REGEX); + expect(headers["x-x402-resource-id"]).toBe("direct-agent"); + expect(headers["x-x402-scheme"]).toBe(accepted.scheme); + expect(headers["x-x402-network"]).toBe(NETWORK); + expect(headers["x-x402-amount"]).toBe(accepted.amount); + expect(headers["x-x402-asset"]).toBe(accepted.asset); + expect(headers["x-x402-pay-to"]).toBe(accepted.payTo); + expect(headers["x-x402-payer"]).toBeUndefined(); + expect(headers["x-x402-transaction"]).toBeUndefined(); + + expect(settledEvents).toHaveLength(1); + expect(settledEvents[0]?.kind).toBe("http-stream-direct"); + expect(settledEvents[0]?.paymentId).toBe(headers["x-x402-payment-id"]); + expect(settledEvents[0]?.transaction).toBe(SETTLE_TRANSACTION); + expect(settledEvents[0]?.payer).toBe(SETTLE_PAYER); + }); + + it("never lets spoofed client x-x402-* values reach the upstream, even when allow-listed", async () => { + resetLogs(); + const url = `${proxyServer.url}/paid/spoof`; + const response = await pay(url, { + "x-x402-payment-id": "spoofed-payment-id", + "x-x402-payer": "0xEvilPayer", + "x-x402-transaction": "0xForgedTx", + }); + expect(response.status).toBe(200); + + const headers = upstreamHits.at(-1)?.headers ?? {}; + // SDK value wins: a fresh UUID, never the spoofed string. + expect(headers["x-x402-payment-id"]).toMatch(UUID_REGEX); + expect(headers["x-x402-payment-id"]).not.toBe("spoofed-payment-id"); + // Not set by the SDK for kind http, and the client values must be stripped. + expect(headers["x-x402-payer"]).toBeUndefined(); + expect(headers["x-x402-transaction"]).toBeUndefined(); + }); + + it("relays the full metadata set, payer and transaction included, on the http-stream lease flow", async () => { + resetLogs(); + const leaseUrl = `${proxyServer.url}/paid/chat/lease`; + const { accepted } = await readPaymentRequirement(leaseUrl, "POST"); + const leaseResponse = await pay(leaseUrl); + expect(leaseResponse.status).toBe(200); + const lease = (await leaseResponse.json()) as { streamUrl: string }; + + // Lease issuance settles once (before any upstream contact) and notifies the host. + expect(order).toEqual(["settle"]); + expect(settledEvents).toHaveLength(1); + expect(settledEvents[0]?.kind).toBe("http-stream"); + expect(settledEvents[0]?.resourceId).toBe("stream-agent"); + + const streamResponse = await fetch(lease.streamUrl, { method: "POST" }); + expect(streamResponse.status).toBe(200); + expect(await streamResponse.text()).toContain("data: one"); + expect(order).toEqual(["settle", "upstream"]); + + const headers = upstreamHits.at(-1)?.headers ?? {}; + expect(headers["x-x402-payment-id"]).toBe(settledEvents[0]?.paymentId); + expect(headers["x-x402-resource-id"]).toBe("stream-agent"); + expect(headers["x-x402-scheme"]).toBe(accepted.scheme); + expect(headers["x-x402-network"]).toBe(NETWORK); + expect(headers["x-x402-amount"]).toBe(accepted.amount); + expect(headers["x-x402-asset"]).toBe(accepted.asset); + expect(headers["x-x402-pay-to"]).toBe(accepted.payTo); + // Settlement happened at lease issuance, so the relay carries the settle results. + expect(headers["x-x402-payer"]).toBe(SETTLE_PAYER); + expect(headers["x-x402-transaction"]).toBe(SETTLE_TRANSACTION); + // The relay must not fire a second settlement or a second event. + expect(settledEvents).toHaveLength(1); + }); + + it("fires onPaymentSettled with kind websocket on the websocket lease path", async () => { + resetLogs(); + const leaseUrl = `${proxyServer.url}/paid/feed/lease`; + const { accepted } = await readPaymentRequirement(leaseUrl, "POST"); + const response = await pay(leaseUrl); + expect(response.status).toBe(200); + const lease = (await response.json()) as { wsUrl: string; leaseSeconds: number }; + expect(lease.wsUrl).toContain("/ws/feed?t="); + expect(lease.leaseSeconds).toBe(60); + + // Settlement happens at lease issuance, before (and without) any socket or upstream call. + expect(order).toEqual(["settle"]); + expect(settledEvents).toHaveLength(1); + const event = settledEvents[0]; + expect(event?.kind).toBe("websocket"); + expect(event?.resourceId).toBe("ws-agent"); + expect(event?.transaction).toBe(SETTLE_TRANSACTION); + expect(event?.network).toBe(NETWORK); + expect(event?.payer).toBe(SETTLE_PAYER); + expect(event?.requirements).toEqual({ + scheme: accepted.scheme, + network: accepted.network, + amount: accepted.amount, + asset: accepted.asset, + payTo: accepted.payTo, + }); + }); + + it("relays leases minted without payment fields (pre-0.2.1 tokens) with no metadata headers", async () => { + resetLogs(); + const legacyToken = createHttpStreamLeaseToken( + { + resourceId: "stream-agent", + exp: Math.floor(Date.now() / 1000) + 60, + jti: `legacy-${Date.now()}`, + method: "POST", + publicPath: "/paid/chat", + upstreamUrl: `${upstreamServer.url}/chat`, + }, + "lease-token-secret-with-32-characters", + ); + const response = await fetch(`${proxyServer.url}/paid/chat?t=${encodeURIComponent(legacyToken)}`, { + method: "POST", + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("data: one"); + const headers = upstreamHits.at(-1)?.headers ?? {}; + expect(Object.keys(headers).filter((name) => name.startsWith("x-x402-"))).toEqual([]); + }); + + it("completes the paid request normally when onPaymentSettled throws or rejects", async () => { + resetLogs(); + try { + callbackBehavior.mode = "sync-throw"; + const throwing = await pay(`${proxyServer.url}/paid/alpha`); + expect(throwing.status).toBe(200); + expect(await throwing.json()).toEqual({ ok: true, path: "/alpha" }); + + callbackBehavior.mode = "reject"; + const rejecting = await pay(`${proxyServer.url}/paid/alpha`); + expect(rejecting.status).toBe(200); + expect(await rejecting.json()).toEqual({ ok: true, path: "/alpha" }); + + // The callback ran both times; its failures were swallowed, not propagated. + expect(settledEvents).toHaveLength(2); + } finally { + callbackBehavior.mode = "none"; + } + }); + + it("sends zero x-x402-* headers when forwardPaymentMetadata is false, but still notifies the host", async () => { + const events: X402PaymentSettledEvent[] = []; + const sdk = createX402ProxySdk({ + defaultNetwork: NETWORK, + defaultPayTo: "0xPayee0000000000000000000000000000000001", + leaseTokenSecret: "lease-token-secret-with-32-characters", + facilitator: { url: facilitatorServer.url }, + syncFacilitatorOnStart: true, + security: { allowInsecureHttpUpstream: true, allowPrivateIpUpstreams: true }, + forwardPaymentMetadata: false, + onPaymentSettled: (event) => { + events.push(event); + }, + resourceStore: new InMemoryX402ResourceStore([ + resource({ + id: "quiet-agent", + kind: "http", + publicPath: "/paid/quiet", + upstreamUrl: `${upstreamServer.url}/alpha`, + }), + ]), + }); + await sdk.refreshResources(); + const app = express(); + sdk.install(app); + const server = await startExpressServer(app); + try { + resetLogs(); + const url = `${server.url}/paid/quiet`; + const { paymentRequired, accepted } = await readPaymentRequirement(url, "POST"); + const response = await fetch(url, { + method: "POST", + headers: { "PAYMENT-SIGNATURE": buildPaymentHeader(paymentRequired, accepted) }, + }); + expect(response.status).toBe(200); + + const headers = upstreamHits.at(-1)?.headers ?? {}; + for (const name of METADATA_HEADER_NAMES) { + expect(headers[name], name).toBeUndefined(); + } + expect(Object.keys(headers).filter((name) => name.startsWith("x-x402-"))).toEqual([]); + + expect(events).toHaveLength(1); + expect(events[0]?.resourceId).toBe("quiet-agent"); + expect(events[0]?.transaction).toBe(SETTLE_TRANSACTION); + expect(events[0]?.payer).toBe(SETTLE_PAYER); + } finally { + await server.close(); + } + }); +}); diff --git a/tests/unit/headerPolicy.test.ts b/tests/unit/headerPolicy.test.ts index eff5e0e..4a869aa 100644 --- a/tests/unit/headerPolicy.test.ts +++ b/tests/unit/headerPolicy.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; import { + applyPaymentMetadataHeaders, applyServiceTokenAccess, applyUpstreamResponseHeaders, createForwardHeaders, + PAYMENT_METADATA_HEADERS, shouldDropProxyHeader, } from "../../src/headerPolicy"; +import type { X402PaymentMetadata } from "../../src/types"; import { FakeResponse } from "../helpers/fakeHttp"; describe("headerPolicy", () => { @@ -202,3 +205,127 @@ describe("applyUpstreamResponseHeaders", () => { expect(res.headers["x-payment"]).toBeUndefined(); }); }); + +describe("applyPaymentMetadataHeaders", () => { + function metadata(overrides: Partial = {}): X402PaymentMetadata { + return { + paymentId: "11111111-2222-4333-8444-555555555555", + resourceId: "agents/alpha beta", + scheme: "exact", + network: "eip155:8453", + amount: "10000", + asset: "0xAsset0000000000000000000000000000000001", + payTo: "0xPayee0000000000000000000000000000000001", + ...overrides, + }; + } + + it("registers all nine metadata names as internal payment headers", () => { + const names = Object.values(PAYMENT_METADATA_HEADERS); + expect(names).toHaveLength(9); + expect(new Set(names)).toEqual( + new Set([ + "x-x402-payment-id", + "x-x402-resource-id", + "x-x402-scheme", + "x-x402-network", + "x-x402-amount", + "x-x402-asset", + "x-x402-pay-to", + "x-x402-payer", + "x-x402-transaction", + ]), + ); + for (const name of names) { + expect(shouldDropProxyHeader(name)).toBe(true); + } + }); + + it("strips client-supplied metadata names even when explicitly allow-listed", () => { + const req = { + headers: { + "x-x402-payment-id": "spoofed-id", + "x-x402-payer": "0xEvil", + "x-x402-transaction": "0xFake", + accept: "*/*", + }, + }; + const headers = createForwardHeaders(req as unknown as Parameters[0], { + forwardRequestHeaders: ["x-x402-payment-id", "x-x402-payer", "x-x402-transaction", "accept"], + }); + expect(headers.get("x-x402-payment-id")).toBeNull(); + expect(headers.get("x-x402-payer")).toBeNull(); + expect(headers.get("x-x402-transaction")).toBeNull(); + expect(headers.get("accept")).toBe("*/*"); + }); + + it("writes all present fields, encoding resourceId", () => { + const headers = new Headers(); + applyPaymentMetadataHeaders( + headers, + metadata({ payer: "0xPayer0000000000000000000000000000000001", transaction: "0xsettled" }), + ); + expect(headers.get("x-x402-payment-id")).toBe("11111111-2222-4333-8444-555555555555"); + expect(headers.get("x-x402-resource-id")).toBe("agents%2Falpha%20beta"); + expect(headers.get("x-x402-scheme")).toBe("exact"); + expect(headers.get("x-x402-network")).toBe("eip155:8453"); + expect(headers.get("x-x402-amount")).toBe("10000"); + expect(headers.get("x-x402-asset")).toBe("0xAsset0000000000000000000000000000000001"); + expect(headers.get("x-x402-pay-to")).toBe("0xPayee0000000000000000000000000000000001"); + expect(headers.get("x-x402-payer")).toBe("0xPayer0000000000000000000000000000000001"); + expect(headers.get("x-x402-transaction")).toBe("0xsettled"); + }); + + it("omits absent optionals (payer/transaction)", () => { + const headers = new Headers(); + applyPaymentMetadataHeaders(headers, metadata()); + expect(headers.get("x-x402-payment-id")).not.toBeNull(); + expect(headers.get("x-x402-payer")).toBeNull(); + expect(headers.get("x-x402-transaction")).toBeNull(); + }); + + it("silently skips unsafe values (control chars, padding, empty, non-ASCII) without throwing", () => { + const headers = new Headers(); + expect(() => + applyPaymentMetadataHeaders( + headers, + metadata({ + scheme: "bad\r\nx-injected: 1", + network: " padded ", + asset: `evil${String.fromCharCode(1)}`, + payTo: "", + payer: "payé", + transaction: "0xok", + }), + ), + ).not.toThrow(); + expect(headers.get("x-x402-scheme")).toBeNull(); + expect(headers.get("x-x402-network")).toBeNull(); + expect(headers.get("x-x402-asset")).toBeNull(); + expect(headers.get("x-x402-pay-to")).toBeNull(); + expect(headers.get("x-x402-payer")).toBeNull(); + expect(headers.get("x-injected")).toBeNull(); + // Safe values on the same call still land. + expect(headers.get("x-x402-amount")).toBe("10000"); + expect(headers.get("x-x402-transaction")).toBe("0xok"); + expect(headers.get("x-x402-resource-id")).toBe("agents%2Falpha%20beta"); + }); + + it("skips a resourceId that cannot be encoded (lone surrogate) without throwing, keeping payment-id", () => { + const headers = new Headers(); + // A lone high surrogate makes encodeURIComponent throw URIError; tryEncodeResourceId + // swallows it so a valid paid request is never 500'd mid-proxy. + expect(() => applyPaymentMetadataHeaders(headers, metadata({ resourceId: "\uD800" }))).not.toThrow(); + expect(headers.get("x-x402-resource-id")).toBeNull(); + // payment-id is set before (and independent of) the resourceId encode, so it survives. + expect(headers.get("x-x402-payment-id")).toBe("11111111-2222-4333-8444-555555555555"); + }); + + it("encodes a path-hostile resourceId (slash + space) so it round-trips via decodeURIComponent", () => { + const headers = new Headers(); + applyPaymentMetadataHeaders(headers, metadata({ resourceId: "agents/alpha beta" })); + const encoded = headers.get("x-x402-resource-id"); + expect(encoded).toBe("agents%2Falpha%20beta"); + expect(decodeURIComponent(encoded ?? "")).toBe("agents/alpha beta"); + }); +}); diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index 943ef1f..daa5c24 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -161,6 +161,20 @@ describe("validateProxySdkConfig", () => { }, "duplicate websocket wsPath"); }); + it("validates the payment metadata options", () => { + expectError((c) => { + (c as { forwardPaymentMetadata?: unknown }).forwardPaymentMetadata = "yes"; + }, "forwardPaymentMetadata must be a boolean when provided"); + expectError((c) => { + (c as { onPaymentSettled?: unknown }).onPaymentSettled = "not-a-function"; + }, "onPaymentSettled must be a function when provided"); + + const config = createValidConfig(); + config.forwardPaymentMetadata = false; + config.onPaymentSettled = () => undefined; + expect(() => validateProxySdkConfig(config)).not.toThrow(); + }); + it("accepts a config with a resourceStore and no endpoints", () => { expect(() => validateProxySdkConfig({