/** * The fire feed's one upstream: a **projection**, served by the machine that * owns the database. * * ### Read this before changing anything here * * The obvious way to build this feed was to copy `fires.sqlite` onto this box * and query it locally. That was rejected, and the reason is not performance and * not dependency count — though it is also both of those. * * The store is centred on a private home. `observations` carries `distance_km`, * `bearing_deg` and `threat`, and `detections` carries `distance_km`, all four * measured from that address. `threat` is the one that was nearly missed: it is * * (16 / distance_to_house)^2 x log10(acres) x momentum x containment * x wind-alignment-to-house * * and acreage and containment are already public — they come from CAL FIRE. So * that expression **inverts**: one fire gives a circle around the house, three * give an intersection. A copy of that database on cloud-2, which is public * facing, is a home address sitting on disk waiting for one careless star-query * in a route somebody writes next year. * * So this module has **no database**. It makes two bounded GETs against an * endpoint whose SELECT lists are written out by hand on the other machine, and * it cannot be careless with columns it was never sent. That ordering is the * security property: the leak is made impossible rather than merely avoided. * * It is also why there is no sqlite driver in this repo's dependency list, and * `scripts/check-dependency-licenses.mjs` is entitled to keep it that way. * * ### Both halves, or neither * * `fetchProjection` asks for incidents and detections in parallel and returns * `null` if **either** fails. That is deliberate and it is the opposite of what * a partial-tolerance instinct suggests. A body carrying live incidents and a * silently empty detection array is a board that has quietly stopped showing the * strongest evidence it has, with nothing to say so — the same class of failure * as a cron that reports success while every snapshot inside it fails. One * snapshot, one answer; `upstream.ts` above this keeps serving the last good * whole body until a whole one arrives. */ import { getJson } from "../http.ts"; import type { FireDetection, FireIncident } from "../../../src/server/wire.ts"; /** How long either GET may take. The upstream reads sqlite behind its own cache. */ const TIMEOUT_MS = 8_000; /** * The most rows this build will adopt from either endpoint. * * The upstream caps its own SQL, so this is the second of two bounds rather than * the only one — but a caller that trusts an upstream's cap is a caller that * inherits the day the upstream's cap changes. `flights/adsb.ts` takes the same * belt-and-braces position for the same reason: `http.ts` bounds the *bytes*, * and this bounds what is kept and served on. */ const MAX_INCIDENTS = 500; const MAX_DETECTIONS = 2_000; export interface FiresSnapshot { /** Epoch ms at which this box completed the fetch. */ fetchedAt: number; /** The upstream de-duplication watermark. Every incident row matched it. */ latestSeen: string | null; incidents: FireIncident[]; detections: FireDetection[]; detectionWindowHours: number; attribution: string[]; } export interface FiresLog { warn(msg: string): void; } /** What the projection endpoint answers with. Restated, never imported. */ interface IncidentsResponse { latestSeen?: unknown; incidents?: unknown; attribution?: unknown; } interface DetectionsResponse { windowHours?: unknown; detections?: unknown; attribution?: unknown; } export async function fetchProjection( base: string, key: string, windowHours: number, log: FiresLog, ): Promise { if (base === "") return null; const headers = key === "" ? undefined : { "x-tera-key": key }; const options = { timeoutMs: TIMEOUT_MS, ...(headers === undefined ? {} : { headers }) }; const [incidentsBody, detectionsBody] = await Promise.all([ getJson(`${base}/incidents`, options), getJson( `${base}/detections?hours=${encodeURIComponent(String(Math.round(windowHours)))}`, options, ), ]); // Both or neither. See the header — a live incident set beside a silently // empty detection array is a board that has stopped saying what it knows. if (incidentsBody === null || detectionsBody === null) { log.warn( "fires:cloud1: the projection did not answer with both halves " + `(incidents ${incidentsBody === null ? "failed" : "ok"}, ` + `detections ${detectionsBody === null ? "failed" : "ok"}); keeping the last whole body`, ); return null; } const incidents = readArray(incidentsBody.incidents, MAX_INCIDENTS, readIncident); const detections = readArray(detectionsBody.detections, MAX_DETECTIONS, readDetection); return { fetchedAt: Date.now(), latestSeen: nonEmptyString(incidentsBody.latestSeen), incidents, detections, detectionWindowHours: finite(detectionsBody.windowHours) ?? Math.round(windowHours), attribution: mergeAttribution(incidentsBody.attribution, detectionsBody.attribution), }; } // ---- Reading somebody else's JSON ----------------------------------------- // // Field by field, checked rather than cast. The upstream is a machine on the // same tailnet run by the same person, which is exactly the relationship that // produces "it will always be the right shape" — and then a collector gains a // column and a renderer draws a fire at 0,0 in the Gulf of Guinea. Nothing here // throws: a row that will not read is dropped, and the fetch above still returns // a body. function readIncident(raw: unknown): FireIncident | null { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; const row = raw as Record; const lat = finite(row.lat); const lon = finite(row.lon); const id = nonEmptyString(row.id); const lastSeen = nonEmptyString(row.lastSeen); // No id, no coordinate or no watermark means nothing downstream can place it, // de-duplicate it or link to it. All three are structural, not cosmetic. if (id === null || lat === null || lon === null || lastSeen === null) return null; return { id, source: nonEmptyString(row.source) ?? "", name: nonEmptyString(row.name), lat, lon, provenance: nonEmptyString(row.provenance) ?? "us-gov", county: nonEmptyString(row.county), type: (nonEmptyString(row.type) ?? "").trim(), url: nonEmptyString(row.url), firstSeen: nonEmptyString(row.firstSeen) ?? lastSeen, lastSeen, observedAt: nonEmptyString(row.observedAt), acres: finite(row.acres), pctContained: finite(row.pctContained), }; } function readDetection(raw: unknown): FireDetection | null { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; const row = raw as Record; const lat = finite(row.lat); const lon = finite(row.lon); if (lat === null || lon === null) return null; const persistentDays = finite(row.persistentDays) ?? 0; return { sat: nonEmptyString(row.sat) ?? "", acquiredAt: nonEmptyString(row.acquiredAt) ?? "", lat, lon, frp: finite(row.frp), // Carried verbatim. MODIS puts an integer 0-100 in this column and VIIRS // puts low/nominal/high; normalising here would mean choosing one of them to // be wrong. `detectionConfidence()` in `src/server/fires.ts` does the branch // once, on the client, where `sat` is beside it. confidence: nonEmptyString(row.confidence), // Absent means not persistent, which is the direction that draws MORE rather // than fewer — so a projection one version behind this one shows the // industrial flare as a weak hot pixel rather than hiding a real fire. persistent: row.persistent === true, persistentDays, }; } function readArray(raw: unknown, cap: number, read: (row: unknown) => T | null): T[] { if (!Array.isArray(raw)) return []; const out: T[] = []; for (const row of raw) { if (out.length >= cap) break; const parsed = read(row); if (parsed !== null) out.push(parsed); } return out; } /** Both credit lines, de-duplicated, in the order they arrived. */ function mergeAttribution(a: unknown, b: unknown): string[] { const out: string[] = []; for (const source of [a, b]) { if (!Array.isArray(source)) continue; for (const line of source) { if (typeof line !== "string" || line === "" || out.includes(line)) continue; out.push(line); } } return out; } function nonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed === "" ? null : trimmed; } function finite(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; }