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:
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, orvercel link && vercel --prod from the repo
root. Project settings that must be right:
- Root Directory — the repository root (not
apps/web). Theapi/directory andpackages/indexboth live aboveapps/web; pointing the root atapps/webhides them and the discovery endpoints will 404. - Framework Preset — Other.
vercel.jsonalready pinsbuildCommand,installCommandandoutputDirectory. - Node.js version — 22.x.
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 asCheck the authoritative answer rather than a cached resolver, then the deployment itself:d1d4fc829fe7bc7c.vercel-dns-017.com. Older guides saycname.vercel-dns.com— do not paste that from memory, and do not reuse a value from another project. The same goes for the apexArecord: read both off the project’s Domains screen.
Environment variables
None are required. With an empty environment the API serves a read-only catalog seeded frompackages/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-instanceinstead 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-forhop becomes a key. - A refusal is machine-readable, like every other rejection here:
429withRetry-After,code: "STELLARSIGHT_RATE_LIMITED"and a non-nullreasonnaming 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:
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:- REST —
KV_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 protocol —
KV_REDIS_URL, e.g.rediss://default:<password>@<host>.example.com:6379. Spoken over TCP through theredispackage.
Prefix tip. Connecting a Marketplace database asks for an environment-variable prefix.KVis the convenient one: the REST pair lands asKV_REST_API_URL/KV_REST_API_TOKENand, on providers that expose it, the protocol URL lands asKV_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 addKV_REDIS_URLorREDIS_URLyourself 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/healthwill keep reportingmode: seedwithdurableStore.configured: false. Redeploy — push a commit, or use Redeploy in the dashboard — and checkhealthagain. 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 becauseseededis re-derived on each upsert, a real announcement clears the seed flag — the same orderingapps/facilitatorrelies on locally. - Storage: one Redis hash,
id -> JSON(record). A hash rather than one blob becauseHSETon 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.
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
Replacestellarsight.xyz with your own deployment URL.
/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
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_URLif 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:4022in the dev server.
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/integrityreturns 404. The console probes it opportunistically for a live validation ledger; no build of the index exposes it yet (the local index on:4022does not either), so the console falls back to its baked ledger. The probe is wrapped in atry/catchand the 404 is expected.GET /discovery/searchwithout aqueryparameter is a 400, per the bazaar spec. A present-but-emptyqueryis 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.