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/README.md
T
karti e41c90fe8d Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:25:31 -07:00

320 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```bash
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 |
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. |
```ini
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
```bash
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.
### 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:
```ini
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.snippet``import 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.yml``cd deploy && docker compose up`, under `env -i`.
## Tests
```bash
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.