From 40be4d180def01240c7d5dd0173ebac61b0435f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:55:33 +0200 Subject: [PATCH] feat(boot): print a first-boot readiness summary before fail-closed loaders throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/instrumentation.ts | 12 ++ .../boot/__tests__/readiness-summary.test.ts | 131 ++++++++++++ src/lib/boot/readiness-summary.ts | 197 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 src/lib/boot/__tests__/readiness-summary.test.ts create mode 100644 src/lib/boot/readiness-summary.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 77ab68ae8..512e1afbf 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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. diff --git a/src/lib/boot/__tests__/readiness-summary.test.ts b/src/lib/boot/__tests__/readiness-summary.test.ts new file mode 100644 index 000000000..4d6211ba2 --- /dev/null +++ b/src/lib/boot/__tests__/readiness-summary.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; + +import { + collectReadiness, + formatReadiness, + logReadinessSummary, +} from "../readiness-summary"; + +const HEX32 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function baseEnv(): Record { + return { + ENCRYPTION_KEY: HEX32, + API_TOKEN_HMAC_KEY: HEX32, + DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog", + NODE_ENV: "production", + }; +} + +function lineFor(env: Record, 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); + }); +}); diff --git a/src/lib/boot/readiness-summary.ts b/src/lib/boot/readiness-summary.ts new file mode 100644 index 000000000..6f1e79f15 --- /dev/null +++ b/src/lib/boot/readiness-summary.ts @@ -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; + +/** 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; + 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 = { + 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, +): 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 }; + } +}