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 1db868ad70 The sky is not something an account grants you
`liveData` was one flag meaning two unrelated things, and it was set to
`tier !== "anon"`. That reasoning does not survive asking what the data actually
is. Cloud cover over San Francisco is a reading from a government sensor. The
aircraft are broadcasting their positions, unencrypted, to anyone within range
who owns a forty-dollar receiver. Neither is withheld from anybody by anybody,
so neither is a thing an account can grant access to — and putting them behind a
sign-in cost the only moment that makes this project land: real fog rolling off
the Pacific onto a city you recognise, at the real time of day, on a first
visit. On an SSO-gated deployment it cost that moment for every visitor there
currently is.

So `liveData` splits. `liveEnvironment` is public and unconditional.
`liveMarkers` is asked for by everyone and granted by the server, because the
marker set is the one feed here that can carry something private — a company's
pipeline, a person's job search — and whether it is public is a property of the
deployment, not of a file in this repo.

`TERA_MARKERS_ACCESS` is therefore a server switch and its default is `members`,
which is the safe answer rather than the common one. The failure mode of getting
this wrong is silent: nothing throws, nothing looks broken, the data is simply
readable by the internet. An operator who wires real markers up gets the shut
door without having chosen it and has to say `public` out loud — and saying it
appends a line to `degraded[]`, so `/api/v1/health` reports that this box is
publishing its map without anyone having to go and read the env file. Same
reasoning as `TERA_ADMIN_SUBJECTS=*`.

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

638 lines
25 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,
WeatherSourceId,
} from "../../src/server/wire.ts";
export interface WeatherConfig {
source: WeatherSourceId;
/** Sent as the User-Agent to sources that require an identifiable caller. */
contact: string;
ttlSeconds: number;
}
export interface FlightsConfig {
source: FlightsSourceId;
/** Base URL for the `adsb` source. */
endpoint: string;
/** Radius in nautical miles for the `adsb` source. */
radiusNm: number;
/** Path to a local dump1090 `aircraft.json`. */
dump1090Path: string;
/** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */
epochMs: number;
seed: number;
ttlSeconds: number;
}
export interface MarkersConfig {
source: MarkersSourceId;
/** Path to the JSON snapshot written by the sync oneshot. */
file: string;
/**
* Provenance values the public-shape gate will serve. Anything else is
* refused row by row. See CONTRACT.md §8 and `markers/gate.ts`.
*/
provenanceAllowlist: string[];
ttlSeconds: number;
/**
* 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;
markers: MarkersConfig;
offices: { dir: string };
auth: AuthConfig;
/** One sentence per demotion. Empty on a fully-configured box. */
degraded: string[];
}
type Env = Record<string, string | undefined>;
export function loadConfig(env: Env = process.env): Config {
const degraded: string[] = [];
const weather = loadWeather(env, degraded);
const flights = loadFlights(env, degraded);
const 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,
markers,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
auth,
degraded,
};
}
// ---- Sections -------------------------------------------------------------
const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];
function loadWeather(env: Env, degraded: string[]): WeatherConfig {
const ttlSeconds = num(env, "TERA_WEATHER_TTL", 600, degraded);
const contact = str(env, "TERA_WEATHER_CONTACT", "");
const asked = str(env, "TERA_WEATHER_SOURCE", "none");
let source = oneOf(asked, WEATHER_SOURCES);
if (source === null) {
degraded.push(
`TERA_WEATHER_SOURCE="${asked}" is not one of ${WEATHER_SOURCES.join(", ")}; ` +
`serving synthetic weather instead.`,
);
source = "none";
}
// NWS and MET Norway both require a contact string in the User-Agent and are
// entitled to block a caller who does not send one. Calling them anyway with a
// generic agent is the rude failure mode; refusing to boot is the useless one.
if ((source === "nws" || source === "metno") && contact === "") {
degraded.push(
`TERA_WEATHER_SOURCE=${source} needs TERA_WEATHER_CONTACT (an email or URL ` +
`the operator can be reached at) — ${source} requires an identifiable ` +
`caller. Demoted to synthetic weather; the server is otherwise fine.`,
);
source = "none";
}
if (source === "openmeteo") {
// Not a demotion — a warning that stays on the record. Open-Meteo's *data*
// is CC-BY 4.0, but its free tier is non-commercial, so this is opt-in and
// never a default. CONTRACT.md §5.2.
degraded.push(
"TERA_WEATHER_SOURCE=openmeteo: Open-Meteo's free tier is non-commercial. " +
"Fine for a self-hosted map; check your terms before putting it behind a " +
"product page.",
);
}
return { source, contact, ttlSeconds };
}
const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];
/**
* The simulated plan's phase origin. Deliberately a constant rather than boot
* time: `t0 = Date.now()` would put every aircraft back at the start of its leg
* on every restart, and a fleet of jets teleporting to their departure gates is
* a very visible way to announce a deploy.
*/
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
let source = oneOf(asked, FLIGHT_SOURCES);
if (source === null) {
degraded.push(
`TERA_FLIGHTS_SOURCE="${asked}" is not one of ${FLIGHT_SOURCES.join(", ")}; ` +
`serving the simulated plan instead.`,
);
source = "sim";
}
const dump1090Path = str(env, "TERA_DUMP1090_PATH", "");
if (source === "dump1090" && dump1090Path === "") {
degraded.push(
"TERA_FLIGHTS_SOURCE=dump1090 needs TERA_DUMP1090_PATH pointing at your " +
"receiver's aircraft.json. Demoted to the simulated plan.",
);
source = "sim";
}
return {
source,
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
radiusNm: 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 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";
}