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/scripts/check-zero-config-boot.mjs
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

347 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* The zero-config boot gate: hand the API nothing at all and it still answers.
*
* This exists because two independent server designs made the same mistake —
* defaulting the weather source to a provider that requires a contact string,
* then failing hard when the contact was absent — which breaks the one
* acceptance test the whole repo is built around: a stranger clones this, runs
* one command, and gets a working box with no account, no key and no network.
* CONTRACT.md §5.1 resolves it (a source configured without what it needs is
* demoted, not fatal) and this script is what keeps the resolution honest.
*
* The environment handed to the server is genuinely empty — `env: {}`, the
* in-process equivalent of `env -i` — which is possible only because the child
* is launched by absolute path (`process.execPath`) and so needs no PATH to find
* itself. Nothing is stubbed and no config object is constructed by hand: this
* starts the real entry point and asks the real socket, which is the difference
* between this and `server/src/test/boot.test.ts`, which asserts the same
* property one layer down.
*
* node scripts/check-zero-config-boot.mjs
* node scripts/check-zero-config-boot.mjs --compose
*
* The second form is CONTRACT.md §0's literal wording — `docker compose up` under
* an empty environment — and needs a working Docker. CI runs the first form,
* because a gate that depends on docker-in-docker being present on the runner is
* measuring the runner rather than the repo. The two assert the same thing: the
* compose file adds only `TERA_HOST` and container hardening, and every other
* variable in it carries a `:-` default precisely so that an empty environment
* resolves it to the empty string.
*/
import { spawn } from "node:child_process";
import { connect } from "node:net";
import { fileURLToPath } from "node:url";
// ---- Where and what ----
const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
const HEALTH_HOST = "127.0.0.1";
const HEALTH_PORT = 8431;
const HEALTH_URL = `http://${HEALTH_HOST}:${HEALTH_PORT}/api/v1/health`;
/** How long the server gets to bind and answer before this gives up. */
const PROCESS_DEADLINE_MS = 20_000;
/** Compose has to build an image first, which is a different order of patience. */
const COMPOSE_DEADLINE_MS = 300_000;
const useCompose = process.argv.slice(2).includes("--compose");
// ---- Reporting ----
function fail(headline, detail = [], log = []) {
console.error(`\ncheck-zero-config-boot: FAIL — ${headline}\n`);
for (const line of detail) console.error(line);
if (log.length > 0) {
console.error("\n--- what the server said ---");
console.error(log.join("").trimEnd());
console.error("--- end ---");
}
console.error("");
process.exitCode = 1;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---- Making sure we are testing our own server ----
/**
* Is anything already listening on the port we are about to claim?
*
* This guard is here because the check silently passed without it. The port was
* occupied by an unrelated process, the server we spawned died on `EADDRINUSE`
* within a second, and the poll cheerfully collected a 200 from the stranger —
* a green gate asserting nothing at all. A check that can pass against a server
* it did not start is worse than no check, so an occupied port is a hard stop.
*/
function portInUse() {
return new Promise((resolve) => {
const socket = connect({ host: HEALTH_HOST, port: HEALTH_PORT });
const settle = (answer) => {
socket.destroy();
resolve(answer);
};
socket.setTimeout(1_500);
socket.once("connect", () => settle(true));
socket.once("timeout", () => settle(false));
socket.once("error", () => settle(false));
});
}
async function assertPortFree() {
if (!(await portInUse())) return true;
fail(`something is already listening on ${HEALTH_HOST}:${HEALTH_PORT}.`, [
"This check has to own that port, because otherwise it polls whatever is there and",
"reports a healthy stranger while the server it started is dead in a ditch. It cannot",
"move to another port either: choosing one would mean setting TERA_PORT, and the empty",
"environment is the thing under test.",
"",
"Stop whatever is on the port and run it again.",
]);
return false;
}
/**
* A second line of defence against answering for somebody else's process: if the
* server on the other end has been up longer than this check has been running,
* it is not the one we started.
*/
function uptimeLooksLikeOurs(body, startedAt) {
const allowed = Math.ceil((Date.now() - startedAt) / 1000) + 2;
return typeof body.uptimeSeconds !== "number" || body.uptimeSeconds <= allowed;
}
// ---- The assertion ----
/**
* Poll until health answers or the deadline passes. `stillRunning` lets the
* caller abort early when the thing under test has already died, so a server
* that exits on boot reports its own error instead of a twenty-second timeout.
*/
async function waitForHealth(deadlineMs, stillRunning) {
const deadline = Date.now() + deadlineMs;
let lastError = "nothing was listening";
while (Date.now() < deadline) {
if (!stillRunning()) return { dead: true, lastError };
try {
const response = await fetch(HEALTH_URL, {
signal: AbortSignal.timeout(2_000),
});
const text = await response.text();
return { status: response.status, text };
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await sleep(250);
}
return { timedOut: true, lastError };
}
/**
* Everything the health body has to say on a box that was handed nothing.
*
* The source and mode checks are not padding. A default that needs configuration
* is exactly the bug this gate was written for, and it would still return 200
* while being wrong — the failure showed up as an empty sky, not as a dead
* process. `degraded` being non-empty means the config layer demoted something,
* which on an empty environment means a default asked for something it was never
* going to be given.
*/
function checkPosture(body) {
const problems = [];
if (body.ok !== true) {
problems.push(` health reported ok: ${JSON.stringify(body.ok)}, expected true`);
}
if (body.sources?.weather !== "none") {
problems.push(
` weather source defaulted to ${JSON.stringify(body.sources?.weather)}; CONTRACT.md §5.1`,
` requires "none", because every other source wants a contact string or a key.`,
);
}
if (body.auth?.mode !== "none") {
problems.push(
` auth mode defaulted to ${JSON.stringify(body.auth?.mode)}; CONTRACT.md §6 requires "none"`,
` so that a self-hoster never creates an account anywhere.`,
);
}
if (!Array.isArray(body.degraded)) {
problems.push(` health body has no degraded array; it is how demotions become visible.`);
} else if (body.degraded.length > 0) {
problems.push(
` the config layer demoted ${body.degraded.length} source(s) on an empty environment:`,
...body.degraded.map((line) => ` ${line}`),
` A demotion here means a default was chosen that needs configuration to work.`,
);
}
return problems;
}
function report(body) {
console.log("check-zero-config-boot: ok — 200 from /api/v1/health on an empty environment.");
console.log(` service: ${body.service} ${body.version}`);
console.log(
` sources: weather=${body.sources?.weather} flights=${body.sources?.flights} ` +
`satellites=${body.sources?.satellites} markers=${body.sources?.markers}`,
);
console.log(` auth: ${body.auth?.mode}`);
console.log(` degraded: none`);
}
/** Shared tail of both modes: parse, assert, print. */
function finish(result, log, startedAt) {
if (result.dead) {
fail(
"the server exited before it ever answered.",
[
"It was started with a genuinely empty environment, which is the whole point: a box",
"that needs a variable set before it will boot is not self-hostable. See CONTRACT.md §5.1.",
` last connection attempt: ${result.lastError}`,
],
log,
);
return;
}
if (result.timedOut) {
fail(
`nothing answered ${HEALTH_URL} before the deadline.`,
[` last connection attempt: ${result.lastError}`],
log,
);
return;
}
if (result.status !== 200) {
fail(
`${HEALTH_URL} returned ${result.status}, expected 200.`,
[
"Health touches no upstream and reads no file by design, so a non-200 here is the",
"server refusing to be healthy without configuration it should not need.",
` body: ${result.text.slice(0, 500)}`,
],
log,
);
return;
}
let body;
try {
body = JSON.parse(result.text);
} catch {
fail("health answered 200 but the body was not JSON.", [` body: ${result.text.slice(0, 500)}`], log);
return;
}
if (!uptimeLooksLikeOurs(body, startedAt)) {
fail(
`${HEALTH_URL} answered, but from a server this check did not start.`,
[
` it reports ${body.uptimeSeconds}s of uptime; this check has been running for`,
` ${Math.ceil((Date.now() - startedAt) / 1000)}s. Something else claimed the port first.`,
],
log,
);
return;
}
const problems = checkPosture(body);
if (problems.length > 0) {
fail("health answered 200, but not from a zero-config box.", problems, log);
return;
}
report(body);
}
// ---- Mode: the real entry point under an empty environment ----
async function runProcessMode() {
if (!(await assertPortFree())) return;
console.log("check-zero-config-boot: starting server/src/index.ts with env -i (no variables at all)");
const startedAt = Date.now();
const child = spawn(process.execPath, ["server/src/index.ts"], {
cwd: REPO_ROOT,
// The empty environment is the test. `process.execPath` is absolute, so the
// child needs no PATH to exist, and Node needs nothing else to run.
env: {},
stdio: ["ignore", "pipe", "pipe"],
});
const log = [];
child.stdout.on("data", (chunk) => log.push(String(chunk)));
child.stderr.on("data", (chunk) => log.push(String(chunk)));
let alive = true;
let exitInfo = "";
child.on("exit", (code, signal) => {
alive = false;
exitInfo = signal ? `killed by ${signal}` : `exited with code ${code}`;
});
child.on("error", (err) => {
alive = false;
exitInfo = `could not spawn: ${err.message}`;
});
try {
const result = await waitForHealth(PROCESS_DEADLINE_MS, () => alive);
if (result.dead) log.push(`\n(process ${exitInfo})\n`);
finish(result, log, startedAt);
} finally {
if (alive) {
child.kill("SIGTERM");
// The entry point closes on SIGTERM; if it does not, this is a check, not a
// supervisor, and a lingering child would hang CI.
for (let waited = 0; alive && waited < 5_000; waited += 100) await sleep(100);
if (alive) child.kill("SIGKILL");
}
}
}
// ---- Mode: docker compose, for whoever has a Docker ----
function docker(args, timeoutMs) {
return new Promise((resolve) => {
const child = spawn("docker", args, {
cwd: REPO_ROOT,
// PATH and HOME are for the Docker CLI itself — finding its binary and its
// config — and reach the container through nothing. The compose file
// interpolates only TERA_* variables, all with `:-` defaults, so the
// container's own environment is empty either way.
env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" },
stdio: ["ignore", "pipe", "pipe"],
timeout: timeoutMs,
});
const out = [];
child.stdout.on("data", (chunk) => out.push(String(chunk)));
child.stderr.on("data", (chunk) => out.push(String(chunk)));
child.on("close", (code) => resolve({ code, out: out.join("") }));
child.on("error", (err) => resolve({ code: -1, out: `could not run docker: ${err.message}` }));
});
}
async function runComposeMode() {
if (!(await assertPortFree())) return;
const compose = ["compose", "-f", "deploy/docker-compose.yml"];
console.log("check-zero-config-boot: docker compose up, with no .env file and no TERA_* set");
const up = await docker([...compose, "up", "-d", "--build"], COMPOSE_DEADLINE_MS);
if (up.code !== 0) {
fail("`docker compose up` failed.", [" " + up.out.trim().split("\n").join("\n ")]);
return;
}
// After the build, not before it: `up -d` returns with the container just
// started, so the uptime it reports is measured from about here.
const startedAt = Date.now();
try {
const result = await waitForHealth(60_000, () => true);
const logs = await docker([...compose, "logs", "--no-color"], 30_000);
finish(result, [logs.out], startedAt);
} finally {
await docker([...compose, "down", "-v"], 60_000);
}
}
// ---- Run ----
await (useCompose ? runComposeMode() : runProcessMode());