1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server
karti b20fe41a68 The sky the real catalogue served, rather than the one it was assumed to serve
Three fixes, all of them found by pointing the thing at celestrak.org and
looking at what came back.

**The catalogue was 100% Starlink.** The supplemental feed is 10,766
objects against a 6,000-object ceiling, so a first-wins merge spent the
whole budget on it and served a sky with no ISS, no GPS, no weather
satellites — every other group fetched, parsed, and thrown away. `merge`
now reserves the ceiling for the small groups and lets the fill group
take the remainder: 1,044 of everything else and 4,956 Starlinks. The
truncation warning is what caught it, which is the argument for the
no-silent-caps rule; it now reports the split rather than only the fact
that a cap was hit.

**You could get outside the sky.** The dome sat at 1.2 board spans and
the camera orbits to 1.5, so pulling all the way back put the viewer
outside it looking in, with half the constellation behind the camera.
2.0 is inside the far plane at 3.0 and outside the orbit.

**Size attenuation was wrong in principle.** A satellite does not get
bigger because you zoomed the map in. Fixed pixel size, at roughly what a
naked-eye Starlink actually looks like — the attenuated version shrank to
sub-pixel specks at the far end of the zoom and read as noise.

Measured over San Francisco at 21:19 local: 324 above the horizon, 267
fully lit, 96 at local midnight. The terminator does what it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:20:56 -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 query body cached
GET /api/v1/health HealthBody never
GET /api/v1/flights ?city= or ?lat=&lng=; 400 for a place this box does not serve FlightsBody public, TERA_FLIGHTS_TTL
GET /api/v1/weather ?city= or ?lat=&lng=; 400 for a place this box does not serve WeatherBody public, TERA_WEATHER_TTL
GET /api/v1/markers MarkersBody private; 401 unless signed in — public empty body when TERA_MARKERS_SOURCE=none
GET /api/v1/offices/:id OfficeDoc public offices only
GET /api/v1/offices/:id/presence PresenceBody private; 401 unless signed in — never publicly cached

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.

Both location parameters are optional and omitting them answers for the default region, which is the first entry in TERA_REGIONS. Giving both city and a coordinate is a 400, as is a coordinate this deployment has nothing to say about; see Regions below for why that is a refusal and not a lookup. radiusNm is accepted on /flights and deliberately ignored — routes/flights.ts explains what a caller-chosen cache key would dissolve.

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.

Regions

A caller's coordinate is never forwarded upstream. It only selects among the points the operator configured. Weather and flights answer for a resolved region, and a request for somewhere this box does not serve is a 400 naming what it does.

That is an allowlist rather than a lookup because the obvious version — take ?lat=&lng= and hand it to NWS — turns an unauthenticated endpoint into a free geocoding proxy for the planet: an amplifier pointed at somebody else's public-good API, from an address they will blame, with the operator's own contact string on every request. It is also what bounds everything downstream, since the upstream key space is the region list: the per-region caches, the NWS station cache and the adsb.lol poll budget are all bounded by the environment file and cannot be grown by anybody sending requests. (src/regions.ts has the full reasoning, including why snapping to a coarse grid was rejected.)

variable default what it does
TERA_REGIONS (empty) id:lat,lng[:radiusKm], separated by ; or newlines. The list, in the operator's order; the first is the default.
TERA_REGIONS=sf:37.7749,-122.4194;socal:33.82,-118.05:150

Ids are the same ones the browser's city packs use. radiusKm defaults to 120, which covers both shipped boards with room to spare and leaves them disjoint. A malformed entry is dropped with a line in degraded, and a spec in which nothing parses falls back to the shipped pair — a typo is a demotion, never a refusal to boot.

Left empty, the box serves the two cities the map ships with. An operator who pointed TERA_ORIGIN_LAT/_LNG somewhere else additionally gets that point as a region named origin, first in the list and therefore the default, so a bare GET /api/v1/weather on their box answers exactly as it did before regions existed.

