Skip to content

feat: body-matched resources + http-stream-direct kind + facilitator sync hardening (0.2.0)#8

Merged
j4ys0n merged 3 commits into
mainfrom
feat/openai-shared-path-direct-stream
Jul 11, 2026
Merged

feat: body-matched resources + http-stream-direct kind + facilitator sync hardening (0.2.0)#8
j4ys0n merged 3 commits into
mainfrom
feat/openai-shared-path-direct-stream

Conversation

@j4ys0n

@j4ys0n j4ys0n commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Why

missionsquad-api needs x402 in front of its existing OpenAI-compatible endpoint (POST /v1/chat/completions) with per-agent pricing selected by the model body 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)

  • New optional X402Resource.match: { bodyField, equals } — many resources may share one publicPath, discriminated by a parsed-JSON body field (e.g. model). Each carries its own pricing (amount/network/payTo).
  • Payment routes register under synthetic per-resource keys (/__x402/match/<id>); context.path uses 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.
  • Requests matching no discriminator fall through to the host app, so unknown models keep existing behavior (e.g. 401/404 from the host's own routes). Body-matched resources take precedence over unmatched ones on the same path.
  • Duplicate (method, path, bodyField, equals) claims → invalid at refresh and rejected by InMemoryX402ResourceStore.
  • Allowed on http and http-stream-direct kinds only (payment happens where the body is present); requires a JSON body parser before the middleware.

2. http-stream-direct kind

  • Single-request paid streaming: verify → settle → pipe unbuffered (reuses proxyStreamingHttpRequest, including client-disconnect abort propagation). No lease endpoint/token/use-store.
  • Content-agnostic pipe: serves OpenAI stream:true SSE and stream:false JSON through one resource.
  • Settlement completes before the upstream call (pay-for-access; documented in README security notes).

3. Facilitator sync hardening

  • initPromise no longer floats unhandled: a detached rejection guard prevents unhandled-rejection crashes; the awaited path still observes failures.
  • RouteConfigurationError from @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.
  • Other sync failures (facilitator unreachable): requests fail 503 FACILITATOR_SYNC_ERROR (retryable), surface as diagnostics().facilitatorSyncError, and the sync retries on the next payment request; the error clears on success.
  • Refresh race (matched resource, route missing from swapped generation): 503 RESOURCE_ROUTE_SYNC_ERROR instead of a hung request.
  • Protected-request hooks that grant access now serve without settlement on all kinds (previously: 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.
  • Validator unit cases for match/kind combinations and the no-lease rule for http-stream-direct.

yarn typecheck ✅ · yarn build ✅ · yarn test ✅ 23 files / 214 tests

🤖 Generated with Claude Code

j4ys0n and others added 3 commits July 10, 2026 11:41
…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>
@j4ys0n

j4ys0n commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial self-review — 4 findings, all fixed in 33954cc

Ran an adversarial review of this diff against @x402/core's actual matching internals before handing off. Findings (each verified in @x402/core dist/cjs/server/index.js source, not assumed):

  1. CRITICAL — synthetic match keys broke for path-hostile resource ids. @x402/core decodes incoming paths (normalizePathdecodeURIComponent, slash collapsing) but compiles route keys literally as case-insensitive patterns where *.*? and [x] → param. Deriving the key from encodeURIComponent(resource.id) therefore failed to match its own route for any id containing /, %, space, etc. (→ no-payment-required), and ids with */case variants could cross-match other resources' routes → wrong price/payTo. Fixed: the synthetic segment is now a sha256 lowercase-hex digest of the id — immune to every transform. E2E-covered with id OpenAI/GPT 4* [x] 100%.

  2. CRITICAL — the granted outcome was fail-open. no-payment-required can only arise from a route-matching miss or an onProtectedRequest grant hook — and this SDK never registers such hooks. Combined with finding 1, a hostile id would have been served without payment. Fixed: after the route key is confirmed present, no-payment-required fails closed with 503 RESOURCE_ROUTE_SYNC_ERROR. (The PR description's original mention of "hooks that grant access now serve without settlement" is superseded by this — there is no grant path.)

  3. MAJOR — http-stream-direct settled before contacting the upstream, charging payers for upstream outages/5xx. Fixed to mirror handleHttp's settle-on-success rule: connect → status < 400 → settle → relay. Upstream errors are never charged; a settlement failure after connect aborts the upstream run; PAYMENT-RESPONSE still precedes all body bytes. New openStreamingUpstream() split in httpProxy makes this possible without duplicating the relay/backpressure/disconnect logic.

  4. MINOR — concurrent first-sync prune race could 503 a second in-flight request and transiently poison diagnostics while recovery was already underway; generation swaps mid-await could also return without syncing. Fixed: followers re-evaluate/follow the fresh sync; bounded by an iteration cap.

Also from review nits: match now requires a body-carrying method (POST/PUT/PATCH) — a GET match resource was silently dead config.

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 (upstream before settle).

🤖 Generated with Claude Code

@j4ys0n j4ys0n merged commit c7dd1f4 into main Jul 11, 2026
1 check passed
@j4ys0n j4ys0n deleted the feat/openai-shared-path-direct-stream branch July 11, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant