Spaces: the inside of the world, and a sun that is actually where it should be
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* The environment, read once, with every default chosen so that reading nothing
|
||||
* still produces a working server.
|
||||
*
|
||||
* This module is where CONTRACT.md §5.1 is enforced, and it is worth stating the
|
||||
* rule in one sentence because two independent designs got it wrong the same
|
||||
* way: **a source configured without what it needs is demoted, not fatal.** The
|
||||
* acceptance test for this whole repo is a stranger with no keys, and a boot
|
||||
* that throws because `TERA_WEATHER_CONTACT` is unset fails it. Every demotion
|
||||
* appends one loud sentence to `degraded`, which `index.ts` logs and
|
||||
* `/api/v1/health` serves, so the state is visible without being terminal.
|
||||
*
|
||||
* The same applies to malformed values: `TERA_PORT=banana` warns and falls back
|
||||
* to 8431. A process that will not start is worse than a process that starts on
|
||||
* the default port and says so.
|
||||
*
|
||||
* Prefix is `TERA_*` throughout, with no exceptions and no legacy aliases.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import type {
|
||||
AuthMode,
|
||||
FlightsSourceId,
|
||||
MarkersSourceId,
|
||||
WeatherSourceId,
|
||||
} from "../../src/server/wire.ts";
|
||||
|
||||
export interface WeatherConfig {
|
||||
source: WeatherSourceId;
|
||||
/** Sent as the User-Agent to sources that require an identifiable caller. */
|
||||
contact: string;
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface FlightsConfig {
|
||||
source: FlightsSourceId;
|
||||
/** Base URL for the `adsb` source. */
|
||||
endpoint: string;
|
||||
/** Radius in nautical miles for the `adsb` source. */
|
||||
radiusNm: number;
|
||||
/** Path to a local dump1090 `aircraft.json`. */
|
||||
dump1090Path: string;
|
||||
/** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */
|
||||
epochMs: number;
|
||||
seed: number;
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface MarkersConfig {
|
||||
source: MarkersSourceId;
|
||||
/** Path to the JSON snapshot written by the sync oneshot. */
|
||||
file: string;
|
||||
/**
|
||||
* Provenance values the public-shape gate will serve. Anything else is
|
||||
* refused row by row. See CONTRACT.md §8 and `markers/gate.ts`.
|
||||
*/
|
||||
provenanceAllowlist: string[];
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
mode: AuthMode;
|
||||
/** Where a browser sends someone to sign in. `sso` mode only. */
|
||||
entryUrl: string;
|
||||
/** Server-side token check. `sso` mode only; this box holds no credentials. */
|
||||
revalidateUrl: string;
|
||||
/** Cookie a browser session arrives in, when it is not an Authorization header. */
|
||||
cookieName: string;
|
||||
/** HS256 shared secret. Primary, because the fleet's issuer signs HS256. */
|
||||
jwtSecret: string;
|
||||
/** Set to `jwks` to verify asymmetric signatures instead. */
|
||||
jwtVerify: "hs256" | "jwks";
|
||||
jwksUrl: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: string;
|
||||
version: string;
|
||||
/** The city this box is serving, in degrees. Used by weather and by flights. */
|
||||
origin: { lat: number; lng: number };
|
||||
/** Allowed CORS origins. Empty means same-origin only, which is the default. */
|
||||
corsOrigins: string[];
|
||||
/** `max-age` for routes that opt in to public caching. */
|
||||
publicMaxAge: number;
|
||||
weather: WeatherConfig;
|
||||
flights: FlightsConfig;
|
||||
markers: MarkersConfig;
|
||||
offices: { dir: string };
|
||||
auth: AuthConfig;
|
||||
/** One sentence per demotion. Empty on a fully-configured box. */
|
||||
degraded: string[];
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export function loadConfig(env: Env = process.env): Config {
|
||||
const degraded: string[] = [];
|
||||
|
||||
const weather = loadWeather(env, degraded);
|
||||
const flights = loadFlights(env, degraded);
|
||||
const markers = loadMarkers(env, degraded);
|
||||
const auth = loadAuth(env, degraded);
|
||||
|
||||
return {
|
||||
host: str(env, "TERA_HOST", "127.0.0.1"),
|
||||
port: num(env, "TERA_PORT", 8431, degraded),
|
||||
logLevel: str(env, "TERA_LOG_LEVEL", "info"),
|
||||
version: readVersion(),
|
||||
origin: {
|
||||
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
|
||||
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded),
|
||||
},
|
||||
corsOrigins: list(env, "TERA_CORS_ORIGIN"),
|
||||
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
|
||||
weather,
|
||||
flights,
|
||||
markers,
|
||||
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
|
||||
auth,
|
||||
degraded,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Sections -------------------------------------------------------------
|
||||
|
||||
const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];
|
||||
|
||||
function loadWeather(env: Env, degraded: string[]): WeatherConfig {
|
||||
const ttlSeconds = num(env, "TERA_WEATHER_TTL", 600, degraded);
|
||||
const contact = str(env, "TERA_WEATHER_CONTACT", "");
|
||||
const asked = str(env, "TERA_WEATHER_SOURCE", "none");
|
||||
|
||||
let source = oneOf(asked, WEATHER_SOURCES);
|
||||
if (source === null) {
|
||||
degraded.push(
|
||||
`TERA_WEATHER_SOURCE="${asked}" is not one of ${WEATHER_SOURCES.join(", ")}; ` +
|
||||
`serving synthetic weather instead.`,
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
// NWS and MET Norway both require a contact string in the User-Agent and are
|
||||
// entitled to block a caller who does not send one. Calling them anyway with a
|
||||
// generic agent is the rude failure mode; refusing to boot is the useless one.
|
||||
if ((source === "nws" || source === "metno") && contact === "") {
|
||||
degraded.push(
|
||||
`TERA_WEATHER_SOURCE=${source} needs TERA_WEATHER_CONTACT (an email or URL ` +
|
||||
`the operator can be reached at) — ${source} requires an identifiable ` +
|
||||
`caller. Demoted to synthetic weather; the server is otherwise fine.`,
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
if (source === "openmeteo") {
|
||||
// Not a demotion — a warning that stays on the record. Open-Meteo's *data*
|
||||
// is CC-BY 4.0, but its free tier is non-commercial, so this is opt-in and
|
||||
// never a default. CONTRACT.md §5.2.
|
||||
degraded.push(
|
||||
"TERA_WEATHER_SOURCE=openmeteo: Open-Meteo's free tier is non-commercial. " +
|
||||
"Fine for a self-hosted map; check your terms before putting it behind a " +
|
||||
"product page.",
|
||||
);
|
||||
}
|
||||
|
||||
return { source, contact, ttlSeconds };
|
||||
}
|
||||
|
||||
const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];
|
||||
|
||||
/**
|
||||
* The simulated plan's phase origin. Deliberately a constant rather than boot
|
||||
* time: `t0 = Date.now()` would put every aircraft back at the start of its leg
|
||||
* on every restart, and a fleet of jets teleporting to their departure gates is
|
||||
* a very visible way to announce a deploy.
|
||||
*/
|
||||
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);
|
||||
|
||||
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
|
||||
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
|
||||
let source = oneOf(asked, FLIGHT_SOURCES);
|
||||
if (source === null) {
|
||||
degraded.push(
|
||||
`TERA_FLIGHTS_SOURCE="${asked}" is not one of ${FLIGHT_SOURCES.join(", ")}; ` +
|
||||
`serving the simulated plan instead.`,
|
||||
);
|
||||
source = "sim";
|
||||
}
|
||||
|
||||
const dump1090Path = str(env, "TERA_DUMP1090_PATH", "");
|
||||
if (source === "dump1090" && dump1090Path === "") {
|
||||
degraded.push(
|
||||
"TERA_FLIGHTS_SOURCE=dump1090 needs TERA_DUMP1090_PATH pointing at your " +
|
||||
"receiver's aircraft.json. Demoted to the simulated plan.",
|
||||
);
|
||||
source = "sim";
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
|
||||
radiusNm: num(env, "TERA_ADSB_RADIUS_NM", 40, degraded),
|
||||
dump1090Path,
|
||||
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
|
||||
seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded),
|
||||
ttlSeconds: num(env, "TERA_FLIGHTS_TTL", 300, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];
|
||||
|
||||
/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
|
||||
export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"];
|
||||
|
||||
function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
|
||||
const asked = str(env, "TERA_MARKERS_SOURCE", "none");
|
||||
let source = oneOf(asked, MARKER_SOURCES);
|
||||
if (source === null) {
|
||||
degraded.push(
|
||||
`TERA_MARKERS_SOURCE="${asked}" is not one of ${MARKER_SOURCES.join(", ")}; ` +
|
||||
`serving no markers.`,
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
const file = str(env, "TERA_MARKERS_FILE", "");
|
||||
if (source === "file" && file === "") {
|
||||
degraded.push(
|
||||
"TERA_MARKERS_SOURCE=file needs TERA_MARKERS_FILE. Serving no markers.",
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST");
|
||||
return {
|
||||
source,
|
||||
file,
|
||||
provenanceAllowlist: allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST,
|
||||
ttlSeconds: num(env, "TERA_MARKERS_TTL", 300, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"];
|
||||
|
||||
function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
const asked = str(env, "TERA_AUTH_MODE", "none");
|
||||
let mode = oneOf(asked, AUTH_MODES);
|
||||
if (mode === null) {
|
||||
degraded.push(
|
||||
`TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` +
|
||||
`running open (mode=none).`,
|
||||
);
|
||||
mode = "none";
|
||||
}
|
||||
|
||||
const entryUrl = str(env, "TERA_AUTH_ENTRY_URL", "");
|
||||
const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
|
||||
const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
|
||||
const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", "");
|
||||
const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
|
||||
|
||||
// A demotion here has teeth: it takes private offices with it, which is the
|
||||
// safe direction. Unverifiable credentials must never mean "let them in".
|
||||
if (mode === "sso" && revalidateUrl === "") {
|
||||
degraded.push(
|
||||
"TERA_AUTH_MODE=sso needs TERA_AUTH_REVALIDATE_URL — this box holds no " +
|
||||
"credentials and cannot check a session without somewhere to ask. " +
|
||||
"Demoted to mode=none; private offices will answer 404 to everyone.",
|
||||
);
|
||||
mode = "none";
|
||||
}
|
||||
if (mode === "jwt" && jwtVerify === "hs256" && jwtSecret === "") {
|
||||
degraded.push(
|
||||
"TERA_AUTH_MODE=jwt needs TERA_AUTH_JWT_SECRET (or TERA_AUTH_JWT_VERIFY=jwks " +
|
||||
"with TERA_AUTH_JWKS_URL). Demoted to mode=none; private offices will " +
|
||||
"answer 404 to everyone.",
|
||||
);
|
||||
mode = "none";
|
||||
}
|
||||
if (mode === "jwt" && jwtVerify === "jwks" && jwksUrl === "") {
|
||||
degraded.push(
|
||||
"TERA_AUTH_JWT_VERIFY=jwks needs TERA_AUTH_JWKS_URL. Demoted to mode=none.",
|
||||
);
|
||||
mode = "none";
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
entryUrl,
|
||||
revalidateUrl,
|
||||
cookieName: str(env, "TERA_AUTH_COOKIE", "tera_session"),
|
||||
jwtSecret,
|
||||
jwtVerify,
|
||||
jwksUrl,
|
||||
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
|
||||
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Readers --------------------------------------------------------------
|
||||
|
||||
function str(env: Env, key: string, fallback: string): string {
|
||||
const raw = env[key];
|
||||
if (raw === undefined) return fallback;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? fallback : trimmed;
|
||||
}
|
||||
|
||||
function num(env: Env, key: string, fallback: number, degraded: string[]): number {
|
||||
const raw = env[key];
|
||||
if (raw === undefined || raw.trim() === "") return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
degraded.push(`${key}="${raw}" is not a number; using ${fallback}.`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Comma-separated, whitespace-tolerant, empties dropped. */
|
||||
function list(env: Env, key: string): string[] {
|
||||
return str(env, key, "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== "");
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: string, allowed: T[]): T | null {
|
||||
return allowed.includes(value as T) ? (value as T) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version served by `/health`, read from `package.json` so it cannot drift
|
||||
* from the thing that is actually deployed. A missing or unreadable file is not
|
||||
* worth dying over — nothing depends on this but a human reading a health check.
|
||||
*/
|
||||
function readVersion(): string {
|
||||
try {
|
||||
const url = new URL("../package.json", import.meta.url);
|
||||
const parsed: unknown = JSON.parse(readFileSync(url, "utf8"));
|
||||
if (parsed !== null && typeof parsed === "object" && "version" in parsed) {
|
||||
const v = (parsed as { version: unknown }).version;
|
||||
if (typeof v === "string") return v;
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
Reference in New Issue
Block a user