The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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. |
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 anattributionarray 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 indegradedsaying 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: noneis 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=filewithTERA_AUTH_MODE=noneis 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 intodegradedsaying 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
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:
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_apiinto 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, underenv -i.
Tests
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.