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 a229fb2721 The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.

Two things are worth knowing about the shape of it:

  - There is no region parameter. An aeroplane at 10,000 m is local and
    a satellite at 550 km is above the horizon for a circle two thousand
    kilometres across, so one catalogue serves both boards and the client
    decides what is above its own horizon. Only the observer is per-city,
    which is why `main.ts` shares the elements and rebuilds the catalogue.
  - The layer draws on a dome, because it cannot draw anywhere else.
    `world.metres(550_000)` is 21,000 scene units against a far plane at
    3,000. Azimuth and elevation are real; the radius carries nothing.

Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.

Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.

`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:57:14 -07:00

696 lines
27 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 type {
AuthMode,
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. */
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 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 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 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;
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;
/** 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 auth = loadAuth(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,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
presence: { dir: str(env, "TERA_PRESENCE_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: 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 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";
}