1
0

feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul

The build the studios needed, across eight workstreams and one strict file
partition.

**The render rig was the quality ceiling.** The renderer ran three's
NoToneMapping default while atmosphere drove the sun to 2.35 and assets set
emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is
why walls blew out and every fitting looked like a white rectangle. ACES filmic
tone mapping and an explicit output colour space land in `stage.ts`, and the
atmosphere intensity table and palette headroom are re-tuned against the new
curve rather than left tuned for the clipping we removed.

`engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally,
so nothing binary is committed. There was no environment map anywhere before, so
every `metalness > 0` role had nothing to reflect and rendered dull grey — a
defect the code already documented against itself in `office/optimus.ts`, where a
whole material role was abandoned over it, and worked around in `modelX.ts` with
a fake emissive that this change deletes. Atmosphere remains the sole light
owner; the rig derives from the `LightingState` it already produced.

**Studio hardware exists.** There was no device concept anywhere in the product:
no type, no route, no state. `devices/types.ts` fixes a declaration/state/
capability/command contract that a smart light, a thermostat, a door sensor and a
charger all fit without a schema change, and both studios now carry a desk mic
and a computer speaker with deterministic simulated behaviour behind an adapter
seam a real API can occupy later. Reads are the demo and are open; commands are a
signed-in action and are kept off the read body entirely, because a shared cache
replaying a GET that turned a microphone on is exactly what the fail-closed
cache default exists to prevent.

**The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the
response was served publicly cacheable, and the attribution hardcoded adsb.lol
regardless of where the endpoint pointed — one env var away from republishing
non-redistributable data under an open-terms credit. The host is now allowlisted,
the credit is derived from the host actually configured, public cacheability is
conditional on redistributability, and a refused endpoint demotes to simulated
flights and says so in `degraded[]`. The gate is on the source, not the feature:
live aircraft and their detail cards stay open to anonymous visitors.

**The LA studio was never the smaller pack** — 16 rooms and 248 props against
SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were
ceiling troffers, it bound no props to seats, placed none of the habitat kit, and
12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather
than more instances, because `furnish.ts` draws once per kind and folds colour
into the batch key, so repeat instances add nothing the eye can read.

**The interface stops being forty imperative mutations.** Every visibility
decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the
chrome has coverage for the first time. Deleted: ~100 lines of CSS and two
bindings targeting elements that no longer exist, and a `body:has()` rule that
shifted the desktop layout by 160px for touch controls hidden there. Fixed: the
office picker tabs that drew their label and their badge on top of each other.
Added: a first-run flow, because the product is two verbs and neither was ever
stated on screen. Mobile is designed on its own terms instead of being the
desktop with things hidden — the plan view comes back, and the keyboard-only
shortcuts button is replaced by touch controls.

`arena/studioOps.ts` frames the whole thing as the multi-variable environment it
is, wrapping the same simulators the renderer drives rather than a headless copy.

Also removed `input/vehicle.ts`, which nothing but its own test imported.

Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six
matrix cells, no-binaries, provenance, dependency licences, zero-config boot and
arena source hashes all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:44:24 -07:00
parent 8738367258
commit db074e9cf7
150 changed files with 36237 additions and 2586 deletions
+32 -2
View File
@@ -12,9 +12,10 @@
* one and binds it.
*/
import Fastify, { type FastifyInstance } from "fastify";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { registerCachePolicy } from "./cache.ts";
import { loadConfig, type Config } from "./config.ts";
import { registerDevices } from "./routes/devices.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
@@ -54,6 +55,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerMedia(app, services);
registerOffices(app, services);
registerPresence(app, services);
registerDevices(app, services);
registerRealtime(app, services);
registerSession(app, services);
@@ -62,7 +64,35 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
return reply.code(404).send(body);
});
app.setErrorHandler(async (err, _req, reply) => {
/**
* The one error handler, and the one place a client's mistake is told apart
* from ours.
*
* It used to answer 500 to everything, which was right for the only thing
* that reached it at the time — a route that threw. It stopped being right
* the moment a route accepted a body: Fastify raises its own errors for a
* payload that is not JSON, a content type it was not offered and a body over
* a route's `bodyLimit`, and every one of those is the caller's mistake,
* carries a 4xx `statusCode`, and was being reported as "something went wrong
* on the server". An operator watching error rates cannot tell a broken box
* from somebody POSTing nonsense at it, and the caller is told to retry
* something that will never work.
*
* So a 4xx from the framework is passed through with its own status and the
* `ErrorBody` shape every other refusal here uses; anything else is still a
* 500 with nothing in it, because the inside of an exception is not a thing
* to hand to the internet.
*/
app.setErrorHandler(async (err: FastifyError, req, reply) => {
const status = typeof err.statusCode === "number" ? err.statusCode : 500;
if (status >= 400 && status < 500) {
req.log.info({ err: err.message, status }, "refused a malformed request");
const body: ErrorBody =
status === 404
? { error: "not_found", message: "No such route." }
: { error: "bad_request", message: "That request could not be read." };
return reply.code(status).send(body);
}
app.log.error({ err }, "unhandled error");
const body: ErrorBody = { error: "internal", message: "Something went wrong." };
return reply.code(500).send(body);
+154 -2
View File
@@ -21,8 +21,10 @@ import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import { loadRegions, type RegionSet } from "./regions.ts";
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts";
import type {
AuthMode,
DevicesSourceId,
FlightsSourceId,
MarkersSourceId,
SatellitesSourceId,
@@ -38,8 +40,32 @@ export interface WeatherConfig {
export interface FlightsConfig {
source: FlightsSourceId;
/** Base URL for the `adsb` source. */
/**
* Base URL for the `adsb` source, **validated and normalised**.
*
* Empty on every other source, including a source that was demoted to `sim`
* because its endpoint failed the licence gate. That is deliberate: a refused
* URL does not survive into the config, so no later code can fetch it by
* accident and no later reader can mistake it for one this box vouches for.
* `flights/licence.ts` is the gate and explains what it is protecting.
*/
endpoint: string;
/**
* The credit lines a live body carries, derived from the endpoint's host.
*
* Not a constant, and not written next to the fetch. The whole point of
* computing it here is that there is no way for the credit and the source to
* disagree — which they did, for every value of `TERA_ADSB_ENDPOINT` that was
* not adsb.lol.
*/
attribution: string[];
/**
* Whether this source's terms let the box re-serve the bytes to third
* parties. Gates public caching on `/api/v1/flights`.
*/
redistributable: boolean;
/** The licence id behind `redistributable`, or `null` when nothing is live. */
licence: string | null;
/** Radius in nautical miles for the `adsb` source. */
radiusNm: number;
/** Path to a local dump1090 `aircraft.json`. */
@@ -61,6 +87,32 @@ export interface SatellitesConfig {
ttlSeconds: number;
}
export interface DevicesConfig {
source: DevicesSourceId;
/**
* How long a device snapshot may be held before it is asked for again.
*
* Short, and shorter than weather by two orders of magnitude, because the two
* are different kinds of fact: cloud cover moves over ten minutes and a mute
* button moves when somebody presses it. This is also the TTL the browser is
* told to poll on, so it is the floor on how long a viewer waits to see the
* result of somebody else's command.
*
* It is deliberately **not** a public cache lifetime. Nothing on the devices
* routes is ever publicly cached — see `routes/devices.ts`.
*/
ttlSeconds: number;
/**
* The simulator's seed, so a deployment can be reproduced.
*
* The same seed and the same declarations give the same sequence of readings
* on every box, which is what makes a bug report about a level meter
* actionable and what lets the arena wrap this exact simulator and replay a
* rollout. `src/devices/sim.ts` owns the arithmetic.
*/
seed: number;
}
export interface MarkersConfig {
source: MarkersSourceId;
/** Path to the JSON snapshot written by the sync oneshot. */
@@ -171,6 +223,7 @@ export interface Config {
flights: FlightsConfig;
satellites: SatellitesConfig;
markers: MarkersConfig;
devices: DevicesConfig;
offices: { dir: string };
/**
* Where the rosters are. Separate from `offices.dir` because the two hold
@@ -192,6 +245,7 @@ export function loadConfig(env: Env = process.env): Config {
const weather = loadWeather(env, degraded);
const flights = loadFlights(env, degraded);
const satellites = loadSatellites(env, degraded);
const devices = loadDevices(env, degraded);
const auth = loadAuth(env, degraded);
const ice = loadIce(env, degraded);
// After auth, because a marker feed with nobody able to sign in is worth a
@@ -222,6 +276,7 @@ export function loadConfig(env: Env = process.env): Config {
flights,
satellites,
markers,
devices,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
auth,
@@ -330,6 +385,15 @@ const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];
*/
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);
/**
* The feed pointed at when `TERA_FLIGHTS_SOURCE=adsb` and nothing else is said.
*
* It is on the allowlist, so the default configuration passes its own gate —
* which is the only kind of default worth shipping, and is asserted in
* `test/adsbLicence.test.ts` so it stays that way.
*/
const DEFAULT_ADSB_ENDPOINT = "https://api.adsb.lol";
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
let source = oneOf(asked, FLIGHT_SOURCES);
@@ -350,9 +414,47 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
source = "sim";
}
const askedEndpoint = str(env, "TERA_ADSB_ENDPOINT", DEFAULT_ADSB_ENDPOINT);
let endpoint = "";
let attribution: string[] = [];
let redistributable = false;
let licence: string | null = null;
if (source === "adsb") {
// The licence gate. Everything the wire will say about this source is
// decided here, from the host, before a single request goes out.
const verdict = checkAdsbEndpoint(askedEndpoint);
if (verdict.ok) {
endpoint = verdict.endpoint;
attribution = verdict.attribution;
redistributable = verdict.terms.redistributable;
licence = verdict.terms.licence;
if (verdict.caveat !== null) degraded.push(verdict.caveat);
} else {
degraded.push(
`TERA_ADSB_ENDPOINT="${askedEndpoint}" ${verdict.reason}. Demoted to the simulated ` +
"plan: this box will not republish a feed whose terms it cannot name, and it will " +
"not credit one feed for another feed's data.",
);
source = "sim";
}
}
if (source === "dump1090") {
// The same claim the loopback entry makes, from the same table, because a
// receiver's own aircraft.json and a receiver's own HTTP port are the same
// data arriving by different doors and must not be credited differently.
attribution = adsbAttribution(FIRST_PARTY_RECEIVER);
redistributable = FIRST_PARTY_RECEIVER.redistributable;
licence = FIRST_PARTY_RECEIVER.licence;
}
return {
source,
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
endpoint,
attribution,
redistributable,
licence,
radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
dump1090Path,
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
@@ -380,6 +482,56 @@ function radius(asked: number, degraded: string[]): number {
return clamped;
}
const DEVICE_SOURCES: DevicesSourceId[] = ["none", "sim", "homeassistant"];
/**
* `none` by default, and the default is the honest one rather than the
* impressive one.
*
* A box that has not been told about any hardware has no hardware. It serves an
* empty array and the studio's panels say so, which is the correct picture of a
* deployment nobody has wired anything into — the same posture
* `TERA_WEATHER_SOURCE` takes for exactly the reason CONTRACT.md §5.1 gives.
* `sim` is one variable away and is what the reference deployment runs: a
* deterministic state machine, `synthetic: true` on every reading it produces,
* and every panel that draws it carries the declaration's own disclosure
* sentence.
*
* `homeassistant` is in the union and is not implemented. That is deliberate
* and it demotes loudly rather than silently serving simulated readings under a
* name that promises real ones — a source that quietly downgraded from a real
* bridge to a simulator would be the exact `first-party-sensor`/`simulated`
* confusion `DeviceProvenance` exists to prevent, and it would do it in the one
* direction that matters.
*/
function loadDevices(env: Env, degraded: string[]): DevicesConfig {
const asked = str(env, "TERA_DEVICES_SOURCE", "none");
let source = oneOf(asked, DEVICE_SOURCES);
if (source === null) {
degraded.push(
`TERA_DEVICES_SOURCE="${asked}" is not one of ${DEVICE_SOURCES.join(", ")}; ` +
`serving no device state at all.`,
);
source = "none";
}
if (source === "homeassistant") {
degraded.push(
"TERA_DEVICES_SOURCE=homeassistant is named in the wire contract and is not " +
"implemented in this build. Demoted to none rather than to sim: serving " +
"invented readings under a source that promises a real bridge is the one " +
"mistake this field exists to prevent.",
);
source = "none";
}
return {
source,
ttlSeconds: num(env, "TERA_DEVICES_TTL", 5, degraded),
seed: num(env, "TERA_DEVICES_SEED", 8731, degraded),
};
}
const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];
/**
+178
View File
@@ -0,0 +1,178 @@
/**
* Which source answers for device state, and what happens when none does.
*
* The shape `createWeatherService` and `createFlightsService` established: one
* function, the source chosen by the environment, and **it never throws**.
* `current()` always returns a body and `command()` always returns an outcome,
* because the two routes above this have nothing sensible to do with an
* exception and a 500 on a device panel is a studio that looks broken.
*
* Three rules beyond that, and the first is the one that differs from weather:
*
* 1. **`none` serves an empty array, not a fabrication.** The flights service
* falls back to a simulated plan because an empty sky over a city reads as a
* bug; an office with no hardware in it reads as an office with no hardware
* in it, which is the truth and is a perfectly good picture. A box that
* invented microphones nobody had configured would be making a claim about a
* room. `TERA_DEVICES_SOURCE=sim` is one variable away for anyone who wants
* the demonstration studio, and it is what the reference deployment runs.
* 2. **Nothing is simulated until somebody asks.** No interval, no background
* tick; see `devices/sim.ts`.
* 3. **The declarations come from the pack, never from the caller.**
* `devices/store.ts` resolves them against a `Plan` this process built, which
* is what makes a command checkable at all.
*
* ### Commands are memory-only and bounded
*
* A command mutates a simulator held in this process and nothing else. Nothing
* is written to disk, no state outlives a restart, and the number of offices
* simulated at once is capped (CONTRACT.md §5). That is the honest scope of
* what this build's write surface is: a shared, resettable, obviously-simulated
* studio — not a control system, and never a control system by accident.
*/
import { resolveDevices } from "./store.ts";
import { createDeviceRuntime, type DeviceRuntime } from "./sim.ts";
import {
normalizeDeviceCommand,
type DeviceCommand,
type DeviceState,
} from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { Config } from "../config.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";
export interface DevicesService {
/**
* Every device in one office, right now. Never throws; an office with no
* declarations, or a box with no source, is an empty list and a 200.
*/
current(office: Office): DevicesBody;
/**
* Apply one command.
*
* The failure cases are collapsed into one on purpose. `no-such-device` and
* `not-permitted-op` are different mistakes by the same caller, and the route
* answers 400 to both — a caller who has been told *which* of its guesses was
* wrong is a caller being helped to guess again.
*/
command(office: Office, command: DeviceCommand): DeviceCommandOutcome;
/** What this box will serve for an office. The route uses it for its 400s. */
declarationCount(office: Office): number;
}
export type DeviceCommandOutcome =
| { ok: true; device: DeviceState }
| { ok: false; reason: string };
export interface DevicesLog {
warn(msg: string): void;
}
/**
* Attribution for a source nobody but us produced.
*
* Empty, and it stays empty for `sim`: the readings are this repo's own
* arithmetic and there is nobody to thank for them. Crediting anybody would be
* the same mistake the flights service made when it credited adsb.lol for its
* own simulator's aircraft. A `homeassistant` bridge would put the operator's
* own attribution here, which is why the field exists on the body at all.
*/
const NO_ATTRIBUTION: string[] = [];
export function createDevicesService(config: Config, log: DevicesLog): DevicesService {
const { source, ttlSeconds, seed } = config.devices;
// Built even for `none`, because it costs one empty `Map` and it means the
// two branches below differ by a single condition rather than by a structure.
const runtime: DeviceRuntime = createDeviceRuntime({ seed });
/**
* Offices already complained about, so a poll every five seconds does not
* become a log line every five seconds. Bounded by the same office-id key
* space everything else here is, and it is only ever added to when a pack is
* genuinely broken.
*/
const complained = new Set<string>();
/**
* One line, once, for a pack that declares hardware none of which resolved.
*
* The single operator-facing diagnostic this service has, and it is worth
* having: every drop in `store.ts` is silent by design — a public route must
* not narrate a pack's mistakes — so without this a mistyped `anchor.propId`
* produces an empty panel and no explanation anywhere.
*/
const report = (office: Office, resolved: { declarations: readonly unknown[]; authored: number }) => {
if (resolved.authored === 0 || resolved.declarations.length > 0) return;
if (complained.has(office.id) || complained.size > 64) return;
complained.add(office.id);
log.warn(
`devices: office "${office.id}" declares ${resolved.authored} device(s) and none of them ` +
"resolved — check that each anchor.propId names a prop on that level whose kind is the " +
"declaration's assetId. See server/src/devices/store.ts.",
);
};
/** The shared shell of a body, so the two paths cannot disagree about it. */
const body = (office: Office, devices: DeviceState[], observedAt: number): DevicesBody => ({
officeId: office.id,
devices,
observedAt,
source,
// Never `false` in this build. The only implemented source is a state
// machine, and a body that claimed observation would be a lie told by a
// constructor — the same sentence `initialDeviceState` carries.
synthetic: true,
ttlSeconds,
...(NO_ATTRIBUTION.length > 0 ? { attribution: NO_ATTRIBUTION } : {}),
});
return {
current(office: Office): DevicesBody {
const now = Date.now();
if (source === "none") return body(office, [], now);
const resolved = resolveDevices(office);
report(office, resolved);
const { declarations } = resolved;
if (declarations.length === 0) return body(office, [], now);
const simulator = runtime.advance(office.id, declarations, now);
// Restamped with the request's clock: the simulator's own `observedAt` is
// its epoch plus its simulated elapsed time, which lags by up to a step
// and by the whole of a catch-up cap. What a viewer wants to know is when
// this reading was taken, and that is now.
return body(
office,
simulator.current().map((state) => ({ ...state, observedAt: now })),
now,
);
},
command(office: Office, command: DeviceCommand): DeviceCommandOutcome {
if (source === "none") return { ok: false, reason: "this deployment has no device source" };
const { declarations } = resolveDevices(office);
const declaration = declarations.find((d) => d.id === command.deviceId);
// The whole of the authorisation for a write, in two lines. The device
// must be one this process found in the pack, and the op must be one that
// declaration declared — checked against the *resolved* pack rather than
// against anything in the request, which is what makes this a boundary
// rather than a formality.
if (declaration === undefined) return { ok: false, reason: "no such device in this office" };
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return { ok: false, reason: "that device will not accept that command" };
const now = Date.now();
const simulator = runtime.advance(office.id, declarations, now);
simulator.command(normalized);
const device = simulator.current().find((state) => state.id === normalized.deviceId);
// Unreachable: `normalized` names a declaration this simulator was built
// from. Reported rather than asserted anyway — a route that threw here
// would turn a device the pack author renamed into a 500.
if (device === undefined) return { ok: false, reason: "no such device in this office" };
return { ok: true, device: { ...device, observedAt: now } };
},
declarationCount(office: Office): number {
return source === "none" ? 0 : resolveDevices(office).declarations.length;
},
};
}
+166
View File
@@ -0,0 +1,166 @@
/**
* The simulator, on a wall clock.
*
* `src/devices/sim.ts` is a fixed-step state machine that reads no clock. This
* file is the ten lines that make it answer questions asked over HTTP: one
* simulator per office, advanced to *now* the moment somebody asks and never
* otherwise.
*
* **It is the same module the browser and the arena drive.** Not a port, not a
* server-side reimplementation — the import is `src/devices/sim.ts`. That is
* the property that makes a bug report about a level meter reproducible and
* makes an arena rollout describe the same studio a viewer is looking at, and
* it is worth the one awkwardness it costs: a package under `src/` imported by
* the server, which `media/bindings.ts` and `regions.ts` already do for exactly
* the same reason.
*
* ### Nothing ticks in the background
*
* There is no interval here. A box nobody is looking at advances no simulation,
* makes no outbound request and does no work at all — the third rule
* `upstream.ts` states for weather and flights, applied to a source that
* happens to be local. The cost is that the first request after a quiet hour
* has an hour to catch up on, which is what `MAX_CATCHUP_STEPS` is about.
*/
import { createSimulatedDevices, type SimulatedDevices } from "../../../src/devices/sim.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";
/**
* Seconds per simulated step, server-side.
*
* A tenth of a second, matching the arena's `fixedStepSeconds`, so a rollout
* and a deployment are running the same physics at the same resolution. Finer
* would buy nothing over a five-second poll; coarser would make a level meter
* step visibly between polls.
*/
const STEP_SECONDS = 0.1;
/**
* How far one request may advance a simulator that has been idle.
*
* Sixty seconds' worth. Past that the simulated clock simply jumps: catching up
* honestly on an office nobody has opened since yesterday would be nearly a
* million steps inside one request, to arrive at a level meter reading that
* nobody watched accumulate and that carries no information — the state a
* microphone converges to is not a function of how long it has been ignored.
* Commands and settings are unaffected, because they are held state rather than
* integrated state.
*/
const MAX_CATCHUP_STEPS = 600;
/**
* A ceiling on how many offices are simulated at once.
*
* CONTRACT.md §5: memory-only, bounded state. The key space is office ids and
* `offices/store.ts` will look up any id matching its pattern, so without a cap
* an anonymous caller — well, a *signed-in* caller, this route takes a session —
* could grow this map one request at a time. The least recently touched entry
* is dropped, which loses nothing that cannot be rebuilt: a dropped simulator
* comes back powered-off, which is where it started.
*/
const MAX_OFFICES = 16;
export interface DeviceRuntime {
/** The simulator for one office, advanced to `nowMs`. */
advance(officeId: string, declarations: readonly DeviceDeclaration[], nowMs: number): SimulatedDevices;
/** How many offices are currently being simulated. For tests and for the bound. */
size(): number;
}
interface Entry {
simulator: SimulatedDevices;
/** The declaration ids and capabilities this simulator was built for. */
signature: string;
/** Simulated time, in epoch milliseconds, that this simulator has reached. */
clockMs: number;
/** When it was last asked for, so the cap can drop the coldest. */
touchedMs: number;
}
export interface DeviceRuntimeOptions {
seed: number;
}
export function createDeviceRuntime(options: DeviceRuntimeOptions): DeviceRuntime {
const offices = new Map<string, Entry>();
return {
advance(officeId, declarations, nowMs): SimulatedDevices {
const signature = signatureOf(declarations);
let entry = offices.get(officeId);
// A pack that has been edited on disk is a different studio, and resuming
// a simulator built for the old one would leave readings for devices that
// no longer exist and none for the ones that do. Rebuilt rather than
// patched: the state a device machine carries is a few booleans and a
// level, and none of it is worth migrating.
if (entry === undefined || entry.signature !== signature) {
entry = {
simulator: createSimulatedDevices(declarations, {
// The office id is mixed into the seed so two studios on one box do
// not run in lockstep — every mic in the building peaking together
// is the tell that gives a simulation away.
seed: (options.seed ^ hash(officeId)) | 0,
fixedStepSeconds: STEP_SECONDS,
epochMs: nowMs,
}),
signature,
clockMs: nowMs,
touchedMs: nowMs,
};
offices.set(officeId, entry);
evict(offices);
return entry.simulator;
}
const stepMs = STEP_SECONDS * 1000;
const behind = Math.max(0, nowMs - entry.clockMs);
const steps = Math.min(MAX_CATCHUP_STEPS, Math.floor(behind / stepMs));
for (let i = 0; i < steps; i += 1) entry.simulator.stepFixed();
// The clock is set to `now` whichever branch ran. Advancing it by
// `steps * stepMs` instead would leave a simulator that had been capped
// permanently behind, and it would try to catch up again on every
// subsequent request — one poll's worth of work turning into a treadmill.
entry.clockMs = nowMs;
entry.touchedMs = nowMs;
// Re-inserted so the map's iteration order is least-recently-touched
// first, which is what makes `evict` drop the coldest office rather than
// the oldest one.
offices.delete(officeId);
offices.set(officeId, entry);
return entry.simulator;
},
size: () => offices.size,
};
}
function evict(offices: Map<string, Entry>): void {
while (offices.size > MAX_OFFICES) {
const coldest = offices.keys().next();
if (coldest.done) return;
offices.delete(coldest.value);
}
}
/**
* What a simulator was built for, as a string.
*
* Ids and capabilities, in order — everything that changes which readings exist
* and which commands are legal. Deliberately not the labels or the disclosure,
* which are prose a pack author may reword without changing an instrument.
*/
function signatureOf(declarations: readonly DeviceDeclaration[]): string {
return declarations.map((d) => `${d.id}:${d.kind}:${d.capabilities.join(",")}`).join("|");
}
/** FNV-1a, so two office ids that differ by one character seed differently. */
function hash(text: string): number {
let value = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
value ^= text.charCodeAt(i);
value = Math.imul(value, 0x01000193);
}
return value >>> 0;
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Which devices an office actually has, decided by the server.
*
* The device routes never take a client's word for what is in a room. A read
* answers with the hardware **this** process found in the pack, and a command is
* refused unless the id it names is one of them — which is the same move
* `officeHasMediaBinding()` makes for a screen share, for the same reason: a
* device id in a request body is a claim, and the only thing that can check a
* claim about a building is the building.
*
* ### It asks `Plan`, and does not hold a second opinion
*
* The rules a device has to pass are real and they are exacting — the anchor
* prop must exist, be on the level the declaration claims, and not be some
* *other* instrument's hardware; the declaration must validate; the id must be
* unique; the disclosure must say "simulated" if the provenance does. All of
* that is implemented once, in `Plan.resolveDevice`, because it is the same
* question the renderer asks and the answers have to agree.
*
* They have to agree in a specific direction that is easy to miss. `Plan`
* deliberately *allows* a microphone anchored to a desk — `DeviceAnchor.offset`
* exists for exactly those few centimetres — and only refuses an anchor to a
* prop that is itself a device of another kind. A stricter copy of that rule
* here would silently drop a legitimate self-hosted pack's devices from the API
* while the browser drew them, which is the worst kind of disagreement: the
* panel is populated and every command it sends is refused.
*
* So this file resolves one `Plan` and reads `allDevices()` off it. What it adds
* is the two things a request path needs and a build step does not: it never
* throws, and it is bounded.
*
* ### Why it cannot simply trust the pack
*
* An `Office` reaches this from two places: a bundled pack compiled into this
* repo, and a JSON file in `TERA_OFFICES_DIR` that an operator wrote by hand.
* The second is untrusted input in the ordinary sense — `Plan` is written for
* authored TypeScript and reads a declaration's `label.trim()` without asking
* whether it is a string, which a hand-edited file is entitled to get wrong. A
* `TypeError` from inside a resolver would leave the route answering 500 for a
* studio whose only fault is a typo, so the whole resolution is wrapped and a
* pack this process cannot read has no devices. `offices/store.ts` takes the
* same posture toward the document that carries it.
*/
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";
/**
* A ceiling on how many devices one office may declare.
*
* Not a statement about studio size — the two shipped packs declare a handful
* each. It is a bound on what one file can do to this process: every device is
* a simulator entry advanced on every poll and a row in a body served to every
* viewer, so a pack with fifty thousand microphones in it, by mistake or
* otherwise, is a box that stops answering. Dropping the tail is visible and
* recoverable; the alternative is not. `presence/store.ts` bounds a roster for
* the identical reason.
*/
const MAX_DEVICES = 64;
export interface ResolvedDevices {
/** The devices this box will serve and command, in pack order. */
declarations: readonly DeviceDeclaration[];
/**
* How many the pack *tried* to declare.
*
* Carried so that "this office has no devices" and "this office declares
* devices and not one of them resolved" can be told apart by the one party
* who can fix the second — the operator, through one line in the log. Without
* it a pack with a mistyped `propId` is indistinguishable from a pack that
* never mentioned a microphone, and the visible symptom of both is an empty
* panel.
*/
authored: number;
}
interface Cached extends ResolvedDevices {
office: Office;
}
/**
* One entry per office, keyed on the id and validated against the object.
*
* `Plan` is not free — it re-resolves every wall, prop and seat — and the device
* routes are polled every few seconds, so resolving per request would make a
* device panel the most expensive thing on the box. The `office` field is the
* real key: a bundled pack is one stable object for the life of the process, and
* a pack read off disk is a fresh object every time the file is re-read, which
* is exactly when the answer should be recomputed.
*/
const cache = new Map<string, Cached>();
/**
* A ceiling on how many offices are remembered at once.
*
* "Bounded by what is on disk" is bounded by whatever a caller can name, and
* `offices/store.ts` will look up any id matching its pattern. So the map is
* capped and the oldest entry is dropped, which keeps this a cache rather than
* an unbounded index a stranger can grow by asking for offices that do not
* exist. CONTRACT.md §5.
*/
const MAX_CACHED_OFFICES = 16;
/** The devices this box will serve and command for one office. Never throws. */
export function resolveDevices(office: Office): ResolvedDevices {
const hit = cache.get(office.id);
if (hit !== undefined && hit.office === office) return hit;
const resolved = resolve(office);
if (cache.size >= MAX_CACHED_OFFICES) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(office.id, { office, ...resolved });
return resolved;
}
/** For tests, and for anything that swaps a pack under a running process. */
export function forgetResolvedDevices(): void {
cache.clear();
}
function resolve(office: Office): ResolvedDevices {
const authored = authoredCount(office);
if (authored === 0) return { declarations: [], authored: 0 };
let resolved;
try {
// `full` depth, because this is the server deciding what hardware exists,
// not what a particular viewer may see. Who may *read* the state is the
// route's decision and it is made before this is ever called. `warn: false`
// because a pack's problems are the pack author's business and this is a
// request path, not a build step.
resolved = new Plan(office, { depth: "full", warn: false }).allDevices();
} catch {
// See the header: a hand-edited pack is entitled to be malformed, and the
// honest answer to one this process cannot read is that it has no devices —
// not a 500 on a studio somebody is standing in.
return { declarations: [], authored };
}
const declarations = resolved.slice(0, MAX_DEVICES).map(
(device): DeviceDeclaration => ({
id: device.id,
kind: device.kind,
label: device.label,
assetId: device.assetId,
// The resolved coordinate is deliberately dropped. This side never renders
// anything, and a declaration here exists to answer two questions — does
// this device exist, and may it be asked to do this — neither of which a
// position is part of. `anchor.seatId` survives because it is the one
// address the simulator uses: a microphone's level responds to whether
// anybody is at the desk it serves.
anchor: {
levelId: device.levelId,
propId: device.propId,
...(device.roomId === undefined ? {} : { roomId: device.roomId }),
...(device.seatId === undefined ? {} : { seatId: device.seatId }),
},
capabilities: [...device.capabilities],
provenance: device.provenance,
disclosure: device.disclosure,
}),
);
return { declarations, authored };
}
/**
* How many devices the pack's files mention, whatever state they are in.
*
* Counted rather than resolved, and read as `unknown` rather than through the
* `Floorplan` type, because the whole value of the number is that it is
* available when the resolution produced nothing — including when the pack is
* malformed enough that `Plan` refused it outright.
*/
function authoredCount(office: Office): number {
const levels: unknown = office.levels;
if (!Array.isArray(levels)) return 0;
let count = 0;
for (const level of levels) {
if (level === null || typeof level !== "object") continue;
const floorplan = (level as { floorplan?: unknown }).floorplan;
if (floorplan === null || typeof floorplan !== "object") continue;
const devices = (floorplan as { devices?: unknown }).devices;
if (Array.isArray(devices)) count += devices.length;
}
return count;
}
+50 -8
View File
@@ -2,19 +2,23 @@
* Real aircraft, from feeds that can actually be pointed at.
*
* Two sources, one shape. `adsb.lol` and `airplanes.live` serve the same
* volunteer-fed ADS-B in the same JSON, keyless and with open terms — set
* `TERA_ADSB_ENDPOINT` to whichever. A local `dump1090` writes that same JSON to
* disk, and reading it is the best answer of the three: an RTL-SDR on a box in
* the Bay produces first-party data with no terms to comply with at all.
* volunteer-fed ADS-B in the same JSON, keyless and under the ODbL — set
* `TERA_ADSB_ENDPOINT` to whichever, and to nothing else: `licence.ts` holds
* the allowlist and derives the credit from whichever answered. A local
* `dump1090` writes that same JSON to disk, and reading it is the best answer
* of the three: an RTL-SDR on a box in the Bay produces first-party data with
* no terms to comply with at all.
*
* FlightRadar24 is deliberately absent and will stay absent. Their terms forbid
* scraping and forbid redistribution, so a client for it in an Apache-2.0 repo
* would be shipping instructions for breaking a ToS. If a private deployment
* wants it, it is an adapter in that deployment. ARCHITECTURE.md §4.
* FlightRadar24 is deliberately absent and will stay absent: their terms do not
* permit scraping and do not permit redistribution, so a client for it in an
* Apache-2.0 repo would be shipping instructions for breaking a ToS. If a
* private deployment wants it, it is an adapter in that deployment.
* ARCHITECTURE.md §4.
*/
import { readFile } from "node:fs/promises";
import { getJson } from "../http.ts";
import { isOpenAdsbUrl } from "./licence.ts";
import type { WireAircraft } from "../../../src/server/wire.ts";
/** The shared dump1090/readsb aircraft record, as both feeds emit it. */
@@ -55,6 +59,15 @@ export interface AdsbLog {
*
* How often this is allowed to be called, and the arithmetic that keeps it
* inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`.
*
* The same argument applies to *which* feed, and is enforced twice. `config.ts`
* refuses an endpoint that is not on `licence.ts`'s allowlist at boot, so the
* only string that can reach this parameter today is one an operator was told
* about. The check below is the second lock, on the door itself: this function
* will fetch anywhere, and it is the one that turns a URL into bytes this box
* republishes under a derived open-terms credit. A caller that one day builds
* the endpoint from somewhere else pays one `new URL()` per poll to find that
* out, rather than the public finding out later.
*/
export async function fetchAdsb(
endpoint: string,
@@ -63,6 +76,13 @@ export async function fetchAdsb(
log?: AdsbLog,
): Promise<FlightsSnapshot | null> {
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
if (!isOpenAdsbUrl(url)) {
log?.warn(
`flights:adsb: refusing to fetch ${endpoint} — it is not an openly-licensed feed this ` +
`build may republish. See server/src/flights/licence.ts.`,
);
return null;
}
const body = await getJson<AircraftEnvelope>(url);
if (body === null) return null;
return normalise(body, (dropped) =>
@@ -151,9 +171,17 @@ function normalise(
const callsign = a.flight?.trim();
const id = a.hex ?? callsign;
if (id === undefined || id === "") continue;
const address = icao24(a.hex);
aircraft.push({
id,
callsign: callsign === "" ? undefined : callsign,
// Carried only when it is one. Both feeds emit `~`-prefixed anonymous
// addresses for TIS-B and MLAT targets, which are not ICAO addresses at
// all, and `id` falls back to the callsign for a record with no `hex` —
// so the id is not a reliable address and a detail card must not present
// it as one. `WireAircraft.icao24` says the same thing from the other
// end of the wire.
...(address === null ? {} : { icao24: address }),
lat: a.lat,
lng: a.lon,
// The feeds report barometric altitude in feet, and send the string
@@ -165,6 +193,20 @@ function normalise(
return { aircraft, observedAt: observedAtMs(body.now) };
}
/**
* A transponder address as the feeds write one: six hex digits, no prefix.
*
* Anchored and lowercased rather than pattern-matched loosely, because the
* output is something a person pastes into a registry lookup — a wrong one
* names a different aircraft, which is worse than saying nothing. Anything
* else, including the `~abcdef` anonymous form, is `null`.
*/
function icao24(hex: string | undefined): string | null {
if (typeof hex !== "string") return null;
const trimmed = hex.trim().toLowerCase();
return /^[0-9a-f]{6}$/.test(trimmed) ? trimmed : null;
}
/**
* dump1090 stamps `now` in seconds and the hosted feeds stamp it in
* milliseconds, using the same field name. Anything past the year 2001 in
+19 -3
View File
@@ -14,6 +14,18 @@
* empty coastline apart, with no sane radius that covers both. The requested
* region supplies the centre; the radius stays what the operator configured.
*
* ### Whose data this is
*
* Nothing in this file decides who gets the credit. `TERA_ADSB_ENDPOINT` is
* checked against an allowlist of openly-licensed feeds in `flights/licence.ts`
* before the process finishes booting, and the credit lines, the licence id and
* the one bit that says whether a shared cache may keep the body all come out
* of the entry that matched. An endpoint that is not on the list never reaches
* this file at all — `config.ts` has already demoted the source to `sim` and
* said so on `/api/v1/health`. The version of this file that hardcoded
* `adsb.lol` into the attribution regardless of the endpoint is why that gate
* exists.
*
* ### Staying inside adsb.lol's limits
*
* adsb.lol asks for **no more than one request per second** from a client and
@@ -75,6 +87,10 @@ const RECEIVER_KEY = "receiver";
export function createFlightsService(config: Config, log: FlightsLog): FlightsService {
const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights;
// Both derived in `config.ts` from the host that will actually answer, by
// `flights/licence.ts`. Read once here so that no code path in this file can
// construct a live body with a credit line it made up.
const { attribution, redistributable, licence } = config.flights;
// Built once per region and kept: the plan is a pure function of the centre,
// and it is handed out on every cacheable request.
@@ -119,9 +135,9 @@ export function createFlightsService(config: Config, log: FlightsLog): FlightsSe
observedAt: snapshot.observedAt,
aircraft: snapshot.aircraft,
ttlSeconds: liveTtl,
...(source === "adsb"
? { attribution: ["Aircraft positions from the adsb.lol community feed"] }
: {}),
redistributable,
...(attribution.length > 0 ? { attribution } : {}),
...(licence === null ? {} : { licence }),
};
},
};
+296
View File
@@ -0,0 +1,296 @@
/**
* Which ADS-B feeds this box is allowed to republish, and who gets the credit.
*
* This is the one module in the repo that exists because of a **live real-world
* exposure** rather than a feature. `TERA_ADSB_ENDPOINT` used to be a free-form
* string: whatever it pointed at, the aircraft that came back were served from
* `/api/v1/flights` with `Cache-Control: public` and an `attribution` array that
* said, unconditionally, *adsb.lol*. Three things were therefore true at once on
* any deployment one environment variable away from the default:
*
* 1. this box would fetch a feed nobody had checked the terms of,
* 2. it would hand the bytes to a shared cache to hand to everyone else, and
* 3. it would credit a community feed that had never seen them.
*
* (1) is an operator's business. (2) is *Publicly Using a Derivative Database*
* and is the trigger CONTRACT.md §8 is about — the same reasoning that made the
* geocoder a US Census one. (3) is worse than either: an open-terms credit
* attached to data that did not come from the open lane is a false licence
* statement, and it is false in the direction that invites a downstream
* consumer to redistribute something they may not.
*
* So the endpoint is checked against a table, and **everything the wire says
* about the source is derived from the host that actually answered**. There is
* no path here that lets a credit line and a hostname disagree, because the
* credit is computed from the hostname. An endpoint that is not in the table is
* refused — `config.ts` demotes the source to the simulated plan and writes one
* sentence into `degraded[]` naming the host — and the demotion is deliberately
* not fatal, for the same reason nothing else in `config.ts` is: CONTRACT.md
* §5.1 says a misconfigured source is demoted, not a boot failure. The operator
* gets a working map, a plan-mode sky and a line on `/api/v1/health` telling
* them exactly which variable to fix.
*
* ### Adding a feed
*
* Read the feed's published terms. Write down, in the entry: the licence, a URL
* where the next person can read the same terms, the credit line that feed asks
* for, and whether those terms let this box re-serve the bytes to third parties.
* If you cannot answer all four, the entry does not go in — a guess here is the
* failure this module exists to prevent. `adsb.fi` and a handful of other
* community mirrors serve the same `/v2/point` shape and are plausible next
* entries; they are absent because nobody on this side has read their terms,
* which is the honest reason and the only one that should ever appear here.
*
* ARCHITECTURE.md §4, NOTICE's AIRCRAFT DATA block.
*/
import type { FlightsBody } from "../../../src/server/wire.ts";
/**
* The licence a feed's data arrives under.
*
* `first-party` is not a licence at all and says so: it is the operator's own
* antenna, and there is nobody to comply with. See `LOOPBACK_CAVEAT` for why
* that claim is only ever as good as the operator making it.
*/
export type AdsbLicenceId = "ODbL-1.0" | "first-party";
export interface AdsbFeedTerms {
/** Lowercased hostname, matched exactly against the configured endpoint's. */
host: string;
/** Named in the credit line. This is who the bytes came from, in prose. */
credit: string;
licence: AdsbLicenceId;
/** Where a human reads the terms this entry claims. Never a guess. */
terms: string;
/**
* May this box re-serve the data to third parties?
*
* Gates `publicCache` on the flights route — see `mayRepublish`. A feed that
* is free to *use* and not free to *redistribute* is a real category (several
* aviation feeds are exactly that), and the whole point of carrying the flag
* rather than assuming it is that such an entry can be added later without
* anybody having to remember to also change the route.
*/
redistributable: boolean;
/**
* Plain HTTP is acceptable for this host.
*
* True only for loopback: a `readsb` on the same box has no certificate and
* needs none, and forcing TLS there would push self-hosters towards a public
* feed for no security gain. Everything reachable off-box must be https —
* without it, whoever is between us and the feed chooses what this box
* publishes under an open-terms credit.
*/
loopback?: boolean;
/** One sentence for `degraded[]` when this entry is the configured one. */
caveat?: string;
}
/**
* A receiver on the same box. First-party by construction — and by trust.
*
* Shared with the `dump1090` file source, which is the same data arriving by a
* different door, so the two cannot drift apart in what they claim.
*/
export const FIRST_PARTY_RECEIVER: AdsbFeedTerms = {
host: "localhost",
credit: "this deployment's own ADS-B receiver",
licence: "first-party",
terms: "no licence: data received by the operator's own antenna",
redistributable: true,
loopback: true,
caveat:
"TERA_ADSB_ENDPOINT points at a loopback address, so aircraft are credited to this " +
"deployment's own receiver. That credit is derived from the host and only you can vouch " +
"for it: if the process on that port is a proxy for somebody else's feed, this box is " +
"publishing their data under your name.",
};
/**
* The feeds this repo will point at, with the terms it publishes about them.
*
* Two hosted community feeds and the loopback family. Both hosted entries serve
* volunteer-fed ADS-B under the Open Database Licence, both are keyless, and
* both answer the same `/v2/point/:lat/:lng/:radiusNm` shape `adsb.ts` parses —
* which is not a coincidence, they are the same lineage of software.
*
* The apex domains are absent on purpose: `adsb.lol` serves a website and
* `api.adsb.lol` serves the API, and an operator who types the former gets a
* near-miss hint out of `checkAdsbEndpoint` rather than a 404 loop.
*/
export const ADSB_ALLOWLIST: readonly AdsbFeedTerms[] = [
{
host: "api.adsb.lol",
credit: "the adsb.lol community feed",
licence: "ODbL-1.0",
terms: "https://adsb.lol/legal-and-license/",
redistributable: true,
},
{
host: "api.airplanes.live",
credit: "the airplanes.live community feed",
licence: "ODbL-1.0",
terms: "https://airplanes.live/",
redistributable: true,
caveat:
"TERA_ADSB_ENDPOINT=airplanes.live: their feed is volunteer-funded and asks callers to " +
"stay inside a request-per-second ceiling, which flights/index.ts holds structurally. " +
"The ODbL credit is attached to every body automatically.",
},
FIRST_PARTY_RECEIVER,
{ ...FIRST_PARTY_RECEIVER, host: "127.0.0.1" },
{ ...FIRST_PARTY_RECEIVER, host: "::1" },
];
export type AdsbEndpointVerdict =
| {
ok: true;
/** The endpoint, normalised: origin plus path, no trailing slash. */
endpoint: string;
terms: AdsbFeedTerms;
/** What the wire must say about this source. Derived, never authored. */
attribution: string[];
/** One sentence for `degraded[]`, or `null` when there is nothing to say. */
caveat: string | null;
}
| { ok: false; reason: string };
/**
* Is this endpoint one this box may fetch, republish and credit?
*
* Every refusal `reason` is a fragment that reads correctly after
* `TERA_ADSB_ENDPOINT="…"` — `config.ts` builds the sentence, this builds the
* clause, and the host is always named so the operator can see which part of
* their URL was the problem.
*/
export function checkAdsbEndpoint(raw: string): AdsbEndpointVerdict {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, reason: "is empty" };
let url: URL;
try {
url = new URL(trimmed);
} catch {
return { ok: false, reason: "is not a URL" };
}
// A credential is not a syntax problem, it is a category one: every feed in
// the table is keyless, so a URL carrying a secret is by construction not one
// of them — it is somebody's paid account, and paid accounts are exactly the
// terms that do not permit republication.
if (url.username !== "" || url.password !== "") {
return {
ok: false,
reason:
"carries credentials, and every openly-licensed feed here is keyless — a URL with a " +
"secret in it is an account, and an account's data is not ours to re-serve",
};
}
// `adsb.ts` appends `/v2/point/…` to this string. A query or a fragment
// would end up in the middle of the path, so it is refused rather than
// silently dropped: an operator who put an API key in `?key=` needs to see
// that it was neither used nor honoured.
if (url.search !== "" || url.hash !== "") {
return {
ok: false,
reason:
"carries a query string or fragment; this is a base URL that /v2/point/:lat/:lng/:nm " +
"is appended to, so anything after it would land in the middle of the path",
};
}
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]/`
// parses to a hostname of `[::1]` — so they come off before the table is
// consulted, and the table stores the address the way a person writes it.
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
const terms = ADSB_ALLOWLIST.find((entry) => entry.host === host);
if (terms === undefined) {
return { ok: false, reason: `points at ${host}, which ${notOnTheList(host)}` };
}
if (url.protocol !== "https:" && !(terms.loopback === true && url.protocol === "http:")) {
return {
ok: false,
reason:
`reaches ${host} over ${url.protocol.replace(":", "")}, and only https will do off-box: ` +
"without it, whoever is on the path chooses what this deployment publishes under an " +
"open-terms credit",
};
}
return {
ok: true,
endpoint: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
terms,
attribution: adsbAttribution(terms),
caveat: terms.caveat ?? null,
};
}
/**
* The credit lines for a feed, built from the entry rather than written twice.
*
* ODbL §4.3 wants the notice to name the source *and* point at the licence, so
* an ODbL feed gets two lines and a first-party receiver gets one. The browser
* displays whatever it is sent, in order, which is why the source line is first.
*/
export function adsbAttribution(terms: AdsbFeedTerms): string[] {
const lines = [`Aircraft positions from ${terms.credit}`];
if (terms.licence === "ODbL-1.0") {
lines.push(
"Made available under the Open Database License (ODbL) v1.0 — " +
"https://opendatacommons.org/licenses/odbl/1-0/",
);
}
return lines;
}
/**
* The last line of defence, at the point of the request.
*
* `config.ts` has already validated the endpoint by the time anything calls
* `fetchAdsb`, so this should never fire. It is here because "should never" is
* a property of today's call graph and this module is about the case where that
* property quietly stops holding — a future caller that builds a URL from
* somewhere else pays one `new URL()` per poll to find out it was wrong,
* instead of the public getting a credited copy of a feed nobody checked.
*/
export function isOpenAdsbUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return ADSB_ALLOWLIST.some((entry) => entry.host === host);
} catch {
return false;
}
}
/**
* May a shared cache keep this body and hand it to the next caller?
*
* The plan is this project's own arithmetic under Apache-2.0, so it always may.
* A live body may only if the entry the positions came from said so. The route
* asks this instead of asking the config, because the thing being cached is the
* body, and a body that outlives a config change is precisely the bug a shared
* cache produces.
*/
export function mayRepublish(body: FlightsBody): boolean {
return body.mode === "plan" ? true : body.redistributable;
}
/**
* "…is not on the open-feed allowlist", plus a nudge when the host looks like
* somebody reaching for one of the entries and missing by a subdomain.
*/
function notOnTheList(host: string): string {
const near = ADSB_ALLOWLIST.find(
(entry) => entry.host.endsWith(`.${host}`) || host.endsWith(`.${entry.host}`),
);
const hosts = ADSB_ALLOWLIST.map((entry) => entry.host).join(", ");
const hint = near === undefined ? "" : ` (did you mean https://${near.host}?)`;
return (
`is not one of the openly-licensed feeds this build will republish — ${hosts}${hint}. ` +
"The endpoint is not fetched and its data is not credited to anyone"
);
}
+24 -4
View File
@@ -1,4 +1,13 @@
/** Server-authoritative lookup for authored office screen surfaces. */
/**
* Server-authoritative lookup for the things an office pack *authored* screen
* surfaces today, device hardware as well now.
*
* The rule both callers share: a client says "this screen", "this microphone",
* and the server checks the claim against a pack it resolved itself rather than
* against anything in the request. `officeHasMediaBinding` does it for a screen
* share; `server/src/devices/store.ts` does it for a device command, and reads
* the same bundled packs through `bundledOffice` for the same reason.
*/
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
@@ -17,10 +26,21 @@ const BUNDLED_OFFICES: ReadonlyMap<string, Office> = new Map(
);
/**
* A bundled pack is public sample geometry, so its exact authored screen
* catalogue is the only bundled exception. No real/private pack is inferred.
* The three packs this repo ships, by id, or `null` for anything else.
*
* A bundled pack is public sample geometry it is compiled into the browser
* bundle, so its authored catalogue of screens and devices is already public by
* construction and the server reading its own copy tells nobody anything new.
* That is what makes it a safe exception, and it is the **only** exception: no
* real or private pack is ever inferred from anything, and an id that is not one
* of these three is not an office as far as this function is concerned.
*
* Why it is needed at all: the browser renders bundled packs without asking the
* API for them, so a viewer can be standing in `lumbridge-hq` on a deployment
* that has no `TERA_OFFICES_DIR` at all. Without this, every server-side check
* about the room they are in would have nothing to check against.
*/
export function bundledMediaOffice(officeId: string): Office | null {
export function bundledOffice(officeId: string): Office | null {
return BUNDLED_OFFICES.get(officeId) ?? null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export {
type MediaSignalService,
type MediaSignalServiceOptions,
} from "./service.ts";
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
export { bundledOffice, officeHasMediaBinding } from "./bindings.ts";
export {
createIceCredentialProvider,
deriveIceCredentialRateKeys,
+175
View File
@@ -0,0 +1,175 @@
/**
* `GET /api/v1/offices/:id/devices` and
* `POST /api/v1/offices/:id/devices/command`.
*
* The read is a sibling of `/presence` and behaves exactly like it: viewer
* first, then the office, 401 for anonymous, 404 for anything this caller may
* not see, and never a shared cache. The reasoning is written out at length in
* `routes/presence.ts` and is not repeated here; what follows is what is
* *different* about devices, which is the write.
*
* ### Two routes, and that is the security property
*
* A command never rides in the read body and a read never applies one. This is
* the first write surface in this product that changes something another viewer
* can see, and folding it into the GET would mean a body that mutates a
* microphone is a body a shared cache is entitled to keep and replay. That is
* precisely the outcome the fail-closed `private, no-store` default in
* `cache.ts` exists to prevent, and the way to not have that problem is to not
* have that shape: reads are GETs and are never cached, writes are POSTs with a
* body of their own.
*
* Neither route ever calls `publicCache`. Stated rather than merely omitted,
* because the absence of a call is not self-evidently a decision the same
* note `routes/presence.ts` leaves for the same reason.
*
* ### What a command is checked against
*
* The pack, resolved by this process. `devices/store.ts` builds a `Plan` and
* accepts a declaration only if the prop it is anchored to exists, is on the
* level it claims, and *is* the hardware the declaration names. So a command
* carries an id, and the id either names one of those or it does not nothing
* in the request describes the device, which means nothing in the request can
* describe it wrongly. `officeHasMediaBinding()` makes the identical move for a
* screen share.
*
* ### Reading is the demo; writing is the account
*
* An anonymous visitor gets 401 here and gets a **locally simulated studio**
* from `src/devices/adapter.ts` instead alive, labelled, and honest about
* what it is. That is the anon-first posture applied to a route that genuinely
* cannot be opened: the readings describe a room somebody is standing in, and
* a command changes it for everybody else in there. What an account buys is the
* shared room, not the demonstration.
*/
import type { FastifyInstance } from "fastify";
import { bundledOffice } from "../media/index.ts";
import { isDeviceCommandOp, type DeviceCommand } from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type {
DeviceCommandBody,
DeviceCommandResultBody,
ErrorBody,
} from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
const UNAUTHORIZED: ErrorBody = {
error: "unauthorized",
message: "Device state is for signed-in members.",
};
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." };
/**
* The largest command body this route will read, in bytes.
*
* A `DeviceCommand` is three short fields and the largest legitimate one is
* well under two hundred bytes. The cap is not about those it is about the
* body nobody meant to send, and it is set here rather than trusted to a global
* because this is the only route on the box that accepts one.
*/
const MAX_COMMAND_BYTES = 2048;
export function registerDevices(app: FastifyInstance, services: Services): void {
app.get<{ Params: { id: string } }>("/api/v1/offices/:id/devices", async (req, reply) => {
// Viewer first, before the id is looked at. The ordering is the property
// that stops this being an enumeration oracle — an anonymous caller gets
// the same 401 for a real office, a private one and an invented one — and
// `routes/presence.ts` explains why doing it the other way round is the bug.
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const office = await officeFor(services, req.params.id);
if (office === null) return reply.code(404).send(NOT_FOUND);
// No `publicCache`, ever. See the header.
return services.devices.current(office);
});
app.post<{ Params: { id: string }; Body: unknown }>(
"/api/v1/offices/:id/devices/command",
{ bodyLimit: MAX_COMMAND_BYTES },
async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const office = await officeFor(services, req.params.id);
if (office === null) return reply.code(404).send(NOT_FOUND);
const command = readCommand(req.body);
if (command === null) {
const error: ErrorBody = { error: "bad_request", message: "Not a device command." };
return reply.code(400).send(error);
}
const outcome = services.devices.command(office, command);
if (!outcome.ok) {
// One status and one message for every way a command can be wrong. A
// caller told *which* of its guesses missed is a caller being helped to
// guess again, and the honest audience for the distinction is the log.
req.log.info({ officeId: office.id, reason: outcome.reason }, "device command refused");
const error: ErrorBody = { error: "bad_request", message: "That command was refused." };
return reply.code(400).send(error);
}
const body: DeviceCommandResultBody = {
officeId: office.id,
device: outcome.device,
observedAt: outcome.device.observedAt,
};
return body;
},
);
}
/**
* The office this request is about, or `null` if there is not one this viewer
* may see.
*
* Served packs win over bundled ones, so an operator who has put their own
* `lumbridge-hq.json` in `TERA_OFFICES_DIR` gets theirs. A bundled pack is the
* fallback and not a leak: it is compiled into the browser bundle that made the
* request, so its authored device list is already in the caller's hands see
* `bundledOffice`. Without it, every deployment that has not configured an
* offices directory would answer 404 for the very studios it is rendering.
*
* Visibility is honoured the way `routes/offices.ts` honours it: a private pack
* is 404 to anyone who is not signed in. By the time this is called the viewer
* already is, so the check that remains is the one for a document that does not
* exist at all.
*/
async function officeFor(services: Services, id: string): Promise<Office | null> {
const doc = await services.offices.get(id);
if (doc !== null) return doc.floor;
return bundledOffice(id);
}
/**
* One command, read out of an untrusted body.
*
* Shape only. Whether the device exists, whether it accepts this op and whether
* the value is in range are all decided against the resolved pack in
* `devices/index.ts`, which is the only place that can decide them this
* function's whole job is to make sure there is something of the right shape to
* hand it.
*/
function readCommand(raw: unknown): DeviceCommand | null {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
const body = raw as Partial<DeviceCommandBody>;
const command: unknown = body.command;
if (command === null || typeof command !== "object" || Array.isArray(command)) return null;
const c = command as Record<string, unknown>;
if (typeof c.deviceId !== "string" || c.deviceId === "") return null;
if (!isDeviceCommandOp(c.op)) return null;
// A value of the wrong type is dropped rather than passed through, which
// makes it a *missing* value — and `normalizeDeviceCommand` refuses a command
// whose op needs one and has none. The refusal is therefore made in the one
// place that knows which ops need what, rather than half here.
const value = typeof c.value === "boolean" || typeof c.value === "number" ? c.value : undefined;
return { deviceId: c.deviceId, op: c.op, ...(value === undefined ? {} : { value }) };
}
+13 -4
View File
@@ -7,9 +7,17 @@
* 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 of a region for its whole TTL. Aircraft are not personal
* data and this body never varies by who asked only by where.
* Publicly cacheable **when the licence allows it**, because the whole design of
* the plan is that one response 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.
*
* The condition is not hypothetical caution. Handing a body to a shared cache is
* republication: the CDN serves it to people this box never spoke to, under
* whatever credit the body carries. So the answer comes from the body's own
* `redistributable` flag, which `flights/licence.ts` derived from the terms of
* the feed that answered and a body that may not be shared simply keeps the
* fail-closed `private, no-store` every reply starts with (CONTRACT.md §5).
*
* ### `radiusNm` on the query is ignored, deliberately
*
@@ -30,6 +38,7 @@
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import { mayRepublish } from "../flights/licence.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
@@ -43,7 +52,7 @@ export function registerFlights(app: FastifyInstance, services: Services): void
}
const body = await services.flights.current(resolved.region);
publicCache(req, reply, body.ttlSeconds);
if (mayRepublish(body)) publicCache(req, reply, body.ttlSeconds);
return body;
});
}
+10 -18
View File
@@ -11,36 +11,27 @@
* 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
* `regions` is a field of `HealthBody` for the same reason, and it is a field
* of `HealthBody` rather than of a local alias widening it next to this route:
* a body's shape stated anywhere but the wire contract is a shape the browser
* cannot read. Weather and flights now refuse a place this box does not serve,
* so a client that guesses `?city=` and earns 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: HealthBodyWithRegions = {
const body: HealthBody = {
ok: true,
service: "tera-api",
version: config.version,
@@ -50,6 +41,7 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
flights: config.flights.source,
satellites: config.satellites.source,
markers: config.markers.source,
devices: config.devices.source,
},
auth: {
mode: config.auth.mode,
+2 -2
View File
@@ -12,7 +12,7 @@ import type {
ScreenShareSignalRequest,
ScreenShareStopRequest,
} from "../../../src/media/signalingTypes.ts";
import { bundledMediaOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import { bundledOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import type { Services } from "../services.ts";
const BODY_LIMIT = 32 * 1024;
@@ -34,7 +34,7 @@ function fail(reply: FastifyReply, failure: { code: MediaSignalFailureCode; mess
async function bindingAllowed(binding: ScreenShareBinding, services: Services): Promise<boolean> {
const doc = await services.offices.get(binding.officeId);
const office = doc?.floor ?? bundledMediaOffice(binding.officeId);
const office = doc?.floor ?? bundledOffice(binding.officeId);
return office !== null && officeHasMediaBinding(office, binding);
}
+3
View File
@@ -8,6 +8,7 @@
*/
import { createAuth, type AuthService } from "./auth/index.ts";
import { createDevicesService, type DevicesService } from "./devices/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import {
@@ -29,6 +30,7 @@ export interface Services {
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
devices: DevicesService;
media: MediaSignalService;
ice: IceCredentialProvider;
offices: OfficeStore;
@@ -51,6 +53,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
devices: createDevicesService(config, log),
media: createMediaSignalService(),
ice: createIceCredentialProvider(config.ice),
offices: createOfficeStore(config.offices.dir),
+346
View File
@@ -0,0 +1,346 @@
/**
* The licence gate on `TERA_ADSB_ENDPOINT`.
*
* This is the test file for the one exposure in this repo that is not a
* feature. Before the gate, `TERA_ADSB_ENDPOINT` accepted any string; whatever
* came back was served from `/api/v1/flights` with `Cache-Control: public` and
* an `attribution` array that read "Aircraft positions from the adsb.lol
* community feed" hardcoded, next to the fetch, regardless of where the fetch
* went. A deployment was therefore one environment variable away from
* publishing somebody else's non-redistributable data, to a shared cache, under
* an open-terms credit belonging to a volunteer feed that had never seen it.
*
* Two properties are asserted here more than any other, because they are the
* two that were false:
*
* 1. **An allowlisted host is credited to itself.** Not to the default, not
* to the first entry in the table to the host that actually answered.
* 2. **A host that is not on the allowlist is refused**, loudly, rather than
* fetched and mis-credited. Refused means: no request, no live body, one
* sentence in `degraded[]` naming the host, and the simulated plan on the
* wire instead.
*
* Everything else in this file is a door on the same room: schemes, credentials,
* query strings, loopback, and the `redistributable` bit that decides whether a
* CDN is allowed to keep a copy.
*/
import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { createFlightsService } from "../flights/index.ts";
import {
ADSB_ALLOWLIST,
adsbAttribution,
checkAdsbEndpoint,
FIRST_PARTY_RECEIVER,
isOpenAdsbUrl,
mayRepublish,
type AdsbFeedTerms,
} from "../flights/licence.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";
const realFetch = globalThis.fetch;
let calls: string[] = [];
/** One aircraft, from whatever host was asked. Shape is the shared dump1090 one. */
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
calls.push(url);
if (!/\/v2\/point\//.test(url)) return new Response("nope", { status: 404 });
return new Response(
JSON.stringify({
now: 1_770_000_000_000,
ac: [{ hex: "a1b2c3", flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
});
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb", ...env });
config.logLevel = "silent";
return { config, app: buildApp(config) };
}
/**
* The URL an operator would write for this entry. Loopback speaks plain http,
* and an IPv6 literal has to be bracketed before it is a URL at all which is
* exactly the kind of detail a table-driven test finds and a hand-written one
* does not.
*/
function baseUrlFor(terms: AdsbFeedTerms): string {
const host = terms.host.includes(":") ? `[${terms.host}]` : terms.host;
return `${terms.loopback === true ? "http" : "https"}://${host}`;
}
/** The `degraded[]` lines this configuration produced, minus the ones about radius etc. */
function endpointLines(degraded: string[]): string[] {
return degraded.filter((line) => line.includes("TERA_ADSB_ENDPOINT"));
}
describe("an allowlisted endpoint", () => {
it("credits the host that actually answered, not the default", async () => {
// The exact bug, in one assertion: point the box at airplanes.live and the
// credit must say airplanes.live. It used to say adsb.lol.
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://api.airplanes.live" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(config.flights.source, "adsb");
assert.ok(body.mode === "live");
const credits = (body.attribution ?? []).join(" ");
assert.match(credits, /airplanes\.live/);
assert.doesNotMatch(credits, /adsb\.lol/);
assert.equal(calls[0], "https://api.airplanes.live/v2/point/37.7749/-122.4194/40");
});
it("credits every entry to itself, whichever one is configured", () => {
// Generalises the assertion above over the whole table, so an entry added
// later cannot inherit the previous one's credit by being copied.
for (const terms of ADSB_ALLOWLIST) {
const verdict = checkAdsbEndpoint(baseUrlFor(terms));
assert.ok(verdict.ok, `${terms.host} should be allowed`);
const credits = verdict.attribution.join(" ");
assert.ok(credits.includes(terms.credit), `${terms.host} must be credited to itself`);
for (const other of ADSB_ALLOWLIST) {
if (other.credit === terms.credit) continue;
assert.ok(
!credits.includes(other.credit),
`${terms.host} must not be credited to ${other.host}`,
);
}
}
});
it("states the licence as well as the source, which is what ODbL asks for", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol");
assert.ok(verdict.ok);
assert.equal(verdict.terms.licence, "ODbL-1.0");
assert.match(verdict.attribution.join(" "), /Open Database License/);
assert.match(verdict.attribution.join(" "), /opendatacommons\.org/);
});
it("passes its own gate on the default configuration", () => {
// A default that fails its own validation is a gate nobody can use.
const { config } = appWith({});
assert.equal(config.flights.source, "adsb");
assert.equal(config.flights.endpoint, "https://api.adsb.lol");
assert.deepEqual(endpointLines(config.degraded), []);
assert.equal(config.flights.redistributable, true);
});
it("normalises the endpoint so a trailing slash is not a second URL", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol///");
assert.ok(verdict.ok);
assert.equal(verdict.endpoint, "https://api.adsb.lol");
});
});
describe("an endpoint that is not on the allowlist", () => {
it("is refused rather than fetched and mis-credited", async () => {
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
after(() => app.close());
// Demoted, not fatal: CONTRACT.md §5.1. The map still works.
assert.equal(config.flights.source, "sim");
const lines = endpointLines(config.degraded);
assert.equal(lines.length, 1, "exactly one sentence about the endpoint");
assert.match(lines[0] ?? "", /flights\.example\.com/, "the line names the host");
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(body.mode, "plan");
assert.deepEqual(calls, [], "a refused endpoint is never fetched");
// The whole point: nobody is credited for data nobody served.
assert.equal(JSON.stringify(body).includes("adsb.lol"), false);
});
it("does not leave the refused URL anywhere later code could use it", () => {
const { config } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
assert.equal(config.flights.endpoint, "");
assert.deepEqual(config.flights.attribution, []);
assert.equal(config.flights.redistributable, false);
assert.equal(config.flights.licence, null);
});
it("refuses plain http off-box, where the path chooses what we publish", () => {
const verdict = checkAdsbEndpoint("http://api.adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /https/);
});
it("refuses a URL carrying credentials, because open feeds are keyless", () => {
const verdict = checkAdsbEndpoint("https://user:secret@api.adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /credential/);
});
it("refuses a query string rather than pasting it into the middle of a path", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol?key=hunter2");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /query string/);
});
it("refuses something that is not a URL at all", () => {
assert.ok(!checkAdsbEndpoint("api.adsb.lol").ok);
assert.ok(!checkAdsbEndpoint("").ok);
});
it("refuses a host that merely contains an allowlisted name", () => {
// The attack the allowlist has to survive: an exact-match table, never a
// substring test.
for (const host of ["api.adsb.lol.example.com", "notapi.adsb.lol", "adsb.lol.evil.test"]) {
const verdict = checkAdsbEndpoint(`https://${host}`);
assert.ok(!verdict.ok, `${host} must not pass`);
}
});
it("points a near miss at the entry it was probably reaching for", () => {
// `adsb.lol` serves a website; `api.adsb.lol` serves the API. An operator
// who types the first one deserves better than a flat refusal.
const verdict = checkAdsbEndpoint("https://adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /did you mean https:\/\/api\.adsb\.lol/);
});
});
describe("a receiver of one's own", () => {
it("takes loopback over plain http and credits the operator, not a feed", async () => {
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "http://127.0.0.1:8080" });
after(() => app.close());
assert.equal(config.flights.source, "adsb");
assert.equal(config.flights.licence, "first-party");
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live");
assert.match((body.attribution ?? []).join(" "), /own ADS-B receiver/);
assert.equal(calls[0], "http://127.0.0.1:8080/v2/point/37.7749/-122.4194/40");
});
it("says out loud that only the operator can vouch for a loopback claim", () => {
const { config } = appWith({ TERA_ADSB_ENDPOINT: "http://localhost:8080" });
const lines = endpointLines(config.degraded);
assert.equal(lines.length, 1);
assert.match(lines[0] ?? "", /loopback/);
});
it("credits the dump1090 file source from the same table", () => {
// Same data, different door. If these two ever disagree about who to
// credit, one of them is lying.
const config = loadConfig({
TERA_FLIGHTS_SOURCE: "dump1090",
TERA_DUMP1090_PATH: "/tmp/aircraft.json",
});
assert.equal(config.flights.source, "dump1090");
assert.deepEqual(config.flights.attribution, adsbAttribution(FIRST_PARTY_RECEIVER));
assert.equal(config.flights.licence, "first-party");
});
});
describe("what a shared cache is allowed to keep", () => {
it("lets a CDN hold a redistributable live body", async () => {
const { app } = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.match(res.headers["cache-control"] as string, /^public, max-age=\d+$/);
});
it("keeps a non-redistributable body private, which is the fail-closed default", () => {
// No entry in today's table is non-redistributable, and the flag exists so
// that adding one — a feed we may use but not re-serve — needs no change to
// the route. This is the branch that would then run.
const body: FlightsBody = {
mode: "live",
source: "adsb",
observedAt: 0,
aircraft: [],
ttlSeconds: 5,
redistributable: false,
};
assert.equal(mayRepublish(body), false);
assert.equal(mayRepublish({ ...body, redistributable: true }), true);
});
it("always lets the simulated plan be shared, because it is ours", () => {
const plan: FlightsBody = {
mode: "plan",
source: "sim",
t0: 0,
seed: 1,
routes: [],
ttlSeconds: 300,
};
assert.equal(mayRepublish(plan), true);
});
});
describe("the second lock, on the door itself", () => {
it("will not fetch a non-allowlisted endpoint even if one reaches the service", async () => {
// `config.ts` cannot produce this. A future caller building the endpoint
// from somewhere else could, and this is what happens when it does: no
// request, and the plan on the wire.
const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb" });
config.flights.endpoint = "https://flights.example.com";
const service = createFlightsService(config, { warn: () => {} });
const body = await service.current(config.regions[0]);
assert.equal(body.mode, "plan");
assert.deepEqual(calls, []);
});
it("knows an open feed's URL from any other", () => {
assert.equal(isOpenAdsbUrl("https://api.adsb.lol/v2/point/1/2/40"), true);
assert.equal(isOpenAdsbUrl("https://flights.example.com/v2/point/1/2/40"), false);
assert.equal(isOpenAdsbUrl("not a url"), false);
});
});
describe("the table itself", () => {
it("says where every claim it makes can be checked", () => {
for (const terms of ADSB_ALLOWLIST) {
assert.equal(terms.host, terms.host.toLowerCase(), `${terms.host} must be lowercase`);
assert.notEqual(terms.credit, "", `${terms.host} needs a credit line`);
assert.notEqual(terms.terms, "", `${terms.host} needs terms a human can read`);
assert.ok(
terms.licence === "ODbL-1.0" || terms.licence === "first-party",
`${terms.host} names a licence`,
);
// Anything off-box must be https-only; only a loopback entry may relax it.
if (terms.loopback !== true) {
assert.ok(!checkAdsbEndpoint(`http://${terms.host}`).ok, `${terms.host} must be https`);
}
}
});
it("carries no feed whose terms forbid what this box does with it", () => {
// A `redistributable: false` entry is legitimate, but it must never be
// reachable while the route is still handing bodies to a shared cache. That
// is `mayRepublish`'s job, and this asserts the two stay wired together.
for (const terms of ADSB_ALLOWLIST) {
const verdict = checkAdsbEndpoint(baseUrlFor(terms));
assert.ok(verdict.ok);
assert.equal(
mayRepublish({
mode: "live",
source: "adsb",
observedAt: 0,
aircraft: [],
ttlSeconds: 5,
redistributable: verdict.terms.redistributable,
}),
terms.redistributable,
);
}
});
});
+572
View File
@@ -0,0 +1,572 @@
/**
* The device routes: the refusal, the ordering, and the write.
*
* Three properties carry this file, and the third is the one that is new to
* this repo everything before devices was read-only.
*
* **The refusal.** Device state describes a room somebody is standing in, so
* the read takes a session unconditionally, exactly as occupancy does. An
* anonymous caller gets an identical 401 for a real office, a private one and
* an invented one, because the viewer is resolved before the id is looked at:
* check the office first and the status codes tell them apart perfectly, which
* is CONTRACT.md §6's enumeration oracle wearing a different number.
*
* **The cache.** Neither route may ever be publicly cached, in any
* configuration, including the ones that answer 401 and 404. A shared cache
* holding a body that took a credential to obtain is one viewer's studio served
* to the next; a shared cache holding a *command* is a microphone that can be
* switched on by replaying a request nobody made. Both are asserted, on every
* status code, because `cache.ts` is fail-closed by default and the way that
* default gets lost is a route quietly opting out.
*
* **The write.** A command is checked against the pack this process resolved
* and not against anything in the request: an id that names no authored
* declaration, an op the declaration never declared, or a value of the wrong
* type is a 400 and this is the assertion that matters **no state
* changes**. The read after the refused write is what proves it.
*/
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { forgetResolvedDevices, resolveDevices } from "../devices/store.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceState } from "../../../src/devices/types.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";
const SECRET = "not-a-real-secret-and-never-was";
const MIC = "tera:device.mic.desk";
const SPEAKER = "tera:device.speaker.desk";
/**
* A studio with two real props and four declarations, three of which are wrong
* in a different way.
*
* Written out rather than borrowed from a shipped pack on purpose: the shipped
* packs are the `packs` workstream's to change, and a route test that fails
* because somebody moved a desk in Los Angeles is a test nobody trusts. What is
* asserted here is the *rule*, and the rule needs a pack that deliberately
* breaks it.
*/
function studio(id: string): Office {
return {
id,
name: "Test studio",
viewpoints: [],
levels: [
{
id: "l1",
name: "Ground",
elevation: 0,
wallHeight: 3,
floorplan: {
rooms: [
{
id: "room",
name: "Room",
floor: "tera:carpet.loop",
outline: [
{ x: 0, z: 0 },
{ x: 8, z: 0 },
{ x: 8, z: 6 },
{ x: 0, z: 6 },
],
},
],
walls: [],
seats: [{ id: "desk-01", position: { x: 2, z: 2 }, facing: 0 }],
props: [
{ id: "mic-prop", kind: MIC, position: { x: 2, z: 2 }, rotation: 0 },
{ id: "speaker-prop", kind: SPEAKER, position: { x: 3, z: 2 }, rotation: 0 },
{ id: "desk-prop", kind: "tera:desk.workstation", position: { x: 2, z: 2.4 }, rotation: 0 },
],
devices: [
{
id: "mic-1",
kind: "mic",
label: "Desk mic",
assetId: MIC,
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
capabilities: ["power", "mute", "gain", "level"],
provenance: "simulated",
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
},
{
id: "speaker-1",
kind: "speaker",
label: "Monitor",
assetId: SPEAKER,
anchor: { levelId: "l1", propId: "speaker-prop" },
capabilities: ["power", "volume", "playback"],
provenance: "simulated",
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
},
// Standing on a desk rather than being its own prop. This is the
// ordinary case and it is **accepted**: `DeviceAnchor.offset` exists
// for exactly those few centimetres, and a desk claims to be no
// instrument at all, so there is nothing for it to disagree with.
{
id: "desk-mounted-mic",
kind: "mic",
label: "Boom mic",
assetId: MIC,
anchor: { levelId: "l1", propId: "desk-prop", offset: { x: 0, y: 0.74, z: 0.1 } },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated studio hardware.",
},
// Anchored to another instrument's hardware. A microphone bolted to
// a speaker is not a rendering nit — it is a command routed to the
// wrong instrument in a real room — and it is dropped.
{
id: "mic-on-a-speaker",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "speaker-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated.",
},
// Anchored to a prop that does not exist.
{
id: "mic-nowhere",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "no-such-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated.",
},
// Says it is simulated in its provenance and not in its words. The
// same check `resolveRobotOperations` makes, applied by
// `validateDeviceDeclaration` and enforced here.
{
id: "mic-undisclosed",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "mic-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "A microphone.",
},
],
},
},
],
} as unknown as Office;
}
let offices = "";
before(async () => {
offices = await mkdtemp(join(tmpdir(), "tera-devices-"));
await writeFile(
join(offices, "open.json"),
JSON.stringify({ id: "open", name: "Open", visibility: "public", floor: studio("open") }),
);
await writeFile(
join(offices, "closed.json"),
JSON.stringify({ id: "closed", name: "Closed", visibility: "private", floor: studio("closed") }),
);
await writeFile(
join(offices, "bare.json"),
JSON.stringify({
id: "bare",
name: "Bare",
visibility: "public",
floor: { id: "bare", name: "Bare", levels: [], viewpoints: [] },
}),
);
});
const jwt = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET };
const sim = { TERA_DEVICES_SOURCE: "sim" };
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_OFFICES_DIR: offices, ...env });
config.logLevel = "silent";
return buildApp(config);
}
function hs256(claims: Record<string, unknown>): string {
const encode = (value: unknown): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}
function bearer(): { authorization: string } {
return {
authorization: `Bearer ${hs256({ sub: "someone", exp: Math.floor(Date.now() / 1000) + 3600 })}`,
};
}
const url = (id: string) => `/api/v1/offices/${id}/devices`;
describe("the refusal", () => {
it("refuses an anonymous read even for a public office", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open") });
assert.equal(res.statusCode, 401);
assert.equal(res.headers["www-authenticate"], "Bearer");
// The point: the office itself is public and readable by this same caller.
const doc = await app.inject({ method: "GET", url: "/api/v1/offices/open" });
assert.equal(doc.statusCode, 200);
});
it("refuses an anonymous command", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 401);
});
it("tells a real, a private and an absent office apart for nobody", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const real = await app.inject({ method: "GET", url: url("open") });
const priv = await app.inject({ method: "GET", url: url("closed") });
const gone = await app.inject({ method: "GET", url: url("no-such-thing") });
assert.equal(real.statusCode, 401);
assert.equal(priv.statusCode, 401);
assert.equal(gone.statusCode, 401);
assert.deepEqual(real.json(), priv.json());
assert.deepEqual(real.json(), gone.json());
});
it("answers 404, never 403, for an office a signed-in caller cannot see", async () => {
// `offices/store.ts` refuses an id that is not filename-shaped, which is the
// same 404 by a different door — both are "there is no office here".
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const gone = await app.inject({ method: "GET", url: url("no-such-thing"), headers: bearer() });
assert.equal(gone.statusCode, 404);
assert.equal(gone.json().error, "not_found");
const traversal = await app.inject({ method: "GET", url: url(".."), headers: bearer() });
assert.ok(traversal.statusCode === 404 || traversal.statusCode === 400);
});
});
describe("the cache", () => {
it("never lets a shared cache keep device state, whatever the answer was", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const anonymous = await app.inject({ method: "GET", url: url("open") });
const missing = await app.inject({ method: "GET", url: url("nope"), headers: bearer() });
const ok = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
const commanded = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
for (const res of [anonymous, missing, ok, commanded]) {
assert.equal(res.headers["cache-control"], "private, no-store");
}
});
});
describe("what the pack is allowed to declare", () => {
it("serves only the declarations whose hardware is really there", () => {
forgetResolvedDevices();
const { declarations, authored } = resolveDevices(studio("open"));
assert.deepEqual(
declarations.map((d) => d.id),
["mic-1", "speaker-1", "desk-mounted-mic"],
);
// Every one of the six was authored, which is what lets the service tell
// "no devices" from "no devices survived".
assert.equal(authored, 6);
});
it("accepts a microphone standing on a desk, and refuses one bolted to a speaker", () => {
forgetResolvedDevices();
const { declarations } = resolveDevices(studio("open"));
// The rule is `Plan`'s and there is deliberately no second copy of it here:
// a stricter one on this side would drop a legitimate pack's devices from
// the API while the browser went on drawing them, which is the worst kind
// of disagreement — a populated panel whose every command is refused.
assert.ok(declarations.some((d) => d.id === "desk-mounted-mic"));
assert.equal(declarations.find((d) => d.id === "mic-on-a-speaker"), undefined);
assert.equal(declarations.find((d) => d.id === "mic-nowhere"), undefined);
});
it("drops a simulated device whose disclosure does not say so", () => {
forgetResolvedDevices();
const { declarations } = resolveDevices(studio("open"));
assert.equal(declarations.find((d) => d.id === "mic-undisclosed"), undefined);
});
it("is a 200 and an empty list for an office that declares nothing", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("bare"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.deepEqual(body.devices, []);
// Not a 404: "no such office" and "no hardware in this office" are
// different facts with different fixes.
assert.equal(body.officeId, "bare");
});
});
describe("what a signed-in member reads", () => {
it("serves one state per surviving declaration, all marked synthetic", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.equal(body.source, "sim");
assert.equal(body.synthetic, true);
assert.ok(body.ttlSeconds > 0);
assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
for (const device of body.devices) assert.equal(device.synthetic, true);
// Readings follow the declaration, not the kind: this mic declared `gain`
// and `level`, this speaker declared neither.
const mic = body.devices[0] as DeviceState;
assert.equal(typeof mic.gainDb, "number");
assert.equal(typeof mic.levelDb, "number");
const speaker = body.devices[1] as DeviceState;
assert.equal(speaker.levelDb, undefined);
assert.equal(typeof speaker.volume, "number");
});
it("reports no hardware at all when the box has no device source", async () => {
const app = appWith(jwt);
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.equal(body.source, "none");
// Empty rather than invented. A box nobody has configured has no hardware,
// and that is the truth rather than a degraded picture of one.
assert.deepEqual(body.devices, []);
});
});
describe("the command", () => {
it("applies one, and the next read shows it", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const before = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal((before.json() as DevicesBody).devices[0]?.powered, false);
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 200);
assert.equal(res.json().device.powered, true);
const then = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal((then.json() as DevicesBody).devices[0]?.powered, true);
});
it("clamps a value into the declared range rather than refusing it", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "gain", value: 400 } },
});
assert.equal(res.statusCode, 200);
// 36 dB is the top of `DEVICE_RANGES.gain`. A slider reporting 400 is a
// caller who overshot, not an attack — and the body says what the hardware
// actually did, so the control can snap to it.
assert.equal(res.json().device.gainDb, 36);
});
it("refuses a device that does not name an authored declaration, and changes nothing", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
// Deliberately the id of a declaration the pack *did* write and the server
// dropped, because its prop is a desk. It exists in the file and it must
// not be commandable.
for (const deviceId of ["mic-on-a-speaker", "mic-nowhere", "mic-undisclosed", "invented"]) {
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId, op: "power", value: true } },
});
assert.equal(res.statusCode, 400, deviceId);
assert.equal(res.json().error, "bad_request");
}
const read = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
const body = read.json() as DevicesBody;
assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
// Nothing was created and nothing was switched on by any of that.
assert.equal(body.devices.every((d) => d.powered === false), true);
});
it("refuses an op the declaration never declared", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
// The speaker declares power, volume and playback. `gain` is a microphone's.
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "speaker-1", op: "gain", value: 4 } },
});
assert.equal(res.statusCode, 400);
});
it("refuses a body that is not a command", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const bodies: unknown[] = [
{},
{ command: null },
{ command: [] },
{ command: { op: "power", value: true } },
{ command: { deviceId: "mic-1", op: "explode", value: true } },
{ command: { deviceId: "mic-1", op: "power" } },
{ command: { deviceId: "mic-1", op: "power", value: "yes" } },
{ command: { deviceId: "mic-1", op: "gain", value: true } },
];
for (const payload of bodies) {
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: payload as never,
});
assert.equal(res.statusCode, 400, JSON.stringify(payload));
assert.equal(res.json().error, "bad_request");
}
});
it("refuses a body that is not JSON at all, as the caller's mistake", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: { ...bearer(), "content-type": "text/plain" },
payload: "turn the microphone on please",
});
// 400 or 415 depending on which of Fastify's own checks fires first, and
// the assertion that matters is neither of those numbers: it is that this
// is a 4xx carrying the one error shape rather than a 500. This route was
// the first body on the box, and until it existed the error handler
// reported every framework refusal as a server fault.
assert.ok(res.statusCode >= 400 && res.statusCode < 500, `status ${res.statusCode}`);
assert.equal(res.json().error, "bad_request");
});
it("refuses a command body larger than a command could be", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true }, padding: "x".repeat(4096) },
});
assert.equal(res.statusCode, 413);
assert.equal(res.json().error, "bad_request");
});
it("refuses a command for an office that does not exist", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("no-such-thing")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 404);
});
it("refuses every command when the box has no device source", async () => {
const app = appWith(jwt);
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 400);
});
});
describe("health says whether asking is worth it", () => {
it("names the device source and the served regions", async () => {
const app = appWith(sim);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.sources.devices, "sim");
assert.ok(Array.isArray(body.regions));
assert.ok(body.regions.length > 0);
});
it("says none on a box nobody configured, without calling it a demotion", async () => {
const app = appWith({});
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
assert.equal(body.sources.devices, "none");
// The default is a choice, not a failure. A zero-config box's `degraded`
// list stays empty, which `check-zero-config-boot.mjs` also asserts.
assert.deepEqual(body.degraded, []);
});
it("demotes a source it cannot honour and says which", async () => {
const app = appWith({ TERA_DEVICES_SOURCE: "homeassistant" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
assert.equal(body.sources.devices, "none");
assert.equal(
body.degraded.filter((line: string) => line.includes("TERA_DEVICES_SOURCE")).length,
1,
);
});
});