feat(boot): print a first-boot readiness summary before fail-closed loaders throw

The fail-closed crypto loader throws a stack trace on a missing/short
ENCRYPTION_KEY rather than a sentence an operator can act on, and
check-env is a dev/CI tool the image operator never runs. Add a boot-time
readiness summary (instrumentation register) that prints a clear line per
core secret — with the exact openssl fix on a bad one — before anything
throws, plus a one-glance status of the optional channels and the
Secure-cookie transport hint. Never logs secret values; never throws.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:55:33 +02:00
1 parent 24dafeafee
commit 40be4d180d
3 files changed
+340

No files matched your search

+12
View File
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// First-boot readiness summary: a human-readable block on the core secrets
// (with the exact fix on a bad one) before any fail-closed loader throws,
// plus a one-glance status of the optional channels. Runs for every process
// type (web + worker). Never throws — the real gate stays the loaders.
try {
const { logReadinessSummary } =
await import("@/lib/boot/readiness-summary");
logReadinessSummary();
} catch {
// A readiness summary must never break boot.
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import {
collectReadiness,
formatReadiness,
logReadinessSummary,
} from "../readiness-summary";
const HEX32 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
function baseEnv(): Record<string, string | undefined> {
return {
ENCRYPTION_KEY: HEX32,
API_TOKEN_HMAC_KEY: HEX32,
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog",
NODE_ENV: "production",
};
}
function lineFor(env: Record<string, string | undefined>, label: string) {
return collectReadiness(env).lines.find((l) => l.label === label);
}
describe("collectReadiness — core secrets", () => {
it("reports no blocker when the three core secrets are valid", () => {
const report = collectReadiness(baseEnv());
expect(report.hasBlocker).toBe(false);
expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok");
expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok");
expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok");
});
it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
const report = collectReadiness(env);
expect(report.hasBlocker).toBe(true);
const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY");
expect(line?.status).toBe("error");
expect(line?.detail).toContain("openssl rand -hex 32");
});
it("flags a too-short ENCRYPTION_KEY as a blocker", () => {
const env = baseEnv();
env.ENCRYPTION_KEY = "deadbeef";
expect(collectReadiness(env).hasBlocker).toBe(true);
});
it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 });
const report = collectReadiness(env);
expect(report.hasBlocker).toBe(false);
expect(
report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status,
).toBe("ok");
});
it("flags a malformed ENCRYPTION_KEYS map", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
env.ENCRYPTION_KEYS = '{"v1":"nothex"}';
expect(collectReadiness(env).hasBlocker).toBe(true);
});
it("flags a missing DATABASE_URL", () => {
const env = baseEnv();
delete env.DATABASE_URL;
expect(collectReadiness(env).hasBlocker).toBe(true);
});
});
describe("collectReadiness — transport + optional", () => {
it("warns Secure-on when NODE_ENV=production and no override", () => {
const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE");
expect(line?.detail).toContain("Secure cookie flag ON");
});
it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => {
const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" };
expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain(
"Secure cookie flag OFF",
);
});
it("marks optional subsystems as warn when unconfigured", () => {
const smtp = lineFor(baseEnv(), "SMTP (email)");
expect(smtp?.status).toBe("warn");
});
it("marks off-host backup ok only when every required var is set", () => {
const env = {
...baseEnv(),
BACKUP_S3_ENDPOINT: "https://r2",
BACKUP_S3_BUCKET: "b",
BACKUP_S3_ACCESS_KEY: "a",
BACKUP_S3_SECRET_KEY: "s",
BACKUP_ENCRYPTION_KEY: HEX32,
};
expect(lineFor(env, "Off-host backup")?.status).toBe("ok");
});
});
describe("formatReadiness + logReadinessSummary", () => {
it("renders a header that flags a blocker", () => {
const env = baseEnv();
delete env.API_TOKEN_HMAC_KEY;
const text = formatReadiness(collectReadiness(env));
expect(text).toContain("CORE SECRET MISSING/INVALID");
expect(text).toContain("✗ API_TOKEN_HMAC_KEY");
});
it("logs blockers to error and healthy summaries to log; never throws", () => {
const errors: string[] = [];
const logs: string[] = [];
const sink = {
error: (m: string) => errors.push(m),
log: (m: string) => logs.push(m),
};
logReadinessSummary(baseEnv(), sink);
expect(logs).toHaveLength(1);
expect(errors).toHaveLength(0);
const bad = baseEnv();
delete bad.ENCRYPTION_KEY;
logReadinessSummary(bad, sink);
expect(errors).toHaveLength(1);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* First-boot readiness summary.
*
* The bundled image promises "single `docker compose up`", but the reality is
* three hand-generated secrets plus a handful of optional subsystems — and the
* crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a
* stack trace on first use rather than a sentence an operator can act on. This
* module inspects the environment ONCE at boot and prints a compact,
* human-readable readiness block: a clear line per core secret (with the exact
* `openssl` fix on a bad one) before anything throws, plus a one-glance status
* of the optional channels.
*
* It NEVER logs a secret value — only presence + shape validity. It never
* throws: the fail-closed loaders remain the real gate; this is a friendlier
* signpost in front of them.
*/
export interface ReadinessLine {
/** Short label, e.g. "ENCRYPTION_KEY". */
label: string;
/** ok = green, warn = optional-not-configured, error = missing/invalid core. */
status: "ok" | "warn" | "error";
/** One-line human detail (no secret material). */
detail: string;
}
export interface ReadinessReport {
lines: ReadinessLine[];
/** True when at least one core secret is missing or malformed. */
hasBlocker: boolean;
}
type Env = Record<string, string | undefined>;
/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */
function isValid32ByteKey(raw: string | undefined): boolean {
if (!raw) return false;
const v = raw.trim();
if (/^[0-9a-fA-F]{64}$/.test(v)) return true;
if (/^[A-Za-z0-9+/=]+$/.test(v)) {
try {
return Buffer.from(v, "base64").length === 32;
} catch {
return false;
}
}
return false;
}
function allSet(env: Env, keys: string[]): boolean {
return keys.every((k) => (env[k] ?? "").trim().length > 0);
}
function anySet(env: Env, keys: string[]): boolean {
return keys.some((k) => (env[k] ?? "").trim().length > 0);
}
/** Inspect the environment and build the readiness report. Pure + side-effect free. */
export function collectReadiness(env: Env = process.env): ReadinessReport {
const lines: ReadinessLine[] = [];
// --- Core secrets (boot blockers) ---------------------------------------
// Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key.
const keysMap = (env.ENCRYPTION_KEYS ?? "").trim();
let encryptionOk = false;
if (keysMap) {
try {
const parsed = JSON.parse(keysMap) as Record<string, string>;
const entries = Object.values(parsed ?? {});
encryptionOk =
entries.length > 0 && entries.every((v) => isValid32ByteKey(v));
} catch {
encryptionOk = false;
}
lines.push({
label: "ENCRYPTION_KEYS",
status: encryptionOk ? "ok" : "error",
detail: encryptionOk
? "key-rotation map present and valid"
: "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys",
});
} else {
encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY);
lines.push({
label: "ENCRYPTION_KEY",
status: encryptionOk ? "ok" : "error",
detail: encryptionOk
? "present (64 hex chars)"
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
});
}
const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY);
lines.push({
label: "API_TOKEN_HMAC_KEY",
status: hmacOk ? "ok" : "error",
detail: hmacOk
? "present (64 hex chars)"
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
});
const dbOk = (env.DATABASE_URL ?? "").trim().length > 0;
lines.push({
label: "DATABASE_URL",
status: dbOk ? "ok" : "error",
detail: dbOk
? "present"
: "missing — the bundled compose derives it from POSTGRES_PASSWORD",
});
// --- Transport hint (a warning, never a blocker) ------------------------
const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase();
const secureFlagOn =
secureOverride === "true" ||
(secureOverride !== "false" && env.NODE_ENV === "production");
lines.push({
label: "SESSION_COOKIE_SECURE",
status: "ok",
detail: secureFlagOn
? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host"
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
});
// --- Optional subsystems (informational) --------------------------------
const optional: Array<[string, boolean]> = [
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],
[
"Off-host backup",
allSet(env, [
"BACKUP_S3_ENDPOINT",
"BACKUP_S3_BUCKET",
"BACKUP_S3_ACCESS_KEY",
"BACKUP_S3_SECRET_KEY",
"BACKUP_ENCRYPTION_KEY",
]),
],
[
"APNs (native iOS push)",
allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) &&
anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]),
],
[
"Web Push (VAPID env)",
allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]),
],
];
for (const [label, configured] of optional) {
lines.push({
label,
status: configured ? "ok" : "warn",
detail: configured ? "configured" : "not configured (optional)",
});
}
const hasBlocker = lines.some((l) => l.status === "error");
return { lines, hasBlocker };
}
const GLYPH: Record<ReadinessLine["status"], string> = {
ok: "✓",
warn: "·",
error: "✗",
};
/** Render the report as a compact multi-line block. */
export function formatReadiness(report: ReadinessReport): string {
const body = report.lines
.map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`)
.join("\n");
const header = report.hasBlocker
? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):"
: "HealthLog boot readiness:";
return `${header}\n${body}`;
}
/**
* Collect + print the readiness summary. Blockers go to stderr (they precede a
* fail-closed throw), the healthy summary to stdout. Never throws.
*/
export function logReadinessSummary(
env: Env = process.env,
log: Pick<Console, "log" | "error"> = console,
): ReadinessReport {
try {
const report = collectReadiness(env);
const text = formatReadiness(report);
if (report.hasBlocker) {
log.error(text);
} else {
log.log(text);
}
return report;
} catch {
// A readiness summary must never be the thing that breaks boot.
return { lines: [], hasBlocker: false };
}
}