> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stellarsight.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Operator guide

> Run your own facilitator and Bazaar: deploy topology, environment, catalog modes, rate limits, and curl verification.

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](/architecture#93-self-hosting)
checkable rather than rhetorical.

The other two paths: a seller wiring a paid API into an existing facilitator wants the
[seller quickstart](/guides/seller); a buyer or agent wants the
[agent and MCP guide](/guides/agent).

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

| Path                        | Served by                     | Notes                                                                                        |
| --------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
| `/`, `/console`, assets     | `apps/web/dist`               | SPA, via the catch-all rewrite                                                               |
| `GET /discovery/resources`  | `api/discovery/resources.mjs` | filters + offset pagination                                                                  |
| `POST /discovery/resources` | `api/discovery/resources.mjs` | auto-cataloging — off unless configured                                                      |
| `GET /discovery/search`     | `api/discovery/search.mjs`    | ranked, results under `resources`, `partialResults`, cursor                                  |
| `GET /discovery/health`     | `api/discovery/health.mjs`    | mode, record count, commit                                                                   |
| `GET /supported`            | `api/facilitator.mjs`         | the facilitator's x402 v2 advertisement — scheme, network, sponsored fees, asset             |
| `POST /verify`              | `api/facilitator.mjs`         | payment verification; a rejection carries a non-null `invalidReason`                         |
| `POST /settle`              | `api/facilitator.mjs`         | wraps the settlement in a fee-bump, submits to Stellar RPC, inside the function's 60s budget |
| `GET /health`               | `api/facilitator.mjs`         | facilitator health: network, asset, fee payer, catalog backend                               |
| `GET /events`               | `api/facilitator.mjs`         | SSE; a stream ends when the function's clock does and the client reconnects                  |
| `GET /v1/:path*`            | `api/seller.mjs`              | the example paid API: three priced routes answering 402 with their terms                     |
| `GET /.well-known/x402`     | `api/seller.mjs`              | the seller's x402 discovery document                                                         |

`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:

```json theme={null}
"rewrites": [
  { "source": "/discovery/resources", "destination": "/api/discovery/resources" },
  { "source": "/discovery/search",    "destination": "/api/discovery/search" },
  { "source": "/discovery/health",    "destination": "/api/discovery/health" },
  { "source": "/discovery/:path*",    "destination": "/api/discovery/unknown" },
  … 5 facilitator rules (/supported, /verify, /settle, /health, /events) → /api/facilitator
  … 2 seller rules (/v1/:path*, /.well-known/x402)                      → /api/seller
  { "source": "/(.*)",                "destination": "/index.html" }
]
```

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 Preset** — *Other*. `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:

```bash theme={null}
dig @<registrar-ns> stellarsight.xyz A +short
curl -s https://stellarsight.xyz/discovery/health | jq '.mode, .records, .build.commitShort'
```

***

## 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.

| Variable                                              | Required | Effect                                                                                                                                                                                                                                                     |
| ----------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KV_REST_API_URL`                                     | no       | Redis/KV **REST** endpoint. With the token, switches the catalog to `kv` mode.                                                                                                                                                                             |
| `KV_REST_API_TOKEN`                                   | no       | Bearer token for the above.                                                                                                                                                                                                                                |
| `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` | no       | Accepted as aliases when you wire Upstash up yourself.                                                                                                                                                                                                     |
| `KV_REDIS_URL`                                        | no       | Redis **protocol** connection URL, `redis://` or `rediss://` (TLS). Used when no REST pair is set.                                                                                                                                                         |
| `REDIS_URL`                                           | no       | Accepted as an alias for `KV_REDIS_URL`.                                                                                                                                                                                                                   |
| `STELLARSIGHT_WRITE_TOKEN`                            | no       | Enables `POST /discovery/resources`. Callers must send `Authorization: Bearer <value>`.                                                                                                                                                                    |
| `STELLARSIGHT_KV_KEY`                                 | no       | Redis hash key. Default `stellarsight:catalog:v1`.                                                                                                                                                                                                         |
| `STELLARSIGHT_KV_TTL_MS`                              | no       | How long a store snapshot is reused before re-reading. Default `5000`.                                                                                                                                                                                     |
| `STELLARSIGHT_KV_TIMEOUT_MS`                          | no       | Per-command timeout against the store. Default `4000`.                                                                                                                                                                                                     |
| `STELLARSIGHT_REDIS_CONNECT_TIMEOUT_MS`               | no       | Connect timeout, protocol transport only. Default `2000`.                                                                                                                                                                                                  |
| `STELLARSIGHT_CACHE_S_MAXAGE`                         | no       | CDN `s-maxage` on the read endpoints. Default `60`.                                                                                                                                                                                                        |
| `STELLARSIGHT_CACHE_SWR`                              | no       | CDN `stale-while-revalidate`. Default `600`.                                                                                                                                                                                                               |
| `SEED_CATALOG`                                        | no       | `0` boots an empty catalog instead of the seed corpus.                                                                                                                                                                                                     |
| `VITE_INDEX_URL`                                      | no       | Build-time override for where the web console points. Leave unset.                                                                                                                                                                                         |
| `STELLARSIGHT_HEALTH_VERBOSE`                         | no       | `1` prints the durable store's full `host:port` on `/discovery/health`. Off by default: the public payload keeps the provider domain and drops the instance label, which is all a reader needs to diagnose reachability.                                   |
| `PLAYGROUND_FAUCET_SECRET`                            | no       | Distributor secret for `POST /playground/fund`. Falls back to `ISSUER_SECRET`. **Without either, the browser playground cannot hand out the demo asset** and answers `503 FAUCET_DISABLED` with that reason.                                               |
| `PLAYGROUND_FAUCET_DISABLED`                          | no       | `1` switches the faucet off while leaving the secret in place. Answers `503` with a distinct reason so an operator can tell "off" from "unconfigured".                                                                                                     |
| `FAUCET_AMOUNT_SXT`                                   | no       | Size of one grant. Default `2` — roughly 40 of the most expensive route.                                                                                                                                                                                   |
| `FAUCET_IP_DAILY_LIMIT`                               | no       | Grants per IP per 24h. Default `10`. IPs are hashed, never stored.                                                                                                                                                                                         |
| `FAUCET_GLOBAL_DAILY_LIMIT`                           | no       | Grants per day across the whole deployment. Default `200`.                                                                                                                                                                                                 |
| `FEEPAYER_PUBLIC`                                     | no       | The fee-payer **public** key (not a secret). `GET /explorer/feed` keys on it to read this deployment's settlements from Horizon; without it the feed answers `503` naming what is missing.                                                                 |
| `FACILITATOR_RATE_LIMIT`                              | no       | Requests per window per caller on `/verify` and `/settle`. Default `120`. **`0` disables rate limiting entirely.**                                                                                                                                         |
| `FACILITATOR_RATE_WINDOW_S`                           | no       | Length of that window, in seconds. Default `60`.                                                                                                                                                                                                           |
| `FACILITATOR_RATE_GLOBAL_LIMIT`                       | no       | Requests per window across all callers. Default `0`, meaning no global cap.                                                                                                                                                                                |
| `FACILITATOR_FEE_BPS`                                 | no       | Facilitator fee in basis points. Default `0`, and reported on `/health`. Fee **collection** is Tranche 3 work inside the audit scope, so a non-zero value is refused at boot rather than silently ignored — see [The business model](#the-business-model). |

### 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:

```bash theme={null}
curl -s https://stellarsight.xyz/health | grep -o '"rateLimit":{[^}]*}'
# {"rateLimit":{"enabled":true,"perCallerPerWindow":120,"globalPerWindow":null,"windowSeconds":60}}
```

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](/architecture#11-operating-as-a-public-good) 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 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](#two-ways-to-reach-redis-and-which-one-you-get)) — 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.

```bash theme={null}
# 1. Which mode is live, how many records, which commit
curl -s https://stellarsight.xyz/discovery/health | jq

# 2. Search — ranked, with the score breakdown. NOTE the array is `resources`, not
#    `items`: the search and list envelopes differ deliberately (see CONTRACT.md).
curl -s 'https://stellarsight.xyz/discovery/search?query=invoice%20ocr&limit=3' | jq \
  '.resources[] | {resource, name: .serviceName, score: ._score, price: .accepts[0].amount}'

# 3. The full _explain on the top hit
curl -s 'https://stellarsight.xyz/discovery/search?query=invoice%20ocr&limit=1' \
  | jq '.resources[0]._explain'

# 4. List with filters — the LIST endpoint uses `items` and offset pagination
curl -s 'https://stellarsight.xyz/discovery/resources?type=mcp&limit=5' | jq '.total, .items[].resource'

# 5. Cursor pagination
CURSOR=$(curl -s 'https://stellarsight.xyz/discovery/search?query=stellar&limit=2' | jq -r .pagination.cursor)
curl -s "https://stellarsight.xyz/discovery/search?query=stellar&limit=2&cursor=$CURSOR" | jq '.resources[].id'

# 5b. What a stock consumer sees: the payable offer, straight off a search hit
curl -s 'https://stellarsight.xyz/discovery/search?query=invoice%20ocr&limit=1' \
  | jq '.resources[0] | {resource, x402Version, lastUpdated, accepts}'

# 6. CORS preflight — must answer 204 with Access-Control-Allow-Origin: *
curl -s -i -X OPTIONS https://stellarsight.xyz/discovery/resources | head -8

# 7. The SPA catch-all must NOT shadow the API: this must be JSON, not text/html
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
  'https://stellarsight.xyz/discovery/search?query=test'

# 8. Write path (kv mode + STELLARSIGHT_WRITE_TOKEN only)
curl -s -X POST https://stellarsight.xyz/discovery/resources \
  -H "Authorization: Bearer $STELLARSIGHT_WRITE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"resource":{"url":"https://api.example.com/v1/thing","serviceName":"Thing",
        "description":"Does a thing, described well enough to be discoverable.",
        "tags":["thing"]},
       "type":"http","payTo":"G...","asset":"C...","maxAmountRequired":"1000",
       "input":{"type":"http","method":"GET"},"output":{"type":"json"},
       "extensions":["bazaar"]}' | jq
```

There are three healthy states, not two, and `/discovery/health` names which one you are in:

| `mode` | `writable` | `durableStore.transport` | What it means                                                                                                                          |
| ------ | ---------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `seed` | `false`    | `null`                   | No store configured. Reads work off the seed corpus; `POST` is `503`.                                                                  |
| `kv`   | `false`    | `redis` / `rest`         | Store attached, but no `STELLARSIGHT_WRITE_TOKEN`. Reads work and survive cold starts; `POST` is still `503`, with a reason saying so. |
| `kv`   | `true`     | `redis` / `rest`         | Both set. `POST` is `401` until the caller presents the token.                                                                         |

`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

```bash theme={null}
npm run verify:api
```

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:

```bash theme={null}
docker run -d -p 6399:6379 redis:7-alpine
STELLARSIGHT_TEST_REDIS_URL=redis://127.0.0.1:6399 npm test
```

***

## 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.
