Skip to main content
Monorepo, plain npm workspaces, Node 22 or newer (CI and the deployment run 24), ESM ("type": "module"). No TypeScript build step anywhere except apps/web (Vite). Everything must run with node <file>.mjs or npm run dev.

Ports (fixed, do not change)

Shared env — /.env at repo root (written by scripts/setup-testnet.mjs)

packages/index — public API (ESM named exports from packages/index/src/index.mjs)

The INTERNAL record shape

This is what upsert() takes, what the store holds and what list()/search() return. It is not what goes on the wire — see the next section.

The WIRE shape — DiscoveryResource

packages/index/src/discovery.mjs (toDiscoveryResource) projects the internal record onto the type the shipped SDK declares (@x402/extensions/dist/esm/index-*.d.mts). All three transport adapters go through it: This register previously carried a KNOWN DRIFT here: the facilitator hand-rolled its own /discovery/resources and /discovery/search and returned catalog.list() / catalog.search() verbatim, so :4022 served the INTERNAL record shape while the deployment served the wire shape. It is closed. The facilitator now calls mountDiscoveryRoutes(indexApp, catalog), and apps/agent/src/bazaar.mjs reads either shape through one fieldsOf() accessor rather than reaching for rec.resource.url. GET /health on :4022 reports wireShape: "spec": the shared routes are the only ones there are.
Three things a stock consumer needs that the internal record does not give it, and the reasons they are easy to get wrong:
  1. resource is a URL string. The presentation fields move to the top level.
  2. accepts is required — without it a client cannot construct a payment from a search result. x402 v2 PaymentRequirements names the price amount, NOT maxAmountRequired; the v1 name fails PaymentRequirementsSchema in the installed @x402/core.
  3. lastUpdated is ISO 8601, where the record keeps epoch ms in lastSeenAt.

HTTP surfaces — do not rename fields

  • GET /supported -> { kinds: [{ x402Version: 2, scheme: "exact", network: "stellar:testnet", extra: { areFeesSponsored: true, asset } }] }
  • POST /verify -> { isValid, invalidReason|null, payer }
  • POST /settle -> { success, errorReason|null, transaction, network, payer } + header EXTENSION-RESPONSES
  • GET /discovery/resources?type&payTo&scheme&network&extensions&limit&offset -> { x402Version, items: DiscoveryResource[], pagination: { limit, offset, total } } (plus flat total/limit/offset)
  • GET /discovery/search?query&limit&cursor&...filters -> { x402Version, resources: DiscoveryResource[], partialResults, pagination: { limit, cursor } }
  • EXTENSION-RESPONSES header = base64(JSON) of { bazaar: { status: "success"|"processing"|"rejected", rejectedReason? } }
  • POST /mcp -> MCP over Streamable HTTP, stateless (one JSON-RPC request per POST; GET answers 405). stellarsight_search / _browse / _describe are live; stellarsight_pay is registered and refused on the hosted origin.
/verify and /settle are rate limited; nothing else is. Over the configured per-caller budget they answer 429 with Retry-After and \{ ok: false, code: "STELLARSIGHT_RATE_LIMITED", reason, scope, limit, windowSeconds, retryAfterSeconds } — a transport refusal, deliberately not the isValid/success shape, because a rate limit is not a verdict about the payment. /supported, /health and /events are never limited: /supported is an RFP acceptance criterion and must answer a stock client unconditionally. Defaults are 120 requests per 60s per caller; FACILITATOR_RATE_LIMIT=0 disables it. GET /health reports the policy in force under rateLimit, and the facilitator’s fee under fee. The two discovery envelopes differ deliberately, and so does their pagination. DiscoveryResourcesResponse names the array items and paginates by offset/total; SearchDiscoveryResourcesResponse names it resources and paginates by cursor. withBazaar() returns the parsed body untransformed, so a search response carrying only items makes search.resources undefined and throws on iteration. Search currently ALSO emits items as a deprecated duplicate alias of the same array, for one release. New consumers must read resources. None of this is asserted by reading the field names this repo emits — that is a belief, not an observation, and it is how the items/resources divergence shipped in the first place. npm run verify:api imports the real withBazaar from @x402/extensions, drives it against the actual handlers over a socket, and validates every accepts entry with @x402/core’s own PaymentRequirementsSchema. Change a field name here and that harness is what tells you.

apps/web contract

Reads from INDEX_URL. MUST render fully with a baked-in fallback fixture at apps/web/src/data/fixture.json when the API is unreachable — the demo cannot depend on localhost being up. Show a small “LIVE / DEMO” pill reflecting which source is active. Routes: / (landing), /console (live search + payment loop viewer).

Assets

Generated assets land in apps/web/public/assets/. Web must degrade gracefully (CSS-only fallback) if an asset file is missing.