1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/config.ts
T
karti db074e9cf7 feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file
partition.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:44:24 -07:00

908 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import { loadRegions, type RegionSet } from "./regions.ts";
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts";
import type {
AuthMode,
DevicesSourceId,
FlightsSourceId,
MarkersSourceId,
SatellitesSourceId,
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, **validated and normalised**.
*
* Empty on every other source, including a source that was demoted to `sim`
* because its endpoint failed the licence gate. That is deliberate: a refused
* URL does not survive into the config, so no later code can fetch it by
* accident and no later reader can mistake it for one this box vouches for.
* `flights/licence.ts` is the gate and explains what it is protecting.
*/
endpoint: string;
/**
* The credit lines a live body carries, derived from the endpoint's host.
*
* Not a constant, and not written next to the fetch. The whole point of
* computing it here is that there is no way for the credit and the source to
* disagree — which they did, for every value of `TERA_ADSB_ENDPOINT` that was
* not adsb.lol.
*/
attribution: string[];
/**
* Whether this source's terms let the box re-serve the bytes to third
* parties. Gates public caching on `/api/v1/flights`.
*/
redistributable: boolean;
/** The licence id behind `redistributable`, or `null` when nothing is live. */
licence: string | null;
/** Radius in nautical miles for the `adsb` source. */
radiusNm: number;
/** Path to a local dump1090 `aircraft.json`. */
dump1090Path: string;
/** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */
epochMs: number;
seed: number;
ttlSeconds: number;
}
export interface SatellitesConfig {
source: SatellitesSourceId;
/**
* Sent as the User-Agent. Not required — CelesTrak does not demand one the way
* NWS does — but sending it is the same courtesy, and it is what lets them
* mail an operator who is hammering them instead of just blocking the address.
*/
contact: string;
ttlSeconds: number;
}
export interface DevicesConfig {
source: DevicesSourceId;
/**
* How long a device snapshot may be held before it is asked for again.
*
* Short, and shorter than weather by two orders of magnitude, because the two
* are different kinds of fact: cloud cover moves over ten minutes and a mute
* button moves when somebody presses it. This is also the TTL the browser is
* told to poll on, so it is the floor on how long a viewer waits to see the
* result of somebody else's command.
*
* It is deliberately **not** a public cache lifetime. Nothing on the devices
* routes is ever publicly cached — see `routes/devices.ts`.
*/
ttlSeconds: number;
/**
* The simulator's seed, so a deployment can be reproduced.
*
* The same seed and the same declarations give the same sequence of readings
* on every box, which is what makes a bug report about a level meter
* actionable and what lets the arena wrap this exact simulator and replay a
* rollout. `src/devices/sim.ts` owns the arithmetic.
*/
seed: number;
}
export interface MarkersConfig {
source: MarkersSourceId;
/** Path to the JSON snapshot written by the sync oneshot. */
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;
/**
* Who may read the feed. `TERA_MARKERS_ACCESS`, `members` by default.
*
* The default is the safe answer rather than the common one, deliberately.
* A marker set is the one feed here that can carry something private — a
* company's pipeline, a person's job search — and the failure mode of getting
* it wrong is silent: nothing errors, nothing looks broken, the data is just
* readable by the internet. So an operator who wires real data up gets
* `members` without having chosen it, and has to say `public` out loud to
* publish it. The weather and the aircraft need no such switch, because a
* government sensor reading and an unencrypted ADS-B broadcast are not
* withheld from anyone by anybody.
*/
access: "members" | "public";
}
/**
* The one account `TERA_AUTH_MODE=password` will sign in, once the environment
* has been read and found to contain a usable credential. `null` everywhere
* else, and `routes/session.ts` reads that null as "this box cannot sign anyone
* in" and answers 404 on the login endpoint.
*/
export interface PasswordLogin {
username: string;
/** Parsed at boot so a typo in the hash is a health line, not a login that never works. */
hash: ScryptHash;
/** Lifetime of the token this box issues for itself, in seconds. */
sessionTtlSeconds: number;
/** Failed attempts one IP may make inside `rateWindowSeconds` before 429s start. */
rateAttempts: number;
rateWindowSeconds: number;
}
/**
* Who holds the god tier, resolved from `TERA_ADMIN_SUBJECTS`. See `loadAdmins`
* for the rules; the shape exists so that "everyone" is a state the type system
* knows about rather than a magic string left sitting in `subjects`.
*/
export interface AdminGrant {
/** `TERA_ADMIN_SUBJECTS=*`. Development only; see `loadAdmins`. */
everyone: boolean;
/** Exact subject ids, trimmed. Empty with `everyone: false` means no admins. */
subjects: string[];
}
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;
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
passwordLogin: PasswordLogin | null;
/** Subjects the server will call admins. Unset means nobody; see `loadAdmins`. */
admins: AdminGrant;
}
export interface IceConfig {
configured: boolean;
urls: string[];
/** Server-only coturn REST shared secret. Never serialize this config. */
sharedSecret: string;
credentialTtlSeconds: number;
rateAttempts: number;
rateWindowSeconds: number;
}
export interface Config {
host: string;
port: number;
logLevel: string;
version: string;
/**
* The default place this box serves, in degrees.
*
* Kept because it is what a self-hoster already wrote in their env file, and
* because `regions[0]` is derived from it. Nothing per-request reads it any
* more: weather and flights answer for a *resolved region*, since one origin
* cannot describe two cities six hundred kilometres apart. See `regions.ts`.
*/
origin: { lat: number; lng: number };
/** Every place this box will answer for. The first one is the default. */
regions: RegionSet;
/** 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;
satellites: SatellitesConfig;
markers: MarkersConfig;
devices: DevicesConfig;
offices: { dir: string };
/**
* Where the rosters are. Separate from `offices.dir` because the two hold
* different kinds of secret — see `presence/store.ts`. Unset is the default
* and means nobody is in any building, which renders correctly.
*/
presence: { dir: string };
auth: AuthConfig;
ice: IceConfig;
/** 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 satellites = loadSatellites(env, degraded);
const devices = loadDevices(env, degraded);
const auth = loadAuth(env, degraded);
const ice = loadIce(env, degraded);
// After auth, because a marker feed with nobody able to sign in is worth a
// sentence and the sentence is only true once `mode` has finished demoting.
const markers = loadMarkers(env, auth.mode, degraded);
const origin = {
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, 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,
regions: loadRegions({
spec: str(env, "TERA_REGIONS", ""),
origin,
originConfigured:
str(env, "TERA_ORIGIN_LAT", "") !== "" || str(env, "TERA_ORIGIN_LNG", "") !== "",
degraded,
}),
corsOrigins: list(env, "TERA_CORS_ORIGIN"),
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
weather,
flights,
satellites,
markers,
devices,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
auth,
ice,
degraded,
};
}
// ---- Sections -------------------------------------------------------------
function loadIce(env: Env, degraded: string[]): IceConfig {
const rawUrls = env.TERA_ICE_URLS;
const rawSecret = env.TERA_TURN_SHARED_SECRET;
const absent = (rawUrls === undefined || rawUrls.trim() === "") &&
(rawSecret === undefined || rawSecret === "");
const disabled: IceConfig = {
configured: false,
urls: [],
sharedSecret: "",
credentialTtlSeconds: 300,
rateAttempts: 30,
rateWindowSeconds: 60,
};
if (absent) return disabled;
const urls = (rawUrls ?? "").split(",").map((value) => value.trim()).filter(Boolean);
const secret = rawSecret ?? "";
const ttl = strictInteger(env.TERA_TURN_CREDENTIAL_TTL, 300, 60, 3_600);
const attempts = strictInteger(env.TERA_ICE_RATE_ATTEMPTS, 30, 1, 300);
const windowSeconds = strictInteger(env.TERA_ICE_RATE_WINDOW, 60, 1, 3_600);
const urlsValid = urls.length > 0 && urls.length <= 8 && new Set(urls).size === urls.length &&
urls.every(isSafeIceUrl) && urls.some((url) => url.startsWith("turn:") || url.startsWith("turns:"));
const secretValid = secret.length >= 32 && secret.length <= 4_096 && secret === secret.trim() &&
!/[\u0000-\u001f\u007f]/.test(secret);
if (!urlsValid || !secretValid || ttl === null || attempts === null || windowSeconds === null) {
degraded.push("ICE credential service is disabled because its TURN configuration is incomplete or invalid.");
return disabled;
}
return {
configured: true,
urls,
sharedSecret: secret,
credentialTtlSeconds: ttl,
rateAttempts: attempts,
rateWindowSeconds: windowSeconds,
};
}
function strictInteger(raw: string | undefined, fallback: number, minimum: number, maximum: number): number | null {
if (raw === undefined || raw === "") return fallback;
if (!/^[0-9]+$/.test(raw)) return null;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null;
}
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);
/**
* The feed pointed at when `TERA_FLIGHTS_SOURCE=adsb` and nothing else is said.
*
* It is on the allowlist, so the default configuration passes its own gate —
* which is the only kind of default worth shipping, and is asserted in
* `test/adsbLicence.test.ts` so it stays that way.
*/
const DEFAULT_ADSB_ENDPOINT = "https://api.adsb.lol";
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
let source = oneOf(asked, FLIGHT_SOURCES);
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";
}
const askedEndpoint = str(env, "TERA_ADSB_ENDPOINT", DEFAULT_ADSB_ENDPOINT);
let endpoint = "";
let attribution: string[] = [];
let redistributable = false;
let licence: string | null = null;
if (source === "adsb") {
// The licence gate. Everything the wire will say about this source is
// decided here, from the host, before a single request goes out.
const verdict = checkAdsbEndpoint(askedEndpoint);
if (verdict.ok) {
endpoint = verdict.endpoint;
attribution = verdict.attribution;
redistributable = verdict.terms.redistributable;
licence = verdict.terms.licence;
if (verdict.caveat !== null) degraded.push(verdict.caveat);
} else {
degraded.push(
`TERA_ADSB_ENDPOINT="${askedEndpoint}" ${verdict.reason}. Demoted to the simulated ` +
"plan: this box will not republish a feed whose terms it cannot name, and it will " +
"not credit one feed for another feed's data.",
);
source = "sim";
}
}
if (source === "dump1090") {
// The same claim the loopback entry makes, from the same table, because a
// receiver's own aircraft.json and a receiver's own HTTP port are the same
// data arriving by different doors and must not be credited differently.
attribution = adsbAttribution(FIRST_PARTY_RECEIVER);
redistributable = FIRST_PARTY_RECEIVER.redistributable;
licence = FIRST_PARTY_RECEIVER.licence;
}
return {
source,
endpoint,
attribution,
redistributable,
licence,
radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
dump1090Path,
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded),
ttlSeconds: num(env, "TERA_FLIGHTS_TTL", 300, degraded),
};
}
/**
* The largest circle the hosted feeds will answer for: adsb.lol and
* airplanes.live both cap `/v2/point/:lat/:lng/:radius` at 250 nautical miles
* and reject anything larger outright. Clamping here rather than letting the
* request fail keeps the failure legible — an operator who typed 2500 gets a map
* with aircraft on it and a sentence in `degraded`, not a silently empty sky.
*/
const MAX_ADSB_RADIUS_NM = 250;
function radius(asked: number, degraded: string[]): number {
if (asked >= 1 && asked <= MAX_ADSB_RADIUS_NM) return asked;
const clamped = Math.min(MAX_ADSB_RADIUS_NM, Math.max(1, asked));
degraded.push(
`TERA_ADSB_RADIUS_NM=${asked} is outside the 1${MAX_ADSB_RADIUS_NM} nautical ` +
`miles the hosted feeds answer for; using ${clamped}.`,
);
return clamped;
}
const DEVICE_SOURCES: DevicesSourceId[] = ["none", "sim", "homeassistant"];
/**
* `none` by default, and the default is the honest one rather than the
* impressive one.
*
* A box that has not been told about any hardware has no hardware. It serves an
* empty array and the studio's panels say so, which is the correct picture of a
* deployment nobody has wired anything into — the same posture
* `TERA_WEATHER_SOURCE` takes for exactly the reason CONTRACT.md §5.1 gives.
* `sim` is one variable away and is what the reference deployment runs: a
* deterministic state machine, `synthetic: true` on every reading it produces,
* and every panel that draws it carries the declaration's own disclosure
* sentence.
*
* `homeassistant` is in the union and is not implemented. That is deliberate
* and it demotes loudly rather than silently serving simulated readings under a
* name that promises real ones — a source that quietly downgraded from a real
* bridge to a simulator would be the exact `first-party-sensor`/`simulated`
* confusion `DeviceProvenance` exists to prevent, and it would do it in the one
* direction that matters.
*/
function loadDevices(env: Env, degraded: string[]): DevicesConfig {
const asked = str(env, "TERA_DEVICES_SOURCE", "none");
let source = oneOf(asked, DEVICE_SOURCES);
if (source === null) {
degraded.push(
`TERA_DEVICES_SOURCE="${asked}" is not one of ${DEVICE_SOURCES.join(", ")}; ` +
`serving no device state at all.`,
);
source = "none";
}
if (source === "homeassistant") {
degraded.push(
"TERA_DEVICES_SOURCE=homeassistant is named in the wire contract and is not " +
"implemented in this build. Demoted to none rather than to sim: serving " +
"invented readings under a source that promises a real bridge is the one " +
"mistake this field exists to prevent.",
);
source = "none";
}
return {
source,
ttlSeconds: num(env, "TERA_DEVICES_TTL", 5, degraded),
seed: num(env, "TERA_DEVICES_SEED", 8731, degraded),
};
}
const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];
/**
* Off by default, which is the opposite of the flight plan and is the right way
* round for this one.
*
* The simulated sky costs nothing and touches no network, so it can be the
* default. A satellite catalogue is somebody else's multi-megabyte file, fetched
* from a service run by one person, and a repo whose every clone starts pulling
* it the moment `npm run dev` finishes is a repo that has volunteered CelesTrak
* to host its onboarding. An operator who wants the layer says so.
*
* The zero-config boot check (`scripts/check-zero-config-boot.mjs`) depends on
* this too: a box with no environment at all must make no outbound requests.
*/
function loadSatellites(env: Env, degraded: string[]): SatellitesConfig {
const asked = str(env, "TERA_SATELLITES_SOURCE", "none");
let source = oneOf(asked, SATELLITE_SOURCES);
if (source === null) {
degraded.push(
`TERA_SATELLITES_SOURCE="${asked}" is not one of ${SATELLITE_SOURCES.join(", ")}; ` +
`serving no satellites.`,
);
source = "none";
}
return {
source,
// Falls back to the weather contact, because it is the same operator and the
// same courtesy, and making somebody type their email into two variables to
// get one User-Agent is a way of ensuring one of them stays empty.
contact: str(env, "TERA_SATELLITES_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
ttlSeconds: num(env, "TERA_SATELLITES_TTL", 21_600, 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, authMode: AuthMode, 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";
}
// Not a demotion — the feed stays configured and the route keeps refusing
// correctly — but an operator who mounted a marker file on a box where nobody
// can sign in has built something no browser will ever see, and one sentence
// now is cheaper than an afternoon. `routes/markers.ts` has the reasoning for
// why the feed is members-only with no public escape hatch.
const askedAccess = str(env, "TERA_MARKERS_ACCESS", "members");
const access = askedAccess === "public" ? "public" : "members";
if (askedAccess !== "members" && askedAccess !== "public") {
degraded.push(
`TERA_MARKERS_ACCESS="${askedAccess}" is not one of members, public; ` +
"keeping the feed members-only.",
);
}
if (source === "file" && access === "public") {
// Not a demotion — a deliberate choice, announced. It goes in `degraded[]`
// because that array is what `/api/v1/health` publishes, and an operator
// reading their own health endpoint should be able to see that this box is
// serving its marker set to the world without going and reading the env
// file. The same reasoning as `TERA_ADMIN_SUBJECTS=*`.
degraded.push(
"TERA_MARKERS_ACCESS=public: the marker feed is served to anonymous " +
"callers. Correct for a public map; wrong for anything private.",
);
}
// Members-only and nobody can ever be a member is the combination that answers
// 401 to the entire internet, which looks like an outage rather than a policy.
if (source === "file" && access === "members" && authMode === "none") {
degraded.push(
"TERA_MARKERS_SOURCE=file needs an authentication mode: the marker feed is " +
"refused to anonymous callers, and with TERA_AUTH_MODE=none nobody is ever " +
"anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE, or " +
"TERA_MARKERS_ACCESS=public if the map is meant to be open.",
);
}
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),
access,
};
}
/**
* `password` is a mode an operator asks for, not a mode the rest of the server
* ever sees. It resolves to `jwt` below; see `loadPasswordLogin` for why.
*/
type ConfiguredAuthMode = AuthMode | "password";
const AUTH_MODES: ConfiguredAuthMode[] = ["none", "sso", "jwt", "password"];
function loadAuth(env: Env, degraded: string[]): AuthConfig {
const asked = str(env, "TERA_AUTH_MODE", "none");
let mode: ConfiguredAuthMode | null = 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", "");
let jwtVerify: "hs256" | "jwks" =
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";
}
// `password` collapses into `jwt` here, and that is the whole design rather
// than an implementation detail. `routes/session.ts` signs exactly the token
// `jwt` mode already verifies, so a browser that signed in on this box and a
// browser carrying a token from the fleet's issuer arrive at `offices.ts`
// through the same `resolve()` — one authorisation path that can be reasoned
// about, rather than two that have to be kept in agreement. What `password`
// adds is an issuer, not a verifier.
let passwordLogin: PasswordLogin | null = null;
if (mode === "password") {
passwordLogin = loadPasswordLogin(env, jwtSecret, degraded);
if (passwordLogin === null) {
mode = "none";
} else {
mode = "jwt";
if (jwtVerify === "jwks") {
// Verifying against someone else's public keys while signing with a
// local secret means this box would reject its own sessions.
degraded.push(
"TERA_AUTH_MODE=password issues its own HS256 tokens, so " +
"TERA_AUTH_JWT_VERIFY=jwks was ignored; this box verifies the " +
"sessions it signs.",
);
jwtVerify = "hs256";
}
}
}
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", ""),
passwordLogin,
// Last, because it wants the mode *after* every demotion above has run: a
// list of admins on a box that just demoted to mode=none is worth a
// sentence, and the sentence is only true once `mode` has settled.
admins: loadAdmins(env, mode, degraded),
};
}
/** `TERA_ADMIN_SUBJECTS=*`; see `loadAdmins` for why this is a dev switch. */
const ADMIN_WILDCARD = "*";
/**
* Who gets the god tier — the time controls, the debug panel, and whatever else
* ends up behind `Viewer.admin`.
*
* `TERA_ADMIN_SUBJECTS` is a comma-separated list of **subject ids**: the `sub`
* claim this box verifies, not an email and not a display name. Under
* `TERA_AUTH_MODE=password` the subject is `TERA_AUTH_PASSWORD_USER`, so the
* single self-hosted account becomes an admin by naming it here — and only by
* naming it here. Password mode is deliberately not auto-admin: one grant path,
* written down in the environment, is the property worth having.
*
* **Unset means no admins, and that is the safe default.** A box handed nothing
* serves the public map to everyone and the god tier to nobody. Matching is
* exact after trimming, and case-sensitive: subject ids are opaque strings an
* issuer minted, `user_01H…` and `USER_01H…` can be two different accounts, and
* folding case here would silently widen a grant to an id nobody configured.
*
* The one wildcard is `*`, which makes **every authenticated subject an admin**.
* It is here so a self-hoster poking at this on a laptop does not have to go
* find their own subject id first. It is a development switch, it must never
* reach a deployment env file, and it pushes a line into `degraded` so that
* `/api/v1/health` says out loud that the box is handing out godmode.
*
* That loudness is the whole point, and it is paid for. lumbridge-v4 shipped
* `ADMIN_EMAILS` with `admin@lumbridgecorp.com` as a committed default while
* nobody had ever registered that address — a standing offer of admin to
* whoever signed up for it first, invisible because nothing anywhere announced
* it. A grant nobody can see is a grant nobody revokes. Hence: no committed
* defaults, no implicit grants, and the one blanket switch reports itself.
*
* Note what is *not* here: no count and no list ever reaches the wire.
* `routes/health.ts` serves `degraded`, and these sentences name the variable,
* never its contents.
*/
function loadAdmins(env: Env, mode: AuthMode, degraded: string[]): AdminGrant {
const configured = list(env, "TERA_ADMIN_SUBJECTS");
const everyone = configured.includes(ADMIN_WILDCARD);
const subjects = configured.filter((subject) => subject !== ADMIN_WILDCARD);
if (everyone) {
degraded.push(
"TERA_ADMIN_SUBJECTS=* grants the admin tier to every authenticated " +
"subject on this box, time controls and debug included. That is a " +
"development switch; anywhere reachable from outside, list the subject " +
"ids instead.",
);
}
// Not a demotion of this setting — nothing falls back — but a line worth
// printing, because admin is a property of an *authenticated* viewer and
// mode=none never produces one. An operator who listed admins here and reads
// `degraded` learns in one sentence why nobody is getting them.
if ((everyone || subjects.length > 0) && mode === "none") {
degraded.push(
"TERA_ADMIN_SUBJECTS is set, but authentication resolved to mode=none: " +
"nobody can sign in, so nobody is an admin. Set TERA_AUTH_MODE, and " +
"check the lines above for an auth demotion that got you here.",
);
}
return { everyone, subjects };
}
/**
* The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
* the environment did not supply a usable one.
*
* Every failure here is a demotion to `mode=none` rather than a refusal to boot,
* which is the rule the whole file follows — but note which direction the
* demotion runs: mode=none makes every private office answer 404 to everybody,
* including the operator. Nobody is let in by a misconfiguration.
*
* `TERA_AUTH_PASSWORD_HASH` is a hash and there is deliberately no plaintext
* equivalent to set. `auth/password.ts` carries the command that produces one.
*/
function loadPasswordLogin(
env: Env,
jwtSecret: string,
degraded: string[],
): PasswordLogin | null {
const username = str(env, "TERA_AUTH_PASSWORD_USER", "");
const encoded = str(env, "TERA_AUTH_PASSWORD_HASH", "");
if (username === "" || encoded === "") {
degraded.push(
"TERA_AUTH_MODE=password needs TERA_AUTH_PASSWORD_USER and " +
"TERA_AUTH_PASSWORD_HASH (a scrypt hash — see server/src/auth/password.ts " +
"for the one-liner that prints one). Demoted to mode=none; private " +
"offices will answer 404 to everyone.",
);
return null;
}
if (jwtSecret === "") {
degraded.push(
"TERA_AUTH_MODE=password needs TERA_AUTH_JWT_SECRET to sign the session " +
"cookie it issues — try `openssl rand -base64 48`. Demoted to mode=none.",
);
return null;
}
const hash = parseScryptHash(encoded);
if (hash === null) {
degraded.push(
"TERA_AUTH_PASSWORD_HASH is not a hash this server can read; it should " +
"look like `scrypt$16384$8$1$<salt>$<key>`. Demoted to mode=none.",
);
return null;
}
return {
username,
hash,
sessionTtlSeconds: num(env, "TERA_AUTH_SESSION_TTL", 43_200, degraded),
rateAttempts: num(env, "TERA_AUTH_LOGIN_ATTEMPTS", 8, degraded),
rateWindowSeconds: num(env, "TERA_AUTH_LOGIN_WINDOW", 300, degraded),
};
}
// ---- 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";
}