1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+35 -5
View File
@@ -1,18 +1,48 @@
/**
* `GET /api/v1/flights`.
* `GET /api/v1/flights` — for a city, not for the box.
*
* `?city=socal`, or `?lat=&lng=` resolved against the same allowlist the weather
* route uses, or neither for the default region. `regions.ts` owns the
* validation, the refusal, and the reasoning behind refusing at all: a live
* traffic endpoint that will fetch any coordinate on demand is an amplifier
* pointed at a volunteer-funded feed.
*
* Publicly cacheable, because the whole design of the plan is that one response
* serves every viewer for its whole TTL. Aircraft are not personal data and this
* body never varies by who asked.
* serves every viewer of a region for its whole TTL. Aircraft are not personal
* data and this body never varies by who asked — only by where.
*
* ### `radiusNm` on the query is ignored, deliberately
*
* The browser sends one. It is dropped, and the size of the circle stays
* `TERA_ADSB_RADIUS_NM`, for a reason that is not stubbornness: the cache key
* would have to include it, and a caller who can choose the key can make this
* box hold an unbounded number of entries and issue an unbounded number of
* distinct upstream requests — each one more expensive than the last, since a
* wider circle is more work for the feed to answer. Every bound in
* `flights/index.ts` and `regions.ts` rests on the key space being the operator's
* region list, and a query parameter that widens it dissolves all of them.
*
* An operator whose board is bigger than the circle raises
* `TERA_ADSB_RADIUS_NM`; 60 nm covers both shipped cities. The client already
* discards aircraft outside the region it drew, so a circle that is too large
* costs a little bandwidth and nothing else.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
export function registerFlights(app: FastifyInstance, services: Services): void {
app.get("/api/v1/flights", async (req, reply) => {
const body = await services.flights.current();
app.get<{ Querystring: RegionQuery }>("/api/v1/flights", async (req, reply) => {
const resolved = resolveRegion(services.config.regions, req.query);
if (!resolved.ok) {
const error: ErrorBody = { error: "bad_request", message: resolved.message };
return reply.code(400).send(error);
}
const body = await services.flights.current(resolved.region);
publicCache(req, reply, body.ttlSeconds);
return body;
});
+25 -1
View File
@@ -10,17 +10,37 @@
* `degraded` is what makes it more than a liveness probe: every demotion the
* config made is printed here, so "why is the weather always clear" has an
* answer that does not require log access.
*
* `regions` joins it for the same reason. Weather and flights now refuse a place
* this box does not serve, so a client that guesses `?city=` and a 400 it cannot
* explain is the failure this field prevents: ask health once, learn what may be
* asked for, and an operator diagnosing "why is there no SoCal weather" reads
* the answer instead of the env file. Publishing the allowlist gives nothing
* away — knowing what is served is not the same as widening it, and the ids are
* the names of the cities the map already draws.
*/
import type { FastifyInstance } from "fastify";
import type { Region } from "../regions.ts";
import type { HealthBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
/**
* `HealthBody` plus the served regions.
*
* The field belongs in `src/server/wire.ts` beside the body it extends, and it
* is stated here only because that file is the browser side of this change and
* lands with it. Fold `regions: Region[]` into `HealthBody` and this alias goes
* away; nothing else has to move, because the shape is already exactly what the
* route serves.
*/
type HealthBodyWithRegions = HealthBody & { regions: Region[] };
export function registerHealth(app: FastifyInstance, services: Services): void {
const { config, startedAt } = services;
app.get("/api/v1/health", async () => {
const body: HealthBody = {
const body: HealthBodyWithRegions = {
ok: true,
service: "tera-api",
version: config.version,
@@ -36,6 +56,10 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
? config.auth.entryUrl
: null,
},
// In the config's order, so the first entry is the region a request with
// no query gets. A client reading this can pick its default the same way
// the server does.
regions: config.regions,
degraded: config.degraded,
};
return body;
+57 -8
View File
@@ -1,24 +1,73 @@
/**
* `GET /api/v1/markers`.
* `GET /api/v1/markers` — the one route the `member` tier is about.
*
* The public snapshot, and nothing else. Private per-user markers are never
* proxied through this box — an authenticated browser calls Workie directly with
* its own token, so a private row never enters this process and cannot leave it.
* CONTRACT.md §5.
* CONTRACT.md §5. Every row served here has been through the provenance gate in
* `markers/gate.ts`, which is what keeps a public snapshot from quietly becoming
* a Publicly Used Derivative Database under ODbL.
*
* Every row served here has been through the provenance gate in `markers/gate.ts`,
* which is what keeps a public snapshot from quietly becoming a Publicly Used
* Derivative Database under ODbL.
* ### Why a configured feed is refused to anonymous callers
*
* `src/access.ts` has three tiers and, until this route, the middle one gated
* nothing at all: `member` was a word in a type union that no server behaviour
* corresponded to. A tier that never refuses anybody anything is not a tier, and
* one that lives only in the client is worse — it is a UI hiding a control over
* a body the API hands to whoever asks. **So: where a marker feed is configured,
* it takes a session.** That refusal is the whole of what makes membership real,
* it is enforced here rather than drawn in the browser, and there is
* deliberately no `TERA_MARKERS_PUBLIC` escape hatch to undo it.
*
* Two consequences worth stating out loud:
*
* - **A box with no feed still answers 200 and an empty list.** `source: none`
* is the zero-config default and there is nothing there to protect; making a
* stranger sign in to be told "no markers" would fail the acceptance test in
* `boot.test.ts` and gain nobody anything.
* - **A feed with `TERA_AUTH_MODE=none` is unreachable by everyone**, because
* nobody on such a box is ever authenticated. That is the fail-closed
* direction, it is the one private offices already take, and `config.ts`
* pushes a line into `degraded` saying so rather than letting an operator
* discover it from an empty map.
*
* ### 401 here, 404 for an office
*
* `offices.ts` answers 404 for a private office because a 403 there is an
* enumeration oracle — walk the id space, read the status codes, learn every
* tenant. Nothing is enumerable here: there is one feed, at a fixed path, and
* `/api/v1/health` already publishes `sources.markers`, so whether this
* deployment has markers is not a secret being kept. What the caller needs to
* know is "sign in and ask again", which is what 401 with `WWW-Authenticate`
* says. A 404 would be a lie told to protect nothing.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
const UNAUTHORIZED: ErrorBody = {
error: "unauthorized",
message: "The marker feed is for signed-in members.",
};
export function registerMarkers(app: FastifyInstance, services: Services): void {
app.get("/api/v1/markers", async (req, reply) => {
const body = await services.markers.current();
publicCache(req, reply, services.config.markers.ttlSeconds);
return body;
if (services.config.markers.source === "none") {
const body = await services.markers.current();
publicCache(req, reply, services.config.markers.ttlSeconds);
return body;
}
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
// No `publicCache`. This body took a credential to obtain, and a shared
// cache holding it would hand one member's copy to the next caller — the
// exact thing the fail-closed default in `cache.ts` exists to prevent.
return services.markers.current();
});
}
+28 -7
View File
@@ -1,19 +1,40 @@
/**
* `GET /api/v1/weather`.
* `GET /api/v1/weather` — for a place, not for the box.
*
* Always 200, always a body. A source that is down, misconfigured or absent
* produces `synthetic: true` and a clear day — there is no failure mode here in
* which the caller has to decide what to render, because the answer to "what is
* the sky doing" is never allowed to be a 503.
* `?city=sf`, or `?lat=&lng=` which resolves to whichever configured region
* claims the point, or neither, which answers for the default region. The
* validation and the refusal both live in `regions.ts`; the paragraph there on
* why this is an allowlist rather than a lookup is the one worth reading before
* touching this file.
*
* Two kinds of answer, and the split is deliberate:
*
* - **A bad request is a 400.** `?lat=banana`, or a coordinate in a city this
* deployment does not serve, is a client bug, and a 200 full of clear sky
* would hide it until somebody wondered why the fog never rolled in.
* - **Everything else is a 200 with a body.** A source that is down,
* misconfigured or absent produces `synthetic: true` and a clear day. There is
* no failure mode in which the caller has to decide what to render, because
* the answer to "what is the sky doing" is never allowed to be a 503.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
export function registerWeather(app: FastifyInstance, services: Services): void {
app.get("/api/v1/weather", async (req, reply) => {
const body = await services.weather.current();
app.get<{ Querystring: RegionQuery }>("/api/v1/weather", async (req, reply) => {
const resolved = resolveRegion(services.config.regions, req.query);
if (!resolved.ok) {
const error: ErrorBody = { error: "bad_request", message: resolved.message };
return reply.code(400).send(error);
}
const body = await services.weather.current(resolved.region);
// Query strings are part of a shared cache's key, so two regions cannot
// collide here and no extra `Vary` is owed.
publicCache(req, reply, services.config.weather.ttlSeconds);
return body;
});