Configuration and deploy
Running locally
Requires Node.js 22.18 or later (.nvmrc). There are no runtime dependencies.
npm install # dev tools only: typescript, @types/node
npm start # API, marketing page (/) and app (/app) on http://127.0.0.1:8080
npm run dev # same, restarting on TypeScript changes
npm run check # typecheck + tests + e2e (what CI's check job runs, plus the harness)
npm run keygen # print a new Ed25519 signing key (PEM) for SIGNING_KEY_PEM
DEMO_PUBLISHERS=on npm start # also serve and route to the five demo publishers
WIKIMEDIA_SOURCES=all npm start # also route to live Wikinews and Wikipedia
Local runs use an ephemeral signing key, leave /v1/admin/* open (unless ADMIN_TOKEN is set) and append ledger and telemetry records to var/*.jsonl.
Environment variables
All configuration is environment variables, read by src/config.ts. Out-of-range values stop startup with an error naming the variable.
| Variable | Default | Meaning |
|---|---|---|
PORT, HOST | 8080, 127.0.0.1 | Listen address |
PUBLIC_URL | http://localhost:$PORT | Entitlement issuer and audience. Must match the public hostname |
NODE_ENV | (unset) | production refuses to start (exit 78) unless ADMIN_TOKEN (32+ chars), a signing key and an https PUBLIC_URL are set. The container image sets it |
ADMIN_TOKEN | (unset) | If set, protects /v1/admin/*. Mandatory in production |
SIGNING_KEY_PEM | (unset) | Ed25519 private key as PKCS#8 PEM text. Takes precedence over SIGNING_KEY_FILE. Literal \n sequences are converted to newlines |
SIGNING_KEY_FILE | (unset) | Path to an Ed25519 PEM private key. With neither set, an ephemeral key is generated |
CLEARING_FEE_BPS | 1000 | Clearing fee in basis points (10%), added on top of the publisher price |
OFFER_TTL_SECONDS | 300 | How long an offer can be accepted |
ENTITLEMENT_TTL_SECONDS | 900 | Entitlement validity for retrieval |
TELEMETRY_GRACE_SECONDS | 604800 | Late telemetry window after expiry |
VAR_DIR | ./var | Where JSONL records go. Empty (VAR_DIR=) for memory only |
CATALOGUE_DIR | ./data | Seed catalogue location |
SOURCES_FILE | (unset) | Sources files to load, comma separated. See Adding a source |
DEMO_PUBLISHERS | (unset: off) | on serves the mock demo publishers from this process and routes to them |
DEMO_VERTICALS | (unset: on) | Route to the fictional demo verticals in data/verticals (finance, developer docs, research). off leaves them out. See Demo supply |
SITE_HOSTS | (unset) | Hostnames that serve the landing page, comma separated; the first is canonical and the rest redirect to it (fly.toml sets clearexchange.ai,www.clearexchange.ai). See Hostnames |
APP_HOSTS | (unset) | Hostnames that serve the app at their root, and the API (fly.toml sets app.clearexchange.ai) |
DOCS_HOSTS | (unset) | Hostnames that serve the docs at their root, comma separated (fly.toml sets docs.clearexchange.ai). Other hosts serve them at /docs/. See Docs site |
SUPPLY_TIMEOUT_MS | 4000 | Hard cap on any one source's search; the fan-out budget normally binds first |
FANOUT_CAP | 5 | Most sources one query is sent to |
EXPLORE_SLOTS | 1 | Of those, slots kept for new or under-served sources. Must be below FANOUT_CAP |
EXPLORE_MIN_QUERIES | 20 | A source is under-served until routed this many queries |
FANOUT_BUDGET_MS | 1500 | Total time budget for a query's fan-out |
FANOUT_RESERVE_MS | 100 | Kept back from the budget for scoring, pricing and the response. Must be below the budget |
MIN_SOURCE_TMAX_MS | 100 | A source whose tmax would fall below this is skipped |
ROUTE_CACHE_TTL_SECONDS, ROUTE_CACHE_MAX_ENTRIES | 60, 500 | Candidate-pool cache per normalised query (0 = off) |
BAND_RATIO, BAND_FLOOR | 0.9, 0.5 | Relevance band: only candidates ≥ ratio × top and ≥ floor are offered; the cheapest in-band offer is selected. When nothing clears it the API answers no_good_match |
RECENCY_BOOST, RECENCY_PENALTY, RECENCY_UNDATED_PENALTY | 0.1, 0.3, 0.1 | For queries that ask for a period: most relevance a recent item gains, most a stale item loses, and what an undated item loses |
SELF_SCORE_WEIGHT, SELF_SCORE_CAP | 0.1, 0.05 | Weight of a source's self-score and the most it can move relevance |
DIVERGENCE_PENALTY, DIVERGENCE_TOLERANCE | 0.25, 0.15 | Penalty per unit of overclaim, and the excess tolerated first |
PRIOR_WEIGHT, PRIOR_STRENGTH | 0.1, 10 | Weight of the telemetry prior, and pseudo-observations behind its neutral 0.5 |
WIKIMEDIA_SOURCES | (unset: off) | Live sources: wikinews, wikipedia (comma separated), all or off |
WIKIMEDIA_TIMEOUT_MS | 2500 | Budget for all requests one Wikimedia search or retrieval makes |
WIKIMEDIA_CACHE_TTL_SECONDS | 900 | How long Wikimedia results stay fresh in cache (stale entries are served if a refresh fails) |
WIKIMEDIA_CONTACT | repo URL | Contact URL or email in the User-Agent, as Wikimedia's policy asks |
Credentials for http-offer publishers are also environment variables, named by each source's auth_token_env in the sources file.
The deployed demo (Fly.io)
The public demo runs at https://clearexchange.ai (landing page), https://app.clearexchange.ai (app and API) and https://docs.clearexchange.ai (docs), all also served at https://clear-exchange.fly.dev: Fly app clear-exchange, region lhr, built from Dockerfile with fly.toml. It deploys only from GitHub Actions: every push to main that passes CI is deployed. Nobody runs flyctl or logs in to Fly by hand.
What fly.toml and the image set:
NODE_ENV=production,HOST=0.0.0.0,PORT=8080,PUBLIC_URL=https://app.clearexchange.ai(the canonical issuer; see Hostnames),VAR_DIR=""(ledger and telemetry in memory).WIKIMEDIA_SOURCES=wikinews,wikipediaandDEMO_PUBLISHERS=on, so the demo shows live supply and routing across several sources.SITE_HOSTS=clearexchange.ai,www.clearexchange.ai,APP_HOSTS=app.clearexchange.aiandDOCS_HOSTS=docs.clearexchange.ai, so each hostname serves its own part and old paths redirect (Hostnames). https://docs.clearexchange.ai serves the docs at its root. The image includesdocs/and renders it at startup, so each deploy publishes the docs as committed. See Docs site.- Fly terminates TLS and
force_httpsredirects plain HTTP. The health check isGET /healthz. - One machine, always on (
auto_stop_machines = "off",min_machines_running = 1, deployed with--ha=false), because offers and sessions live in process memory and an offer made on one machine cannot be accepted on another. - Ledger and telemetry reset on every restart or deploy. This is deliberate for a demo where no money moves and the publishers are fictional; the JSONL trail is not replayed at startup anyway, and a volume would pin the app to one host. When the ledger matters, the right step is a database behind
AppendLog, not a volume. One consequence: an entitlement issued before a restart and retrieved after it is recorded again in the fresh ledger. - Stable signing key from the
SIGNING_KEY_PEMsecret, so entitlements and the JWKS survive restarts. Replacing the secret rotates the key and invalidates outstanding entitlements. - If you rename the app or move the API to another hostname, change
PUBLIC_URLto match.
CI and continuous deployment
.github/workflows/ci.yml runs on every push and pull request:
| Job | What it does |
|---|---|
check | npm ci, npm run typecheck, npm test, npm run e2e, then the deterministic agent harness (npm run harness -- --quiet) and a small rule-based customer survey (no model calls). Both reports are uploaded as artifacts. |
container | Builds the production image; checks it refuses to start without secrets (exit 78); starts it with throwaway secrets; checks admin returns 401 without the token and every UI path returns 200 with a CSP; checks every docs/*.md page is served at /docs/ and that a docs hostname serves the docs at its root; checks the landing page, app and redirects on site, www and app hostnames; runs e2e and the harness against the container. An advisory step starts the image with live Wikimedia sources and probes them; an outage warns but never fails CI. |
deploy | Only on pushes to main, only after check and container pass. Pull requests and other branches never deploy. |
The deploy job runs in the GitHub environment production, which holds the secrets and accepts only main. It:
- checks the three secrets are present and well formed (
ADMIN_TOKEN≥ 32 characters,SIGNING_KEY_PEMan Ed25519 key), failing with an error naming any that are missing; - creates the Fly app if it does not exist (in
vars.FLY_ORG, defaultpersonal) and checksprimary_regionislhr; - stages
ADMIN_TOKENandSIGNING_KEY_PEMon Fly withflyctl secrets set --stage; - runs
flyctl deploy --remote-only --ha=false(built on Fly's builders); - makes sure the app has a public IPv6 and IPv4 address (allocating a dedicated IPv6 and a shared IPv4, both free, only if missing) and prints them with the DNS records for every custom hostname in the log and job summary;
- makes sure a TLS certificate exists for each hostname in
CERT_HOSTNAMES(defaultclearexchange.ai www.clearexchange.ai app.clearexchange.ai docs.clearexchange.ai) withflyctl certs add, treating "already exists" as success. The job summary shows each certificate's status (see Hostnames and DNS); - smoke-checks
/healthz,/and/docs/return 200 and/v1/admin/ledgerreturns 401 on the fly.dev URL, then checks each custom hostname answers and probes the live sources (both advisory).
Deploys never overlap and CI runs on main are queued rather than cancelled, so an older commit never deploys after a newer one. Secrets reach flyctl only through environment variables on the steps that need them; the third-party setup-flyctl action is pinned to a commit SHA. A deploy whose secrets are wrong fails its health check and Fly keeps the previous release running.
The three GitHub secrets
Created once, by a repository admin, as environment secrets of production (Settings → Environments → production; restrict deployment branches to main). The workflow never generates them.
| Secret | Contents | How to generate |
|---|---|---|
FLY_API_TOKEN | Fly.io API token for CI | An org token for the first deploy (it must be able to create the app); afterwards it can be narrowed to an app deploy token for clear-exchange. Include the FlyV1 prefix. |
ADMIN_TOKEN | Bearer token for /v1/admin/*, 32+ characters | openssl rand -hex 32. Keep a copy in a password manager: neither GitHub nor Fly shows it again. |
SIGNING_KEY_PEM | Ed25519 private key (PKCS#8 PEM) that signs entitlements | npm run -s keygen, or openssl genpkey -algorithm ed25519. Paste the whole multi-line value. Changing it invalidates outstanding entitlements. |
If the app must live in an organisation other than the personal one, add an environment or repository variable FLY_ORG with the org slug. If the app name is taken, change app and PUBLIC_URL in fly.toml and FLY_APP in the workflow.
Checking the deployment
curl -s https://clear-exchange.fly.dev/healthz
curl -s -o /dev/null -w '%{http_code}\n' https://clear-exchange.fly.dev/v1/admin/ledger # 401
ADMIN_TOKEN="<the saved token>" npm run e2e -- https://clear-exchange.fly.dev
The e2e walkthrough runs query, accept, verify, retrieve and report against the live service and needs ADMIN_TOKEN for its final ledger and telemetry checks. Each run adds a small entry to the demo ledger. Logs are in the Fly dashboard under the clear-exchange app.