GET /api/v1/health publishes the resolved list as regions, in the same order, so a client can pick its default the way the server does instead of guessing a ?city= and getting a 400 it cannot explain.

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 Default region only, and superseded entirely by TERA_REGIONS. Nothing per-request reads it.
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 Clamped to 1250 nm, which is what both feeds accept, with a degraded line.
TERA_DUMP1090_PATH (empty) Path to your receiver's aircraft.json.
TERA_FLIGHTS_TTL 300 For live sources, clamped into 515 s.
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.

The floor under the live TTL is the load-bearing half of that clamp, and it is why the cell above reads as a range. adsb.lol asks for no more than one request per second and airplanes.live publishes the same ceiling; both are volunteer-fed. TERA_FLIGHTS_TTL=0 used to mean one upstream request per inbound request — the exact flood the limit exists to stop, delivered by a setting that reads like "as fresh as possible". With the floor the worst case is arithmetic rather than a guess: regions ÷ 5 requests per second with every region under continuous load, which for the two shipped here is 0.4/s. An operator configuring more than five regions and keeping them all warm is the case to watch.

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.

Where a marker feed is configured, it takes a session. This is the one route the member tier is about, and until it refused somebody the tier was a word in a type union that no server behaviour corresponded to. An anonymous caller gets 401 with WWW-Authenticate: Bearer; a member gets the snapshot with no public cache policy on it, because a body that took a credential to obtain must not sit in a shared cache waiting for the next caller. There is deliberately no TERA_MARKERS_PUBLIC escape hatch. (401 rather than the 404 an office answers with: nothing here is enumerable — one feed, one path, and /api/v1/health already publishes sources.markers — so the caller is told the useful thing, which is "sign in and ask again".)

Two consequences worth knowing before you configure it:

  • A box with no feed still answers 200 and an empty list. source: none is the zero-config default, there is nothing there to protect, and making a stranger sign in to be told "no markers" would fail the acceptance test at the top of this file.
  • TERA_MARKERS_SOURCE=file with TERA_AUTH_MODE=none is unreachable by everyone, because nobody on such a box is ever authenticated. That is the fail-closed direction and it is the one private offices already take; the config pushes a line into degraded saying so, rather than letting it be discovered from an empty map.

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_PRESENCE_DIR (empty) One <officeId>.json per roster. Empty means nobody is in. Keep this directory separate from the offices one — a pack is publishable and a roster never is.
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.

The god tier

variable default what it does
TERA_ADMIN_SUBJECTS (empty) Comma-separated subject ids that get the admin tier. Empty means no admins.

There are three tiers on the wire and the server decides all three. GET /api/v1/session answers { authenticated, subject, admin, passwordLogin }: anonymous is authenticated: false, a member is authenticated: true, and a god is admin: true. The client reads admin to decide what to draw — the time/date scrubber, the debug panel — and nothing is authorised by it. A boolean that arrived over the wire is a rendering hint; anything that actually matters is checked again where it is enforced.

The list holds subject ids, meaning the sub claim this box verifies — not an email and not a display name. Under TERA_AUTH_MODE=password the subject is TERA_AUTH_PASSWORD_USER, so the single self-hosted account becomes an admin by naming it here:

TERA_AUTH_MODE=password
TERA_AUTH_PASSWORD_USER=karti
TERA_ADMIN_SUBJECTS=karti

Password mode is deliberately not auto-admin. One grant path, written down in the environment, is worth more than a convenience that makes "who is a god on this box" a question you answer by reading code.

Matching is exact after trimming and case-sensitive: karti and KARTI are two ids as far as an issuer is concerned, and folding case here would widen a grant to something nobody configured.

TERA_ADMIN_SUBJECTS=* grants the tier to every authenticated subject. It is a development escape hatch for a self-hoster who does not want to go find their own subject id first, it must never reach a deployment env file, and it pushes a line into degraded so /api/v1/health announces it. lumbridge-v4 is why: ADMIN_EMAILS shipped with admin@lumbridgecorp.com as a committed default while nobody had registered that address — a standing offer of admin to whoever claimed it first, invisible because nothing said it was on. A grant nobody can see is a grant nobody revokes.

Health never serves the list or its length. The degraded lines name the variable; they never name a subject.

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.