Files
Karti Tripathi fe43077ff8
gates / clean-clone (push) Successful in 16s
gates / zero-config-boot (push) Successful in 9s
gates / no-binary-art (push) Successful in 4s
The same bug the camera had, still live in the default light rig
Three loose ends turned into four, because one of them was not a stale comment.

`cityDaylight()` closed its fog at 210/460 — numbers tuned when San Francisco's
230-unit board was the only one. Every caller who supplies an `Atmosphere`
overrides them, which is why this survived: the demo does, so nothing looked
wrong. A self-hoster who renders a city with no clock and no weather gets the
default path, and on the Bay Area's 1003 units that fog closes well inside the
city. It now scales to the board exactly as the camera limits do, with the old
constants preserved as the default for a caller holding a palette but no world.

The three that really were comments: socal.ts justified its `latScale` by
citing the 340-unit orbit cap and the 460-unit fog as things the engine
insisted on, and the engine stopped insisting when both became board-relative —
285 is now a choice the pack makes because its lot sizes and hill radii were
authored against it. CONTRACT.md and ARCHITECTURE.md still measured the
retention argument against a 336,864-point San Francisco that has been the
whole Bay Area for some time. And server/package.json listed five routes where
there are six.

None of these changed behaviour except the first. All of them would have gone
on quietly disagreeing with the pages that now cite them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 04:34:00 -07:00
..

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 an attribution array 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 in degraded saying 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.snippetimport tera_api into 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.ymlcd deploy && docker compose up, under env -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.