Spaces: the inside of the world, and a sun that is actually where it should be

Ten agents wrote this in parallel against CONTRACT.md, which exists because the
five design agents before them collided on fifteen blocking points — four files
specified twice with incompatible contents, three separate backends for one box,
and `Environment` exported twice meaning different things.

What landed: a Stage owning only the renderer and the loop, with the city and an
office as two scenes over it. They cannot share one — San Francisco is ~94 m per
scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and
the city is paused rather than disposed on the way in, because rebuilding its
336,864-point heightfield costs about a second on the way back out.

Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six
seats, and it is the file a self-hoster copies. Walls are a segment list with
1-D openings, so doors and windows are holes punched in a wall rather than
placed objects, and the pass that splits a wall around its openings hands the
walk-mode collider its segments for free.

The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at
all — not even three.js — so time of day keeps working on a laptop in a field.
Verified against known values: 75.45 degrees at the June solstice in SF, 28.79
at December, sunset at 03:15Z. The first screenshot after wiring it was a black
rectangle, which turned out to be correct: it was midnight in San Francisco.

Presence binds to a seat id and never to a coordinate. The pack knows where
`eng-04` is; who is sitting in it is private data behind an API. Same shape as
the marker rule, one level in.

Two corrections to ARCHITECTURE.md are in here. Containment does not discharge
ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database
wherever the rows live, so the rule is about the geocoder (US Census, public
domain) and not the storage. And a person at a desk is not a Marker; markers are
geographic.

