Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tera API
One Fastify service on 127.0.0.1:8431, serving /api/v1/* behind Caddy. It
answers with flight plans, weather, a public marker snapshot and office packs,
and it does all of it with a single runtime dependency.
It boots with no configuration at all. No account, no Supabase project, no
API key, no network. That is not a nice property, it is the acceptance test —
src/test/boot.test.ts starts this server under a genuinely empty environment
and asks it for its health, and CI does the same thing from outside the
container.
npm ci # from the repo root; server/ is a workspace
npm start -w @lumbridge/tera-api
curl -s localhost:8431/api/v1/health | jq
There is no build step. Node runs the TypeScript sources directly by stripping
types at load, which needs Node ≥ 22.18 and is why erasableSyntaxOnly is
set in tsconfig.json — an enum or a parameter property would break the runtime
without breaking the typecheck, which is the wrong order to find out.
Routes
| route | body | cached |
|---|---|---|
GET /api/v1/health |
HealthBody |
never |
GET /api/v1/flights |
FlightsBody |
public, TERA_FLIGHTS_TTL |
GET /api/v1/weather |
WeatherBody |
public, TERA_WEATHER_TTL |
GET /api/v1/markers |
MarkersBody |
public, TERA_MARKERS_TTL |
GET /api/v1/offices/:id |
OfficeDoc |
public offices only |
Every body is declared once, in src/server/wire.ts in the root package —
type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other.
Cache-Control is fail-closed: a global hook stamps private, no-store on every
reply before any route runs, and a route opts in explicitly. A request that
arrived with an Authorization header or a cookie never gets a public policy,
whatever the route asked for.
Configuration
Everything is TERA_*, everything is optional, and nothing is fatal. A
source configured without what it needs is demoted, not fatal: the server logs
one loud TERA DEGRADED: line, serves the fallback, and lists the demotion in
the degraded array on /api/v1/health. Two earlier designs failed to boot on a
missing weather contact string; this is the correction. (CONTRACT.md §5.1.)
| variable | default | what it does |
|---|---|---|
TERA_HOST |
127.0.0.1 |
Bind address. 0.0.0.0 inside a container, nowhere else. |
TERA_PORT |
8431 |
|
TERA_LOG_LEVEL |
info |
|
TERA_ORIGIN_LAT / _LNG |
SF | The city this box serves. Weather point and flight-plan centre. |
TERA_CORS_ORIGIN |
(empty) | Comma-separated. Empty means same-origin only. |
TERA_PUBLIC_MAX_AGE |
60 |
max-age for routes without their own TTL. |
Weather
| variable | default | what it does |
|---|---|---|
TERA_WEATHER_SOURCE |
none |
none, nws, metno, openmeteo. |
TERA_WEATHER_CONTACT |
(empty) | An email or URL. Required by nws and metno. |
TERA_WEATHER_TTL |
600 |
Seconds between upstream fetches. |
nws— api.weather.gov. US only, keyless, and its output is a US government work in the public domain, so nothing downstream of it owes anybody attribution. The default once a contact is set.metno— the global fallback. CC BY 4.0, so the body carries anattributionarray the consumer is expected to display.openmeteo— opt-in and off by default. The data is CC BY 4.0, but the free tier is non-commercial, which is the wrong default for a product page. Turning it on records a line indegradedsaying so. (CONTRACT.md §5.2.)
none — the default — serves a synthetic clear day with synthetic: true. That
is a supported steady state, not an error path.
Flights
| variable | default | what it does |
|---|---|---|
TERA_FLIGHTS_SOURCE |
sim |
sim, adsb, dump1090. |
TERA_ADSB_ENDPOINT |
https://api.adsb.lol |
Also works with airplanes.live. |
TERA_ADSB_RADIUS_NM |
40 |
|
TERA_DUMP1090_PATH |
(empty) | Path to your receiver's aircraft.json. |
TERA_FLIGHTS_TTL |
300 |
Clamped to 15 s for live sources. |
TERA_FLIGHTS_SEED |
4711 |
The simulated source is served as a route plan, not as positions: the routes, a fixed phase origin and a seed, which every browser evaluates in closed form against wall-clock time. One cacheable request replaces a poll per second, and two people on different machines see the same aircraft in the same places.
There is no FlightRadar24 client and there will not be one — their terms forbid
scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would
be publishing instructions for breaking a ToS. An RTL-SDR and dump1090 on a box
you own is the best of the three sources anyway: first-party data with nothing to
comply with. (ARCHITECTURE.md §4.)
Markers
| variable | default | what it does |
|---|---|---|
TERA_MARKERS_SOURCE |
none |
none or file. |
TERA_MARKERS_FILE |
(empty) | JSON snapshot written by the sync oneshot. |
TERA_MARKERS_PROVENANCE_ALLOWLIST |
us-census,hand-placed,synthetic |
|
TERA_MARKERS_TTL |
300 |
The API serves a file. It holds no database and no credential, and private per-user markers are never proxied through it — an authenticated browser calls Workie directly with its own token, so a private row never enters this process.
Every row must declare where its coordinate came from, and the gate refuses anything not on the allowlist. Serving a snapshot of geocoded coordinates is Public Use of a Derivative Database; if those coordinates came from Nominatim, ODbL §4.3 and §4.4 attach to everything served alongside them, no matter where the rows are stored. Google, Mapbox and HERE are not an escape either — their terms restrict storing and redistributing what they return. The sanctioned geocoder is the US Census Geocoder, whose output is public domain. (CONTRACT.md §8. Adding to the allowlist is a licence decision, not a config tweak.)
A row carrying a field the gate does not recognise is refused whole rather than trimmed, and the refusal counts are served on the wire so a broken sync is visible from outside instead of only in a log.
The sync oneshot
TERA_SYNC_SOURCE_URL=https://workie.example/api/public/markers \
TERA_SYNC_TOKEN=... \
TERA_MARKERS_FILE=/var/lib/tera/markers.json \
npm run sync -w @lumbridge/tera-api
The only second process, and the only holder of a credential. It runs on a timer, puts every row through the same gate, and writes the snapshot atomically. One refused row aborts the whole sync and leaves the previous snapshot in place — a stale map is a cheap mistake, and publishing coordinates whose licence nobody can vouch for is not one that a later fix undoes.
TERA_SYNC_PROVENANCE asserts a provenance for rows that arrive without one.
Setting it is a licence claim you are making on the record.
Offices and auth
| variable | default | what it does |
|---|---|---|
TERA_OFFICES_DIR |
(empty) | One <id>.json per office. Empty means no offices. |
TERA_AUTH_MODE |
none |
none, sso, jwt. |
TERA_AUTH_ENTRY_URL |
(empty) | Where a browser sends someone to sign in. sso. |
TERA_AUTH_REVALIDATE_URL |
(empty) | Server-side token check. sso. |
TERA_AUTH_COOKIE |
tera_session |
Cookie a session may arrive in. |
TERA_AUTH_JWT_SECRET |
(empty) | HS256 shared secret. jwt. |
TERA_AUTH_JWT_VERIFY |
hs256 |
Set to jwks for asymmetric verification. |
TERA_AUTH_JWKS_URL |
(empty) | |
TERA_AUTH_JWT_ISSUER / _AUDIENCE |
(empty) | Checked when set. |
A self-hoster gets none, an open office, and never creates an account
anywhere. sso is what Lumbridge's own deployment uses: this world holds no
credentials, only an entry URL and a revalidate URL, and enforcement happens
here on the server.
Where a JWT is verified directly, HS256 against a shared secret is the primary
path and JWKS sits behind an env switch. That ordering comes from verified
fact rather than taste: the issuer this runs against signs {"alg":"HS256"}, and
a JWKS-only implementation would reject every real token. (CONTRACT.md §6.)
A private office returns 404, not 403 — byte-identical to an office that was never created — so the endpoint cannot be used to enumerate what exists. A pack that does not declare its visibility is treated as private.
Deploying
Three files in ../deploy, and exactly one of each:
Caddyfile.snippet—import tera_apiinto the site that serves the build.tera-api.service— systemd, with an optional environment file so the unit starts on a box where nobody wrote one.docker-compose.yml—cd deploy && docker compose up, underenv -i.
Tests
npm test -w @lumbridge/tera-api
npm run typecheck -w @lumbridge/tera-api
The three that are load-bearing: the empty-environment boot, the provenance gate, and the office 404.