feat: server loads all settings through the config environment adapter (E00-S04-T04)

This commit is contained in:
implementer
2026-08-30 04:17:19 +00:00
parent 7aeeeaaa97
commit 1214a33adb
2 changed files with 25 additions and 33 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02) and automatic secret redaction from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story.", "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly (E00-S04-T04); the Fastify 5 application shell lands in a later story.",
"scripts": { "scripts": {
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", "build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", "typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
+24 -32
View File
@@ -23,10 +23,17 @@
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's * setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
* required field, Security-and-Operations §32/§26) fails fast at startup * required field, Security-and-Operations §32/§26) fails fast at startup
* with an error naming the missing field instead of booting with an invalid * with an error naming the missing field instead of booting with an invalid
* configuration. The parsed config keeps the committed defaults for * configuration.
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes *
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws * [E00-S04-T04] environment adapter: ALL settings flow through the config
* `MissingRequiredSettingError` naming the missing field. * 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.
* *
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting * [E00-S04-T03] secret redaction: ALL log output goes through the redacting
* logger (`createLogger`, defined below — every line is scrubbed of the * logger (`createLogger`, defined below — every line is scrubbed of the
@@ -43,25 +50,21 @@
*/ */
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { assertValidConfig } from '@personal-blog/config'; import { loadConfigFromEnv } from '@personal-blog/config';
import { redactConfig, redactText, type Config } from '@personal-blog/config'; import { redactConfig, redactText, type Config } from '@personal-blog/config';
import { Pool } from '@personal-blog/database-postgres'; import { Pool } from '@personal-blog/database-postgres';
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres'; import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
import type { Migration } from '@personal-blog/database-postgres'; import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */ // [E00-S04-T04] environment adapter: ALL settings flow through the config
const PORT = resolvePort(process.env.PORT); // package's adapter — the workspace's single owner of process.env reads — so
// the server never reads process.env directly. The adapter maps the
// [E00-S04-T02] field-specific startup error: validate the startup // environment onto the validated config shape and validates it with
// configuration before anything else, so a missing required setting (e.g. // assertValidConfig (E00-S04-T02) before the server binds, so a missing
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming // required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
// the missing field — never boots with an invalid configuration. // startup with an error naming the missing field — never boots with an
const config = assertValidConfig({ // invalid configuration.
host: '0.0.0.0', const config = loadConfigFromEnv();
port: PORT,
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.EPPP_SESSION_SECRET,
});
// [E00-S04-T03] secret redaction: every log line goes through the redacting // [E00-S04-T03] secret redaction: every log line goes through the redacting
// logger, seeded with the validated config's secrets — and the resolved // logger, seeded with the validated config's secrets — and the resolved
@@ -96,17 +99,6 @@ const MIGRATIONS: readonly Migration[] = [];
*/ */
let migrationsComplete = false; let migrationsComplete = false;
/**
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
* out-of-range override falls back to the default so a bad `PORT` value cannot
* crash the process at startup.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
/** Writes a JSON response with an explicit content-length. */ /** Writes a JSON response with an explicit content-length. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void { function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.writeHead(statusCode, { res.writeHead(statusCode, {
@@ -177,7 +169,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const server = createServer(handleRequest); const server = createServer(handleRequest);
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = config.databaseUrl;
if (databaseUrl === undefined) { if (databaseUrl === undefined) {
// No DATABASE_URL configured (e.g. local non-container dev): there are no // No DATABASE_URL configured (e.g. local non-container dev): there are no
@@ -207,8 +199,8 @@ if (databaseUrl === undefined) {
}); });
} }
server.listen(PORT, () => { server.listen(config.port, () => {
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`); 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 // `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the