385 lines
20 KiB
Markdown
385 lines
20 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 | 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 |
|
||
| `POST /api/v1/realtime/join` | — | strict `JoinRequest` or `ResumeRequest` | **private; never cached** |
|
||
| `POST /api/v1/realtime/events` | — | fetch-streamed SSE, opened with a strict `ResumeRequest` | **private; never cached** |
|
||
| `POST /api/v1/realtime/pose` | — | one owned `EntityPoseSnapshot` | **private; never cached** |
|
||
| `POST /api/v1/realtime/leave` | — | session id + opaque resume token | **private; never cached** |
|
||
| `POST /api/v1/media/join` | — | strict create or explicitly opted-in join request | **private; never cached** |
|
||
| `POST /api/v1/media/events` | — | fetch-streamed SSE opened with a strict resume request | **private; never cached** |
|
||
| `POST /api/v1/media/signal` | — | strict targeted SDP/ICE request | **private; never cached** |
|
||
| `POST /api/v1/media/leave` | — | strict presenter stop or revoke request | **private; never cached** |
|
||
|
||
Every body is declared once in the **root** package: ordinary feeds live in
|
||
`src/server/wire.ts`, realtime in `src/realtime`, and screen signaling in
|
||
`src/media/signalingTypes.ts`. The browser and service import the same strict
|
||
contracts without either transport 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.
|
||
|
||
### Realtime sessions
|
||
|
||
Hosted presence is an optional, in-memory adapter. It stores no pose history on
|
||
disk and disappears cleanly on restart; the static city and office continue to
|
||
work when it is absent. Joining requires the deployment's existing auth, and a
|
||
session owns exactly one opaque actor plus a bounded list of interest cells.
|
||
The server validates entity ownership, monotonic sequence/time, cell membership,
|
||
speed, climb and turn rate before broadcasting a pose only to the same cell.
|
||
|
||
The event stream is deliberately a **POST consumed with `fetch()`**, not an
|
||
`EventSource` URL. Session/resume credentials stay in the request body and
|
||
therefore out of browser history, referrers, Caddy access logs and copied URLs.
|
||
Tokens are random, stored only as SHA-256 hashes, rotated on resume, and bounded
|
||
by absolute TTL and disconnect grace. Reconnect, room capacity and pose rate
|
||
are capped. Authentication subjects remain server-only; peers see page-scoped
|
||
opaque entity ids and no profile name, face texture, email, or media locator.
|
||
|
||
California, city, office, floor and room cells are distinct coordinate and
|
||
authorization boundaries. Disk-backed office packs are checked before a floor
|
||
or room may be joined. The three public demo offices bundled into this repo may
|
||
use their office envelope without a duplicate server pack; that exception does
|
||
not invent floor/room access and does not apply to arbitrary tenant ids.
|
||
|
||
### Office screen signaling
|
||
|
||
Office screen sharing uses a separate, in-memory signaling service; it never
|
||
enters the game-state realtime service. The server stores only short-lived SDP
|
||
and ICE messages, hashed capability grants and server-only auth subjects. It
|
||
does not receive or store media, track data, recordings, stream URLs or source
|
||
locators. All capabilities travel in authenticated POST bodies, including the
|
||
fetch-streamed SSE request, never in a query string.
|
||
|
||
A share binds to an exact authored `{officeId, levelId, roomId, screenId}`. The
|
||
server resolves the office pack and confirms that the id is a monitor/display
|
||
prop at that level and in that room. The only no-disk exception is the exact
|
||
screen catalogue in the three public bundled demo packs. A viewer joins by that
|
||
binding and must send literal `viewerOptIn: true`; the server finds the active
|
||
presenter and issues opaque session/participant ids. One presenter and at most
|
||
seven viewers are allowed. Complete participant snapshots let the presenter
|
||
create and close one peer connection per authorized viewer.
|
||
|
||
This is currently an **authenticated deployment-member policy**, because the
|
||
existing auth model knows only signed-in member versus configured global admin.
|
||
It is not a tenant or per-office membership ACL. Add an authoritative
|
||
office-membership provider before using these routes for tenant-isolated offices.
|
||
|
||
Grants are random, stored only as SHA-256 hashes, rotated on resume and leased
|
||
for a short period. Signal queues, sessions, participants and request rates are
|
||
bounded. Stop, leave, expiry and revocation remove server capabilities and send
|
||
best-effort terminal/snapshot events to connected peers. They cannot instantly
|
||
terminate media already flowing through an established WebRTC connection;
|
||
clients must close missing/revoked peers, and the short lease bounds disconnected
|
||
clients that miss an event.
|
||
|
||
## 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 1–250 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 5–15 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_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:
|
||
|
||
```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.
|