feat: forward trusted payment metadata upstream + onPaymentSettled host callback (0.2.1)#9
Conversation
…ttlement - Nine SDK-minted x-x402-* headers (payment-id, resource-id, scheme, network, amount, asset, pay-to, payer, transaction) injected on upstream requests, built exclusively from the VERIFIED accepted payment requirements; all nine registered as internal payment headers so client-supplied values are always stripped, even when a resource's forwardRequestHeaders lists them. - x-x402-resource-id is encodeURIComponent-encoded (ids may contain hostile chars); other values pass a conservative printable-ASCII guard or are skipped. - onPaymentSettled config callback fires after every successful settlement (all kinds) with paymentId, transaction, facilitator-reported payer, and the accepted requirements — fire-and-forget, never affects the paid request. - paymentId (randomUUID) correlates the upstream headers with the settlement event; http/http-stream-direct omit payer/transaction headers by design (settlement follows the upstream request), while http-stream leases carry settled payment info in the HMAC-signed token so relays forward the full set. - forwardPaymentMetadata config flag (default true) gates the headers only. - 13 new tests (7 integration incl. spoofing + ordering + legacy-lease compat, 5 headerPolicy unit, 1 validation unit); 232 total green. Version 0.2.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…client echo
Adversarial review found the trusted x-x402-amount/-asset/-pay-to headers and
the onPaymentSettled requirements were built from payment.paymentPayload.accepted
— the CLIENT-submitted echo, which @x402/core only deep-equals against the server
requirement for x402 v2. For v1 the core matches on scheme+network only, so a
payer could submit accepted={amount:"0", asset/payTo:attacker} and have those
forged values flow into the "trusted" headers (the charge itself stays correct —
core settles against the server requirement). Fix: source all forwarded metadata
from payment.paymentRequirements (matchingRequirements — the server's own object,
version-independent). Verified against @x402/core server/index.js:1103-1138.
Also (low): guard encodeURIComponent(resourceId) so a lone-surrogate id skips the
header instead of throwing mid-proxy (the "never throw" invariant).
Tests: F1 anti-spoof (forged v1 payload → server values win in headers + event),
F2 encode-throw safety + round-trip, F3 websocket-lease onPaymentSettled. 236 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial self-review — 1 HIGH fixed (commit 2560ef2)Ran an adversarial pass against this diff; one finding was material and verified against [HIGH] Forwarded metadata trusted the client echo, not the server requirement. [LOW] [LOW] Test gaps closed: the v1 anti-spoof test above, the encode-throw/round-trip units, and a websocket-lease Other verdicts held SOLID: strip-set completeness (all nine names internal, client copies dropped even when a resource allowlists them), builder ordering, HMAC lease integrity + pre-0.2.1-token graceful degradation, settle ordering unchanged, callback isolation. 236 tests green; typecheck + build clean. |
Why
missionsquad-api now does per-run x402 revenue accounting (
source/priceUsdon usage metrics), but payer-address and settlement-tx reconciliation was impossible: the proxy settles in its own layer and nothing payment-related reached the upstream. This PR gives hosts a trusted, spoof-proof channel for that data.What
1. Trusted
x-x402-*upstream request headers (gated byforwardPaymentMetadata, defaulttrue):x-x402-payment-idx-x402-resource-idx-x402-scheme/-network/-amount/-asset/-pay-tox-x402-payer/x-x402-transaction2.
onPaymentSettledconfig callback — fires after every successful settlement (all kinds) with{ paymentId, resourceId, kind, transaction, network, payer?, requirements, settledAt }. Fire-and-forget: a throwing/rejecting callback never affects the paid request. In-process hosts (missionsquad-api) join it to the request viax-x402-payment-id.Security invariants
forwardRequestHeadersexplicitly lists them (integration-tested). An upstream can trust anyx-x402-*value it sees.paymentPayload.accepted(the requirements the core verified) — never from the client-controlled scheme payload (getPayer-style reads).Compatibility
forwardPaymentMetadata: falserestores byte-identical upstream requests.settlePaymentnow returns the full settle result internally (was boolean); no public API removed. Version → 0.2.1.Tests
232 passing (13 new): per-kind header matrices + settlement-event correlation, spoofing attempt via allow-listed names, upstream→settle ordering preserved, legacy-lease relay, throwing callback isolation,
forwardPaymentMetadata: false, header-guard/encoding units, config validation.yarn typecheck+yarn buildclean.Follow-up (separate repo, after publish)
missionsquad-api: pass
onPaymentSettledto persist payer/tx against the usage row viax-x402-payment-id.🤖 Generated with Claude Code