Publish the API at evocation.nerthus.pl¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
This guide bootstraps the public edge in front of the fleet from nothing: no tunnel, no DNS record, and no worker exist beforehand. It puts the fleet's primary daemon on the internet at https://evocation.nerthus.pl, wires the report-and-close Worker at conjure.nerthus.pl, and registers the Discord commands. The daemon never changes — each host stays bound to 127.0.0.1, single-consumer, emitting no TLS and no CORS. Everything a public API needs is added at the Cloudflare edge. The trust-boundary rationale is in Architecture; the host half is Bootstrap a fleet host; this page owns the edge.
flowchart LR
U1["Narrator dashboard<br/>astral.nerthus.pl"]
U2["In-game addon userscript<br/>nerthus.margonem.pl"]
subgraph edge["Cloudflare edge"]
E1["TLS termination"]
E2["CORS Worker"]
E3["Rate-limit / WAF"]
end
subgraph host["The fleet primary"]
T["cloudflared"]
D["nerthusd @ 127.0.0.1:8787"]
end
U1 -->|"https + Bearer"| edge
U2 -->|"https + Bearer"| edge
edge -->|"outbound tunnel"| T
T -->|"loopback HTTP"| D
Prerequisites¶
- A Cloudflare account with the
nerthus.plzone. The domain's nameservers are delegated to Cloudflare (GoDaddy stays the registrar —.plcannot move to Cloudflare Registrar, and does not need to). All records are edited on Cloudflare, not at GoDaddy. - Cloudflare API tokens for the two checkouts. Each repository's committed
.env.examplenames the scopes its own scripts spend. The module'sCF_TOKENneeds DNS · Edit and Workers Scripts · Edit for the report Worker's self-provisioned hostname. The fleet contract'sCF_TOKENneeds Zone WAF · Edit and Cache Rules · Edit — one ruleset phase each,http_ratelimitandhttp_request_cache_settings— plus Workers Routes · Edit, Workers Scripts · Edit, and Workers KV Storage · Edit for the namespace in step 4. Both need the account id for wrangler. A wrong or missing scope surfaces as a bare "Authentication error" rather than as the permission it wanted. - The fleet pipeline's token is a third instance and stays off every device. Set up credentials names its scopes with the rest of the fleet repository's CI/CD variables.
wranglerinstalled and logged in. Nocloudflaredruns on your workstation; the fleet CI writes tunnel config at Cloudflare, and hosts run cloudflared in a container.- The
.envfiles filled in. Each script below reads its credentials from the gitignored.envat the root of its own repo: the module checkout for the GitLab and report-Worker steps, the fleet contract checkout for the edge-rule and CORS steps (its Cloudflare keys areCF_ZONE,CF_TOKEN,CF_ACCOUNT_ID). Set up credentials owns the catalogue — do not restate the keys here.
The Cloudflare account, the GitLab top-level group, and the nerthus.pl registrar each need at least two human owners. A single-owner account is one lost login away from an unrecoverable fleet.
1. Capture the pipeline trigger token¶
Provision the lore project's pipelines and mint the trigger token the report Worker will fire, capturing its full value (it exists only at creation):
Record the printed token into .env as GITLAB_TRIGGER_TOKEN. This pass also removes the default branch's auto-created protection so human and bot direct pushes work; it never protects main (Set up session reports owns the report/close flow).
2. Deploy the report Worker — it provisions its own edge¶
The report-and-close Worker self-provisions its hostname. Its wrangler custom_domain creates the conjure.nerthus.pl DNS record and certificate on deploy; no manual DNS step precedes it:
The script deploys the Worker and sets its GL_TRIGGER_TOKEN secret from .env over stdin. Its committed vm/report-worker.wrangler.toml carries only inert placeholders — the real Discord and GitLab values are supplied at deploy from .env.
3. Register the Discord commands and point interactions at the Worker¶
The script registers guild-scoped commands first, so they appear instantly. It registers whatever vm/discord-commands.json holds — the report and close commands, plus the read-only /status, /sesje-otwarte, and /pu — and refuses to register a command the sibling vm/discord-routing.json does not route, so the Worker can never receive a name it cannot answer. Then, in the Discord developer portal, set the application's Interactions Endpoint URL to https://conjure.nerthus.pl/ — the Worker verifies each interaction's Ed25519 signature, offers it to the live daemon, and falls back to a pipeline trigger for the two kinds CI can also serve (Fleet).
4. Deploy the fleet-health Worker¶
The fleet-health Worker runs the sweep every fifteen minutes and posts fleet notices (Fleet). Its KV namespace is the one channel between the fleet pipeline and the sweep — provision writes the roster's expectations in, the Worker writes its last-known state out — so create the namespace first, from the fleet contract repository:
Put the printed id in wrangler.toml in place of the committed REPLACE_WITH_KV_NAMESPACE_ID placeholder, and set the non-secret DISCORD_GUILD_ID there too. Then deploy and give the Worker the two secrets it needs — the bot token it posts as and the GitLab token it proposes failover MRs with:
wrangler deploy
wrangler secret put DISCORD_BOT_TOKEN # posts to kanal-wewnetrzny-it as the bot
wrangler secret put GL_FLEET_TOKEN # machine-user PAT for role-flip MRs
The Worker resolves the channel by name and posts as the bot; there is no webhook to keep in sync. Record the public URL wrangler deploy prints as health_worker_url in the fleet repository's fleet.settings.yml, so a fleet-state read can see what the sweep saw.
Mirror the same id into CF_KV_NAMESPACE_ID on the fleet repository (Set up credentials). Two halves of one channel fail differently when the variable is missing: provision logs CF_KV_NAMESPACE_ID unset — health Worker expectations NOT updated and exits green, and the Worker's public GET then serves no hosts.json in KV — has fleet:provision run? and sweeps nothing — no alerts, no failover, no dead-man summary.
5. Bootstrap the first host — it becomes the primary¶
The apex follows the primary, so the primary must exist before the apex has a target. Bootstrap the first host now, following Bootstrap a fleet host: its merged roster line provisions the tunnel, the proxied CNAME, and — because it is the primary — the evocation.nerthus.pl apex pointer. Wait for its convergence tick before continuing.
6. Provision the edge rules per hostname¶
ci/Set-NerthusEdgeRules.ps1 in the fleet contract repository upserts the rate-limit and cache-bypass rules through the Cloudflare Rulesets API, one idempotent pass per hostname, keyed by rule description so it never clobbers unrelated rules. Run it from that checkout. The apex answers the API's page callers; conjure.nerthus.pl answers Discord's servers, which can never solve a challenge, so it takes -Action block:
./ci/Set-NerthusEdgeRules.ps1 -Hostname evocation.nerthus.pl -RequestsPerPeriod 100 -PeriodSeconds 10 -CacheBypass
./ci/Set-NerthusEdgeRules.ps1 -Hostname conjure.nerthus.pl -Action block
CF_ZONE and CF_TOKEN come from the fleet contract checkout's .env. -PeriodSeconds is a closed set — Cloudflare's fixed windows 10, 60, 120, 300, 600, 3600 — so a stray value is a parameter error, not a silently wrong limit. Why each rule matters:
- Rate-limit — required. Each daemon's serve loop answers one request at a time. A flood of even unauthenticated
/healthhits starves it, and Margonem login cannot protect availability. - Cache — bypassed because the API is dynamic and authenticated. This matters most for
/v1/api/events: server-sent events must not be buffered (the daemon setsCache-Control: no-cacheand sends a keepalive every 30 s, inside Cloudflare's ~100 s idle window).
-Action defaults to block. Nobody navigates a browser to these hostnames: the narrator dashboard and the in-game addon both call the API from a page's fetch, and the health sweep and the edge read-routing are Workers. None of those can render an interstitial, and a JSON client reads the HTML of one as a corrupt answer rather than as a refusal — a challenge suits a hostname a person browses, which the fleet does not have. Bot Fight Mode is a one-click toggle in the Cloudflare dashboard worth turning on.
7. Publish the CORS Worker¶
Every browser client of the API is cross-origin, and the daemon sends no CORS headers and has no OPTIONS route. Grant access at the edge, from the same fleet contract checkout:
The script deploys the Worker from worker/cors-worker.wrangler.toml and binds the apex route evocation.nerthus.pl/*; the fleet pipeline provisions the per-town routes from cors_worker in fleet.settings.yml. Verify the preflight the daemon cannot answer:
curl -sD - -o /dev/null -X OPTIONS https://evocation.nerthus.pl/v1/api/entities \
-H 'Origin: https://astral.nerthus.pl' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization'
# expect 204 with access-control-allow-origin: https://astral.nerthus.pl
ALLOWED_ORIGINS in worker/cors-worker.js is a closed set of three first-party origins, written out rather than pattern-matched — a regex over *.nerthus.pl would hand every future subdomain the right to call the API with a narrator's bearer token:
| Origin | Consumer |
|---|---|
https://astral.nerthus.pl |
the narrator dashboard against the production fleet |
https://dev-astral.nerthus.pl |
the same bundle against the dev fleet |
https://nerthus.margonem.pl |
the in-game addon, a Tampermonkey userscript on the game page |
The grant is per origin, so each name is its own line and a new consumer is a one-line addition plus a republish. Both dashboard origins are GitLab Pages custom domains rather than fleet hostnames; Deploy the dashboard owns that deployment and the failures a missing grant produces there.
The same Worker can also route reads. The apex points at the primary, which publishes and runs the scheduled ticks, while the replicas hold the same commit and answer almost nothing — so a read the daemon marks replica on GET /routes may be answered by one of them instead. The posture is a KV value rather than a deploy, which is what makes turning it off immediate work rather than a release:
# off (the compiled default), then shadow, then a percentage, then all of it
wrangler kv key put --binding FLEET_KV read-fanout.json '{"mode":"off"}'
wrangler kv key put --binding FLEET_KV read-fanout.json '{"mode":"shadow"}'
wrangler kv key put --binding FLEET_KV read-fanout.json '{"mode":"on","percent":10}'
Shadow serves every reader the apex answer and fetches the replica's only to compare status, entity tag and length, logging any divergence — which keeps the "every host answers identically" premise verified continuously instead of once. KV propagation is eventually consistent, about a minute, so this is a rollout knob and not a kill switch; the hard rollback is redeploying the previous Worker.
What never leaves the apex: writes, OPTIONS, /v1/api/events, any request carrying X-Nerthus-Min-Commit, any bearer no other host can resolve (a sess_ sign-in token — but not a nak_ api key or an fsess_ fleet token), and any path the compiled list does not name. Beyond that, every uncertainty resolves to the apex: no KV, an unparsable value, no fresh replica, a non-2xx, an HTML body, a 409 CommitBehind.
Two response headers make a rollout legible: x-nerthus-fanout carries the decision as one bare word, and x-nerthus-served-by names the town that answered. served is a replica answer and shadow a compared one; fallback and fallback-commit are a replica answer the apex had to redo; every other value is a skip and names its reason — off, marked, not-apex, write, events, derived, min-commit, bearer, route, no-host, sampled-out. A replica's requestCount on its own /health rising is the other half of the confirmation.
The route list is compiled into the Worker rather than fetched per request, and Nerthus.Core has a test that fails when it drifts from the daemon's own table. The asymmetry matters when changing it: a removal must reach the edge before Core ships the flag change (a list naming a route that stopped being replica-safe serves a wrong answer), while an addition can follow at leisure (it only misses an optimization).
8. Verify end to end¶
https://evocation.nerthus.pl/health→200(no auth), enriched with version, schema, sync freshness, and image digest.- Preflight →
204+access-control-allow-origin(step 7 curl). - From the in-game addon on
nerthus.margonem.pl: Margonem sign-in mints a session token (see the consumer contract below). - An authenticated read (
GET /v1/api/entities) succeeds from a page — from the addon with that token, and from the dashboard onastral.nerthus.plwith an API key. - An own-scoped write lands as a
gracz:<margonem-id>line in the audit stream and syncs to GitLab. GET /v1/api/eventsconnects and streams an event.- A burst trips the rate-limit rule.
/zamknij-sesjęin Discord reachesconjure.nerthus.pland is enacted — by the daemon when it is awake (the followup ends with⚡), by a close pipeline otherwise./statusanswers from the daemon; with the primary stopped, the same command answers that the daemon is unavailable — the read-only commands have no CI fallback by design.
The consumer contract¶
What a public consumer must honour. Three exist: the narrator dashboard on astral.nerthus.pl, the in-game addon userscript on nerthus.margonem.pl, and any bot. Each lives in its own repository and implements this:
- Base URL
https://evocation.nerthus.pl/v1/api.GET /healthalso answers bare at the root. The apex follows the primary; a minted token (session or named) is host-local and 401s on every other town host by design, while a committed API key (Permissions) authenticates on every town hostname. - Authenticate with
POST /v1/api/auth/margonem(a public route) carrying the Margonem-signed payload; it returns a session token scoped to the caller'sgraczrole. Login is roster-gated — a Margonem id absent from the roster gets404, never a token — andgracz-pinned. Elevated capabilities come only from out-of-band per-person grants (Permissions). Only a*.margonem.plpage can obtain that signed payload, so the addon signs in this way and the dashboard takes an API key instead (The dashboard has the reason). - Call every other route with
Authorization: Bearer <token>. Never ship a named or machine token to a browser; the Margonem flow needs no client secret because the daemon verifies the signature against Margonem's public key. - Live updates stream from
GET /v1/api/events(SSE viafetch+ReadableStream, the token in the header). - Transport, for the addon.
nerthus.margonem.plsets no CSP today, so nativefetchworks; keepGM_xmlhttpRequest+@connect evocation.nerthus.plas a designed fallback, since that domain's headers are not ours to control.
Notes¶
- No daemon code changes. The loopback bind and the absent CORS are unchanged — the whole public surface is edge configuration plus the Worker templates: the report Worker in the module's
vm/, the CORS Worker in the fleet contract'sworker/. - The unauthenticated surface is small, and Capabilities owns the list — a copy here drifts the moment a route is added. Everything not on it needs a bearer token (API reference).
GET /routesreports the live set anonymously, which is the answer that cannot go stale. - The host half — the container pair, the tunnel token, the converge loop — is Bootstrap a fleet host; the roster and failover are Fleet.
See also¶
- Bootstrap a fleet host · Fleet
- Set up credentials — the
.envcatalogue every step here reads, and the fleet repository's CI/CD variables fleet.settings.yml—cors_worker,health_worker_url, and the rest of the environment file- Deploy the dashboard — the consumer behind two of the three allowed origins
- Set up session reports — the report and close Worker at
conjure.nerthus.pl - API reference — the public routes and the write gate