feat: body-matched resources + http-stream-direct kind + facilitator sync hardening (0.2.0)#8
Conversation
…sync hardening
Enables putting x402 in front of an existing OpenAI-compatible endpoint with
per-model pricing, using the standard single-request 402-retry flow:
- match: { bodyField, equals } discriminator on X402Resource lets many
resources share one publicPath, selected by a parsed-JSON body field
(e.g. model). Payment routes are registered under synthetic per-resource
keys; the 402 challenge still advertises the real request URL. Requests
matching no discriminator fall through to the host app. Allowed on http
and http-stream-direct kinds; duplicate (path, field, value) claims are
rejected at refresh and in the in-memory store.
- New http-stream-direct kind: verify -> settle -> pipe the upstream
response unbuffered on a single request (no lease machinery). Serves both
SSE (stream:true) and buffered JSON bodies through the same pipe.
Settlement completes before the upstream call (pay-for-access).
Hardening:
- A failed facilitator sync no longer floats as an unhandled rejection and
no longer poisons every paid route until the next refresh: routes on
networks the facilitator does not support are pruned to the invalid list
(per-resource isolation, visible in diagnostics); other sync failures
return 503 FACILITATOR_SYNC_ERROR, surface as
diagnostics().facilitatorSyncError, and retry on the next payment request.
- Requests hitting the brief refresh-race window (matched resource, route
missing from the swapped generation) get 503 RESOURCE_ROUTE_SYNC_ERROR
instead of hanging with no response.
- Protected-request hooks that grant access now serve without settlement on
all kinds instead of hanging the request.
BREAKING-ish: none intended; existing kinds/flows unchanged. New exports:
X402ResourceMatch, FacilitatorSyncError, ResourceRouteSyncError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…osed mounting Hosts sharing the paid path with first-party authenticated traffic need to wrap the resource middleware in a credential bypass (authenticated requests must never see a 402, e.g. an owner testing their own paid model while logged in). install(app) is all-or-nothing, so expose: - sdk.middleware: the resource runtime middleware as a standalone handler - sdk.installManagementRoutes(app): diagnostics + discovery routes only install(app) behavior is unchanged (management routes + middleware). Documented the compose pattern and covered it with an integration test (credentialed request bypasses, anonymous gets 402, diagnostics works without install). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nts, settle ordering, sync races 1. CRITICAL — synthetic match route keys now use a sha256 hex digest of the resource id instead of encodeURIComponent(id). @x402/core decodes incoming paths (decodeURIComponent + slash collapsing) but compiles route keys literally as case-insensitive patterns where * is a wildcard and [x] a param — raw ids containing '/', '%', '*', '[', spaces, or uppercase would fail to match their own payment route or cross-match other resources' routes (wrong price/payTo). Lowercase hex is immune to all transforms. Covered by an e2e test with id "OpenAI/GPT 4* [x] 100%". 2. CRITICAL — removed the fail-open "granted" outcome. This SDK never registers @x402/core onProtectedRequest hooks, so after the route key is confirmed present a no-payment-required result can only be a proxy<->core matching discrepancy; it now fails closed with 503 RESOURCE_ROUTE_SYNC_ERROR instead of serving the paid resource for free. 3. MAJOR — http-stream-direct now settles AFTER the upstream accepts the request (status < 400) and before any body bytes relay, mirroring the http kind's settle-on-success rule: upstream outages and error responses are never charged; a settlement failure after connect aborts the upstream run; PAYMENT-RESPONSE still precedes the stream. Added openStreamingUpstream()/StreamingUpstreamConnection to httpProxy (connect and relay split); proxyStreamingHttpRequest is unchanged behaviorally. 4. MINOR — ensureInitialized no longer throws a spurious 503 when a concurrent request already pruned and scheduled a fresh sync; generation swaps mid-await re-evaluate instead of returning unsynced; bounded by an iteration cap. Also: match now requires a request-body method (POST/PUT/PATCH) — a GET match resource could never match anything and was silently dead config. Tests: 23 files / 219 passing (new: hostile-id e2e, upstream-500 and upstream-unreachable not charged, settle-failure aborts upstream, corrected settle-ordering assertion, GET+match validator case). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial self-review — 4 findings, all fixed in 33954ccRan an adversarial review of this diff against
Also from review nits: Suite: 23 files / 219 tests passing; typecheck + build clean. New regression tests: hostile-id e2e (402 → pay → stream, never free), upstream-500 and upstream-unreachable → relayed error with no settlement, settle-failure → 402 + upstream aborted, corrected settle-ordering assertion ( 🤖 Generated with Claude Code |
Why
missionsquad-api needs x402 in front of its existing OpenAI-compatible endpoint (
POST /v1/chat/completions) with per-agent pricing selected by themodelbody field (owner/agent-slug), using the standard single-request 402-retry flow — no derivative endpoints, no lease dance. Design doc:msq/docs/x402-per-agent-payment-config-design.md(v2).Two capabilities were missing, plus hardening that becomes critical once resource definitions come from user-writable data:
What
1. Body-matched resources (
match)X402Resource.match: { bodyField, equals }— many resources may share onepublicPath, discriminated by a parsed-JSON body field (e.g.model). Each carries its ownpricing(amount/network/payTo)./__x402/match/<id>);context.pathuses the same key during payment processing, while the 402 challenge advertises the real request URL (adapter.getUrl()fallback) and discovery URLs are built from the real publicPath.(method, path, bodyField, equals)claims → invalid at refresh and rejected byInMemoryX402ResourceStore.httpandhttp-stream-directkinds only (payment happens where the body is present); requires a JSON body parser before the middleware.2.
http-stream-directkindproxyStreamingHttpRequest, including client-disconnect abort propagation). No lease endpoint/token/use-store.stream:trueSSE andstream:falseJSON through one resource.3. Facilitator sync hardening
initPromiseno longer floats unhandled: a detached rejection guard prevents unhandled-rejection crashes; the awaited path still observes failures.RouteConfigurationErrorfrom@x402/core(missing_facilitator/missing_scheme) now prunes only the offending resources to the invalid list (visible in diagnostics) and retries the sync — one bad network config no longer poisons the whole paid surface, and previously the rejected promise was never cleared, so every paid request 500'd until a manual refresh.503 FACILITATOR_SYNC_ERROR(retryable), surface asdiagnostics().facilitatorSyncError, and the sync retries on the next payment request; the error clears on success.503 RESOURCE_ROUTE_SYNC_ERRORinstead of a hung request.API surface
New exports:
X402ResourceMatch,FacilitatorSyncError,ResourceRouteSyncError;X402ProxyDiagnostics.facilitatorSyncError?;X402LoadedResource.match?. Existing kinds/flows unchanged. Version → 0.2.0.Tests
tests/integration/shared-path-model-match.integration.test.ts— per-model 402 pricing on a shared path, real (non-synthetic) challenge URL, settle-before-upstream ordering, service-token injection, SSE + buffered relays, unknown-model/missing-body fallthrough, duplicate-claim rejection (runtime + store).tests/integration/facilitator-sync-hardening.integration.test.ts— unsupported-network pruning with continued service, 503 + diagnostics + recovery when the facilitator is down, no unhandled rejection from a failed background sync.match/kind combinations and the no-lease rule forhttp-stream-direct.yarn typecheck✅ ·yarn build✅ ·yarn test✅ 23 files / 214 tests🤖 Generated with Claude Code