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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user