/** * 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; 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$$`. 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(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"; }