[E00-S04-T04] No module reads process.env except configuration adapter #403

Merged
kpcto merged 9 commits from feature/185 into main 2026-08-30 05:02:49 +00:00
2 changed files with 25 additions and 33 deletions
Showing only changes of commit 1214a33adb - Show all commits
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"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": {
"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",
+24 -32
View File
@@ -23,10 +23,17 @@
* 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. The parsed config keeps the committed defaults for
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
* `MissingRequiredSettingError` naming the missing field.
* 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.
*
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
* logger (`createLogger`, defined below — every line is scrubbed of the
@@ -43,25 +50,21 @@
*/
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 { Pool } from '@personal-blog/database-postgres';
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */
const PORT = resolvePort(process.env.PORT);
// [E00-S04-T02] field-specific startup error: validate the startup
// configuration before anything else, so a missing required setting (e.g.
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
// the missing field — never boots with an invalid configuration.
const config = assertValidConfig({
host: '0.0.0.0',
port: PORT,
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.EPPP_SESSION_SECRET,
});
// [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
@@ -96,17 +99,6 @@ const MIGRATIONS: readonly Migration[] = [];
*/
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. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.writeHead(statusCode, {
@@ -177,7 +169,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const server = createServer(handleRequest);
const databaseUrl = process.env.DATABASE_URL;
const databaseUrl = config.databaseUrl;
if (databaseUrl === undefined) {
// No DATABASE_URL configured (e.g. local non-container dev): there are no
@@ -207,8 +199,8 @@ if (databaseUrl === undefined) {
});
}
server.listen(PORT, () => {
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
server.listen(config.port, () => {
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