One contract gap surfaced only in a screenshot: two agents read `height` on a
viewpoint differently, so the establishing shot aimed at empty air fourteen
metres above the roof. It now means what the same field means for a city.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 00:11:01 -07:00
parent 36471bbad7
commit d464459838
77 changed files with 14266 additions and 216 deletions
+38
View File
@@ -0,0 +1,38 @@
# The Tera API, as one small image.
#
# TypeScript is not compiled: Node runs the .ts sources directly by stripping
# types at load, which is why there is no build stage, no dist/ and no source
# maps to keep in sync. It is also why `erasableSyntaxOnly` is on in
# server/tsconfig.json — an enum or a parameter property would break the runtime
# and not the typecheck, which is the worst possible order to find out.
#
# The build context is the repo root, because the server's type contract lives
# in the root package at src/server/wire.ts.
FROM node:24-alpine
WORKDIR /app
# The lockfile and both manifests, so the layer cache survives a source change.
COPY package.json package-lock.json ./
COPY server/package.json ./server/
# Only the server workspace's production dependencies. Without --workspace this
# would also install three.js, which the API has no use for.
RUN npm ci --omit=dev --workspace @lumbridge/tera-api
# The whole browser package's source, for the sake of src/server/wire.ts. Every
# import of it is type-only and is erased before anything is loaded, so none of
# this is read at runtime — it is here so that a typecheck inside the image tells
# the truth.
COPY src ./src
COPY server/src ./server/src
ENV NODE_ENV=production
# Inside a container, loopback is a different loopback. This is the only reason
# TERA_HOST exists.
ENV TERA_HOST=0.0.0.0
EXPOSE 8431
USER node
CMD ["node", "server/src/index.ts"]
+12
View File
@@ -0,0 +1,12 @@
# The build context is the repo root, so this keeps a 300 MB node_modules and a
# built dist/ out of the daemon. BuildKit reads `<dockerfile>.dockerignore`
# before the context's own .dockerignore, which is what lets this live beside
# the Dockerfile it belongs to instead of at the root of somebody else's repo.
node_modules
**/node_modules
dist
.git
public/logos
public/offices
public/props
public/kits
+191
View File
@@ -0,0 +1,191 @@
# The Tera API
One Fastify service on `127.0.0.1:8431`, serving `/api/v1/*` behind Caddy. It
answers with flight plans, weather, a public marker snapshot and office packs,
and it does all of it with a single runtime dependency.
**It boots with no configuration at all.** No account, no Supabase project, no
API key, no network. That is not a nice property, it is the acceptance test —
`src/test/boot.test.ts` starts this server under a genuinely empty environment
and asks it for its health, and CI does the same thing from outside the
container.
```bash
npm ci # from the repo root; server/ is a workspace
npm start -w @lumbridge/tera-api
curl -s localhost:8431/api/v1/health | jq
```
There is no build step. Node runs the TypeScript sources directly by stripping
types at load, which needs **Node ≥ 22.18** and is why `erasableSyntaxOnly` is
set in `tsconfig.json` — an enum or a parameter property would break the runtime
without breaking the typecheck, which is the wrong order to find out.
## Routes
| route | body | cached |
| --- | --- | --- |
| `GET /api/v1/health` | `HealthBody` | never |
| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` |
| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` |
| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` |
| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only |
Every body is declared once, in `src/server/wire.ts` in the **root** package —
type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other.
`Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every
reply before any route runs, and a route opts in explicitly. A request that
arrived with an `Authorization` header or a cookie never gets a public policy,
whatever the route asked for.
## Configuration
Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A
source configured without what it needs is demoted, not fatal: the server logs
one loud `TERA DEGRADED:` line, serves the fallback, and lists the demotion in
the `degraded` array on `/api/v1/health`. Two earlier designs failed to boot on a
missing weather contact string; this is the correction. (CONTRACT.md §5.1.)
| variable | default | what it does |
| --- | --- | --- |
| `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. |
| `TERA_PORT` | `8431` | |
| `TERA_LOG_LEVEL` | `info` | |
| `TERA_ORIGIN_LAT` / `_LNG` | SF | The city this box serves. Weather point and flight-plan centre. |
| `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. |
| `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. |
### Weather
| variable | default | what it does |
| --- | --- | --- |
| `TERA_WEATHER_SOURCE` | `none` | `none`, `nws`, `metno`, `openmeteo`. |
| `TERA_WEATHER_CONTACT` | *(empty)* | An email or URL. Required by `nws` and `metno`. |
| `TERA_WEATHER_TTL` | `600` | Seconds between upstream fetches. |
- **`nws`** — api.weather.gov. US only, keyless, and its output is a US
government work in the public domain, so nothing downstream of it owes anybody
attribution. The default *once a contact is set*.
- **`metno`** — the global fallback. CC BY 4.0, so the body carries an
`attribution` array the consumer is expected to display.
- **`openmeteo`** — opt-in and off by default. The data is CC BY 4.0, but the
free tier is non-commercial, which is the wrong default for a product page.
Turning it on records a line in `degraded` saying so. (CONTRACT.md §5.2.)
`none` — the default — serves a synthetic clear day with `synthetic: true`. That
is a supported steady state, not an error path.
### Flights
| variable | default | what it does |
| --- | --- | --- |
| `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. |
| `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. |
| `TERA_ADSB_RADIUS_NM` | `40` | |
| `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. |
| `TERA_FLIGHTS_TTL` | `300` | Clamped to 15 s for live sources. |
| `TERA_FLIGHTS_SEED` | `4711` | |
The simulated source is served as a **route plan**, not as positions: the routes,
a fixed phase origin and a seed, which every browser evaluates in closed form
against wall-clock time. One cacheable request replaces a poll per second, and
two people on different machines see the same aircraft in the same places.
There is no FlightRadar24 client and there will not be one — their terms forbid
scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would
be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box
you own is the best of the three sources anyway: first-party data with nothing to
comply with. (ARCHITECTURE.md §4.)
### Markers
| variable | default | what it does |
| --- | --- | --- |
| `TERA_MARKERS_SOURCE` | `none` | `none` or `file`. |
| `TERA_MARKERS_FILE` | *(empty)* | JSON snapshot written by the sync oneshot. |
| `TERA_MARKERS_PROVENANCE_ALLOWLIST` | `us-census,hand-placed,synthetic` | |
| `TERA_MARKERS_TTL` | `300` | |
The API serves a file. It holds no database and no credential, and private
per-user markers are never proxied through it — an authenticated browser calls
Workie directly with its own token, so a private row never enters this process.
**Every row must declare where its coordinate came from, and the gate refuses
anything not on the allowlist.** Serving a snapshot of geocoded coordinates is
Public Use of a Derivative Database; if those coordinates came from Nominatim,
ODbL §4.3 and §4.4 attach to everything served alongside them, no matter where
the rows are stored. Google, Mapbox and HERE are not an escape either — their
terms restrict storing and redistributing what they return. The sanctioned
geocoder is the US Census Geocoder, whose output is public domain. (CONTRACT.md
§8. Adding to the allowlist is a licence decision, not a config tweak.)
A row carrying a field the gate does not recognise is refused whole rather than
trimmed, and the refusal counts are served on the wire so a broken sync is
visible from outside instead of only in a log.
### The sync oneshot
```bash
TERA_SYNC_SOURCE_URL=https://workie.example/api/public/markers \
TERA_SYNC_TOKEN=... \
TERA_MARKERS_FILE=/var/lib/tera/markers.json \
npm run sync -w @lumbridge/tera-api
```
The only second process, and the only holder of a credential. It runs on a
timer, puts every row through the same gate, and writes the snapshot atomically.
**One refused row aborts the whole sync and leaves the previous snapshot in
place** — a stale map is a cheap mistake, and publishing coordinates whose
licence nobody can vouch for is not one that a later fix undoes.
`TERA_SYNC_PROVENANCE` asserts a provenance for rows that arrive without one.
Setting it is a licence claim you are making on the record.
### Offices and auth
| variable | default | what it does |
| --- | --- | --- |
| `TERA_OFFICES_DIR` | *(empty)* | One `<id>.json` per office. Empty means no offices. |
| `TERA_AUTH_MODE` | `none` | `none`, `sso`, `jwt`. |
| `TERA_AUTH_ENTRY_URL` | *(empty)* | Where a browser sends someone to sign in. `sso`. |
| `TERA_AUTH_REVALIDATE_URL` | *(empty)* | Server-side token check. `sso`. |
| `TERA_AUTH_COOKIE` | `tera_session` | Cookie a session may arrive in. |
| `TERA_AUTH_JWT_SECRET` | *(empty)* | HS256 shared secret. `jwt`. |
| `TERA_AUTH_JWT_VERIFY` | `hs256` | Set to `jwks` for asymmetric verification. |
| `TERA_AUTH_JWKS_URL` | *(empty)* | |
| `TERA_AUTH_JWT_ISSUER` / `_AUDIENCE` | *(empty)* | Checked when set. |
A self-hoster gets `none`, an open office, and never creates an account
anywhere. `sso` is what Lumbridge's own deployment uses: this world holds **no
credentials**, only an entry URL and a revalidate URL, and enforcement happens
here on the server.
Where a JWT is verified directly, **HS256 against a shared secret is the primary
path** and JWKS sits behind an env switch. That ordering comes from verified
fact rather than taste: the issuer this runs against signs `{"alg":"HS256"}`, and
a JWKS-only implementation would reject every real token. (CONTRACT.md §6.)
**A private office returns 404, not 403** — byte-identical to an office that was
never created — so the endpoint cannot be used to enumerate what exists. A pack
that does not declare its visibility is treated as private.
## Deploying
Three files in `../deploy`, and exactly one of each:
- `Caddyfile.snippet``import tera_api` into the site that serves the build.
- `tera-api.service` — systemd, with an *optional* environment file so the unit
starts on a box where nobody wrote one.
- `docker-compose.yml``cd deploy && docker compose up`, under `env -i`.
## Tests
```bash
npm test -w @lumbridge/tera-api
npm run typecheck -w @lumbridge/tera-api
```
The three that are load-bearing: the empty-environment boot, the provenance
gate, and the office 404.
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@lumbridge/tera-api",
"version": "0.1.0",
"private": true,
"description": "The one Tera API. Health, flights, weather, markers and office packs on 127.0.0.1:8431.",
"license": "Apache-2.0",
"type": "module",
"main": "src/index.ts",
"engines": {
"node": ">=22.18"
},
"scripts": {
"start": "node src/index.ts",
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node --test \"src/test/*.test.ts\"",
"sync": "node src/sync/workie.ts"
},
"dependencies": {
"fastify": "^5.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.8.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* The one server.
*
* One Fastify instance on `127.0.0.1:8431` serving `/api/v1/*`, behind Caddy,
* with one systemd unit and one Caddy snippet. Three separate backends were
* designed for this box — three ports, three frameworks, three deploy files that
* would have overwritten each other — and this is the one that replaces them.
* Weather, flights, markers and offices are route modules here, not services on
* ports of their own. CONTRACT.md §5.
*
* `buildApp` never listens. Tests build one and use `inject()`; `index.ts` builds
* one and binds it.
*/
import Fastify, { type FastifyInstance } from "fastify";
import { registerCachePolicy } from "./cache.ts";
import { loadConfig, type Config } from "./config.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts";
import type { ErrorBody } from "../../src/server/wire.ts";
export function buildApp(config: Config = loadConfig()): FastifyInstance {
const app = Fastify({
logger: { level: config.logLevel },
// Caddy is the only thing that talks to this socket, so its X-Forwarded-For
// is the client address. Nothing else can reach the port to forge one.
trustProxy: true,
});
// One loud line per demotion, at boot, before anything can be served. The
// health body carries the same list for anyone without log access.
for (const line of config.degraded) {
app.log.warn(`TERA DEGRADED: ${line}`);
}
registerCachePolicy(app);
registerCors(app, config.corsOrigins);
const services = createServices(config, app.log);
registerHealth(app, services);
registerFlights(app, services);
registerWeather(app, services);
registerMarkers(app, services);
registerOffices(app, services);
app.setNotFoundHandler(async (_req, reply) => {
const body: ErrorBody = { error: "not_found", message: "No such route." };
return reply.code(404).send(body);
});
app.setErrorHandler(async (err, _req, reply) => {
app.log.error({ err }, "unhandled error");
const body: ErrorBody = { error: "internal", message: "Something went wrong." };
return reply.code(500).send(body);
});
return app;
}
/**
* CORS, by hand.
*
* The default is an empty allowlist — the browser build is served from the same
* origin through Caddy, so nothing needs this until somebody runs the dev server
* on a different port and sets `TERA_CORS_ORIGIN`. A plugin whose entire job is
* fifteen lines of header-setting is not worth the dependency, and the fifteen
* lines being visible here is worth something on a route that decides who may
* read the API from where.
*/
function registerCors(app: FastifyInstance, allowed: string[]): void {
if (allowed.length === 0) return;
app.addHook("onRequest", async (req, reply) => {
const origin = req.headers.origin;
if (typeof origin !== "string" || !allowed.includes(origin)) return;
reply.header("access-control-allow-origin", origin);
reply.header("access-control-allow-headers", "authorization, content-type");
reply.header("access-control-allow-methods", "GET, OPTIONS");
// Whatever the response ends up being, it depended on the Origin header.
reply.header("vary", "Origin");
if (req.method === "OPTIONS") await reply.code(204).send();
});
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Who is asking — in three modes, two of which most deployments never turn on.
*
* - **`none`** is the default and the one the acceptance test runs. Everything
* this box serves is public, nobody has an account anywhere, and a private
* office simply does not exist as far as the API is concerned.
* - **`sso`** is what Lumbridge's own deployment uses. This world holds **no
* credentials**: it is handed an entry URL to send people to and a
* revalidate URL to ask about a token, and the answer comes back from the
* thing that issued the session. Reusing the pattern already running on the
* fleet means there is no second identity system to keep secure, and both ends
* are env vars, which is what a dev kit needs. CONTRACT.md §6.
* - **`jwt`** verifies a token here, for a deployment that would rather not make
* an outbound call per request. See `jwt.ts` for why HS256 is the primary path.
*
* Enforcement is on the server in all three. A viewer object that says
* `authenticated: false` is the only thing a route ever sees, and the route
* answers 404 — never 403 — so the endpoint cannot be used to enumerate what
* exists.
*/
import { createHash } from "node:crypto";
import type { FastifyRequest } from "fastify";
import type { AuthConfig } from "../config.ts";
import { verifyJwt } from "./jwt.ts";
export interface Viewer {
authenticated: boolean;
/** Stable subject id where one is known. Never a token, never an email. */
subject: string | null;
}
export interface AuthService {
resolve(req: FastifyRequest): Promise<Viewer>;
}
const ANONYMOUS: Viewer = { authenticated: false, subject: null };
/** Positive revalidations are held briefly; negative ones are not held at all. */
const SESSION_TTL_MS = 60_000;
export function createAuth(config: AuthConfig): AuthService {
const sessions = new Map<string, { viewer: Viewer; checkedAt: number }>();
async function revalidate(token: string): Promise<Viewer> {
// The cache is keyed on a hash so that a heap dump, a debugger or a stray
// log line never contains a usable session token.
const key = createHash("sha256").update(token).digest("hex");
const hit = sessions.get(key);
if (hit !== undefined && Date.now() - hit.checkedAt < SESSION_TTL_MS) return hit.viewer;
let viewer = ANONYMOUS;
try {
const res = await fetch(config.revalidateUrl, {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(4000),
});
if (res.ok) {
const body = (await res.json().catch(() => null)) as { sub?: unknown } | null;
const sub = typeof body?.sub === "string" ? body.sub : null;
viewer = { authenticated: true, subject: sub };
}
} catch {
// An unreachable identity service means nobody is authenticated. That is
// the safe direction, and it is why this returns a viewer rather than
// throwing: the route still answers, it just answers 404.
return ANONYMOUS;
}
if (viewer.authenticated) sessions.set(key, { viewer, checkedAt: Date.now() });
return viewer;
}
return {
async resolve(req: FastifyRequest): Promise<Viewer> {
if (config.mode === "none") return ANONYMOUS;
const token = bearerToken(req) ?? cookieToken(req, config.cookieName);
if (token === null) return ANONYMOUS;
if (config.mode === "sso") return revalidate(token);
const claims = await verifyJwt(token, config);
if (claims === null) return ANONYMOUS;
return { authenticated: true, subject: typeof claims.sub === "string" ? claims.sub : null };
},
};
}
function bearerToken(req: FastifyRequest): string | null {
const header = req.headers.authorization;
if (typeof header !== "string") return null;
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
return match?.[1] ?? null;
}
/**
* Cookies are parsed by hand rather than with a plugin. One header, one split,
* and the alternative is a dependency whose entire job is this function.
*/
function cookieToken(req: FastifyRequest, name: string): string | null {
const header = req.headers.cookie;
if (typeof header !== "string") return null;
for (const pair of header.split(";")) {
const eq = pair.indexOf("=");
if (eq === -1) continue;
if (pair.slice(0, eq).trim() !== name) continue;
const value = pair.slice(eq + 1).trim();
return value === "" ? null : decodeURIComponent(value);
}
return null;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* JWT verification, with HS256 as the primary path.
*
* That ordering is a correction from verified fact rather than a preference. The
* issuer this runs against — the fleet's Supabase — signs `{"alg":"HS256"}`, so
* a JWKS-only implementation would reject every real token that ever arrived.
* Asymmetric verification is here and works, but it sits behind
* `TERA_AUTH_JWT_VERIFY=jwks`. CONTRACT.md §6.
*
* Written against `node:crypto` rather than a JWT library on purpose: HS256 is
* an HMAC and a handful of claim checks, and this service's dependency list is
* short enough to read in one breath. The parts that are easy to get wrong —
* verifying before parsing, comparing signatures in constant time, rejecting
* `alg: none` and rejecting an algorithm the operator did not ask for — are all
* below and all deliberate.
*/
import { createHmac, createPublicKey, timingSafeEqual, verify as cryptoVerify } from "node:crypto";
import type { KeyObject } from "node:crypto";
import { getJson } from "../http.ts";
import type { AuthConfig } from "../config.ts";
export interface Claims {
sub?: string;
exp?: number;
nbf?: number;
iss?: string;
aud?: string | string[];
[claim: string]: unknown;
}
/** Sixty seconds of tolerance for clocks that disagree, which they do. */
const CLOCK_SKEW_SECONDS = 60;
export async function verifyJwt(token: string, config: AuthConfig): Promise<Claims | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerPart, payloadPart, signaturePart] = parts;
if (headerPart === undefined || payloadPart === undefined || signaturePart === undefined) {
return null;
}
const header = decodeJson<{ alg?: string; kid?: string }>(headerPart);
if (header === null) return null;
const signed = `${headerPart}.${payloadPart}`;
const signature = Buffer.from(signaturePart, "base64url");
const signatureOk =
config.jwtVerify === "jwks"
? await verifyAsymmetric(header, signed, signature, config)
: verifyHs256(header.alg, signed, signature, config.jwtSecret);
if (!signatureOk) return null;
// Only now is the payload worth reading. Parsing claims out of an unverified
// token and checking the signature afterwards is how `alg: none` bugs happen.
const claims = decodeJson<Claims>(payloadPart);
if (claims === null) return null;
return claimsValid(claims, config) ? claims : null;
}
function verifyHs256(
alg: string | undefined,
signed: string,
signature: Buffer,
secret: string,
): boolean {
// Pinned, not merely checked against a list: an operator who configured a
// shared secret has said what algorithm they expect, and accepting anything
// else here is the classic confusion attack.
if (alg !== "HS256" || secret === "") return false;
const expected = createHmac("sha256", secret).update(signed).digest();
if (expected.length !== signature.length) return false;
return timingSafeEqual(expected, signature);
}
// ---- JWKS -----------------------------------------------------------------
interface Jwk {
kid?: string;
kty?: string;
alg?: string;
[field: string]: unknown;
}
const ASYMMETRIC: Record<string, { algorithm: string; ieeeP1363: boolean }> = {
RS256: { algorithm: "RSA-SHA256", ieeeP1363: false },
RS384: { algorithm: "RSA-SHA384", ieeeP1363: false },
RS512: { algorithm: "RSA-SHA512", ieeeP1363: false },
ES256: { algorithm: "SHA256", ieeeP1363: true },
ES384: { algorithm: "SHA384", ieeeP1363: true },
};
const keyCache = new Map<string, KeyObject>();
let keysFetchedAt = 0;
async function verifyAsymmetric(
header: { alg?: string; kid?: string },
signed: string,
signature: Buffer,
config: AuthConfig,
): Promise<boolean> {
const alg = header.alg ?? "";
const spec = ASYMMETRIC[alg];
if (spec === undefined) return false;
const key = await resolveKey(header.kid ?? "", config.jwksUrl);
if (key === null) return false;
try {
return cryptoVerify(spec.algorithm, Buffer.from(signed), {
key,
// ECDSA signatures in a JWT are the raw r‖s pair, not the DER sequence
// OpenSSL expects. Without this, every ES256 token fails to verify.
...(spec.ieeeP1363 ? { dsaEncoding: "ieee-p1363" as const } : {}),
}, signature);
} catch {
return false;
}
}
async function resolveKey(kid: string, jwksUrl: string): Promise<KeyObject | null> {
const cached = keyCache.get(kid);
if (cached !== undefined) return cached;
// Refetch on an unseen kid, but not more than once a minute — a rotated key
// should be picked up quickly, and a token with a junk kid should not be able
// to turn one request into one upstream fetch.
if (Date.now() - keysFetchedAt < 60_000) return null;
keysFetchedAt = Date.now();
const jwks = await getJson<{ keys?: Jwk[] }>(jwksUrl);
for (const jwk of jwks?.keys ?? []) {
if (typeof jwk.kid !== "string") continue;
try {
keyCache.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" }));
} catch {
// A key this build of Node cannot represent is not a reason to drop the rest.
}
}
return keyCache.get(kid) ?? null;
}
// ---- Claims ---------------------------------------------------------------
function claimsValid(claims: Claims, config: AuthConfig): boolean {
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp === "number" && claims.exp + CLOCK_SKEW_SECONDS < now) return false;
if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_SECONDS > now) return false;
if (config.issuer !== "" && claims.iss !== config.issuer) return false;
if (config.audience !== "") {
const aud = claims.aud;
const matches = Array.isArray(aud) ? aud.includes(config.audience) : aud === config.audience;
if (!matches) return false;
}
return true;
}
function decodeJson<T>(part: string): T | null {
try {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
} catch {
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* `Cache-Control`, fail-closed.
*
* A global `onRequest` hook stamps `private, no-store` on every reply before any
* route runs, and a route that wants a CDN or a browser to keep a copy has to
* say so out loud with `publicCache()`. The order matters: doing this in
* `onSend` "only if the header is missing" would leave error paths, 404s and
* anything thrown before the handler with no policy at all, and the one body
* that must never be cached is the one that came out of a mistake.
*
* CONTRACT.md §5.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
export function registerCachePolicy(app: FastifyInstance): void {
app.addHook("onRequest", async (_req, reply) => {
reply.header("cache-control", "private, no-store");
});
}
/**
* Opt this reply in to shared caching.
*
* The credential check is not paranoia for its own sake. A route can be
* *usually* public and still be reached with a session attached, and a shared
* cache that stored that response would serve one viewer's body to the next. If
* the request carried anything that could personalise the answer, the fail-closed
* default stands.
*/
export function publicCache(req: FastifyRequest, reply: FastifyReply, seconds: number): void {
if (req.headers.authorization !== undefined || req.headers.cookie !== undefined) return;
const maxAge = Math.max(0, Math.floor(seconds));
reply.header("cache-control", `public, max-age=${maxAge}`);
reply.header("vary", "Origin");
}
+352
View File
@@ -0,0 +1,352 @@
/**
* The environment, read once, with every default chosen so that reading nothing
* still produces a working server.
*
* This module is where CONTRACT.md §5.1 is enforced, and it is worth stating the
* rule in one sentence because two independent designs got it wrong the same
* way: **a source configured without what it needs is demoted, not fatal.** The
* acceptance test for this whole repo is a stranger with no keys, and a boot
* that throws because `TERA_WEATHER_CONTACT` is unset fails it. Every demotion
* appends one loud sentence to `degraded`, which `index.ts` logs and
* `/api/v1/health` serves, so the state is visible without being terminal.
*
* The same applies to malformed values: `TERA_PORT=banana` warns and falls back
* to 8431. A process that will not start is worse than a process that starts on
* the default port and says so.
*
* Prefix is `TERA_*` throughout, with no exceptions and no legacy aliases.
*/
import { readFileSync } from "node:fs";
import type {
AuthMode,
FlightsSourceId,
MarkersSourceId,
WeatherSourceId,
} from "../../src/server/wire.ts";
export interface WeatherConfig {
source: WeatherSourceId;
/** Sent as the User-Agent to sources that require an identifiable caller. */
contact: string;
ttlSeconds: number;
}
export interface FlightsConfig {
source: FlightsSourceId;
/** Base URL for the `adsb` source. */
endpoint: string;
/** Radius in nautical miles for the `adsb` source. */
radiusNm: number;
/** Path to a local dump1090 `aircraft.json`. */
dump1090Path: string;
/** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */
epochMs: number;
seed: number;
ttlSeconds: number;
}
export interface MarkersConfig {
source: MarkersSourceId;
/** Path to the JSON snapshot written by the sync oneshot. */
file: string;
/**
* Provenance values the public-shape gate will serve. Anything else is
* refused row by row. See CONTRACT.md §8 and `markers/gate.ts`.
*/
provenanceAllowlist: string[];
ttlSeconds: number;
}
export interface AuthConfig {
mode: AuthMode;
/** Where a browser sends someone to sign in. `sso` mode only. */
entryUrl: string;
/** Server-side token check. `sso` mode only; this box holds no credentials. */
revalidateUrl: string;
/** Cookie a browser session arrives in, when it is not an Authorization header. */
cookieName: string;
/** HS256 shared secret. Primary, because the fleet's issuer signs HS256. */
jwtSecret: string;
/** Set to `jwks` to verify asymmetric signatures instead. */
jwtVerify: "hs256" | "jwks";
jwksUrl: string;
issuer: string;
audience: string;
}
export interface Config {
host: string;
port: number;
logLevel: string;
version: string;
/** The city this box is serving, in degrees. Used by weather and by flights. */
origin: { lat: number; lng: number };
/** Allowed CORS origins. Empty means same-origin only, which is the default. */
corsOrigins: string[];
/** `max-age` for routes that opt in to public caching. */
publicMaxAge: number;
weather: WeatherConfig;
flights: FlightsConfig;
markers: MarkersConfig;
offices: { dir: string };
auth: AuthConfig;
/** One sentence per demotion. Empty on a fully-configured box. */
degraded: string[];
}
type Env = Record<string, string | undefined>;
export function loadConfig(env: Env = process.env): Config {
const degraded: string[] = [];
const weather = loadWeather(env, degraded);
const flights = loadFlights(env, degraded);
const markers = loadMarkers(env, degraded);
const auth = loadAuth(env, degraded);
return {
host: str(env, "TERA_HOST", "127.0.0.1"),
port: num(env, "TERA_PORT", 8431, degraded),
logLevel: str(env, "TERA_LOG_LEVEL", "info"),
version: readVersion(),
origin: {
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded),
},
corsOrigins: list(env, "TERA_CORS_ORIGIN"),
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
weather,
flights,
markers,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
auth,
degraded,
};
}
// ---- Sections -------------------------------------------------------------
const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];
function loadWeather(env: Env, degraded: string[]): WeatherConfig {
const ttlSeconds = num(env, "TERA_WEATHER_TTL", 600, degraded);
const contact = str(env, "TERA_WEATHER_CONTACT", "");
const asked = str(env, "TERA_WEATHER_SOURCE", "none");
let source = oneOf(asked, WEATHER_SOURCES);
if (source === null) {
degraded.push(
`TERA_WEATHER_SOURCE="${asked}" is not one of ${WEATHER_SOURCES.join(", ")}; ` +
`serving synthetic weather instead.`,
);
source = "none";
}
// NWS and MET Norway both require a contact string in the User-Agent and are
// entitled to block a caller who does not send one. Calling them anyway with a
// generic agent is the rude failure mode; refusing to boot is the useless one.
if ((source === "nws" || source === "metno") && contact === "") {
degraded.push(
`TERA_WEATHER_SOURCE=${source} needs TERA_WEATHER_CONTACT (an email or URL ` +
`the operator can be reached at) — ${source} requires an identifiable ` +
`caller. Demoted to synthetic weather; the server is otherwise fine.`,
);
source = "none";
}
if (source === "openmeteo") {
// Not a demotion — a warning that stays on the record. Open-Meteo's *data*
// is CC-BY 4.0, but its free tier is non-commercial, so this is opt-in and
// never a default. CONTRACT.md §5.2.
degraded.push(
"TERA_WEATHER_SOURCE=openmeteo: Open-Meteo's free tier is non-commercial. " +
"Fine for a self-hosted map; check your terms before putting it behind a " +
"product page.",
);
}
return { source, contact, ttlSeconds };
}
const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];
/**
* The simulated plan's phase origin. Deliberately a constant rather than boot
* time: `t0 = Date.now()` would put every aircraft back at the start of its leg
* on every restart, and a fleet of jets teleporting to their departure gates is
* a very visible way to announce a deploy.
*/
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
let source = oneOf(asked, FLIGHT_SOURCES);
if (source === null) {
degraded.push(
`TERA_FLIGHTS_SOURCE="${asked}" is not one of ${FLIGHT_SOURCES.join(", ")}; ` +
`serving the simulated plan instead.`,
);
source = "sim";
}
const dump1090Path = str(env, "TERA_DUMP1090_PATH", "");
if (source === "dump1090" && dump1090Path === "") {
degraded.push(
"TERA_FLIGHTS_SOURCE=dump1090 needs TERA_DUMP1090_PATH pointing at your " +
"receiver's aircraft.json. Demoted to the simulated plan.",
);
source = "sim";
}
return {
source,
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
radiusNm: num(env, "TERA_ADSB_RADIUS_NM", 40, degraded),
dump1090Path,
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded),
ttlSeconds: num(env, "TERA_FLIGHTS_TTL", 300, degraded),
};
}
const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];
/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"];
function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
const asked = str(env, "TERA_MARKERS_SOURCE", "none");
let source = oneOf(asked, MARKER_SOURCES);
if (source === null) {
degraded.push(
`TERA_MARKERS_SOURCE="${asked}" is not one of ${MARKER_SOURCES.join(", ")}; ` +
`serving no markers.`,
);
source = "none";
}
const file = str(env, "TERA_MARKERS_FILE", "");
if (source === "file" && file === "") {
degraded.push(
"TERA_MARKERS_SOURCE=file needs TERA_MARKERS_FILE. Serving no markers.",
);
source = "none";
}
const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST");
return {
source,
file,
provenanceAllowlist: allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST,
ttlSeconds: num(env, "TERA_MARKERS_TTL", 300, degraded),
};
}
const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"];
function loadAuth(env: Env, degraded: string[]): AuthConfig {
const asked = str(env, "TERA_AUTH_MODE", "none");
let mode = oneOf(asked, AUTH_MODES);
if (mode === null) {
degraded.push(
`TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` +
`running open (mode=none).`,
);
mode = "none";
}
const entryUrl = str(env, "TERA_AUTH_ENTRY_URL", "");
const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", "");
const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
// A demotion here has teeth: it takes private offices with it, which is the
// safe direction. Unverifiable credentials must never mean "let them in".
if (mode === "sso" && revalidateUrl === "") {
degraded.push(
"TERA_AUTH_MODE=sso needs TERA_AUTH_REVALIDATE_URL — this box holds no " +
"credentials and cannot check a session without somewhere to ask. " +
"Demoted to mode=none; private offices will answer 404 to everyone.",
);
mode = "none";
}
if (mode === "jwt" && jwtVerify === "hs256" && jwtSecret === "") {
degraded.push(
"TERA_AUTH_MODE=jwt needs TERA_AUTH_JWT_SECRET (or TERA_AUTH_JWT_VERIFY=jwks " +
"with TERA_AUTH_JWKS_URL). Demoted to mode=none; private offices will " +
"answer 404 to everyone.",
);
mode = "none";
}
if (mode === "jwt" && jwtVerify === "jwks" && jwksUrl === "") {
degraded.push(
"TERA_AUTH_JWT_VERIFY=jwks needs TERA_AUTH_JWKS_URL. Demoted to mode=none.",
);
mode = "none";
}
return {
mode,
entryUrl,
revalidateUrl,
cookieName: str(env, "TERA_AUTH_COOKIE", "tera_session"),
jwtSecret,
jwtVerify,
jwksUrl,
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
};
}
// ---- Readers --------------------------------------------------------------
function str(env: Env, key: string, fallback: string): string {
const raw = env[key];
if (raw === undefined) return fallback;
const trimmed = raw.trim();
return trimmed === "" ? fallback : trimmed;
}
function num(env: Env, key: string, fallback: number, degraded: string[]): number {
const raw = env[key];
if (raw === undefined || raw.trim() === "") return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
degraded.push(`${key}="${raw}" is not a number; using ${fallback}.`);
return fallback;
}
return parsed;
}
/** Comma-separated, whitespace-tolerant, empties dropped. */
function list(env: Env, key: string): string[] {
return str(env, key, "")
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "");
}
function oneOf<T extends string>(value: string, allowed: T[]): T | null {
return allowed.includes(value as T) ? (value as T) : null;
}
/**
* The version served by `/health`, read from `package.json` so it cannot drift
* from the thing that is actually deployed. A missing or unreadable file is not
* worth dying over — nothing depends on this but a human reading a health check.
*/
function readVersion(): string {
try {
const url = new URL("../package.json", import.meta.url);
const parsed: unknown = JSON.parse(readFileSync(url, "utf8"));
if (parsed !== null && typeof parsed === "object" && "version" in parsed) {
const v = (parsed as { version: unknown }).version;
if (typeof v === "string") return v;
}
} catch {
// Fall through.
}
return "0.0.0";
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 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.
*
* 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.
*/
import { readFile } from "node:fs/promises";
import { getJson } from "../http.ts";
import type { WireAircraft } from "../../../src/server/wire.ts";
/** The shared dump1090/readsb aircraft record, as both feeds emit it. */
interface RawAircraft {
hex?: string;
flight?: string;
lat?: number;
lon?: number;
alt_baro?: number | string;
track?: number;
}
interface AircraftEnvelope {
ac?: RawAircraft[];
aircraft?: RawAircraft[];
now?: number;
}
export interface FlightsSnapshot {
aircraft: WireAircraft[];
observedAt: number;
}
export async function fetchAdsb(
endpoint: string,
center: { lat: number; lng: number },
radiusNm: number,
): Promise<FlightsSnapshot | null> {
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
const body = await getJson<AircraftEnvelope>(url);
if (body === null) return null;
return normalise(body);
}
/**
* A receiver's own `aircraft.json`, read off local disk.
*
* dump1090 rewrites this file every second, so a partial read is a real
* possibility rather than a theoretical one — which is why an unparseable body
* returns `null` and lets the caller keep the previous snapshot instead of
* emptying the sky for one tick.
*/
export async function readDump1090(path: string): Promise<FlightsSnapshot | null> {
try {
const text = await readFile(path, "utf8");
return normalise(JSON.parse(text) as AircraftEnvelope);
} catch {
return null;
}
}
function normalise(body: AircraftEnvelope): FlightsSnapshot {
const rows = body.ac ?? body.aircraft ?? [];
const aircraft: WireAircraft[] = [];
for (const a of rows) {
if (typeof a.lat !== "number" || typeof a.lon !== "number") continue;
const callsign = a.flight?.trim();
const id = a.hex ?? callsign;
if (id === undefined || id === "") continue;
aircraft.push({
id,
callsign: callsign === "" ? undefined : callsign,
lat: a.lat,
lng: a.lon,
// The feeds report barometric altitude in feet, and send the string
// "ground" for anything that is not flying. The wire carries metres.
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 0,
heading: typeof a.track === "number" ? a.track : 0,
});
}
return { aircraft, observedAt: observedAtMs(body.now) };
}
/**
* dump1090 stamps `now` in seconds and the hosted feeds stamp it in
* milliseconds, using the same field name. Anything past the year 2001 in
* milliseconds is already too large to be a plausible epoch in seconds, so the
* magnitude tells them apart without needing to know which feed answered.
*/
function observedAtMs(now: number | undefined): number {
if (typeof now !== "number" || !Number.isFinite(now)) return Date.now();
return now > 1e12 ? Math.round(now) : Math.round(now * 1000);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Which sky this box serves.
*
* The simulated source answers from a plan and never touches the network, so it
* is free and it is the default. The two real sources are polled on a timer and
* cached, with the same rule the weather service follows: a feed that stops
* answering serves its last snapshot, and a feed that has never answered falls
* back to the plan rather than to an empty sky. An operator who turned on ADS-B
* and got a blank map would reasonably conclude the renderer was broken.
*/
import type { Config } from "../config.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";
import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts";
import { planFor } from "./plan.ts";
export interface FlightsService {
current(): Promise<FlightsBody>;
}
export interface FlightsLog {
warn(msg: string): void;
}
/**
* How long a live snapshot may be cached. Aircraft move; the plan does not, so
* only the live path is clamped.
*/
const LIVE_MAX_TTL_SECONDS = 15;
export function createFlightsService(config: Config, log: FlightsLog): FlightsService {
const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights;
const routes = planFor(config.origin);
const plan = (): FlightsBody => ({
mode: "plan",
source: "sim",
t0: epochMs,
seed,
routes,
ttlSeconds,
});
const liveTtl = Math.min(ttlSeconds, LIVE_MAX_TTL_SECONDS);
let snapshot: FlightsSnapshot | null = null;
let polledAt = 0;
async function poll(): Promise<void> {
const fresh =
source === "dump1090"
? await readDump1090(dump1090Path)
: await fetchAdsb(endpoint, config.origin, radiusNm);
polledAt = Date.now();
if (fresh !== null) {
snapshot = fresh;
return;
}
log.warn(
`flights: ${source} did not answer; serving ${snapshot === null ? "the simulated plan" : "the last snapshot"}`,
);
}
return {
async current(): Promise<FlightsBody> {
if (source === "sim") return plan();
if (Date.now() - polledAt > liveTtl * 1000) await poll();
if (snapshot === null) return plan();
return {
mode: "live",
source,
observedAt: snapshot.observedAt,
aircraft: snapshot.aircraft,
ttlSeconds: liveTtl,
...(source === "adsb"
? { attribution: ["Aircraft positions from the adsb.lol community feed"] }
: {}),
};
},
};
}
+135
View File
@@ -0,0 +1,135 @@
/**
* The simulated sky, as a plan rather than as positions.
*
* The engine's `SimulatedFlights` already evaluates a `SimRoute[]` in closed
* form against wall-clock time, so the server has nothing to simulate — it hands
* over the routes, a fixed phase origin and a seed, and every browser arrives at
* the same answer. One cacheable request replaces a poll per second, and two
* people on different machines see the same aircraft in the same places, which
* a per-client simulation cannot promise and which is the point of doing it this
* way.
*
* ### Where the coordinates came from
*
* The three Bay Area airport positions are published FAA airport reference
* points, typed in by hand. They are US government facts in the public domain,
* and — as with every other coordinate in this repo — emphatically not derived
* from OpenStreetMap. Everything else here is a waypoint someone made up so the
* legs go the right way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8.
*
* The callsigns are invented, with operator prefixes that belong to nobody. A
* repo that refuses to ship other people's logos should not ship their flight
* numbers either.
*/
import type { WireSimRoute } from "../../../src/server/wire.ts";
const SFO: [number, number] = [37.6188, -122.375];
const OAK: [number, number] = [37.7213, -122.2207];
const SJC: [number, number] = [37.3639, -121.9289];
/**
* Departures climb, arrivals descend, and a third of the traffic is just passing
* through at cruise. That mix is what makes the sky read as a working airspace
* rather than a carousel.
*/
const BAY_AREA: WireSimRoute[] = [
// Departures — out over the Pacific, north-east over the Central Valley, and
// south down the peninsula.
{ callsign: "LMB231", from: SFO, to: [37.3, -123.2], fromAlt: 20, toAlt: 10500, duration: 420 },
{ callsign: "LMB778", from: SFO, to: [38.4, -121.3], fromAlt: 20, toAlt: 11000, duration: 480 },
{ callsign: "PAC1082", from: SFO, to: [38.6, -122.9], fromAlt: 20, toAlt: 10000, duration: 460 },
{ callsign: "BAY412", from: OAK, to: [37.0, -121.2], fromAlt: 20, toAlt: 9500, duration: 400 },
{ callsign: "SIE1440", from: SJC, to: [36.6, -121.6], fromAlt: 20, toAlt: 9800, duration: 430 },
// Arrivals — the long descent over Point Reyes, the south-east downwind, and
// the two east-bay finals.
{ callsign: "GLD566", from: [38.3, -123.1], to: SFO, fromAlt: 6000, toAlt: 20, duration: 500 },
{ callsign: "LMB1889", from: [37.1, -121.6], to: SFO, fromAlt: 5500, toAlt: 20, duration: 520 },
{ callsign: "RDW915", from: [37.9, -121.3], to: OAK, fromAlt: 5200, toAlt: 20, duration: 470 },
{ callsign: "PAC331", from: [38.1, -122.3], to: SJC, fromAlt: 5800, toAlt: 20, duration: 540 },
// Overflights, level the whole way.
{
callsign: "GLD55",
from: [38.8, -122.6],
to: [36.6, -121.4],
fromAlt: 11000,
toAlt: 11000,
duration: 620,
},
{
callsign: "PAC21",
from: [37.9, -121.4],
to: [37.2, -123.4],
fromAlt: 10500,
toAlt: 10500,
duration: 700,
},
{
callsign: "RDW1205",
from: [36.5, -122.4],
to: [38.9, -122.0],
fromAlt: 10800,
toAlt: 10800,
duration: 660,
},
// Low and slow across the bay: general aviation is most of what is actually
// visible from the ground, and it is the only traffic that reads as *near*.
{
callsign: "BAY7789",
from: [37.45, -122.1],
to: [38.05, -122.45],
fromAlt: 900,
toAlt: 900,
duration: 520,
},
{
callsign: "SIE4402",
from: [37.95, -122.55],
to: [37.35, -121.85],
fromAlt: 1400,
toAlt: 1400,
duration: 560,
},
];
/** Degrees, roughly the distance from downtown SF to the far end of the bay. */
const BAY_AREA_RADIUS = 0.75;
/**
* A plan for a city this file has never heard of.
*
* A self-hoster pointing `TERA_ORIGIN_LAT/LNG` at somewhere that is not San
* Francisco should get moving aircraft rather than an empty sky, so eight legs
* are laid out on evenly-spaced bearings through their origin. It is not their
* city's real airspace and does not pretend to be — it is motion in the right
* kind of place, which is all the map ever wanted from this.
*/
function genericPlan(lat: number, lng: number): WireSimRoute[] {
const routes: WireSimRoute[] = [];
const span = 0.55;
for (let i = 0; i < 8; i++) {
const bearing = (i / 8) * Math.PI * 2;
const dLat = Math.cos(bearing) * span;
const dLng = (Math.sin(bearing) * span) / Math.max(0.2, Math.cos((lat * Math.PI) / 180));
const cruise = 8000 + (i % 4) * 900;
routes.push({
callsign: `LMB${100 + i * 37}`,
from: [lat - dLat, lng - dLng],
to: [lat + dLat, lng + dLng],
fromAlt: i % 3 === 0 ? 600 : cruise,
toAlt: cruise,
duration: 380 + i * 45,
});
}
return routes;
}
export function planFor(origin: { lat: number; lng: number }): WireSimRoute[] {
const nearSf =
Math.abs(origin.lat - 37.7749) < BAY_AREA_RADIUS &&
Math.abs(origin.lng + 122.4194) < BAY_AREA_RADIUS;
return nearSf ? BAY_AREA : genericPlan(origin.lat, origin.lng);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The one place this service talks to somebody else's server.
*
* Every outbound call is bounded and every failure is a returned `null` rather
* than a thrown exception, because the callers are all route handlers whose
* contract is that they answer. An upstream that has gone away must degrade the
* body, never the response.
*/
const DEFAULT_TIMEOUT_MS = 6000;
export interface GetJsonOptions {
headers?: Record<string, string>;
timeoutMs?: number;
}
/**
* `null` on any failure at all — transport, status, or unparseable body. The
* caller decides what a missing answer means; nothing here does.
*/
export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
try {
const res = await fetch(url, {
headers: { accept: "application/json", ...opts.headers },
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
/**
* A User-Agent that identifies this software and the operator running it.
*
* NWS and MET Norway both ask for a contact and are entitled to block a caller
* who sends a generic agent. This is also why an empty contact demotes the
* source in `config.ts` rather than being papered over here with a fake address.
*/
export function userAgent(contact: string): string {
return `tera-api (+https://github.com/lumbridge-public/tera; ${contact})`;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* The entry point: read the environment, build the app, bind the socket.
*
* It binds `127.0.0.1` by default and is meant to stay there — Caddy terminates
* TLS and is the only thing that talks to this port. A container sets
* `TERA_HOST=0.0.0.0` because inside a container the loopback interface is a
* different loopback interface, which is the only reason that knob exists.
*
* Nothing is fetched at boot. A source is contacted the first time somebody asks
* for it, so this process is up and answering health before it has any opinion
* about the weather.
*/
import { buildApp } from "./app.ts";
import { loadConfig } from "./config.ts";
const config = loadConfig();
const app = buildApp(config);
try {
await app.listen({ host: config.host, port: config.port });
} catch (err) {
app.log.error({ err }, `could not bind ${config.host}:${config.port}`);
process.exit(1);
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
// `once`, not `on`: a second SIGTERM during a shutdown should kill the process
// outright rather than start a second shutdown.
process.once(signal, () => {
app.log.info(`${signal} — closing`);
void app.close().then(() => process.exit(0));
});
}
+129
View File
@@ -0,0 +1,129 @@
/**
* The public-shape gate — the last thing between a synced row and the internet.
*
* Two checks, and the second one is the one CONTRACT.md §8 exists for.
*
* **Fields.** Only the names on `PUBLIC_MARKER_FIELDS` may cross. A row carrying
* anything else is refused whole rather than trimmed: an unrecognised field
* means the upstream shape changed without anyone reviewing it, and quietly
* dropping it turns a thing somebody should look at into a thing nobody ever
* sees. This is the same fail-closed rule the fleet's `export-site.ts` already
* applies, restated here because this is a different process.
*
* **Provenance.** A row whose coordinate came from a source not on the allowlist
* is refused. Serving a snapshot of OSM-derived coordinates is Public Use of a
* Derivative Database, which brings ODbL §4.3 attribution and §4.4 share-alike
* onto everything served alongside it — regardless of where the rows are stored,
* which is exactly the reasoning ARCHITECTURE.md §3.2 originally got wrong.
* Google, Mapbox and HERE do not help either: their terms restrict storing and
* redistributing returned coordinates, which is what a public snapshot is. The
* sanctioned geocoder is the US Census Geocoder, whose output is a US government
* work in the public domain.
*
* The gate refuses rows; it never repairs them. Anything it drops is a bug in
* the sync oneshot, and the refusal counts are served on the wire so that the
* bug is visible from outside rather than only in a log nobody tails.
*/
import type { WireMarker } from "../../../src/server/wire.ts";
/**
* Every field a marker may carry over the public wire.
*
* This tracks `Marker` in `src/engine/types.ts` plus `provenance`. When that
* type gains a field, this list has to gain it too — and until it does, rows
* carrying the new field are refused, which is the correct direction for a list
* whose job is to be conservative.
*/
export const PUBLIC_MARKER_FIELDS = [
"id",
"label",
"colorKey",
"url",
"blurb",
"lat",
"lng",
"located",
"provenance",
];
export interface GateResult {
accepted: WireMarker[];
/** Aggregated so a broken sync produces one line, not ten thousand. */
refused: { reason: string; count: number }[];
}
export function assertPublicShape(rows: unknown, provenanceAllowlist: string[]): GateResult {
const accepted: WireMarker[] = [];
const refusals = new Map<string, number>();
const refuse = (reason: string): void => {
refusals.set(reason, (refusals.get(reason) ?? 0) + 1);
};
if (!Array.isArray(rows)) {
return { accepted, refused: [{ reason: "snapshot is not an array of markers", count: 1 }] };
}
for (const row of rows) {
if (row === null || typeof row !== "object" || Array.isArray(row)) {
refuse("row is not an object");
continue;
}
const record = row as Record<string, unknown>;
const unknown = Object.keys(record).find((key) => !PUBLIC_MARKER_FIELDS.includes(key));
if (unknown !== undefined) {
refuse(`unknown field "${unknown}"`);
continue;
}
const provenance = record["provenance"];
if (typeof provenance !== "string" || !provenanceAllowlist.includes(provenance)) {
refuse(
`provenance ${JSON.stringify(provenance)} is not on the non-ODbL allowlist ` +
`(${provenanceAllowlist.join(", ")})`,
);
continue;
}
const marker = readMarker(record, provenance);
if (marker === null) {
refuse("missing or malformed id, label, colorKey, lat or lng");
continue;
}
accepted.push(marker);
}
return {
accepted,
refused: [...refusals].map(([reason, count]) => ({ reason, count })),
};
}
function readMarker(record: Record<string, unknown>, provenance: string): WireMarker | null {
const id = record["id"];
const label = record["label"];
const colorKey = record["colorKey"];
const lat = record["lat"];
const lng = record["lng"];
if (typeof id !== "string" || id === "") return null;
if (typeof label !== "string") return null;
if (typeof colorKey !== "string") return null;
if (!isDegrees(lat, 90) || !isDegrees(lng, 180)) return null;
const marker: WireMarker = { id, label, colorKey, lat, lng, provenance };
const url = record["url"];
if (typeof url === "string") marker.url = url;
const blurb = record["blurb"];
if (typeof blurb === "string") marker.blurb = blurb;
const located = record["located"];
if (typeof located === "boolean") marker.located = located;
return marker;
}
function isDegrees(value: unknown, limit: number): value is number {
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= limit;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* The marker snapshot: read from disk, put through the gate, cached.
*
* The API serves a file. It does not hold a Workie credential, does not have a
* database, and does not proxy anything — the sync oneshot writes the snapshot
* and this reads it, which is why a compromise of the public box yields a file
* that was already public.
*
* Private per-user markers never appear here at all. An authenticated browser
* calls Workie directly with its own token, so private rows never transit this
* process. CONTRACT.md §5.
*/
import { readFile } from "node:fs/promises";
import type { Config } from "../config.ts";
import type { MarkersBody } from "../../../src/server/wire.ts";
import { assertPublicShape } from "./gate.ts";
export interface MarkerStore {
current(): Promise<MarkersBody>;
}
export interface MarkerLog {
warn(msg: string): void;
}
interface Snapshot {
generatedAt?: string;
markers?: unknown;
}
export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore {
const { source, file, provenanceAllowlist, ttlSeconds } = config.markers;
let cached: MarkersBody | null = null;
let readAt = 0;
async function load(): Promise<MarkersBody> {
const now = new Date().toISOString();
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(file, "utf8"));
} catch (err) {
log.warn(`markers: cannot read ${file} (${String(err)}); serving no markers`);
return { markers: [], generatedAt: now, refused: [] };
}
// A bare array is accepted because it is the obvious thing to hand-write,
// and a self-hoster's first marker file should not need a wrapper object.
const snapshot: Snapshot =
Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot);
const { accepted, refused } = assertPublicShape(snapshot.markers ?? [], provenanceAllowlist);
for (const entry of refused) {
log.warn(`markers: refused ${entry.count} row(s) — ${entry.reason}`);
}
return {
markers: accepted,
generatedAt: snapshot.generatedAt ?? now,
refused,
};
}
return {
async current(): Promise<MarkersBody> {
if (source === "none") {
return { markers: [], generatedAt: new Date().toISOString(), refused: [] };
}
if (cached === null || Date.now() - readAt > ttlSeconds * 1000) {
cached = await load();
readAt = Date.now();
}
return cached;
},
};
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Office packs, off disk.
*
* `TERA_OFFICES_DIR` holds one `<id>.json` per office, each one an `OfficeDoc`
* wrapping the `Office` that `src/interiors/types.ts` defines — the same bytes a
* self-hoster hand-writes and drops in the directory. There is no database and
* no build step, because the format's whole claim is that a pack written by hand
* and a pack arriving over HTTP are the same thing.
*
* A box with no offices directory configured has no offices. That is the
* default, and it answers 404 to everything.
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { OfficeDoc } from "../../../src/server/wire.ts";
/**
* Ids are the filename, so this is a path-traversal boundary and not a style
* preference. Lowercase, digits and hyphens; nothing that can climb out of the
* directory and nothing that means something different on a case-insensitive
* filesystem.
*/
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
export interface OfficeStore {
get(id: string): Promise<OfficeDoc | null>;
}
export function createOfficeStore(dir: string): OfficeStore {
return {
async get(id: string): Promise<OfficeDoc | null> {
if (dir === "" || !ID_PATTERN.test(id)) return null;
try {
const parsed: unknown = JSON.parse(await readFile(join(dir, `${id}.json`), "utf8"));
return normalise(parsed, id);
} catch {
// Missing, unreadable and unparseable are the same answer to a caller:
// there is no office here. Distinguishing them out loud would leak the
// directory listing one status code at a time.
return null;
}
},
};
}
function normalise(parsed: unknown, id: string): OfficeDoc | null {
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const doc = parsed as Partial<OfficeDoc>;
if (doc.floor === undefined || typeof doc.floor !== "object") return null;
// Visibility defaults to `private`. A pack that forgot to say is a pack whose
// author has not thought about it yet, and the fail-closed reading of that is
// the only one that cannot embarrass anybody.
const visibility =
doc.visibility === "public" || doc.visibility === "unlisted" ? doc.visibility : "private";
return {
id: typeof doc.id === "string" ? doc.id : id,
name: typeof doc.name === "string" ? doc.name : id,
floor: doc.floor,
visibility,
...(typeof doc.updated === "string" ? { updated: doc.updated } : {}),
};
}
+19
View File
@@ -0,0 +1,19 @@
/**
* `GET /api/v1/flights`.
*
* Publicly cacheable, because the whole design of the plan is that one response
* serves every viewer for its whole TTL. Aircraft are not personal data and this
* body never varies by who asked.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { Services } from "../services.ts";
export function registerFlights(app: FastifyInstance, services: Services): void {
app.get("/api/v1/flights", async (req, reply) => {
const body = await services.flights.current();
publicCache(req, reply, body.ttlSeconds);
return body;
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* `GET /api/v1/health`.
*
* The route the CI job asserts on, so it has one job: answer 200 on a box that
* was handed nothing. It touches no upstream, reads no file and takes no lock —
* a health check that can be made to fail by a third party's outage is a health
* check that will page somebody at four in the morning about somebody else's
* server.
*
* `degraded` is what makes it more than a liveness probe: every demotion the
* config made is printed here, so "why is the weather always clear" has an
* answer that does not require log access.
*/
import type { FastifyInstance } from "fastify";
import type { HealthBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
export function registerHealth(app: FastifyInstance, services: Services): void {
const { config, startedAt } = services;
app.get("/api/v1/health", async () => {
const body: HealthBody = {
ok: true,
service: "tera-api",
version: config.version,
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
sources: {
weather: config.weather.source,
flights: config.flights.source,
markers: config.markers.source,
},
auth: {
mode: config.auth.mode,
entryUrl: config.auth.mode === "sso" && config.auth.entryUrl !== ""
? config.auth.entryUrl
: null,
},
degraded: config.degraded,
};
return body;
});
}
+24
View File
@@ -0,0 +1,24 @@
/**
* `GET /api/v1/markers`.
*
* The public snapshot, and nothing else. Private per-user markers are never
* proxied through this box — an authenticated browser calls Workie directly with
* its own token, so a private row never enters this process and cannot leave it.
* CONTRACT.md §5.
*
* Every row served here has been through the provenance gate in `markers/gate.ts`,
* which is what keeps a public snapshot from quietly becoming a Publicly Used
* Derivative Database under ODbL.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { Services } from "../services.ts";
export function registerMarkers(app: FastifyInstance, services: Services): void {
app.get("/api/v1/markers", async (req, reply) => {
const body = await services.markers.current();
publicCache(req, reply, services.config.markers.ttlSeconds);
return body;
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* `GET /api/v1/offices/:id`.
*
* The one authenticated route, and the one place the 404 rule matters: an office
* the caller may not see answers **404, not 403**, and answers it identically to
* an office that does not exist. A 403 is an existence oracle — walk the id space
* and the status code tells you every tenant on the box. CONTRACT.md §6.
*
* The same reasoning is why the not-found path does no work first: resolve the
* viewer, load the doc, and take one exit.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." };
export function registerOffices(app: FastifyInstance, services: Services): void {
app.get<{ Params: { id: string } }>("/api/v1/offices/:id", async (req, reply) => {
const doc = await services.offices.get(req.params.id);
if (doc === null) return reply.code(404).send(NOT_FOUND);
if (doc.visibility === "private") {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) return reply.code(404).send(NOT_FOUND);
}
// Only a public office may be cached by anything shared. An unlisted one is
// reachable by anybody holding the id, but it should not accumulate in a CDN
// where the id is no longer needed to find it.
if (doc.visibility === "public") publicCache(req, reply, services.config.publicMaxAge);
return doc;
});
}
+20
View File
@@ -0,0 +1,20 @@
/**
* `GET /api/v1/weather`.
*
* Always 200, always a body. A source that is down, misconfigured or absent
* produces `synthetic: true` and a clear day — there is no failure mode here in
* which the caller has to decide what to render, because the answer to "what is
* the sky doing" is never allowed to be a 503.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { Services } from "../services.ts";
export function registerWeather(app: FastifyInstance, services: Services): void {
app.get("/api/v1/weather", async (req, reply) => {
const body = await services.weather.current();
publicCache(req, reply, services.config.weather.ttlSeconds);
return body;
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Everything a route needs, built once and handed in.
*
* Routes get this object rather than reaching for module-level singletons, which
* is what makes the tests able to stand a whole server up against a fake
* environment in-process. Each service owns its own cache and its own failure
* behaviour; none of them can throw at a route.
*/
import { createAuth, type AuthService } from "./auth/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createWeatherService, type WeatherService } from "./weather/index.ts";
import type { Config } from "./config.ts";
export interface Services {
config: Config;
weather: WeatherService;
flights: FlightsService;
markers: MarkerStore;
offices: OfficeStore;
auth: AuthService;
/** Epoch milliseconds, for `uptimeSeconds` on the health body. */
startedAt: number;
}
/** The minimum a service needs from a logger. Fastify's satisfies it. */
export interface ServiceLog {
warn(msg: string): void;
}
export function createServices(config: Config, log: ServiceLog): Services {
return {
config,
weather: createWeatherService(config, log),
flights: createFlightsService(config, log),
markers: createMarkerStore(config, log),
offices: createOfficeStore(config.offices.dir),
auth: createAuth(config.auth),
startedAt: Date.now(),
};
}
+97
View File
@@ -0,0 +1,97 @@
/**
* The sync oneshot — the only second process, and the only holder of a credential.
*
* It runs on a timer, asks Workie for the public marker set, puts every row
* through the same gate the API serves behind, and writes a snapshot the API
* reads off disk. The isolation is the point: the public box never holds a
* Workie token, never opens an outbound connection to a private service, and a
* compromise of it yields a file that was already public. CONTRACT.md §5.
*
* ### Provenance is not optional
*
* Every row must say where its coordinate came from, and the gate refuses
* anything whose answer is not on the non-ODbL allowlist. A row that arrives
* without a provenance is refused rather than assumed — `TERA_SYNC_PROVENANCE`
* exists so an operator can *assert* one for a feed that predates the field, and
* setting it is a licence claim they are making on the record.
*
* This is fail-closed by design: **one refused row aborts the whole sync and
* leaves the previous snapshot in place.** Publishing coordinates whose licence
* nobody can vouch for is the failure that cannot be undone by a later fix,
* because ODbL §4.4 would already have attached to everything served alongside
* them. A stale map is a much cheaper mistake. CONTRACT.md §8.
*
* Usage: TERA_SYNC_SOURCE_URL=... TERA_SYNC_TOKEN=... TERA_MARKERS_FILE=... npm run sync
*/
import { rename, writeFile } from "node:fs/promises";
import { getJson } from "../http.ts";
import { assertPublicShape } from "../markers/gate.ts";
import { DEFAULT_PROVENANCE_ALLOWLIST } from "../config.ts";
interface UpstreamBody {
markers?: unknown;
generatedAt?: string;
}
async function main(): Promise<number> {
const sourceUrl = process.env["TERA_SYNC_SOURCE_URL"] ?? "";
const token = process.env["TERA_SYNC_TOKEN"] ?? "";
const target = process.env["TERA_MARKERS_FILE"] ?? "";
const stamp = process.env["TERA_SYNC_PROVENANCE"] ?? "";
const allowlist = (process.env["TERA_MARKERS_PROVENANCE_ALLOWLIST"] ?? "")
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "");
if (sourceUrl === "" || target === "") {
console.error("sync: TERA_SYNC_SOURCE_URL and TERA_MARKERS_FILE are both required.");
return 2;
}
const body = await getJson<UpstreamBody>(sourceUrl, {
headers: token === "" ? {} : { authorization: `Bearer ${token}` },
timeoutMs: 20_000,
});
if (body === null) {
console.error(`sync: ${sourceUrl} did not answer; leaving the snapshot alone.`);
return 1;
}
const rows = Array.isArray(body.markers) ? body.markers : [];
const stamped = stamp === "" ? rows : rows.map((row) => withProvenance(row, stamp));
const { accepted, refused } = assertPublicShape(
stamped,
allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST,
);
if (refused.length > 0) {
for (const entry of refused) console.error(`sync: REFUSED ${entry.count} row(s) — ${entry.reason}`);
console.error("sync: aborting without writing. The previous snapshot is untouched.");
return 1;
}
const snapshot = {
generatedAt: body.generatedAt ?? new Date().toISOString(),
markers: accepted,
};
// Write beside the target and rename, so the API can never read a half-written
// file — `rename` within a directory is atomic and the reader has no lock.
const temp = `${target}.tmp`;
await writeFile(temp, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
await rename(temp, target);
console.log(`sync: wrote ${accepted.length} marker(s) to ${target}`);
return 0;
}
/** Assert a provenance on rows that do not carry one. Never overwrites. */
function withProvenance(row: unknown, provenance: string): unknown {
if (row === null || typeof row !== "object" || Array.isArray(row)) return row;
const record = row as Record<string, unknown>;
return "provenance" in record ? record : { ...record, provenance };
}
process.exitCode = await main();
+158
View File
@@ -0,0 +1,158 @@
/**
* The acceptance test: a stranger with no keys, no account and no environment.
*
* This is CONTRACT.md §5.1 written as code, because it is the thing two
* independent server designs got wrong in the same way — a weather source that
* defaults to a provider needing a contact string, and a hard failure when it is
* absent. The last test in this file starts the real entry point under a
* genuinely empty environment and asks it for its health, which is the same
* assertion the `docker compose up` CI job makes from outside.
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type {
FlightsBody,
HealthBody,
MarkersBody,
WeatherBody,
} from "../../../src/server/wire.ts";
/** `loadConfig({})` is exactly what `env -i` produces, minus the process. */
function emptyEnvApp() {
const config = loadConfig({});
config.logLevel = "silent";
return buildApp(config);
}
describe("a box handed nothing", () => {
it("defaults every source to its keyless setting and reports no demotions", () => {
const config = loadConfig({});
assert.equal(config.weather.source, "none");
assert.equal(config.flights.source, "sim");
assert.equal(config.markers.source, "none");
assert.equal(config.auth.mode, "none");
assert.equal(config.host, "127.0.0.1");
assert.equal(config.port, 8431);
assert.deepEqual(config.degraded, []);
});
it("answers health", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
assert.equal(res.statusCode, 200);
const body = res.json<HealthBody>();
assert.equal(body.ok, true);
assert.equal(body.auth.mode, "none");
assert.equal(body.auth.entryUrl, null);
assert.deepEqual(body.degraded, []);
});
it("serves a synthetic clear day rather than failing on a missing contact", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/weather" });
assert.equal(res.statusCode, 200);
const body = res.json<WeatherBody>();
assert.equal(body.synthetic, true);
assert.equal(body.source, "none");
assert.equal(body.condition, "clear");
});
it("serves the simulated sky as a plan, not as positions", async () => {
const app = emptyEnvApp();
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(body.mode, "plan");
assert.equal(body.source, "sim");
assert.ok(body.mode === "plan" && body.routes.length > 0);
// A fixed origin, so a restart does not teleport every aircraft.
assert.ok(body.mode === "plan" && body.t0 < Date.now());
});
it("serves no markers and says so, rather than 404ing the route", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json<MarkersBody>().markers, []);
});
it("has no offices", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/offices/anything" });
assert.equal(res.statusCode, 404);
});
});
describe("cache-control is fail-closed", () => {
it("stamps private, no-store on anything that did not opt in", async () => {
const app = emptyEnvApp();
after(() => app.close());
const health = await app.inject({ method: "GET", url: "/api/v1/health" });
assert.equal(health.headers["cache-control"], "private, no-store");
const missing = await app.inject({ method: "GET", url: "/api/v1/nope" });
assert.equal(missing.statusCode, 404);
assert.equal(missing.headers["cache-control"], "private, no-store");
});
it("lets a route opt in explicitly", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.match(String(res.headers["cache-control"]), /^public, max-age=\d+$/);
});
it("refuses to opt in when the request carried a credential", async () => {
const app = emptyEnvApp();
after(() => app.close());
const res = await app.inject({
method: "GET",
url: "/api/v1/flights",
headers: { authorization: "Bearer something" },
});
assert.equal(res.headers["cache-control"], "private, no-store");
});
});
describe("the real process under env -i", () => {
it("boots and answers health with no environment at all", async () => {
const entry = new URL("../index.ts", import.meta.url).pathname;
// A genuinely empty environment: no PATH, no HOME, no TERA_*. `execPath` is
// absolute, so the child needs nothing from the parent to start.
const child = spawn(process.execPath, [entry], { env: {}, stdio: "ignore" });
after(() => child.kill("SIGKILL"));
const url = "http://127.0.0.1:8431/api/v1/health";
const deadline = Date.now() + 15_000;
let body: HealthBody | null = null;
while (Date.now() < deadline && body === null) {
try {
const res = await fetch(url);
if (res.ok) body = (await res.json()) as HealthBody;
} catch {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
assert.ok(body !== null, "the server never answered on 127.0.0.1:8431");
assert.equal(body.ok, true);
assert.equal(body.service, "tera-api");
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* Demotion, not fatality.
*
* Every case here is a misconfiguration that an earlier design would have
* thrown on at boot. The rule is that the server comes up, says exactly what it
* gave up on, and serves the degraded body — a self-hoster who typed the wrong
* thing gets a working map and a sentence explaining it, not a process that
* refuses to start. CONTRACT.md §5.1.
*/
import assert from "node:assert/strict";
import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { HealthBody, WeatherBody } from "../../../src/server/wire.ts";
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return { config, app: buildApp(config) };
}
describe("a weather source configured without a contact", () => {
it("demotes to synthetic and says why", async () => {
const { config, app } = appWith({ TERA_WEATHER_SOURCE: "nws" });
after(() => app.close());
assert.equal(config.weather.source, "none");
assert.equal(config.degraded.length, 1);
assert.match(config.degraded[0] ?? "", /TERA_WEATHER_CONTACT/);
const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<HealthBody>();
assert.equal(health.ok, true);
assert.equal(health.sources.weather, "none");
assert.equal(health.degraded.length, 1);
const weather = (
await app.inject({ method: "GET", url: "/api/v1/weather" })
).json<WeatherBody>();
assert.equal(weather.synthetic, true);
});
it("keeps the source once a contact is present", () => {
const { config, app } = appWith({
TERA_WEATHER_SOURCE: "nws",
TERA_WEATHER_CONTACT: "ops@example.com",
});
after(() => app.close());
assert.equal(config.weather.source, "nws");
assert.deepEqual(config.degraded, []);
});
});
describe("other misconfigurations", () => {
it("falls back on an unknown source name rather than exiting", () => {
const { config, app } = appWith({ TERA_WEATHER_SOURCE: "accuweather" });
after(() => app.close());
assert.equal(config.weather.source, "none");
assert.match(config.degraded[0] ?? "", /accuweather/);
});
it("falls back on a port that is not a number", () => {
const config = loadConfig({ TERA_PORT: "banana" });
assert.equal(config.port, 8431);
assert.match(config.degraded[0] ?? "", /TERA_PORT/);
});
it("records that Open-Meteo is a non-commercial tier without disabling it", () => {
const config = loadConfig({ TERA_WEATHER_SOURCE: "openmeteo" });
assert.equal(config.weather.source, "openmeteo");
assert.match(config.degraded[0] ?? "", /non-commercial/);
});
it("demotes sso with nowhere to revalidate, taking private offices with it", () => {
const config = loadConfig({ TERA_AUTH_MODE: "sso", TERA_AUTH_ENTRY_URL: "https://example" });
assert.equal(config.auth.mode, "none");
assert.match(config.degraded[0] ?? "", /TERA_AUTH_REVALIDATE_URL/);
});
it("demotes jwt with no secret and no JWKS", () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt" });
assert.equal(config.auth.mode, "none");
});
it("demotes dump1090 with no path to read", () => {
const config = loadConfig({ TERA_FLIGHTS_SOURCE: "dump1090" });
assert.equal(config.flights.source, "sim");
});
it("demotes a file marker source with no file", () => {
const config = loadConfig({ TERA_MARKERS_SOURCE: "file" });
assert.equal(config.markers.source, "none");
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* The provenance gate, which is the sharpest correction in CONTRACT.md and the
* one with an actual licence behind it.
*
* The row that must be refused is the one that looks completely fine: correct
* fields, plausible coordinates, and a provenance of `nominatim`. Serving it
* would make this endpoint a Publicly Used Derivative Database and pull ODbL
* §4.3 and §4.4 onto everything served next to it. See CONTRACT.md §8.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { DEFAULT_PROVENANCE_ALLOWLIST } from "../config.ts";
import { assertPublicShape } from "../markers/gate.ts";
const good = {
id: "acme-hq",
label: "Acme",
colorKey: "sector.industrial",
lat: 37.79,
lng: -122.4,
provenance: "us-census",
};
describe("the public-shape gate", () => {
it("accepts a US Census row", () => {
const { accepted, refused } = assertPublicShape([good], DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(accepted.length, 1);
assert.deepEqual(refused, []);
assert.equal(accepted[0]?.provenance, "us-census");
});
it("refuses an OSM-derived row that is otherwise perfect", () => {
const row = { ...good, provenance: "nominatim" };
const { accepted, refused } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(accepted.length, 0);
assert.equal(refused.length, 1);
assert.match(refused[0]?.reason ?? "", /allowlist/);
});
it("refuses a row with no provenance at all", () => {
const { provenance: _omitted, ...row } = good;
const { accepted } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(accepted.length, 0);
});
it("refuses commercial geocoders too — 'not OSM' is not the test", () => {
for (const provenance of ["google", "mapbox", "here"]) {
const { accepted } = assertPublicShape(
[{ ...good, provenance }],
DEFAULT_PROVENANCE_ALLOWLIST,
);
assert.equal(accepted.length, 0, `${provenance} must not pass`);
}
});
it("refuses the whole row when it carries a field nobody reviewed", () => {
const row = { ...good, ownerEmail: "someone@example.com" };
const { accepted, refused } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(accepted.length, 0);
assert.match(refused[0]?.reason ?? "", /unknown field "ownerEmail"/);
});
it("refuses malformed coordinates", () => {
const rows = [
{ ...good, lat: 200 },
{ ...good, lng: "west" },
{ ...good, id: "" },
];
const { accepted } = assertPublicShape(rows, DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(accepted.length, 0);
});
it("aggregates refusals so a broken sync is one line, not ten thousand", () => {
const rows = Array.from({ length: 500 }, () => ({ ...good, provenance: "osm" }));
const { refused } = assertPublicShape(rows, DEFAULT_PROVENANCE_ALLOWLIST);
assert.equal(refused.length, 1);
assert.equal(refused[0]?.count, 500);
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Offices, visibility, and the 404 rule.
*
* The assertion that matters is the negative one: a private office and an office
* that was never created must be indistinguishable from outside. If they differ
* — by status code, by body, by timing anybody could measure — the endpoint
* becomes a way to enumerate tenants. CONTRACT.md §6.
*
* HS256 is exercised directly here because it is the primary path: the issuer
* this runs against signs `{"alg":"HS256"}`, and a JWKS-only implementation
* would reject every real token.
*/
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 type { OfficeDoc } from "../../../src/server/wire.ts";
const SECRET = "not-a-real-secret-and-never-was";
/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */
const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] };
let dir = "";
before(async () => {
dir = await mkdtemp(join(tmpdir(), "tera-offices-"));
await writeFile(
join(dir, "open.json"),
JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }),
);
await writeFile(
join(dir, "closed.json"),
JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }),
);
await writeFile(
join(dir, "quiet.json"),
JSON.stringify({ id: "quiet", name: "Unlisted office", visibility: "unlisted", floor }),
);
// No `visibility` at all: the fail-closed reading is `private`.
await writeFile(join(dir, "vague.json"), JSON.stringify({ id: "vague", name: "?", floor }));
});
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...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")}`;
}
describe("with auth off", () => {
it("serves a public office and lets it be cached", async () => {
const app = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/offices/open" });
assert.equal(res.statusCode, 200);
assert.equal(res.json<OfficeDoc>().floor.id, "hq");
assert.match(String(res.headers["cache-control"]), /^public, max-age=/);
});
it("serves an unlisted office but never lets a shared cache keep it", async () => {
const app = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/offices/quiet" });
assert.equal(res.statusCode, 200);
assert.equal(res.headers["cache-control"], "private, no-store");
});
it("answers 404 for a private office, identically to one that does not exist", async () => {
const app = appWith({});
after(() => app.close());
const priv = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
const absent = await app.inject({ method: "GET", url: "/api/v1/offices/no-such-office" });
assert.equal(priv.statusCode, 404);
assert.equal(absent.statusCode, 404);
assert.deepEqual(priv.json(), absent.json());
});
it("treats a pack that forgot to declare visibility as private", async () => {
const app = appWith({});
after(() => app.close());
assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/vague" })).statusCode, 404);
});
it("refuses an id that could climb out of the directory", async () => {
const app = appWith({});
after(() => app.close());
for (const id of ["..", "..%2f..%2fetc%2fpasswd", "Open", "open.json"]) {
const res = await app.inject({ method: "GET", url: `/api/v1/offices/${id}` });
assert.equal(res.statusCode, 404, `${id} must not resolve`);
}
});
});
describe("with TERA_AUTH_MODE=jwt", () => {
const env = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET };
it("opens a private office to a valid HS256 token", async () => {
const app = appWith(env);
after(() => app.close());
const res = await app.inject({
method: "GET",
url: "/api/v1/offices/closed",
headers: { authorization: `Bearer ${hs256({ sub: "someone", exp: now() + 600 })}` },
});
assert.equal(res.statusCode, 200);
assert.equal(res.headers["cache-control"], "private, no-store");
});
it("still answers 404 to an expired token, a wrong secret, or alg: none", async () => {
const app = appWith(env);
after(() => app.close());
const expired = hs256({ sub: "someone", exp: now() - 3600 });
const wrong = `${hs256({ sub: "someone" }).split(".").slice(0, 2).join(".")}.deadbeef`;
const none = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(
JSON.stringify({ sub: "someone" }),
).toString("base64url")}.`;
for (const token of [expired, wrong, none, "not-a-jwt"]) {
const res = await app.inject({
method: "GET",
url: "/api/v1/offices/closed",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
}
});
});
function now(): number {
return Math.floor(Date.now() / 1000);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* One reading of the sky, from whichever numbers a source happened to report.
*
* Every provider has its own vocabulary — NWS sends METAR cloud layers, MET
* Norway sends a symbol code, Open-Meteo sends a WMO weather code — and the
* renderer wants none of them. Deriving the condition from cloud cover,
* precipitation and visibility means one rule for all three sources, and it
* means a source that stops sending its symbol still produces a usable sky.
*/
import type { WeatherCondition } from "../../../src/server/wire.ts";
export interface ConditionInput {
/** 0..1. */
cloudCover: number;
/** 0..1 intensity. */
precipitation: number;
visibilityKm: number | null;
/** The source said the precipitation was frozen. */
frozen?: boolean;
thunder?: boolean;
}
export function deriveCondition(input: ConditionInput): WeatherCondition {
if (input.thunder === true) return "thunderstorm";
if (input.precipitation > 0.02) return input.frozen === true ? "snow" : "rain";
// Fog is checked after precipitation because rain reduces visibility too, and
// "it is raining" is the more useful thing to say about a rainy afternoon.
if (input.visibilityKm !== null && input.visibilityKm < 1.5) return "fog";
if (input.cloudCover >= 0.9) return "overcast";
if (input.cloudCover >= 0.6) return "cloudy";
if (input.cloudCover >= 0.25) return "partly-cloudy";
return "clear";
}
/** Clamp to the 0..1 the wire promises, and turn a missing value into 0. */
export function unitRange(value: number | null | undefined): number {
if (value === null || value === undefined || !Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
export function finiteOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Which source answers, how often it is asked, and what happens when it does not.
*
* Three rules, in order of how much trouble getting them wrong causes:
*
* 1. **This never throws.** `current()` always resolves to a `WeatherBody`.
* 2. **A dead upstream serves the last good observation**, and only falls back
* to the clear day if there has never been one. Ten-minute-old weather is
* better than no weather and much better than a 503.
* 3. **Nothing is fetched until somebody asks.** A box nobody visits makes no
* outbound requests, which matters when the source is a public good with a
* rate limit and a fair-use policy.
*/
import type { Config } from "../config.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
import { fetchMetno } from "./metno.ts";
import { fetchNws } from "./nws.ts";
import { fetchOpenMeteo } from "./openmeteo.ts";
import { clearDay } from "./synthetic.ts";
export interface WeatherService {
current(): Promise<WeatherBody>;
}
export interface WeatherLog {
warn(msg: string): void;
}
export function createWeatherService(config: Config, log: WeatherLog): WeatherService {
const { lat, lng } = config.origin;
const { source, contact, ttlSeconds } = config.weather;
let cached: WeatherBody | null = null;
let fetchedAt = 0;
let inFlight: Promise<void> | null = null;
async function refresh(): Promise<void> {
const fresh =
source === "nws"
? await fetchNws(lat, lng, contact)
: source === "metno"
? await fetchMetno(lat, lng, contact)
: source === "openmeteo"
? await fetchOpenMeteo(lat, lng)
: null;
// Stamp the clock either way. A source that is down should be retried on the
// same cadence as one that is up, not hammered once per request.
fetchedAt = Date.now();
if (fresh !== null) {
cached = fresh;
return;
}
log.warn(`weather: ${source} did not answer; serving ${cached === null ? "a synthetic clear day" : "the last observation"}`);
}
return {
async current(): Promise<WeatherBody> {
if (source === "none") return clearDay(lat, lng);
const stale = Date.now() - fetchedAt > ttlSeconds * 1000;
if (stale) {
// Collapse concurrent misses into one upstream request.
inFlight ??= refresh().finally(() => {
inFlight = null;
});
await inFlight;
}
return cached ?? clearDay(lat, lng);
},
};
}
+79
View File
@@ -0,0 +1,79 @@
/**
* api.met.no — the Norwegian Meteorological Institute.
*
* The global fallback, for the same reason NWS is the default: it is free,
* keyless, and its licence (CC BY 4.0) is one a public map can actually satisfy
* by printing a line of attribution — which is what the `attribution` field on
* the wire carries, and which the consumer is expected to display.
*
* MET requires an identifiable User-Agent and will block callers who do not send
* one, which is why an empty `TERA_WEATHER_CONTACT` demotes this source in
* `config.ts` rather than being quietly worked around here.
*/
import { getJson, userAgent } from "../http.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
import { deriveCondition, finiteOrNull, unitRange } from "./condition.ts";
const BASE = "https://api.met.no/weatherapi/locationforecast/2.0/compact";
interface ForecastResponse {
properties?: {
timeseries?: {
time?: string;
data?: {
instant?: { details?: Record<string, number | undefined> };
next_1_hours?: {
summary?: { symbol_code?: string };
details?: { precipitation_amount?: number };
};
};
}[];
};
}
export async function fetchMetno(
lat: number,
lng: number,
contact: string,
): Promise<WeatherBody | null> {
// MET asks callers to truncate coordinates to four decimals so their cache
// works; a request for 37.774929 and one for 37.7749 are the same weather.
const url = `${BASE}?lat=${lat.toFixed(4)}&lon=${lng.toFixed(4)}`;
const body = await getJson<ForecastResponse>(url, {
headers: { "user-agent": userAgent(contact) },
});
const entry = body?.properties?.timeseries?.[0];
const instant = entry?.data?.instant?.details;
if (entry === undefined || instant === undefined) return null;
const symbol = entry.data?.next_1_hours?.summary?.symbol_code ?? "";
const mm = entry.data?.next_1_hours?.details?.precipitation_amount ?? 0;
// Millimetres in the coming hour, read as an intensity dial: 4 mm/h is
// thoroughly wet, and the renderer has nothing to do with anything past that.
const precipitation = unitRange(mm / 4);
const cloudCover = unitRange((instant["cloud_area_fraction"] ?? 0) / 100);
const windMs = finiteOrNull(instant["wind_speed"]);
return {
observedAt: entry.time ?? new Date().toISOString(),
source: "metno",
synthetic: false,
location: { lat, lng },
temperatureC: finiteOrNull(instant["air_temperature"]),
windKph: windMs === null ? null : windMs * 3.6,
windDirDeg: finiteOrNull(instant["wind_from_direction"]),
cloudCover,
precipitation,
visibilityKm: null,
condition: deriveCondition({
cloudCover,
precipitation,
visibilityKm: null,
frozen: symbol.includes("snow"),
thunder: symbol.includes("thunder"),
}),
attribution: ["Weather data from MET Norway (met.no), licensed CC BY 4.0"],
};
}
+158
View File
@@ -0,0 +1,158 @@
/**
* api.weather.gov — the National Weather Service.
*
* The default source once a contact is configured, and the reason is licensing
* rather than quality: NWS output is a work of the United States government and
* is in the public domain, so nothing served downstream of it carries an
* attribution or share-alike obligation. It is also keyless. The cost is that it
* covers the US only, which is why `metno` exists.
*
* Getting an observation takes three hops — point to station list, station list
* to nearest station, station to latest observation — so the first two are
* resolved once per process and kept. Stations do not move.
*/
import { getJson, userAgent } from "../http.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
import { deriveCondition, finiteOrNull } from "./condition.ts";
const BASE = "https://api.weather.gov";
interface PointsResponse {
properties?: { observationStations?: string };
}
interface StationsResponse {
features?: { properties?: { stationIdentifier?: string } }[];
}
interface Measurement {
value?: number | null;
unitCode?: string;
}
interface ObservationResponse {
properties?: {
timestamp?: string;
temperature?: Measurement;
windSpeed?: Measurement;
windDirection?: Measurement;
visibility?: Measurement;
cloudLayers?: { amount?: string }[];
presentWeather?: { weather?: string; intensity?: string | null }[];
};
}
const stationCache = new Map<string, string>();
export async function fetchNws(
lat: number,
lng: number,
contact: string,
): Promise<WeatherBody | null> {
const headers = { "user-agent": userAgent(contact) };
const station = await resolveStation(lat, lng, headers);
if (station === null) return null;
const obs = await getJson<ObservationResponse>(
`${BASE}/stations/${encodeURIComponent(station)}/observations/latest`,
{ headers },
);
const p = obs?.properties;
if (p === undefined) return null;
const cloudCover = cloudFromLayers(p.cloudLayers);
const present = p.presentWeather ?? [];
const precipitation = precipitationFrom(present);
const visibilityKm = metresToKm(p.visibility);
return {
observedAt: p.timestamp ?? new Date().toISOString(),
source: "nws",
synthetic: false,
location: { lat, lng },
temperatureC: finiteOrNull(p.temperature?.value),
windKph: toKph(p.windSpeed),
windDirDeg: finiteOrNull(p.windDirection?.value),
cloudCover,
precipitation,
visibilityKm,
condition: deriveCondition({
cloudCover,
precipitation,
visibilityKm,
frozen: present.some((w) => (w.weather ?? "").includes("snow")),
thunder: present.some((w) => (w.weather ?? "").includes("thunder")),
}),
// No attribution block: US government works carry no such obligation, and
// claiming one would be inventing a licence term.
};
}
async function resolveStation(
lat: number,
lng: number,
headers: Record<string, string>,
): Promise<string | null> {
const key = `${lat.toFixed(4)},${lng.toFixed(4)}`;
const cached = stationCache.get(key);
if (cached !== undefined) return cached;
const point = await getJson<PointsResponse>(`${BASE}/points/${key}`, { headers });
const stationsUrl = point?.properties?.observationStations;
if (stationsUrl === undefined) return null;
const stations = await getJson<StationsResponse>(stationsUrl, { headers });
// The list arrives nearest-first, which is the only ordering guarantee needed.
const id = stations?.features?.[0]?.properties?.stationIdentifier;
if (id === undefined) return null;
stationCache.set(key, id);
return id;
}
/** METAR sky cover, as a fraction. The reported layers are cumulative, so the
* densest one is the sky. */
function cloudFromLayers(layers: { amount?: string }[] | undefined): number {
if (layers === undefined || layers.length === 0) return 0;
let max = 0;
for (const layer of layers) {
const amount = layer.amount ?? "";
const fraction =
amount === "OVC" || amount === "VV"
? 1
: amount === "BKN"
? 0.75
: amount === "SCT"
? 0.4
: amount === "FEW"
? 0.15
: 0;
if (fraction > max) max = fraction;
}
return max;
}
function precipitationFrom(present: { weather?: string; intensity?: string | null }[]): number {
let max = 0;
for (const entry of present) {
const weather = entry.weather ?? "";
if (!/rain|drizzle|snow|sleet|hail|thunder/.test(weather)) continue;
const intensity = entry.intensity ?? "moderate";
const value = intensity === "light" ? 0.3 : intensity === "heavy" ? 0.9 : 0.6;
if (value > max) max = value;
}
return max;
}
/** Wind arrives as km/h from most stations and m/s from a few. Both are labelled. */
function toKph(m: Measurement | undefined): number | null {
const value = finiteOrNull(m?.value);
if (value === null) return null;
return (m?.unitCode ?? "").includes("m_s") ? value * 3.6 : value;
}
function metresToKm(m: Measurement | undefined): number | null {
const value = finiteOrNull(m?.value);
return value === null ? null : value / 1000;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* open-meteo.com — opt-in, and off by default.
*
* This one needs its reason on the page, because on the numbers it is the best
* of the three: global, keyless, no contact string, one request, and it reports
* visibility and cloud cover directly. The problem is not the data — that is
* CC BY 4.0 — it is the *tier*. Open-Meteo's free API is for non-commercial use,
* and a product page is a commercial use. Shipping it as the default would put
* every self-hoster on a footing the project cannot vouch for.
*
* So it is here, it works, and turning it on is a decision the operator makes
* with `TERA_WEATHER_SOURCE=openmeteo`. CONTRACT.md §5.2.
*/
import { getJson } from "../http.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
import { deriveCondition, finiteOrNull, unitRange } from "./condition.ts";
const BASE = "https://api.open-meteo.com/v1/forecast";
const FIELDS = [
"temperature_2m",
"precipitation",
"cloud_cover",
"visibility",
"wind_speed_10m",
"wind_direction_10m",
"weather_code",
].join(",");
interface ForecastResponse {
current?: Record<string, number | string | undefined>;
}
export async function fetchOpenMeteo(lat: number, lng: number): Promise<WeatherBody | null> {
const url =
`${BASE}?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}` +
`&current=${FIELDS}&wind_speed_unit=kmh&timezone=UTC`;
const body = await getJson<ForecastResponse>(url);
const current = body?.current;
if (current === undefined) return null;
const num = (key: string): number | null => finiteOrNull(current[key]);
const cloudCover = unitRange((num("cloud_cover") ?? 0) / 100);
const precipitation = unitRange((num("precipitation") ?? 0) / 4);
const visibilityM = num("visibility");
const visibilityKm = visibilityM === null ? null : visibilityM / 1000;
const code = num("weather_code") ?? 0;
return {
observedAt: isoTime(current["time"]),
source: "openmeteo",
synthetic: false,
location: { lat, lng },
temperatureC: num("temperature_2m"),
windKph: num("wind_speed_10m"),
windDirDeg: num("wind_direction_10m"),
cloudCover,
precipitation,
visibilityKm,
condition: deriveCondition({
cloudCover,
precipitation,
visibilityKm,
// WMO 4677 codes: 71-77 and 85-86 are the frozen ones, 95-99 thunder.
frozen: (code >= 71 && code <= 77) || code === 85 || code === 86,
thunder: code >= 95,
}),
attribution: ["Weather data by Open-Meteo.com, licensed CC BY 4.0"],
};
}
/** Open-Meteo stamps `2026-08-04T22:00` with no zone marker; we asked for UTC. */
function isoTime(time: string | number | undefined): string {
if (typeof time !== "string") return new Date().toISOString();
return time.endsWith("Z") ? time : `${time}Z`;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The clear day.
*
* This is what a box with no weather source configured serves, and it is a
* supported steady state rather than an error path — the default value of
* `TERA_WEATHER_SOURCE` is `none`, so this is what the acceptance test sees.
* It is also what a configured source falls back to when it has never once
* answered. CONTRACT.md §5.1.
*
* The numbers are deliberately unremarkable: a light scatter of cloud, no rain,
* good visibility. Nothing here pretends to be an observation, which is what
* `synthetic: true` is on the wire to say.
*/
import type { WeatherBody } from "../../../src/server/wire.ts";
export function clearDay(lat: number, lng: number, now = new Date()): WeatherBody {
return {
observedAt: now.toISOString(),
source: "none",
synthetic: true,
location: { lat, lng },
// Null rather than a plausible-looking number. A renderer reads cloud,
// precipitation and visibility; inventing 18 °C would only ever be wrong.
temperatureC: null,
windKph: null,
windDirDeg: null,
cloudCover: 0.08,
precipitation: 0,
visibilityKm: 40,
condition: "clear",
};
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"types": ["node"]
},
"include": ["src"]
}