/** * The HTTP contract: every body that crosses between the browser build and the * Tera API, and nothing else. * * **This file is types only.** It compiles to nothing, which is the whole point. * The browser can import it without paying for a runtime module, and the server * can import it without the browser package becoming one of its dependencies — * so one declaration of each body serves both sides and there is no second * implementation to drift. Everything that imports from here must use * `import type`, which `verbatimModuleSyntax` already enforces. * * Two types here deliberately mirror engine types rather than importing them: * `WireSimRoute` is structurally `SimRoute` from `engine/flights.ts`. The server * must be able to build one and it must not pull three.js in to do it, so the * shape is restated. `WireMarker`, by contrast, genuinely *extends* `Marker`, * because a marker off the wire is handed straight to `setMarkers()` and the two * being the same type is what guarantees that stays true. * * Routes, all under `/api/v1`: * * | route | body | cacheable | * | ---------------- | --------------- | --------- | * | `GET /health` | `HealthBody` | no | * | `GET /flights` | `FlightsBody` | yes | * | `GET /satellites` | `SatellitesBody` | yes | * | `GET /weather` | `WeatherBody` | yes | * | `GET /markers` | `MarkersBody` | yes | * | `GET /offices/:id` | `OfficeDoc` | public offices only | * | `GET /offices/:id/presence` | `PresenceBody` | never | * | `GET /offices/:id/devices` | `DevicesBody` | never | * | `POST /offices/:id/devices/command` | `DeviceCommandResultBody` | never | * * Note the last two. Reading device state and commanding a device are two * routes and two methods, and that is a **security boundary rather than REST * taste**: a command that rode in the read body could be replayed by any shared * cache that had kept a copy of the GET, and turning a microphone on by * replaying a cached read is precisely the outcome the fail-closed * `private, no-store` default in CONTRACT.md §5 exists to prevent. Neither * route is ever `publicCache`d, and the command route is the only body in this * file that goes *up* the wire. * * See CONTRACT.md §5. */ import type { DeviceCommand, DeviceState } from "../devices/types.ts"; import type { Marker, SatelliteGroup } from "../engine/types.ts"; import type { Office, Presence } from "../interiors/types.ts"; export type { SatelliteGroup }; /** Path prefix every route lives under. Stated here so both sides read it once. */ export type ApiBase = "/api/v1"; // ---- Errors --------------------------------------------------------------- /** * The only error shape. `error` is a stable machine token; `message` is for a * human reading a log and may change without notice. * * Note what is absent: there is no `403`. A request for something the caller may * not see gets `not_found`, because an endpoint that distinguishes "does not * exist" from "exists, but not for you" is an enumeration oracle. See * CONTRACT.md §6. */ export interface ErrorBody { error: "not_found" | "bad_request" | "unauthorized" | "upstream_unavailable" | "internal"; message: string; } // ---- Health --------------------------------------------------------------- export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo"; export type FlightsSourceId = "sim" | "adsb" | "dump1090"; export type SatellitesSourceId = "none" | "celestrak"; export type MarkersSourceId = "none" | "file"; /** * Where device readings come from. * * `none` is the default and serves an empty array — a box nobody has told about * any hardware has no hardware, which renders as a studio whose panels say so * rather than as an error. `sim` is the deterministic state machine in * `src/devices/sim.ts`, the same module the arena wraps, and it is what this * build ships. `homeassistant` is named here and implemented nowhere: it is the * door a `first-party-sensor` provenance comes through, and naming it in the * union now is what stops the next person from adding a second, differently * shaped source field when they build it. */ export type DevicesSourceId = "none" | "sim" | "homeassistant"; export type AuthMode = "none" | "sso" | "jwt"; /** * One place this box will answer about. Structurally `Region` in * `server/src/regions.ts`, restated here for the same reason `WireSimRoute` is: * this file is the contract and the server's own module is an implementation of * it, and the browser must not have to import server code to read a body. */ export interface WireRegion { /** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */ id: string; lat: number; lng: number; /** Kilometres. */ radiusKm: number; } /** * What this deployment turned out to be, once the environment had its say. * * `degraded` is the load-bearing field. A source configured without what it * needs is demoted rather than fatal (CONTRACT.md §5.1), and this is where the * demotion is visible to anybody who did not read the boot log — without it, a * misconfigured contact string looks exactly like a clear day. */ export interface HealthBody { ok: true; service: "tera-api"; version: string; uptimeSeconds: number; sources: { weather: WeatherSourceId; flights: FlightsSourceId; satellites: SatellitesSourceId; markers: MarkersSourceId; /** * Newer than the four above it, and read defensively by * `feedsFrom()` in `src/access.ts` for exactly that reason: a browser * meeting a server one version behind this one sees `undefined` and * concludes the box has no devices, which is the safe direction for a * missing field to fall. Its job is to let a client know that asking is * pointless before it opens a watch that will 404 forever. */ devices: DevicesSourceId; }; auth: { mode: AuthMode; /** Where a browser sends someone to sign in. `null` unless mode is `sso`. */ entryUrl: string | null; }; /** * Every place this box will answer about, in the config's order, so the first * entry is what a request with no query gets. * * Here rather than in a `HealthBodyWithRegions` alias next to the route. * Weather and flights refuse a place this box does not serve, so a client * that guesses `?city=` earns a 400 it cannot explain; publishing the * allowlist turns that into one question asked once. It gives nothing away — * knowing what is served is not the same as widening it, and the ids are the * names of cities the map already draws. */ regions: WireRegion[]; /** One human sentence per demotion. Empty on a fully-configured box. */ degraded: string[]; } // ---- Flights -------------------------------------------------------------- /** * A leg the simulator flies. Structurally identical to `SimRoute` in * `engine/flights.ts`; see the note at the top of this file for why it is * restated rather than imported. */ export interface WireSimRoute { callsign: string; from: [number, number]; to: [number, number]; /** Metres at the start and end of the leg. */ fromAlt: number; toAlt: number; /** Seconds for a full traversal. */ duration: number; } /** One aircraft, as `engine/types.ts` `Aircraft` wants it. */ export interface WireAircraft { id: string; lat: number; lng: number; /** Metres. The wire never carries feet, whatever the upstream feed used. */ altitude: number; /** Degrees clockwise from true north. */ heading: number; callsign?: string; /** * Metres per second over the ground, when the feed reported a real one. * * SI, like every other quantity on this wire — the feed's own `gs` is knots * and the conversion happens once, in the adapter, for the same reason * `altitude` is not feet. See the note there. * * **This is the field that makes the sky move.** A client that is handed only * positions can interpolate between the last two of them and nothing more, so * every aircraft replays a leg it has already flown, arrives at the newest * known point and then sits perfectly still until the next snapshot lands * five to fifteen seconds later. Carrying the velocity lets the client * dead-reckon — advance each track along its own track angle at its own * speed, every frame — and an aeroplane that is flying stops being a * screenshot of one. * * Absent, never zeroed, and absent covers three different things that must * all behave the same way downstream: a feed that did not report it, a * ground vehicle or parked aircraft reporting `gs: 0.0`, and a * position-only record with a null `track` — dead-reckoning any of those * along an invented heading would be inventing motion, which is worse than * showing none. `engine/flights.ts` holds a track still when this is missing. */ groundSpeed?: number; /** * Metres per second, positive climbing, when the feed reported a rate. * * Barometric where the feed has it and geometric otherwise; the two disagree * by a few percent in real air and by nothing this renders. Absent rather * than zero for the same reason as `groundSpeed`: level flight and no * information are different facts, and only one of them may be drawn. */ verticalRate?: number; /** * How old the position fix already was when this snapshot was taken, in * seconds — the feed's `seen_pos`. * * Carried because the client is the thing that has to place the aircraft and * it cannot do that without knowing what instant the coordinates describe. * Between the receiver's last message, this box's cache TTL and the browser's * own hold, a fix reaches a viewer several seconds stale, and a dead-reckoner * handed a stale position as if it were current draws the whole sky lagging * by that much. `observedAt` on the body says when the *snapshot* was taken; * this says how far behind that the row already was. * * Small — a fraction of a second on a healthy feed — and worth carrying * anyway, because it is the difference between a client that can reason about * time and one that assumes. */ ageSeconds?: number; /** * The tail number the feed published for this airframe, e.g. `"N68834"`. * * ODbL data off the same record as the position, and publishable on exactly * the same terms — it is the enrichment a commercial feed was once wanted * for, obtained legitimately. Absent, never invented: a registration is what * somebody types into a registry lookup, and a wrong one names another * aircraft altogether. */ registration?: string; /** * ICAO aircraft type designator, e.g. `"B739"`, `"A321"`. * * A four-character code and not a marketing name: the feed publishes the * designator, and expanding it to "Boeing 737-900" would mean shipping a * table this repo would then have to keep true. The card shows the code * beside the registration, which is how a spotter reads it anyway. */ type?: string; /** * The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a * real one. * * Carried explicitly even though `id` is usually the same string, because * "usually" is the problem: `id` falls back to the callsign for a record with * no hex, and both community feeds emit `~`-prefixed anonymous addresses for * TIS-B and MLAT targets, which are *not* ICAO addresses. Somebody pastes * this into a registry lookup, so a wrong one names another aircraft * altogether — and `Aircraft` in `engine/types.ts` has nowhere to put it, * which is why the adapter keeps this record beside the position rather than * inferring the address back out of the id. * * Absent, never invented. `aircraftDetail()` in `engine/flights.ts` is where * it becomes a card. */ icao24?: string; } /** * The simulated sky, sent as a *plan* rather than as positions. * * The server hands over the routes, a phase origin and a seed, and every browser * evaluates the same closed-form function of wall-clock time. That means one * cheap cacheable request instead of a poll every second, and — more usefully — * two people looking at the map from different machines see the same aircraft in * the same places, which a per-client simulation cannot promise. * * `t0` is a fixed epoch and emphatically **not** the server's start time: if it * moved on restart, every aircraft would teleport. */ export interface FlightsPlanBody { mode: "plan"; source: "sim"; /** Epoch milliseconds. The instant at which every route's phase is zero. */ t0: number; /** Seed for the per-route phase offsets, so all viewers agree. */ seed: number; routes: WireSimRoute[]; /** How long the plan may be cached, in seconds. */ ttlSeconds: number; } /** Real traffic, as positions, because there is no closed form for the sky. */ export interface FlightsLiveBody { mode: "live"; source: "adsb" | "dump1090"; /** Epoch milliseconds at which this snapshot was taken. */ observedAt: number; aircraft: WireAircraft[]; ttlSeconds: number; /** * Attribution the consumer is expected to display, if the feed asks for it. * * **Derived from the host that answered**, in `server/src/flights/licence.ts`, * and never authored next to the request. It said `adsb.lol` unconditionally * once, whatever `TERA_ADSB_ENDPOINT` pointed at, which is how a credit line * and a source come to disagree. */ attribution?: string[]; /** * May a shared cache — or the consumer — hand these bytes to a third party? * * The licence the positions arrived under, reduced to the one bit that * changes behaviour. `false` keeps the route on the fail-closed * `private, no-store` default from CONTRACT.md §5, so a feed this deployment * may *use* but not *redistribute* stops at the browser that asked. Required * rather than optional: a body with no answer to this question is a body * somebody will assume `true` for. */ redistributable: boolean; /** * The licence id the source publishes under, e.g. `"ODbL-1.0"`, or * `"first-party"` for an operator's own receiver. For display and for a * human reading `/api/v1/flights` directly; the machine-readable half of the * same fact `attribution` states in prose. */ licence?: string; } export type FlightsBody = FlightsPlanBody | FlightsLiveBody; // ---- Satellites ----------------------------------------------------------- /** * One satellite, sent as its **element set** rather than as a position. * * The same trick `FlightsPlanBody` plays, for the same reason and with better * justification: a TLE is already a closed-form description of an orbit, valid * for days either side of its epoch, and SGP4 is the function that evaluates it. * Sending positions would mean polling — a satellite crosses the sky in ten * minutes — and would mean two people looking at the same overhead pass from * different machines disagreeing about where it is. Sending the elements means * one cacheable request every few hours and universal agreement, which is * exactly the property the flight plan exists to buy. * * It is also the *honest* shape. CelesTrak publishes element sets; positions are * something a consumer computes. A server that computed them would be inserting * itself into a calculation it adds nothing to. * * `line1` and `line2` are the two 69-character TLE lines, verbatim. They are * carried as strings rather than parsed into fields because SGP4 implementations * take exactly this and every parse in between is a chance to lose a digit. */ export interface WireSatellite { /** NORAD catalogue number, from columns 3–7 of line 1. Stable for the object's life. */ noradId: number; name: string; group: SatelliteGroup; /** The first TLE line, 69 characters, unmodified. */ line1: string; /** The second TLE line, 69 characters, unmodified. */ line2: string; } /** * The catalogue this box is serving, and when it last managed to fetch one. * * There is no `mode` discriminant here, unlike `FlightsBody`, because there is * only ever one mode: elements. A box with no satellite source configured serves * `source: "none"` and an **empty array** rather than a synthetic constellation, * and that asymmetry with the flight plan is deliberate. An invented aeroplane is * a plausible aeroplane; an invented Starlink is a lie about a specific object * with a catalogue number, and somebody standing in a field with a telescope * would be entitled to be annoyed about it. The sky either has the real thing in * it or it has nothing. */ export interface SatellitesBody { source: SatellitesSourceId; /** * ISO-8601, the last time a fetch **succeeded**. Not the time of this response: * a body served from a six-hour-old snapshot must say so, because the client * has no other way to tell a fresh catalogue from a stale one and SGP4 accuracy * degrades with distance from the element epoch. */ fetchedAt: string; satellites: WireSatellite[]; ttlSeconds: number; attribution?: string[]; } // ---- Weather -------------------------------------------------------------- /** * Sky conditions, reduced to what a light rig can actually use. * * Not a met report: no dew point, no pressure, no station id. `Atmosphere` * consumes cloud cover, precipitation and visibility and nothing else, and a * field that no renderer reads is a field that gets wrong without anyone * noticing. */ export type WeatherCondition = | "clear" | "partly-cloudy" | "cloudy" | "overcast" | "fog" | "rain" | "snow" | "thunderstorm"; export interface WeatherBody { /** ISO-8601. The observation time, not the fetch time. */ observedAt: string; source: WeatherSourceId; /** * True when nobody was asked and this is the fallback clear day. * * A zero-config box serves `synthetic: true` forever and that is a supported * state, not an error — which is why this is a field and not a 503. */ synthetic: boolean; location: { lat: number; lng: number }; /** `null` where the source did not report it. Never silently zeroed. */ temperatureC: number | null; windKph: number | null; /** Degrees clockwise from true north, the direction the wind blows *from*. */ windDirDeg: number | null; /** 0..1. */ cloudCover: number; /** 0..1, an intensity rather than a rate — the renderer wants a dial. */ precipitation: number; visibilityKm: number | null; condition: WeatherCondition; /** Attribution the consumer must display for this source, where one is owed. */ attribution?: string[]; } // ---- Markers -------------------------------------------------------------- /** * Where a coordinate came from, and the only thing standing between this repo * and an ODbL share-alike obligation. * * Publishing a snapshot of geocoded coordinates is Public Use of a Derivative * Database. If those coordinates came out of Nominatim, ODbL §4.3 and §4.4 * attach to everything served here — no matter that the rows live in a private * database rather than in the repo. Containment was never the discharge. * CONTRACT.md §8. * * The type is a plain string on purpose. The entire point of the gate is that a * value nobody anticipated can arrive and must be *refused at serve time*, which * a closed union would quietly turn into a compile error somewhere upstream * instead. */ export type CoordinateProvenance = KnownProvenance | (string & {}); /** * The values the public gate accepts by default. * * - `us-census` — geocoding.geo.census.gov, a US Government work in the public * domain. The sanctioned geocoder. * - `hand-placed` — typed by a human from a published address. Original. * - `synthetic` — invented for a demo. Owes nobody anything. * * Deliberately absent: `nominatim`, `osm`, `google`, `mapbox`, `here`. The first * two are share-alike; the rest restrict storing and redistributing what they * return, which is exactly what a public snapshot does. */ export type KnownProvenance = "us-census" | "hand-placed" | "synthetic"; /** * A marker as it crosses the wire: an engine `Marker` plus where its coordinate * came from. * * It extends `Marker` rather than restating it so that the adapter is a cast and * not a copy — the engine still knows nothing about provenance, and the day * `Marker` gains a field, this follows it. */ export interface WireMarker extends Marker { provenance: CoordinateProvenance; } export interface MarkersBody { markers: WireMarker[]; /** ISO-8601 timestamp of the snapshot these rows came from. */ generatedAt: string; /** * Rows the public-shape gate refused, by count and reason. Served rather than * only logged: a silent drop looks identical to an empty database. */ refused: { reason: string; count: number }[]; attribution?: string[]; } // ---- Offices -------------------------------------------------------------- /** * One office pack, addressed and wrapped for delivery. * * `floor` is the authored `Office` exactly as `src/interiors/types.ts` defines * it — the pack a self-hoster writes by hand and the pack that arrives over HTTP * are the same bytes, which is the rule that keeps the format from forking. * Everything outside `floor` is deployment metadata the renderer never reads. */ export interface OfficeDoc { id: string; name: string; floor: Office; visibility: OfficeVisibility; /** ISO-8601. */ updated?: string; } /** * Who is in one office, right now. * * Separate from `OfficeDoc` on purpose, and the separation is load-bearing * rather than tidy. A `Presence` carries a `seatId` and no coordinate precisely * so that the geometry can be published while the people are not; putting the * roster inside the pack would mean an operator who wants a public floorplan has * to strip the people out of it by hand, and the first time they forget, the * leak is permanent. Two documents means the safe thing is the default thing. * * It is also the honest shape for the data: a floorplan changes when somebody * moves a wall and a roster changes when somebody sits down, which is why this * one carries `observedAt` and the pack does not. */ export interface PresenceBody { officeId: string; people: Presence[]; /** ISO-8601. Absent when the source did not say, which is not an error. */ observedAt?: string; } /** * `private` means the endpoint answers 404 to anyone who may not see it — see * `ErrorBody`. `unlisted` is served to anybody with the id but never appears in * an index and is never publicly cached. */ export type OfficeVisibility = "public" | "unlisted" | "private"; // ---- Devices -------------------------------------------------------------- /** * What the hardware in one office is doing, right now. * * The runtime half of the split `src/devices/types.ts` opens with, and the * reason this body exists at all: a `DeviceDeclaration` is authored into the * office pack and is therefore public by construction, while a `DeviceState` * never appears in a file anybody can download. It arrives here, from a route * that can refuse it, and it is the same line `PresenceBody` draws between a * floorplan and the people standing on it. * * **Never publicly cached, in any configuration.** Two reasons and they are * different: the body took a credential to obtain, so a shared cache holding it * would hand one viewer's copy to the next; and the state is mutable by a * command, so a cached copy is a stale claim about a room somebody is standing * in. `routes/devices.ts` therefore never calls `publicCache`, and says so out * loud rather than merely omitting the call. * * An office with no authored devices is `devices: []` and a 200 — not a 404. * "This office does not exist" and "nobody has declared any hardware in it" are * different facts with different fixes, exactly as `presence/store.ts` argues * for a missing roster. */ export interface DevicesBody { officeId: string; devices: DeviceState[]; /** Epoch milliseconds at which this snapshot was taken. */ observedAt: number; source: DevicesSourceId; /** * Did anybody observe any of this? * * `true` for everything this build ships, because the only implemented source * is a state machine. It is the body-level statement of the same fact * `DeviceState.synthetic` makes per device, and it is carried separately so * that an empty array still says where it came from — an empty `devices` with * `synthetic: false` is a box with a real bridge and nothing plugged into it, * which is a different picture from a box that is making it all up. */ synthetic: boolean; ttlSeconds: number; attribution?: string[]; } /** * A command, going up. * * The only body in this file that travels from the browser to the server, and * deliberately the narrowest one: exactly one command, for exactly one device, * with no batching. A batch would need partial-failure semantics, and the first * write surface in this product is not the place to invent those. * * The command is validated **server-side against the resolved office plan** — * that the device id names an authored declaration, that the declaration's * asset really is hardware of the kind it claims, and that the op is one that * declaration declared. `normalizeDeviceCommand()` is the shared validator and * both ends run it, which is not redundancy: the browser runs it so a slider * cannot send nonsense, and the server runs it because a browser is not a * boundary. The same move `officeHasMediaBinding()` makes for screens. */ export interface DeviceCommandBody { command: DeviceCommand; } /** * What a command did, as the state that resulted from it. * * The new state rather than an `ok: true`, so the panel has something to draw * without a follow-up GET — and so the answer to "did that work" is the reading * itself rather than an acknowledgement that a request was received. A command * that was accepted and clamped (a gain of 40 dB on a device whose range stops * at 36) reports the clamped value here, and the slider snaps to what the * hardware actually did. */ export interface DeviceCommandResultBody { officeId: string; /** The device as it stands after the command was applied. */ device: DeviceState; /** Epoch milliseconds. */ observedAt: number; }