/** * The places this deployment will answer for, and the refusal of everywhere else. * * `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred * kilometres apart with genuinely different skies — the marine-layer comment in * `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own * weather, not San Francisco's fog. So weather and flights have to answer for a * *requested* place rather than for the box's one origin. * * ### Why this is an allowlist and not a lookup * * The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an * unauthenticated endpoint into a free geocoding proxy for the whole planet. * Two things go wrong with that, and neither is hypothetical. It is an * amplification vector: one cheap request here becomes one expensive request to * somebody else's public-good API, from an address they will blame. And it is * how a deployment's User-Agent gets blocked, because NWS's fair-use policy is * written against exactly this pattern and the contact string in * `TERA_WEATHER_CONTACT` is the operator's own name on the request. * * The rule here is therefore: **a caller's coordinate is never forwarded * upstream. It only selects among the points the operator configured.** A * request for Berkeley resolves to the San Francisco region and fetches San * Francisco's centre; a request for Fresno is refused with a 400 naming what is * served. The upstream key space is the region list, so it is bounded by the * environment file and cannot be grown by anybody sending requests — which is * the property that makes per-region caching, the NWS station cache and the * adsb.lol poll budget in `flights/adsb.ts` all bounded too. * * A coarse grid was the other candidate and was rejected: snapping to 0.5° still * leaves a caller able to name a hundred thousand distinct cells, which bounds * nothing that matters. Refusing is the honest answer, and a self-hoster who * wants their own city writes one line of `TERA_REGIONS`. */ /** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */ const KM_PER_DEGREE = 111.195; /** * How far from a region's centre a request may land and still resolve there, * when the operator did not say. * * 120 km covers both shipped boards with room to spare — the far corner of the * Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving * the two regions comfortably disjoint, since their centres are about 440 km * apart. It is deliberately not tight: the point of the radius is to refuse * somewhere this deployment has nothing to say about, not to police the edge of * the rendered board. */ export const DEFAULT_RADIUS_KM = 120; export interface Region { /** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */ id: string; lat: number; lng: number; /** Kilometres. See `DEFAULT_RADIUS_KM`. */ radiusKm: number; } /** * At least one region, always, with the first one being the default. * * A tuple rather than an array because "the default region" is read on every * request that omits a query, and a `Region | undefined` there would be a * falsehood the type system made everybody handle. */ export type RegionSet = [Region, ...Region[]]; /** * The two cities this repo ships, with the centres their packs declare — * `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`. * * They are restated rather than imported for the same reason `wire.ts` restates * `SimRoute`: a city pack is three thousand lines of coastline for a renderer * that owns three.js, and the API has one runtime dependency and intends to keep * it. Two numbers each, and the day a pack moves its centre the weather resolves * to a point a few kilometres off, which is a rounding error against a radius of * 120 km. */ const SHIPPED: Region[] = [ { id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM }, { id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM }, ]; /** * `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar. * * The decimal places are capped in the pattern rather than checked afterwards * because it is the same cap the query parser applies, and the two agreeing is * the point: a coordinate nobody could ask for is a coordinate nobody should be * able to configure either. */ const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/; export interface LoadRegionsOptions { /** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */ spec: string; /** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */ origin: { lat: number; lng: number }; /** Whether the operator actually wrote an origin, as opposed to inheriting SF. */ originConfigured: boolean; degraded: string[]; } /** * The region set for this box. * * Three cases, in order, and the ordering is what keeps every existing * deployment answering exactly as it did: * * 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order. * 2. Otherwise the two shipped cities, because that is what the bundled map * draws and serving only one of them is wrong for half the board. * 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere * else gets that point as a region of its own, first in the list and * therefore the default. A bare `GET /api/v1/weather` on their box answers * for their origin, exactly as it did before this file existed. If their * origin already falls inside a shipped region, that region moves to the * front instead of being duplicated. * * Malformed entries are dropped with a line in `degraded` rather than being * fatal, and a spec in which *nothing* parses falls all the way back to case 2. * CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot. */ export function loadRegions(opts: LoadRegionsOptions): RegionSet { const configured = parseSpec(opts.spec, opts.degraded); if (configured.length > 0) return asSet(configured, configured[0] as Region); const shipped = SHIPPED.map((region) => ({ ...region })); const containing = shipped.findIndex((region) => contains(region, opts.origin)); if (containing > 0) { const moved = shipped[containing] as Region; shipped.splice(containing, 1); shipped.unshift(moved); } else if (containing === -1 && opts.originConfigured) { shipped.unshift({ id: "origin", lat: opts.origin.lat, lng: opts.origin.lng, radiusKm: DEFAULT_RADIUS_KM, }); } return asSet(shipped, shipped[0] as Region); } /** * `Region[]` to `RegionSet`, with the caller supplying the head it has already * proved is there. The alternative is a cast on the whole array, which would * also silence the empty case this type exists to rule out. */ function asSet(regions: Region[], head: Region): RegionSet { return [head, ...regions.slice(1)]; } function parseSpec(spec: string, degraded: string[]): Region[] { const trimmed = spec.trim(); if (trimmed === "") return []; const regions: Region[] = []; for (const raw of trimmed.split(/[;\n]/)) { const entry = raw.trim(); if (entry === "") continue; const match = ENTRY.exec(entry); if (match === null) { degraded.push( `TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` + `\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`, ); continue; } const id = match[1] as string; const lat = Number(match[2]); const lng = Number(match[3]); const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]); if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) { degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`); continue; } if (regions.some((region) => region.id === id)) { degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`); continue; } regions.push({ id, lat, lng, radiusKm }); } if (regions.length === 0 && trimmed !== "") { degraded.push( "TERA_REGIONS was set but nothing in it parsed; serving the two cities the " + "map ships with instead.", ); } return regions; } // ---- Answering a request -------------------------------------------------- /** * What a route hands in. `unknown` throughout because this is untrusted query * input: Fastify hands back a string for `?lat=1`, an **array** for * `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed * `string` would be a lie the first time somebody repeated a parameter. */ export interface RegionQuery { city?: unknown; lat?: unknown; lng?: unknown; } export type RegionResolution = | { ok: true; region: Region } /** Already a sentence. The route sends it as `ErrorBody.message`. */ | { ok: false; message: string }; /** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */ const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/; const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/; /** * Turn a query into one of the configured regions, or into a refusal. * * Every branch that is not a resolved region is a **400**, not a degraded body. * That is the one place this file departs from the "degrade, never fail" * convention, and the distinction is who made the mistake: an upstream that is * down is not the caller's fault and must not become their problem, whereas * `?lat=banana` is a client bug that a 200 full of clear sky would hide until * somebody wondered why the fog never rolls in. */ export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution { const city = query.city; const hasCity = city !== undefined && city !== ""; const hasLat = query.lat !== undefined && query.lat !== ""; const hasLng = query.lng !== undefined && query.lng !== ""; if (hasCity && (hasLat || hasLng)) { return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." }; } if (hasCity) { if (typeof city !== "string" || !CITY_ID.test(city)) { return { ok: false, message: `city must be one of: ${served(regions)}.` }; } const region = regions.find((candidate) => candidate.id === city); if (region === undefined) { return { ok: false, message: `This deployment serves: ${served(regions)}.` }; } return { ok: true, region }; } if (hasLat !== hasLng) { return { ok: false, message: "lat and lng have to be given together." }; } if (!hasLat) return { ok: true, region: regions[0] }; const lat = degrees(query.lat, 90); const lng = degrees(query.lng, 180); if (lat === null || lng === null) { return { ok: false, message: "lat and lng must be plain decimal degrees within ±90 and ±180, " + "with at most six decimal places.", }; } const region = nearest(regions, lat, lng); if (region === null) { return { ok: false, message: `Nothing this deployment serves is near ${lat},${lng}. It serves: ` + `${served(regions)}. Ask for one of those by id, or add yours to ` + `TERA_REGIONS on the server.`, }; } return { ok: true, region }; } function served(regions: RegionSet): string { return regions.map((region) => region.id).join(", "); } function degrees(raw: unknown, limit: number): number | null { if (typeof raw !== "string") return null; const trimmed = raw.trim(); // The pattern is doing the work `Number()` would do badly: it rejects `NaN`, // `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which // `Number()` either accepts or turns into a value that then has to be // re-checked. One regex, one meaning. if (!DEGREES.test(trimmed)) return null; const value = Number(trimmed); return Number.isFinite(value) && Math.abs(value) <= limit ? value : null; } /** The closest region that claims the point, or `null` if none of them does. */ function nearest(regions: RegionSet, lat: number, lng: number): Region | null { let best: Region | null = null; let bestKm = Infinity; for (const region of regions) { const km = distanceKm(region, lat, lng); if (km <= region.radiusKm && km < bestKm) { best = region; bestKm = km; } } return best; } /** * Equirectangular rather than haversine, because at these distances the error is * under half a percent and the comparison it feeds is against a radius chosen to * the nearest ten kilometres. Trigonometry accurate to the metre would be * decorating a threshold that is deliberately fuzzy. */ function distanceKm(region: Region, lat: number, lng: number): number { const dLat = lat - region.lat; const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180; const dLng = (lng - region.lng) * Math.cos(meanLat); return Math.hypot(dLat, dLng) * KM_PER_DEGREE; } function contains(region: Region, point: { lat: number; lng: number }): boolean { return distanceKm(region, point.lat, point.lng) <= region.radiusKm; }