5bc7258753
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.
Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.
Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".
Three things this run found and fixed rather than shipped:
- entryUrl came off the wire and went straight into an href with no scheme
check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
javascript: URL from navigating. One rejection point in access.ts now.
- A 5xx from /health was the same null as "no API at all" and therefore the
opposite conclusion. Eight seconds of tera-api restarting would have told
every anonymous visitor they were a member. A 5xx is an answer; it fails
closed.
- decodeURIComponent in cookieToken was the one path in auth/index.ts that
threw rather than returning ANONYMOUS, so one malformed cookie header from
an unauthenticated caller turned /api/v1/session into a 500.
Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
11 KiB
Markdown
237 lines
11 KiB
Markdown
# 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 | 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
|
|
|
|
```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.
|