d464459838
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
8.9 KiB
Markdown
192 lines
8.9 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.
|
|
|
|
## 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.
|