Skip to main content
This is the operator guide: everything needed to run your own facilitator and Bazaar, from first deploy to proving the deployment answers correctly. Nothing here is special to the instance at stellarsight.xyz — that instance is this repository deployed by these exact steps, which is what makes the self-hosting argument checkable rather than rhetorical. The other two paths: a seller wiring a paid API into an existing facilitator wants the seller quickstart; a buyer or agent wants the agent and MCP guide. The repo deploys as a single Vercel project: the Vite site in apps/web becomes the static output, and the .mjs files under api/ become Node.js Vercel Functions: four discovery handlers, plus facilitator.mjs and seller.mjs. Together they serve the Bazaar discovery API, the x402 facilitator and a real paid API on one origin. Nothing about local development changes. npm run dev:all still runs the index on :4022 out of apps/facilitator, and the serverless handlers import the same packages/index modules rather than reimplementing anything.

What gets deployed

api/facilitator.mjs and api/seller.mjs are the same Express apps npm run dev:facilitator and npm run dev:seller bind to :4021 and :4023 — imported, not reimplemented — so both halves of “facilitator with Bazaar support” answer on one origin, and the seller announces itself into the catalog through the authenticated write path. It needs FEEPAYER_SECRET, ASSET_SAC, ASSET_CODE and SELLER_PUBLIC in the deployment environment; the signer derives at module load, so a missing secret fails at boot rather than on the first settle. Everything under /discovery/* belongs to the API. An unknown path there returns 404 rather than the single-page app.

Routing, and the trap in it

vercel.json ends with the SPA catch-all "/(.*)" → "/index.html". Rewrites are evaluated in order and the first match wins, so a catch-all placed above the discovery rules would swallow every API request and return HTML with a 200. The discovery rewrites are therefore listed first and the catch-all is last:
Twelve rules in total; the elided seven are shown in full in vercel.json. What matters here is the ordering, not the count: every API rule precedes the catch-all. npm run verify:api asserts that ordering against the real vercel.json — if anyone ever moves the catch-all up, that check fails.

First deploy

Connect the repo in the Vercel dashboard, or vercel link && vercel --prod from the repo root. Project settings that must be right:
  • Root Directory — the repository root (not apps/web). The api/ directory and packages/index both live above apps/web; pointing the root at apps/web hides them and the discovery endpoints will 404.
  • Framework PresetOther. vercel.json already pins buildCommand, installCommand and outputDirectory.
  • Node.js version — 22.x.
Everything else is in vercel.json and needs no dashboard equivalent.

The custom domain

stellarsight.xyz is served from an apex A record and a www CNAME at the registrar, with the registrar’s own nameservers left in place. Vercel’s nameserver option is the alternative, not an addition — taking it moves the whole zone, so every unrelated record (_dmarc, and anything for email) has to be recreated on the Vercel side. It is only required for wildcard domains, which this project does not use.
The CNAME target is per-project. Vercel issues a unique hostname such as d1d4fc829fe7bc7c.vercel-dns-017.com. Older guides say cname.vercel-dns.com — do not paste that from memory, and do not reuse a value from another project. The same goes for the apex A record: read both off the project’s Domains screen.
Check the authoritative answer rather than a cached resolver, then the deployment itself:

Environment variables

None are required. With an empty environment the API serves a read-only catalog seeded from packages/index/src/seed.mjs at cold start. That is the intended baseline: a public Bazaar that answers out of the box beats a write-capable one that needs setup nobody has done.

The faucet’s blast radius

POST /playground/fund is the only endpoint here that submits a transaction for an anonymous caller, so it is worth being explicit about what it can and cannot cost you. It pays out a self-issued testnet token with no value, and its network is hardcoded — no environment variable moves it to pubnet. What an abuser can actually consume is the distributor’s XLM in network fees, which is why there are three independent caps (per-account, per-IP, global) and why the account claim is a single atomic SET NX EX rather than a read-then-write. With no Redis configured the limiter degrades to per-instance counters, and the response says limiter: "per-instance" rather than implying a guarantee the deployment cannot make. A missing, empty or malformed value never crashes a request. A configured-but-unreachable store falls back to the seeded catalog and reports the failure on /discovery/health.

Caller authentication, metering and rate limiting

The RFP leaves the mechanism to the respondent and asks for two things: that it be documented, and that it be configurable. Both, here. The policy. Testnet is deliberately open: no API key, no signup, no account. That is a claim this project makes in four places and it would be dishonest to make it while quietly gating the endpoints. There is no caller authentication on /verify or /settle, and that is a decision rather than an omission — the asset is a self-issued testnet token, the only thing an abusive caller can consume is the fee-payer’s XLM, and the cure for that is a limit rather than a login. The mechanism. A fixed-window counter per caller, applied to /verify and /settle only. /supported, /health and /events are cheap reads and stay unlimited — /supported in particular is an RFP acceptance criterion and has to answer a stock client unconditionally. Defaults are 120 requests per 60 seconds per caller, which is far above anything a reviewer, a demo or the conformance harness produces, and far below what it takes to drain a sponsored fee-payer. The implementation is apps/facilitator/src/rate-limit.mjs, and it is the counter the faucet has been running since it shipped, generalised so the two surfaces cannot drift:
  • Durable when a store is configured, per-instance when it is not. The transport is whatever createKv() resolves, the same Redis the catalog uses.
  • Fails open. If the store is unreachable the request is counted per-instance and the response carries X-RateLimit-Degraded: per-instance instead of being refused. A limiter that 500s when Redis blinks is a worse outage than the one it prevents.
  • The raw IP is never stored or logged — only a truncated SHA-256 of the first x-forwarded-for hop becomes a key.
  • A refusal is machine-readable, like every other rejection here: 429 with Retry-After, code: "STELLARSIGHT_RATE_LIMITED" and a non-null reason naming the limit, the window and when to retry.
GET /health reports the policy in force, so a caller can read it rather than discover it by being refused:
To turn it off in your own deployment: FACILITATOR_RATE_LIMIT=0. Metering is the same counter read the other way round, and the honest status is that per-caller usage accounting — as opposed to per-caller limiting — is not built. It belongs with the per-seller identity work in Tranche 1, because metering a caller you cannot name is bookkeeping without a subject.

The business model

Stated plainly, because the RFP asks for it and because a facilitator whose economics are unstated is one nobody should self-host. Testnet is free, permanently. There is no fee, no key and no account, and no environment variable can introduce one on testnet. The value of this deployment is that it exists and answers; charging for testnet calls would defeat the point of the public instance. Mainnet defaults to a zero fee. FACILITATOR_FEE_BPS defaults to 0, so a self-hoster who clones this repository inherits no fee from us — the operator decides, not the software. That is the shape the RFP asks for: any fee configurable rather than hard wired, and removable. The variable is read and reported today (GET /health carries fee: {basisPoints, configurable, variable}), but fee collection is not implemented: taking a cut changes the amount a buyer authorized, which is not a change to ship unaudited, so it lands in Tranche 3 inside the audit scope. Setting a non-zero value therefore fails at boot with that explanation rather than being silently ignored — an operator who configures a fee and collects nothing has been lied to by their own config, and this repository already handles FEEPAYER_SECRET the same way for the same reason. How the hosted instance is intended to sustain itself, when it reaches mainnet: the operator’s own sellers pay nothing, third-party settlement is expected to carry a low single-digit basis-point fee or none at all depending on volume, and the deliberate alternative to charging is that the whole thing is Apache-2.0 and self-hostable in one command. The second option existing is what keeps the first honest — §11 of ARCHITECTURE.md is the longer version of this argument, and the RFP’s own success outcome is that the ecosystem must not depend on a single hosted operator.

Two ways to reach Redis, and which one you get

Which of these you can use is decided by whoever provisioned the database:
  • RESTKV_REST_API_URL + KV_REST_API_TOKEN. An HTTPS API, stateless, no connection to hold. Vercel KV and Upstash both expose it. Preferred when present, because a function that may be frozen mid-request is a bad place to own a TCP socket.
  • Redis protocolKV_REDIS_URL, e.g. rediss://default:<password>@<host>.example.com:6379. Spoken over TCP through the redis package.
Which of the two you get depends on the provider, so read the variables Vercel actually created rather than assuming. Upstash exposes both a REST pair and a protocol URL; other Marketplace Redis providers expose only the protocol URL. If both are present the REST pair wins, which is the preferred outcome.
Prefix tip. Connecting a Marketplace database asks for an environment-variable prefix. KV is the convenient one: the REST pair lands as KV_REST_API_URL / KV_REST_API_TOKEN and, on providers that expose it, the protocol URL lands as KV_REDIS_URL — all three names this code already reads. Whatever prefix you choose, check the generated names against the table above; a URL under any other name (KV_URL, REDIS_TLS_URL, a provider-specific one) is invisible to this code until you add KV_REDIS_URL or REDIS_URL yourself pointing at the same value.
Adding the variables is not enough on its own. Vercel binds environment variables at deploy time, so a deployment created before you attached the database keeps running without them and /discovery/health will keep reporting mode: seed with durableStore.configured: false. Redeploy — push a commit, or use Redeploy in the dashboard — and check health again. This is the single most common reason a correctly configured store appears not to work.
GET /discovery/health reports which one is live as durableStore.transport ("rest", "redis", or null when unconfigured). The password never appears there: host is host-only and every error string is scrubbed of credentials before it is returned. Connections and the plan cap. The protocol transport opens one connection per warm function instance: nothing connects at module load, the first request that needs the store connects, concurrent requests on a cold instance share that single in-flight connect, and the client is then reused for the life of the instance rather than closed per request. A connection that has gone away — idle timeout, server restart — is detected and rebuilt once, transparently. This matters: the free tiers cap at around 30 connections.

Catalog state: the two modes

seed — the zero-configuration default

Each cold start builds a catalog from packages/index/src/seed.mjs through the normal catalog.upsert path, so the seeded records pass the same integrity validation as live traffic. asSeedRecord pins them to settlements: 0 and flags them seeded: true, so nothing in the catalog ever claims a payment that did not happen. Reads work. Writes return 503 with a reason naming the variables to set. The three real seller routes (/v1/fx/usd-brl, /v1/cep/:cep, /v1/ocr/nota-fiscal) are not seeded. They never were: baking a localhost:4023 URL into a public Bazaar would advertise resources nobody can reach. They enter through the write path instead, which is what api/seller.mjs now does on its first request — /discovery/health reports them as liveRecords: 3, distinct from the 27 seeded ones.

kv — durable and shared

Point the deployment at a Redis — either KV_REST_API_URL + KV_REST_API_TOKEN, or KV_REDIS_URL (see above) — and the catalog gains a shared, persistent layer:
  • Cold start: seed corpus first, then every record in the store. Store records win on a shared id, and because seeded is re-derived on each upsert, a real announcement clears the seed flag — the same ordering apps/facilitator relies on locally.
  • Storage: one Redis hash, id -> JSON(record). A hash rather than one blob because HSET on a field is atomic, so two function instances cataloging different resources concurrently cannot clobber each other.
  • Propagation: a write forces the next read on that instance to reload; other instances pick it up within STELLARSIGHT_KV_TTL_MS.
Add STELLARSIGHT_WRITE_TOKEN to open the write path. Why writes need a token even though the store variables are enough to make them work: an unauthenticated write endpoint on a public discovery index is a spam magnet, and catalog integrity is the load-bearing part of this project. The validator would still soft-drop hostile fields, but nothing stops volume. So a store makes writes possible, the token makes them permitted, and the absence of either is reported plainly rather than silently accepted.

Verifying a deployment with curl

Replace stellarsight.xyz with your own deployment URL.
There are three healthy states, not two, and /discovery/health names which one you are in: records is non-zero in all three, and step 7 prints 200 application/json in all three. The middle row is the easiest to misread as broken: a store really is attached, and writes really are refused, on purpose.

Verifying before you deploy

This imports the actual api/discovery/*.mjs files, drives them with mock and real Node req/res objects, and asserts the response shapes, every filter, both pagination styles, _explain, CORS, the preflight, cache headers, the write path in all four of its states, graceful degradation on a broken store, and the vercel.json rewrite ordering. npx vercel dev is the more faithful check but needs an authenticated Vercel account. The durable store has its own suite in test/store-transport.test.mjs — transport selection, credential scrubbing and graceful degradation run with no Redis at all. The end-to-end round trip skips unless you point it at one:

The web console: LIVE vs DEMO

apps/web/src/lib/api.ts resolves the API base as:
  • VITE_INDEX_URL if set (an explicit override wins everywhere),
  • otherwise '' in a production build — a same-origin relative base, so the deployed console calls its own /discovery/* with no CORS hop and no configuration,
  • otherwise http://localhost:4022 in the dev server.
The pill in the header reads LIVE when the API answered and DEMO when it fell back to apps/web/src/data/fixture.json. That fallback is required by CONTRACT.md and is untouched — the console renders fully even if the API is down.

Known behaviour

  • /discovery/integrity returns 404. The console probes it opportunistically for a live validation ledger; no build of the index exposes it yet (the local index on :4022 does not either), so the console falls back to its baked ledger. The probe is wrapped in a try/catch and the 404 is expected.
  • GET /discovery/search without a query parameter is a 400, per the bazaar spec. A present-but-empty query is a browse over the whole filtered catalog.
  • Cold starts. The first request after an idle period pays for module load plus seeding. The console allows 4s in production before falling back to DEMO.