Clear ExchangeDocs Live demo

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.

VariableDefaultMeaning
PORT, HOST8080, 127.0.0.1Listen address
PUBLIC_URLhttp://localhost:$PORTEntitlement 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_BPS1000Clearing fee in basis points (10%), added on top of the publisher price
OFFER_TTL_SECONDS300How long an offer can be accepted
ENTITLEMENT_TTL_SECONDS900Entitlement validity for retrieval
TELEMETRY_GRACE_SECONDS604800Late telemetry window after expiry
VAR_DIR./varWhere JSONL records go. Empty (VAR_DIR=) for memory only
CATALOGUE_DIR./dataSeed 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_MS4000Hard cap on any one source's search; the fan-out budget normally binds first
FANOUT_CAP5Most sources one query is sent to
EXPLORE_SLOTS1Of those, slots kept for new or under-served sources. Must be below FANOUT_CAP
EXPLORE_MIN_QUERIES20A source is under-served until routed this many queries
FANOUT_BUDGET_MS1500Total time budget for a query's fan-out
FANOUT_RESERVE_MS100Kept back from the budget for scoring, pricing and the response. Must be below the budget
MIN_SOURCE_TMAX_MS100A source whose tmax would fall below this is skipped
ROUTE_CACHE_TTL_SECONDS, ROUTE_CACHE_MAX_ENTRIES60, 500Candidate-pool cache per normalised query (0 = off)
BAND_RATIO, BAND_FLOOR0.9, 0.5Relevance 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_PENALTY0.1, 0.3, 0.1For 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_CAP0.1, 0.05Weight of a source's self-score and the most it can move relevance
DIVERGENCE_PENALTY, DIVERGENCE_TOLERANCE0.25, 0.15Penalty per unit of overclaim, and the excess tolerated first
PRIOR_WEIGHT, PRIOR_STRENGTH0.1, 10Weight 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_MS2500Budget for all requests one Wikimedia search or retrieval makes
WIKIMEDIA_CACHE_TTL_SECONDS900How long Wikimedia results stay fresh in cache (stale entries are served if a refresh fails)
WIKIMEDIA_CONTACTrepo URLContact 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,wikipedia and DEMO_PUBLISHERS=on, so the demo shows live supply and routing across several sources.
  • SITE_HOSTS=clearexchange.ai,www.clearexchange.ai, APP_HOSTS=app.clearexchange.ai and DOCS_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 includes docs/ and renders it at startup, so each deploy publishes the docs as committed. See Docs site.
  • Fly terminates TLS and force_https redirects plain HTTP. The health check is GET /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_PEM secret, 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_URL to match.

CI and continuous deployment

.github/workflows/ci.yml runs on every push and pull request:

JobWhat it does
checknpm 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.
containerBuilds 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.
deployOnly 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:

  1. checks the three secrets are present and well formed (ADMIN_TOKEN ≥ 32 characters, SIGNING_KEY_PEM an Ed25519 key), failing with an error naming any that are missing;
  2. creates the Fly app if it does not exist (in vars.FLY_ORG, default personal) and checks primary_region is lhr;
  3. stages ADMIN_TOKEN and SIGNING_KEY_PEM on Fly with flyctl secrets set --stage;
  4. runs flyctl deploy --remote-only --ha=false (built on Fly's builders);
  5. 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;
  6. makes sure a TLS certificate exists for each hostname in CERT_HOSTNAMES (default clearexchange.ai www.clearexchange.ai app.clearexchange.ai docs.clearexchange.ai) with flyctl certs add, treating "already exists" as success. The job summary shows each certificate's status (see Hostnames and DNS);
  7. smoke-checks /healthz, / and /docs/ return 200 and /v1/admin/ledger returns 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.

SecretContentsHow to generate
FLY_API_TOKENFly.io API token for CIAn 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_TOKENBearer token for /v1/admin/*, 32+ charactersopenssl rand -hex 32. Keep a copy in a password manager: neither GitHub nor Fly shows it again.
SIGNING_KEY_PEMEd25519 private key (PKCS#8 PEM) that signs entitlementsnpm 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.