# 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/ice` | — | strict ICE configuration request | **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` and ICE configuration in `src/media/iceTypes.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.

TURN is optional and fail-closed. When both `TERA_ICE_URLS` and a strong
`TERA_TURN_SHARED_SECRET` are configured, an authenticated caller may POST an
exact `IceConfigRequest` to `/api/v1/media/ice`. The service returns only
short-lived coturn REST credentials: an expiration plus a random opaque nonce
as username, and its HMAC-SHA1 password. An auth subject, email, profile id and
screen id never enter the TURN username. The shared secret stays server-only.
Partial or malformed configuration disables issuance and returns a typed 503;
the hosted browser path reports relay unavailability rather than promising a
connection that will fail across NAT. Caller-owned local screen preview remains
independent of the relay.

Allowed ICE URLs are deliberately narrow: `stun:`, `stuns:`, `turn:` and
`turns:` with a host and optional port. TURN may use only the standard exact
`?transport=udp` or `?transport=tcp` selector; userinfo, credential query
parameters and arbitrary URL syntax are rejected. Issuance is rate-limited by
the trusted proxy client address and every response remains `private, no-store`.

## 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. |
| `TERA_ICE_URLS` | *(empty)* | Comma-separated credential-free STUN/TURN URLs. Must include `turn:` or `turns:` to enable issuance. |
| `TERA_TURN_SHARED_SECRET` | *(empty)* | Server-only coturn `use-auth-secret` value, 32–4096 bytes. Never expose this in the static build. |
| `TERA_TURN_CREDENTIAL_TTL` | `300` | Credential lifetime in seconds, bounded to 60–3600. |
| `TERA_ICE_RATE_ATTEMPTS` | `30` | Maximum grants per trusted client address in one rate window. |
| `TERA_ICE_RATE_WINDOW` | `60` | Rate window in seconds, bounded to 1–3600. |

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.
