Configuration¶
Every environment variable the gate, the dashboard and the buying agent read, what
each one does, and what happens if you leave it alone. The schema in
packages/shared/src/config.ts
is the source of truth; this page is the prose version of it, and a test fails the
build if the two stop agreeing.
How config is loaded¶
.env is read from the repo root, not the current directory. npm workspaces run
each package's scripts with the cwd set to that package, so a cwd-relative lookup
would miss the root file and fall back to defaults, meaning mock payments and an
ephemeral signing key, without saying anything. The root is found by walking up until a
package-lock.json appears.
Two rules make a straight cp .env.example .env work as written:
- A blank value is unset.
EDGE_SECRET=reads the same as leaving the line out, so it takes the default (or stays off) instead of failing validation as an empty string. - A relative file path is read from the repo root, the same root
.envis found from.CREDITS_FIXTURES=./examples/meridian/credits.jsonresolves to the same file whether the gate starts from the root or frompackages/tollgate. This covers every*_PATHsetting andCREDITS_FIXTURES; absolute paths are left alone.
Everything is validated with zod at first getConfig(), and an invalid value throws
before the process serves a request. The point is to fail at boot, not mid-payment.
Tests run with NODE_ENV=test and deliberately do not load your .env, so a
local PAYMENT_MODE=gateway never leaks into a test run.
Secrets never get a default. If a value is a key, a password, or an HMAC secret, it is optional in the schema and the feature that needs it stays dark until you set it. That is what lets the whole demo loop run with no credentials at all.
A few rules are enforced across variables rather than on one of them:
- Any
*_BACKENDset tosupabaserequiresSUPABASE_URLandSUPABASE_SERVICE_KEY. LICENSE_SIGNING_KEYis required once licensing is on and either payments are real or any Supabase backend is in play, because an ephemeral key can't be verified by another instance.LICENSE_TTL_SECONDSmay not exceed 3600, andLICENSE_POP_WINDOW_SECONDSmay not exceed 600. Both are replay windows; the cap is the point.X402_MAX_TIMEOUT_SECONDSmay not go below 604900. See the note in the payment table.
Payments and the settlement rail¶
| Variable | Default | What it does |
|---|---|---|
PAYMENT_MODE |
mock |
mock settles offline with no credentials. gateway uses the real Circle Gateway batching SDK and needs a funded BUYER_PRIVATE_KEY. |
SETTLEMENT_NETWORK |
arcTestnet |
Which chain the gate tolls on: arcTestnet, baseSepolia, or base. One switch selects the whole rail: quote, settlement body, discovery manifest, buyer client, and the testnet or mainnet facilitator. The default is testnet so a misconfigured deploy can never settle real money by accident. |
CIRCLE_API_KEY |
unset | Circle facilitator bearer for a mainnet leg. |
CIRCLE_API_KEY_TESTNET |
unset | Circle issues one test and one live key per account, split by environment rather than chain. Set this and one process can serve both; unset, a testnet leg falls back to CIRCLE_API_KEY (and the testnet facilitator works with no key at all). |
GATEWAY_API_URL |
unset | Overrides the facilitator endpoint. |
ARC_RPC_URL |
unset | Arc mainnet has no public RPC during the private preview, so a settle on arc requires this. It fails at settle time rather than boot, so testnet deploys never need it. |
RELAYER_PRIVATE_KEY |
unset | The EOA that signs the outer transaction and pays gas on the Arc self-relay path. It never touches the transferred funds, since the money moves buyer → author under the buyer's own EIP-3009 authorization, so custody-free still holds. Only needed with PAYMENT_MODE=gateway on a network that ships the Memo predeploy. |
RELAYER_PRIVATE_KEY_MAINNET |
unset | The same role on Arc mainnet, with no fallback to the testnet key. Mainnet gas is real money, so an Arc-mainnet memo settle without this fails loudly. |
USDC_EIP712_NAME |
unset | Override the USDC EIP-712 domain name if it is ever not USD Coin. Confirm against the on-chain name() before a real settle. |
X402_MAX_TIMEOUT_SECONDS |
691200 (8 days) |
The validity window advertised in the 402 quote. Circle's facilitator rejects verify unless at least 7 days of validity remain, so anything under 604900 (the floor plus the SDK's buffer) makes a non-SDK buyer that trusts your advertised number fail with authorization_validity_too_short. The schema refuses to go below it. |
The gate¶
| Variable | Default | What it does |
|---|---|---|
TOLLGATE_PORT |
8402 |
Port the gate listens on. |
ORIGIN_URL |
http://localhost:3000 |
The site the gate sits in front of. |
DEFAULT_PRICE_USDC |
0.001 |
Price of a single read when the credits response doesn't name one. |
CITATION_MULTIPLIER |
5 |
A citation costs this multiple of a read, because a citation has downstream reach: it grounds an answer many people will see. Both resolve to the same payees; only the price differs. 1 prices a citation like a read. |
ARTICLE_PATH_PREFIXES |
essays,articles,posts |
Comma-separated path prefixes that count as gateable articles. |
INGRESS_HOST |
unset | A hostname on which the gate accepts crawler traffic proxied by your CDN. The CDN names your site in Forwarded: host= and sends EDGE_SECRET. Responses served through it link the manifest and licence routes at https://<INGRESS_HOST>/…?host=<your site>, and those links end up in every licence record you sell, so it must be reachable over HTTPS from the public internet and must not change. A bare hostname: no scheme, port or path. Set it together with EDGE_SECRET or not at all; one without the other stops the gate at boot. |
EDGE_SECRET |
unset | The secret your CDN rule sends in X-Naulon-Edge-Auth. At least 32 characters. Set together with INGRESS_HOST. |
CREDITS_API_URL |
unset | When set, the gate asks ${url}/credits/:slug who wrote an article. See credits-api.md. |
CREDITS_API_TOKEN |
unset | Bearer token sent with that request, if your endpoint is not public. |
CREDITS_FIXTURES |
examples/meridian/credits.json |
Local JSON credits file used when CREDITS_API_URL is unset. |
CRAWLER_POLICY_PATH |
data/crawler-policy.json |
Per-crawler allow / charge / block policy. Absent or unreadable means classifier defaults, which is what every deploy has today. See crawler-policy.md. |
BUYER_GUIDE_URL |
the OSS paying guide | Where a 402 sends an agent to learn to pay: the body's links.guide, the rel="help" link and the x402 error field. Must be http(s). |
BUYER_FUND_URL |
unset | Where a person sets up an agent wallet. Named in the 402 only when set. |
BUYER_MCP_URL |
unset | An MCP server an agent can install to buy from this gate. Named in the 402 only when set. |
Hardening¶
| Variable | Default | What it does |
|---|---|---|
TOLLGATE_SECRET |
unset | HMAC secret signing 402 payment nonces. Only mock mode signs nonces. Unset or empty mints an ephemeral one on the first 402: fine for a single instance, wrong for several, and it invalidates outstanding nonces on restart. |
NONCE_TTL_SECONDS |
300 |
How long an issued 402 nonce stays valid. This is also the replay window. |
RATE_LIMIT_RPM |
120 |
Sustained per-client request ceiling. 0 disables rate limiting. |
RATE_LIMIT_BURST |
40 |
Short bursts absorbed above the sustained rate. |
RATE_LIMIT_MAX_BUCKETS |
50000 |
Ceiling on live per-client buckets, shared by the request limiter and the console's failed-sign-in budget. Keying per client is what makes a limiter fair, and it's also what makes its key space as large as the caller's address space: a host with a routed IPv6 /64 can spend a fresh address per request. At the default, 50k buckets is single-digit MB. Too high costs memory; too low costs accuracy, because a live client can be evicted early and start again with a full allowance. |
TRUST_PROXY |
false |
Trust the X-Forwarded-* trail. Turn it on whenever anything sits in front of the gate; it governs two separate things. The client IP (X-Forwarded-For): with it off, every request keys to the proxy's address, the whole deployment shares one bucket, and a single caller can 429 everyone. The public scheme (X-Forwarded-Proto): TLS normally terminates at the edge, so the gate's own socket sees http. That scheme is published in the 402's signed resource.url, and it is signed over as Web-Bot-Auth's @target-uri: leave this off behind TLS and every quote advertises http://, while an agent whose signature covers @target-uri fails verification and is silently treated as unverified. Both trails are read from the right, so a client-forged entry is ignored; the proxy still has to be one you control. |
TRUST_PROXY_HOPS |
1 |
How many trusted hops sit in front, counted outward. 1 is a single reverse proxy or one serverless edge; 2 is a CDN in front of that proxy. Only read when TRUST_PROXY=true. Adding a hop is an env change, never a code change. |
CLIENT_IP_HEADER |
unset | A header your edge sets to the caller's own address, read only by the crawler identity check. Set it to cf-connecting-ip when nothing but Cloudflare can reach the gate (origin pulls locked to Cloudflare); cf-connecting-ipv6 then wins when present, so a pseudo-IPv4 address is never checked. When the header is missing the request is not judged at all, never matched against the socket address. Unset: the socket address, or X-Forwarded-For under TRUST_PROXY. Never set it on a gate the open internet can reach directly, or any caller can name its own address. |
CRAWLER_RANGES |
on |
Fetch crawler operators' published IP ranges for the identity check, on demand and at most every six hours (five minutes while a source has never answered). off makes no outbound fetches and leaves every crawler claim unverified, so free reads are unchanged. Off by default under a test run. |
BOT_AUTH_SIGNING_KEY |
unset | Base64url 32-byte Ed25519 seed (scripts/wba-keygen.mjs). When set, the gate serves and self-signs its Web Bot Auth key directory at /.well-known/http-message-signatures-directory, and the buying agent signs its outbound requests. Unset, both surfaces are dark and the traffic is byte-identical. |
BOT_AUTH_SIGNATURE_AGENT |
unset | The directory host the agent advertises in Signature-Agent. It has to actually serve your directory. |
NAULON_AGENT_TOKEN_ID |
unset | The wayfarer's naulon agent token id, shown at naulon.app/buyer/agents. With NAULON_AGENT_TOKEN, it is how the wayfarer gets an RSL licence from naulon's own licence server. Sent to that server and nowhere else. |
NAULON_LICENCE_ORIGIN |
https://gate.naulon.app |
Where naulon's licence server runs. The agent token goes to this origin and nowhere else, and only for the site being read. Change it only to point the wayfarer at a naulon plane under test. |
NAULON_AGENT_TOKEN |
unset | That agent token. A secret. Without the pair, a page whose licence server is naulon's is bought over x402 instead, which licenses it through the same gate. |
BOT_AUTH_ALLOW_HTTP |
false |
Allow http:// and loopback key directories so a local signer fixture can serve one. Test walks only, because the directory URL is attacker-supplied, so never enable this in production. |
Reporting earnings to the publisher (webhooks)¶
The gate tells you a payment landed with a signed settlement.completed webhook.
Contract: settlement-notifications.md.
There used to be a second wire here, a signed POST to
${ORIGIN_URL}/api/credits/settlement, tuned by eleven SETTLEMENT_* variables. It
is deleted, along with CREDITS_SETTLEMENT_SECRET. If your .env still sets any
of them, they are ignored; the webhook below is the only notification path.
| Variable | Default | What it does |
|---|---|---|
NAULON_WEBHOOK_ENDPOINTS |
unset | JSON array of { url, secret, events?, hostFilter? }. Unset, the webhook plane is entirely dark: no store, no sweep, no POST. It is kept as a raw string here so a bad value fails with a field-specific message instead of a wall of zod. |
WEBHOOK_SWEEP_INTERVAL_MS |
30000 |
How often due deliveries are sent. 0 disables the loop, which is the right setting on serverless, where a cron drives the sweep instead. |
WEBHOOK_DELIVERIES_PATH |
data/webhook-deliveries.jsonl |
Where deliveries live. A journal rather than process memory, because a gate restart used to drop every unsent delivery and because the dashboard is a separate process that can only see gate state through a file. |
Storage backends¶
The open core stores nothing by default. Each plane can move to Supabase independently, same seam and different backend, which is what makes a serverless or multi-instance deploy possible.
| Variable | Default | What it does |
|---|---|---|
EVENTS_BACKEND |
jsonl |
Where attributed events go. jsonl is an append-only local file; supabase is a Postgres table over the REST API, for hosts with no shared disk. |
NONCE_BACKEND |
memory |
Where spent 402 nonces are remembered for replay protection. memory is correct for one instance; supabase holds replay protection across many. |
PENDING_LEGS_BACKEND |
memory |
Where buyer-authorized extra settlement legs wait for their deferred on-chain settle. supabase makes them survive a restart and settle exactly once across instances. |
OBSERVATIONS_BACKEND |
off |
The audit plane: who was served free, denied, or charged. off records nothing, which keeps the open core's zero-overhead posture for anyone embedding it. .env.example sets jsonl, because the console's traffic pages are built from it. supabase for an audit UI across instances. Separate from EVENTS_BACKEND on purpose, because observations are higher-volume and lower-value. |
SUPABASE_URL |
unset | Required as soon as any backend above is supabase. |
SUPABASE_SERVICE_KEY |
unset | Same. It is a secret, so keep it in .env, never in the repo. |
SUPABASE_EVENTS_TABLE |
naulon_events |
Table override. |
SUPABASE_NONCES_TABLE |
naulon_nonces |
Table override. |
SUPABASE_PENDING_LEGS_TABLE |
naulon_pending_legs |
Table override. |
SUPABASE_REVOCATIONS_TABLE |
naulon_revocations |
Table override, used by the online license check. |
SUPABASE_OBSERVATIONS_TABLE |
naulon_observations |
Table override. |
EVENTS_PATH |
data/events.jsonl |
Shared event ledger for the jsonl backend. The gate appends; the dashboard and attribution read. |
OBSERVATIONS_PATH |
data/observations.jsonl |
Observation ledger for the jsonl backend. |
OBSERVATIONS_MAX_MB |
100 |
Size cap for the jsonl observation log. Past it the file moves to <path>.1, replacing the previous one, and a new file starts, so at most twice the cap is on disk. The console reads both. 0 never rotates. |
PAYOUTS_PATH |
data/payouts.jsonl |
Where attribution records payouts. |
Citation licenses¶
A signed receipt handed to a paying agent, which buys it a free re-read inside the TTL. Full spec: citation-license.md.
| Variable | Default | What it does |
|---|---|---|
LICENSES_ENABLED |
true |
On by default, since a license is additive (one extra response header), and the offline path mints with an ephemeral key. |
LICENSE_SIGNING_KEY |
unset | Ed25519 private key (PKCS8 PEM or base64 DER) that signs licenses. Leave it unset only for single-instance mock development; it is required once payments are real or any Supabase backend is on, because verification has to hold across instances. |
LICENSE_RETIRED_PUBLIC_KEYS |
unset | Public keys you rotated out, comma-separated, each the x value of the old key's JWK. They are published in the key set so a citation record signed before the rotation keeps verifying. They never authorize a re-read: an access licence verifies against the live key only. |
LICENSE_TTL_SECONDS |
3600 |
Re-read window. A license is an unrevocable bearer credential on the offline tier, so the TTL is the kill switch, capped at 3600. |
LICENSE_ISSUER |
derived | Issuer and audience string. Defaults to naulon:<gate host> at runtime. |
LICENSE_PAYEES_MODE |
full |
Embed the whole payees graph (transparent) or hashed for a hash plus the primary payee. |
LICENSE_ONLINE_CHECK |
false |
Consult the revocation seam on the online verify tier. Needs shared state. |
LICENSE_POP |
false |
Bind licenses to the payer's wallet (RFC 7800 cnf) and require an EIP-191 proof of possession on re-read. Turn it on to close leak-replay: a captured token is then useless without the wallet key. |
LICENSE_POP_WINDOW_SECONDS |
120 |
Freshness window for that proof, and how long the proof nonce is remembered against replay. Capped at 600, since it only has to cover clock skew plus one round trip. |
The dashboard¶
The operator console. How to expose it safely: operating.md.
| Variable | Default | What it does |
|---|---|---|
DASHBOARD_PORT |
8403 |
Port the console listens on. |
DASHBOARD_BIND |
127.0.0.1 |
Interface it binds. The earnings view has no built-in auth and shows author wallets, so loopback is the default. Set 0.0.0.0 only behind your own auth. |
DASHBOARD_AUTH |
unset | HTTP Basic credential, user:secret. The secret is either a password or, preferably, a scrypt hash minted by npm run hash -w @naulon/dashboard, so the password is not stored in your .env, your compose file or your secret store. Plaintext still works and warns at every boot. With a non-loopback bind and no credential, the console refuses to serve rather than leak wallets; a credential that is set but unreadable (ops:, no colon) also refuses, loopback or not. |
DASHBOARD_AUTH_ROLE |
viewer |
What the machine credential may do once console accounts exist: viewer reads, admin may also run the six ops writes. Ignored while there are no accounts, where DASHBOARD_AUTH is still the whole login and still writes. |
CONSOLE_STATE_PATH |
beside EVENTS_PATH |
Operators and sessions (console.json, mode 0600). Put it on the same volume as your ledger, or sign-ins do not survive a restart. |
CONSOLE_ADMIN_USERNAME |
admin |
Username for the seeded first administrator. |
CONSOLE_ADMIN_PASSWORD |
unset | Seeds the first administrator without an interactive first run, which is the container path. The account it creates must change its password before the console will render anything else. |
CONSOLE_SESSION_IDLE_MINUTES |
480 |
Sign-in expires after this much inactivity. |
CONSOLE_SESSION_ABSOLUTE_HOURS |
168 |
Hard ceiling on a sign-in regardless of activity. |
DASHBOARD_AUTH_FAIL_RPM |
20 |
Failed-sign-in budget per client, shared by HTTP Basic, the sign-in form and the password-change form. Without it a password can be guessed at network speed. Only wrong passwords are charged, so you cannot lock yourself out by using the console hard. 0 disables. |
DASHBOARD_AUTH_FAIL_BURST |
10 |
Burst allowance on that budget. |
DASHBOARD_PUBLIC |
false |
Opt in to a read-only public earnings page with wallets masked and every operational panel hidden. The ops console itself is never public. |
DASHBOARD_ALLOWED_HOSTS |
empty | Extra Host values the console answers to in private (loopback) mode, comma-separated. This is for the "Caddy on :443 → 127.0.0.1:8403" shape, where the browser sends your real domain. Refusing anything else is what defeats DNS rebinding: a malicious page can't read an unauthenticated loopback console by pointing its own hostname at 127.0.0.1. Ignored in authed and public modes. |
GATE_URL |
http://127.0.0.1:8402 |
Where the console checks gate health (GET /healthz). Unreachable shows as "down". |
Buying (the agent side)¶
Only read by the wayfarer agent and the MCP server. See buying.md.
| Variable | Default | What it does |
|---|---|---|
BUYER_ADDRESS |
unset | The buying wallet's address. |
BUYER_PRIVATE_KEY |
unset | Its key. Required for a real (non-mock) payment. |
TOLLGATE_URL |
unset | The gate the agent pays. Required at use, with no localhost fallback. Setting it pins every payment to one gate, which is single-publisher by design. |
WAYFARER_BUDGET_USDC |
0.1 |
Spend ceiling for a run. |
WAYFARER_TOLL_TOLERANCE_BPS |
0 |
The buyer prices and pays in two requests, so the toll can move between them. The agent re-quotes at pay time and aborts if the live total exceeds the quoted total by more than this, in basis points. 0 means abort on any increase; a price drop is always fine. |
WAYFARER_MIN_VALIDITY_SECONDS |
60 |
Floor on the buyer's EIP-3009 validity window. If a gate advertises too short a window, the signed authorization can expire before the relayer submits it. The window only widens to this floor, never shrinks. |
WAYFARER_ALLOW_DOMAINS |
unset | Comma-separated publisher hosts, applied to every paid tool. This is an allowlist, so anything not listed is denied, and it is how one agent buys across many publishers instead of being pinned to a single TOLLGATE_URL. A blank or malformed value reads as unset (no restriction), never as an empty allowlist that would silently skip everything. |
WAYFARER_DENY_DOMAINS |
unset | Hosts never paid. Deny always wins over allow. |
WAYFARER_PER_DOMAIN_CAP |
unset | Maximum payments per host in a run. |
WAYFARER_APPROVAL_USDC |
unset | A toll at or above this becomes an approval request, a human gate, instead of a payment. |
WAYFARER_KILL_SWITCH |
false |
Halt all new spend. Free re-reads of licenses already held still work. |
WAYFARER_LICENSE_PATH |
data/wayfarer-licenses.json |
Where the agent caches licenses it has been issued, so a live one buys a free re-read across runs. |
DEPOSIT_AMOUNT_USDC |
1 |
USDC deposited into the Gateway Wallet at the start of a run. |
CATALOG_URL |
the naulon fleet directory | Where the agent discovers candidate articles. |
VERIFY_PAGE_URL |
https://naulon.app/verify |
The page a citation's proof link opens. It fetches the record from the issuing gate and checks the signature in the reader's browser against that gate's published keys, so the default verifies a self-hosted gate's records too. Point it at your own page if you host one. |
PUBLISHER_URL |
unset | Discover from one publisher's live feed instead. |
RSS_URL |
unset | An explicit feed URL. Precedence is RSS_URL > PUBLISHER_URL > CATALOG_URL; with none of them the agent refuses rather than inventing sources. |
SITEMAP_URL |
unset | Reserved for sitemap-based discovery, which fills the slugs an RSS feed truncates. Unused until that parsing lands. |
OPENAI_API_KEY |
unset | Only for the agent's own reasoning. The toll never needs it. |
NAULON_CLOUD_ENDPOINT |
unset | MCP server only. Opt-in hosted signing: the memo is signed by a remote service instead of a local key. All three of endpoint / token / session address must be set, or the server falls back to the BYO-key path unchanged (packages/wayfarer-mcp/src/cloud-signer.ts:55). |
NAULON_CLOUD_TOKEN |
unset | Bearer token for that endpoint. |
NAULON_BUYER_SESSION_ADDRESS |
unset | The session wallet address the hosted signer signs for. Must be a well-formed 0x…40 address or the hosted path stays off. |
Attribution and payouts¶
| Variable | Default | What it does |
|---|---|---|
MIN_PAYOUT_USDC |
0.005 |
Don't settle a wallet until its accrued tolls reach this, so the per-transfer overhead is amortized across many sub-cent reads. Below it, the balance carries forward. |
PRIMARY_PAYEE_TIEBREAK |
wallet |
How the single on-chain recipient is chosen when two co-authors tie for the top share. wallet breaks ties by address, so who gets the on-chain leg is a pure function of who is credited and a reordered credits graph can't move it. input keeps the credits-graph order. The full split is recorded either way. |
COAUTHOR_ONCHAIN_SPLIT |
false |
Pay co-authors directly on-chain instead of routing the whole toll to the primary author. Off is the stock single-recipient toll, with the rest of the split recorded for you to reconcile. On, the primary's leg drops to its own share and each other co-author gets a direct buyer → author leg, still custody-free. |
Keeping this page honest¶
packages/shared/src/configDocs.test.ts asserts, in both directions, that every key
in the schema appears in this file and in .env.example, that .env.example names
no key the schema lacks, and that this page names no variable nothing reads. Add a
variable without documenting it and the test fails, which is the only reason a
reference page like this stays true.
The reverse direction on this page allows one exception the schema doesn't cover: a
key read straight from process.env rather than through the validated config (the
MCP server's NAULON_CLOUD_* trio). It has to appear in some package's source, so a
row for a variable no code reads still fails.