mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
24dafeafee
commit
40be4d180d
3 files changed
+340
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user