/** * @personal-blog/server — EPPP public server application. * * [E00-S02-T03] minimal serving process: a small HTTP server built on Node's * `node:http` that answers the application health endpoint. `GET /health` * reports the application readiness — HTTP 200 with `{"status":"ok"}` — so * the Compose stack's `app` service stays up and the health endpoint * succeeds once the stack is running. * * [E00-S03-T06] readiness gate: the app does **not** report ready before * migrations complete. When a `DATABASE_URL` is configured, the server runs * the startup migrations (the `MigrationRunner` from * `@personal-blog/database-postgres` over the migration ledger, E00-S03-T03) * before it reports ready: `GET /health` answers HTTP 503 with * `{"status":"not ready"}` while the migration run is in flight, and flips to * HTTP 200 `{"status":"ok"}` only after the run finishes. When no * `DATABASE_URL` is configured (e.g. the local non-container developer path, * E00-S01-T06) there are no migrations to run, so the app reports ready * immediately. * * [E00-S04-T02] field-specific startup error: the required settings are * validated before the server binds, so a deployment missing a required * setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's * required field, Security-and-Operations §32/§26) fails fast at startup * with an error naming the missing field instead of booting with an invalid * configuration. * * [E00-S04-T04] environment adapter: ALL settings flow through the config * package's environment adapter (`loadConfigFromEnv` from * `@personal-blog/config`) — the workspace's single owner of `process.env` * reads — so this module (and every other module outside the config package) * never reads `process.env` directly. The adapter maps the environment * (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated * config shape and validates it with `assertValidConfig` (E00-S04-T02) before * the server binds, so a missing required setting is still a startup error * naming the missing field. `HOST` is validated at the adapter boundary as a * hostname or IP address and the server passes `config.host` to * `server.listen`, so a configured `HOST` binds exactly that interface and * the startup log reflects the actual bind — it never claims a bind the * process does not enforce, and never echoes unvalidated env content. * * [E00-S04-T03] secret redaction: ALL log output goes through the redacting * logger (`createLogger`, defined below — every line is scrubbed of the * config's secret values before it reaches stdout/stderr), so secret values — * the admin-session secret and the password in a `DATABASE_URL` connection * string — automatically redact from logs. The server logs its resolved * configuration at startup through `redactConfig` (the issue's test plan: * "log configuration and confirm secret values are redacted"), so operators * see the effective settings with every secret value replaced by * `[REDACTED]` and no secret value reaches the log output. * * The Fastify 5 application shell (and the real HTTP API) lands in a later * story; this bootstrap keeps the application health-checkable until then. */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { loadConfigFromEnv } from '@personal-blog/config'; import { redactConfig, redactText, type Config } from '@personal-blog/config'; import { Pool } from '@personal-blog/database-postgres'; import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres'; import type { Migration } from '@personal-blog/database-postgres'; // [E00-S04-T04] environment adapter: ALL settings flow through the config // package's adapter — the workspace's single owner of process.env reads — so // the server never reads process.env directly. The adapter maps the // environment onto the validated config shape and validates it with // assertValidConfig (E00-S04-T02) before the server binds, so a missing // required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at // startup with an error naming the missing field — never boots with an // invalid configuration. const config = loadConfigFromEnv(); // [E00-S04-T03] secret redaction: every log line goes through the redacting // logger, seeded with the validated config's secrets — and the resolved // configuration is logged redacted, so operators see the effective settings // while secret values stay out of the log output. const logger = createLogger(config); logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config))); /** Health payload — reported once the startup migration run completes. */ const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); /** Payload while the startup migration run is still in flight — the app is up but NOT ready. */ const NOT_READY_PAYLOAD = JSON.stringify({ status: 'not ready' }); /** Payload for any route that is not the health endpoint. */ const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' }); /** * The migrations the server applies at startup, oldest first. Empty until the * first schema migration lands (E00-S03 is the migration-runner foundation; * real schema migrations arrive with the domain stories). The runner still * ensures the migration ledger (E00-S03-T03) exists and reads the applied * versions, so even an empty run is a real, observable migration step that * readiness waits for. */ const MIGRATIONS: readonly Migration[] = []; /** * Readiness state — false until the startup migration run completes. The app * is "up" (the HTTP server is listening) but reports not-ready (E00-S03-T06) * while migrations are pending. */ let migrationsComplete = false; /** Writes a JSON response with an explicit content-length. */ function sendJson(res: ServerResponse, statusCode: number, body: string): void { res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } /** The server's logger: `log` writes to stdout, `error` writes to stderr — both redacted. */ interface ServerLogger { log(...args: unknown[]): void; error(...args: unknown[]): void; } /** * Creates the redacting logger for the validated configuration (E00-S04-T03): * each argument is serialized (strings verbatim, errors by message, other * values as JSON) and the joined line is scrubbed of the config's secret * values — the admin-session secret and the password embedded in a * `DATABASE_URL` connection string — before it is written, so no secret value * can reach the log output. The server uses this logger for ALL of its * output; a bare `console.log`/`console.error` would bypass the redaction and * is rejected by the test suite. */ function createLogger(config: Config): ServerLogger { const write = (stream: NodeJS.WriteStream, args: unknown[]): void => { stream.write(`${redactText(args.map(serialize).join(' '), config)}\n`); }; return { log: (...args) => write(process.stdout, args), error: (...args) => write(process.stderr, args), }; } /** Serializes one log argument: strings verbatim, errors by message, objects as JSON. */ function serialize(value: unknown): string { if (typeof value === 'string') return value; if (value instanceof Error) return String(value); if (typeof value === 'undefined') return 'undefined'; if (typeof value === 'object' && value !== null) { try { return JSON.stringify(value); } catch { return String(value); } } return String(value); } /** * Routes one request. The application only serves the health endpoint at this * stage; anything else is a 404 so misconfiguration is loud. The health route * is the readiness probe (E00-S03-T06): it answers 200 only after the startup * migration run completes, and 503 while the run is still pending. */ function handleRequest(req: IncomingMessage, res: ServerResponse): void { if (req.method === 'GET' && (req.url ?? '/') === '/health') { if (migrationsComplete) { sendJson(res, 200, HEALTH_PAYLOAD); } else { sendJson(res, 503, NOT_READY_PAYLOAD); } return; } sendJson(res, 404, NOT_FOUND_PAYLOAD); } const server = createServer(handleRequest); const databaseUrl = config.databaseUrl; if (databaseUrl === undefined) { // No DATABASE_URL configured (e.g. local non-container dev): there are no // migrations to run, so the app reports ready from the start. migrationsComplete = true; logger.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run'); } else { // E00-S03-T06: run the startup migrations; readiness follows completion. const pool = new Pool({ connectionString: databaseUrl }); const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool)); runner .run() .then((result) => { migrationsComplete = true; logger.log( `[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`, ); }) .catch((error) => { // Failure diagnostics are E00-S03-T05 (out of scope for T06): the // runner already throws a serializable MigrationFailedError. The app // logs the failure and stays not-ready, so a deployment with failed // migrations is surfaced by the readiness probe instead of // crash-looping. The redacting logger scrubs any secret value (e.g. // the database password) the error text may embed. logger.error('[migrate] startup migration run failed; app stays not-ready:', error); }); } // The server binds the validated bind interface: `config.host` (default // `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to // `server.listen`, so a configured `HOST` binds exactly that interface and // the startup log reflects the actual bind. server.listen(config.port, config.host, () => { logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`); }); // `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the // server and exit cleanly instead of being killed mid-request. for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { server.close(() => process.exit(0)); }); }