The environment adapter now resolves HOST through resolveHost, validating it at the adapter boundary as a hostname (RFC 1123) or IP address (IPv4/IPv6, node:net isIP); an invalid HOST throws a field-specific ConfigStartupError naming host, so arbitrary env content is never used for binding or echoed verbatim into the startup log (issue acceptance criterion, resolving security review finding SEC-3). The server passes config.host to server.listen(config.port, config.host, ...), so a configured HOST binds exactly that interface and the startup log never claims a bind the process does not enforce (resolving SEC-2).
221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
/**
|
|
* @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));
|
|
});
|
|
}
|