From 7aeeeaaa973d857e555791aa1bbca9b02f370fb9 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:19 +0000 Subject: [PATCH 1/9] feat: environment adapter is the single owner of process.env reads (E00-S04-T04) --- packages/config/package.json | 2 +- packages/config/src/env.ts | 78 ++++++++++++++++++++++++++++++++++ packages/config/src/index.ts | 10 ++++- packages/config/src/redact.ts | 2 +- packages/config/src/schema.ts | 2 +- packages/config/src/startup.ts | 7 +-- 6 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 packages/config/src/env.ts diff --git a/packages/config/package.json b/packages/config/package.json index 5ccdfc4..49c69c2 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the .env.example template (E00-S04-T05) land in later tasks.", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts new file mode 100644 index 0000000..bca077c --- /dev/null +++ b/packages/config/src/env.ts @@ -0,0 +1,78 @@ +/** + * EPPP configuration environment adapter — [E00-S04-T04] no module reads + * `process.env` except the configuration adapter. + * + * This module is the workspace's single owner of `process.env` reads. + * `loadConfigFromEnv` maps the environment onto the validated config shape + * (the E00-S04-T01 TypeBox/Ajv schema) and validates it with + * `assertValidConfig` (the E00-S04-T02 startup validation) before returning + * it, so every setting the application uses — `host`, `port`, `databaseUrl`, + * `sessionSecret` — flows through the adapter and no other module reads + * `process.env` directly (the issue's acceptance criteria: "no module reads + * `process.env` directly except the configuration adapter", "all settings + * flow through the adapter"). + * + * The environment mapping (per the schema's documented environment sources, + * `schema.ts`): + * + * - `HOST` → `host` — the interface the HTTP server binds, default `0.0.0.0` + * (the container default). + * - `PORT` → `port` — integer in the valid TCP port range (1–65535), default + * `3000` (the Dockerfile `EXPOSE 3000` / compose `:3000` container port). A + * non-numeric or out-of-range override falls back to the default so a bad + * `PORT` cannot crash the process at startup (the behavior the server's + * entrypoint had before this adapter existed). + * - `DATABASE_URL` → `databaseUrl` — optional; when absent the app has no + * startup migration run to wait for and reports ready immediately (the + * local non-container developer path, E00-S01-T06/E00-S03-T06). + * - `EPPP_SESSION_SECRET` → `sessionSecret` — the required admin-session + * secret (Security-and-Operations §32/§26, ≥ 32 characters); a missing or + * too-short value fails startup with the field-specific error from + * `assertValidConfig`. + * + * `assertValidConfig` is what makes a missing required setting a startup + * error: the adapter hands the mapped value to it, and it throws + * `MissingRequiredSettingError` naming the missing field — the application + * never boots with an invalid configuration. + * + * Rollback note from the issue: revert any module changes that read + * `process.env`. + */ + +import { assertValidConfig } from './startup.js'; +import type { Config } from './schema.js'; + +/** + * 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; +} + +/** + * Maps the environment onto the validated configuration — the E00-S04-T04 + * environment adapter. + * + * Reads every setting from the given environment (defaulting to `process.env` + * — this module is the workspace's single owner of `process.env` reads) and + * returns the validated `Config`; an invalid environment fails fast with the + * field-specific startup error, so a missing or malformed setting is a + * startup error, never a silently-booted invalid configuration. + * + * @param env - the environment to read (defaults to `process.env`) + * @returns the validated configuration + * @throws {MissingRequiredSettingError} when a required setting is missing + * @throws {ConfigStartupError} when the configuration violates the schema + */ +export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config { + return assertValidConfig({ + host: env.HOST ?? '0.0.0.0', + port: resolvePort(env.PORT), + databaseUrl: env.DATABASE_URL, + sessionSecret: env.EPPP_SESSION_SECRET, + }); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2e244e5..dce9fcc 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -18,8 +18,13 @@ * server's redacting logger applies to every log line, so secrets * automatically redact from logs. * - * The environment adapter (E00-S04-T04) builds on this boundary in a later - * task. + * [E00-S04-T04] Environment adapter: the boundary also exposes + * `loadConfigFromEnv` — the workspace's single owner of `process.env` reads. + * It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) + * onto the validated config shape and validates it with `assertValidConfig` + * at startup, so every setting flows through the adapter and no other module + * reads `process.env` directly. The `.env.example` template (E00-S04-T05) + * builds on this boundary in a later task. */ export { configSchema } from './schema.js'; @@ -28,3 +33,4 @@ export { validateConfig } from './validate.js'; export type { ConfigValidationResult } from './validate.js'; export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js'; export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js'; +export { loadConfigFromEnv } from './env.js'; diff --git a/packages/config/src/redact.ts b/packages/config/src/redact.ts index 302062a..a85fc4c 100644 --- a/packages/config/src/redact.ts +++ b/packages/config/src/redact.ts @@ -3,7 +3,7 @@ * logs. * * The config package owns which configuration fields are secrets, so the - * redaction layer lives here (the environment adapter, E00-S04-T04, will feed + * redaction layer lives here (the environment adapter, E00-S04-T04, feeds * the validated config into it via the app's logger): * * - `redactConfig(config)` — a copy of a config value with every secret diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index e07b6e4..5327cc5 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -18,7 +18,7 @@ * - `port` — the port the HTTP server listens on. Integer in the valid TCP * port range (1–65535), default `3000` (the container default, matching * the Dockerfile `EXPOSE 3000` and the compose `:3000` container port; - * `PORT` is read today, E00-S02-T03). + * `PORT` is read by the environment adapter, E00-S04-T04). * - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool` * `connectionString`, E00-S03-T02). Optional: when absent the app has no * startup migration run to wait for and reports ready immediately (the diff --git a/packages/config/src/startup.ts b/packages/config/src/startup.ts index f4953c2..92e07d2 100644 --- a/packages/config/src/startup.ts +++ b/packages/config/src/startup.ts @@ -12,9 +12,10 @@ * `ConfigStartupError` whose message names each violating field too. * * This is deliberately NOT the T04 environment adapter: nothing here reads - * `process.env`. The adapter (E00-S04-T04) maps the environment onto the - * validated config shape and passes it to `assertValidConfig` at startup; - * secret redaction (E00-S04-T03) builds on the same boundary in a later task. + * `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment + * onto the validated config shape and passes it to `assertValidConfig` at + * startup; the secret redaction layer (E00-S04-T03) builds on the same + * boundary. * * Rollback note from the issue: revert the validation error handling. */ From 1214a33adbd5502c982c26761b5ec962a72a40a8 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:19 +0000 Subject: [PATCH 2/9] feat: server loads all settings through the config environment adapter (E00-S04-T04) --- apps/server/package.json | 2 +- apps/server/src/index.ts | 56 +++++++++++++++++----------------------- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index a0358fb..9a1d8c1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 013b9dd..838562d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 From 20173a8241627781df3bd1b683aed951bb5ab073 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:22 +0000 Subject: [PATCH 3/9] test: lock in the env adapter as the single owner of process.env with static scan, mutation and deterministic probes (E00-S04-T04) --- .gitea/workflows/ci.yml | 30 + .gitignore | 4 + tests/config-env-adapter.test.mjs | 930 ++++++++++++++++++++++++++++++ 3 files changed, 964 insertions(+) create mode 100644 tests/config-env-adapter.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f8e49a3..30916ad 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -226,6 +226,36 @@ jobs: - name: Run config log redaction test suite run: node --test tests/config-log-redaction.test.mjs + # E00-S04-T04: the static assertions of tests/config-env-adapter.test.mjs + # gate every PR — the suite locks in the environment adapter (packages/config + # is the single owner of process.env reads; the server and every other module + # read no process.env, all settings flow through loadConfigFromEnv into the + # validated config) with a comment-stripped workspace scan, mutation probes + # (injecting a direct process.env read into any other module fails the scan), + # a deterministic boundary probe (full env mapping, defaults, bad-PORT + # fallback, missing required secret -> MissingRequiredSettingError) and + # server-boot probes (a PORT/HOST override shows up in the resolved + # configuration; a missing required secret still fails startup). The job + # installs the frozen workspace and builds the config and database-postgres + # packages because the probes boot the committed server which imports them. + config-env-adapter: + name: Env adapter owns process.env (E00-S04-T04) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run config env adapter test suite + run: node --test tests/config-env-adapter.test.mjs + # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate # every PR — the suite locks in the TypeBox/Ajv configuration schema # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + diff --git a/.gitignore b/.gitignore index 53a8c6f..03e8a15 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,9 @@ coverage/ # suite into the package (removed in its finally block) .config-startup-probe-*.mjs +# Transient host-side probe file written by the config-env-adapter test +# suite into the package (removed in its finally block) +.config-env-adapter-probe-*.mjs + # OS / editor .DS_Store diff --git a/tests/config-env-adapter.test.mjs b/tests/config-env-adapter.test.mjs new file mode 100644 index 0000000..130b4bf --- /dev/null +++ b/tests/config-env-adapter.test.mjs @@ -0,0 +1,930 @@ +/** + * Config environment adapter test — locks in the [E00-S04-T04] guarantee + * that no module reads `process.env` directly except the configuration + * adapter: every setting flows through `packages/config`. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "no module reads `process.env` directly except the configuration + * adapter" → `packages/config/src/env.ts` is the workspace's single owner + * of `process.env` reads (`loadConfigFromEnv` defaults to `process.env`), + * and a comment-stripped static scan of every source file under `apps/`, + * `packages/` and `extensions/` (the issue's test plan: "static check + * confirms only the adapter reads `process.env`") finds zero + * `process.env` references outside the adapter. Mutation probes prove + * non-vacuity: injecting a direct `process.env` read into the server (or + * into any other module) in a temp copy of the committed tree makes the + * scan fail naming the offending file, and a clean copy passes. + * - "all settings flow through the adapter" → the committed + * `apps/server/src/index.ts` imports `loadConfigFromEnv` from + * `@personal-blog/config` and loads its startup configuration through it + * (binding `config.port`, taking `config.databaseUrl`, never reading + * `process.env` itself); the adapter maps `HOST`/`PORT`/`DATABASE_URL`/ + * `EPPP_SESSION_SECRET` onto the validated config shape and validates it + * with `assertValidConfig` (E00-S04-T02) before returning. Locked in + * statically and behaviorally: the deterministic boundary probe exercises + * the compiled adapter exactly as the server consumes it (full env, + * defaults, bad-`PORT` fallback, missing required secret → + * `MissingRequiredSettingError` naming `sessionSecret`, empty + * `DATABASE_URL` → `ConfigStartupError`, and the no-argument call reading + * the real `process.env`), and the server-boot probes execute the issue's + * test plan against the committed server (a `PORT`/`HOST` override shows + * up in the resolved-configuration log — the settings really flow through + * the adapter — and a missing required secret still fails startup naming + * the missing field). + * + * Run: `node --test tests/config-env-adapter.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched. + * The deterministic probes boot the committed server, which imports + * `@personal-blog/config` and `@personal-blog/database-postgres` — build those + * packages first, exactly as the CI job does.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, writeFileSync, mkdirSync, cpSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer as createNetServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The workspace's single owner of `process.env` reads (E00-S04-T04). */ +const ADAPTER_REL = 'packages/config/src/env.ts'; +const INDEX_SRC = 'packages/config/src/index.ts'; +const SERVER_SRC = 'apps/server/src/index.ts'; +const CONFIG_DIR = 'packages/config'; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the env-adapter criterion on every PR. */ +const CI_JOB = 'config-env-adapter'; + +/** A distinctive >= 32-char admin-session secret for the probes. */ +const SECRET = 's'.repeat(32); + +/** A connection string the probes use for the DATABASE_URL mapping. */ +const DATABASE_URL = 'postgres://eppp:eppp@db:5432/eppp'; + +/** Directories never scanned as package source. */ +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.pnpm-store']); + +/** The workspace groups scanned for source files (mirrors pnpm-workspace.yaml). */ +const GROUP_DIRS = ['apps', 'packages', 'extensions']; + +/** Extensions scanned as package source. */ +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']); + +// --------------------------------------------------------------------------- +// Comment stripping (string/comment aware, preserves line count) +// --------------------------------------------------------------------------- + +/** + * Replaces comments with whitespace while preserving line structure, so + * `process.env` extraction never fires on commented-out references and + * reported line numbers still match the original source. Mirrors the + * stripper in tests/database-postgres-imports.test.mjs. + */ +function stripComments(source) { + let out = ''; + let i = 0; + const n = source.length; + let state = 'code'; // code | line | block | sq | dq | tpl + const tplStack = []; // template states to resume after `${...}` closes + let braceDepth = 0; + + while (i < n) { + const c = source[i]; + const next = source[i + 1]; + + if (state === 'code') { + if (tplStack.length > 0) { + if (c === '{') { + braceDepth += 1; + out += c; + i += 1; + continue; + } + if (c === '}') { + braceDepth -= 1; + out += c; + i += 1; + if (braceDepth === 0) state = tplStack.pop(); + continue; + } + } + if (c === '/' && next === '/') { + out += ' '; + i += 2; + state = 'line'; + continue; + } + if (c === '/' && next === '*') { + out += ' '; + i += 2; + state = 'block'; + continue; + } + if (c === "'") { + out += c; + i += 1; + state = 'sq'; + continue; + } + if (c === '"') { + out += c; + i += 1; + state = 'dq'; + continue; + } + if (c === '`') { + out += c; + i += 1; + state = 'tpl'; + continue; + } + out += c; + i += 1; + continue; + } + + if (state === 'line') { + if (c === '\n') { + out += c; + i += 1; + state = 'code'; + } else { + out += ' '; + i += 1; + } + continue; + } + + if (state === 'block') { + if (c === '*' && next === '/') { + out += ' '; + i += 2; + state = 'code'; + } else { + out += c === '\n' ? c : ' '; + i += 1; + } + continue; + } + + if (state === 'sq' || state === 'dq') { + const quote = state === 'sq' ? "'" : '"'; + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + } else { + if (c === quote) state = 'code'; + i += 1; + } + continue; + } + + // template literal + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + continue; + } + if (c === '`') { + state = 'code'; + i += 1; + continue; + } + if (c === '$' && next === '{') { + out += next; + i += 2; + tplStack.push('tpl'); + braceDepth = 1; + state = 'code'; + continue; + } + i += 1; + } + return out; +} + +// --------------------------------------------------------------------------- +// process.env discovery (the issue's test plan: "static check confirms only +// the adapter reads process.env") +// --------------------------------------------------------------------------- + +const PROCESS_ENV = /\bprocess\.env\b/g; + +function lineOf(stripped, index) { + return stripped.slice(0, index).split('\n').length; +} + +/** Returns [{ relPath, line }] for every `process.env` reference in the given files. */ +function findProcessEnvUsages(files) { + const usages = []; + for (const file of files) { + const stripped = stripComments(file.content); + PROCESS_ENV.lastIndex = 0; + let m; + while ((m = PROCESS_ENV.exec(stripped)) !== null) { + usages.push({ relPath: file.relPath, line: lineOf(stripped, m.index) }); + } + } + return usages; +} + +/** True when a scan-relative posix path is the environment adapter. */ +function isInsideAdapter(relPath) { + return relPath === ADAPTER_REL; +} + +/** Walks the workspace groups under a root and returns [{ relPath, content }] for source files. */ +async function collectSourceFiles(rootDir) { + const files = []; + async function walk(dir, relDir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const abs = path.join(dir, entry.name); + const rel = `${relDir}/${entry.name}`; + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) await walk(abs, rel); + continue; + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + files.push({ relPath: rel, content: readFileSync(abs, 'utf8') }); + } + } + } + for (const group of GROUP_DIRS) { + await walk(path.join(rootDir, group), group); + } + return files; +} + +/** A single process.env usage, formatted for violation reports. */ +function formatUsage(usage) { + return ` ${usage.relPath}:${usage.line} reads process.env`; +} + +/** The process.env usages outside the adapter in a scan root (empty = compliant). */ +async function scanMisplacedEnvReads(rootDir) { + const files = await collectSourceFiles(rootDir); + return findProcessEnvUsages(files).filter((u) => !isInsideAdapter(u.relPath)); +} + +// --------------------------------------------------------------------------- +// Temp-copy helpers (mutation probes over the committed tree) +// --------------------------------------------------------------------------- + +/** Copies the committed tree (minus ignored dirs) into a fresh temp dir. */ +function copyCommittedTree() { + const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-env-adapter-')); + for (const entry of readdirSync(REPO_ROOT, { withFileTypes: true })) { + if (IGNORED_DIRS.has(entry.name) || entry.name === 'tests') continue; + cpSync(path.join(REPO_ROOT, entry.name), path.join(dir, entry.name), { + recursive: true, + }); + } + return dir; +} + +/** Injects a process.env read into a source file inside a tree copy. */ +function injectEnvRead(rootDir, relPath, line) { + const abs = path.join(rootDir, relPath); + const original = readFileSync(abs, 'utf8'); + writeFileSync(abs, `${line}\n${original}`); + return abs; +} + +// --------------------------------------------------------------------------- +// Static assertions on the committed sources +// --------------------------------------------------------------------------- + +/** + * Asserts the adapter module is the workspace's single owner of `process.env` + * reads: it exports `loadConfigFromEnv(env = process.env)`, maps every + * setting (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the + * validated config shape, and validates the mapped environment with + * `assertValidConfig` before returning (E00-S04-T02). Fails fast on a + * deviation; the mutation probes below prove the assertions are non-vacuous. + */ +function assertAdapterSource(src) { + assert.match( + src, + /import \{ assertValidConfig \} from '\.\/startup\.js'/, + 'the adapter must validate the mapped environment with assertValidConfig (the E00-S04-T02 startup validation)', + ); + assert.match( + src, + /import type \{ Config \} from '\.\/schema\.js'/, + 'the adapter must build on the E00-S04-T01 schema (the Config type)', + ); + assert.match( + src, + /export function loadConfigFromEnv\(env: NodeJS\.ProcessEnv = process\.env\): Config/, + 'the adapter must export loadConfigFromEnv reading process.env by default (the workspace\'s single owner of process.env reads)', + ); + assert.match( + src, + /env\.HOST/, + 'the adapter must map HOST onto the validated config (host)', + ); + assert.match( + src, + /resolvePort\(env\.PORT\)/, + 'the adapter must map PORT onto the validated config (port, resolved by resolvePort)', + ); + assert.match( + src, + /env\.DATABASE_URL/, + 'the adapter must map DATABASE_URL onto the validated config (databaseUrl)', + ); + assert.match( + src, + /env\.EPPP_SESSION_SECRET/, + 'the adapter must map EPPP_SESSION_SECRET onto the validated config (sessionSecret)', + ); + assert.match( + src, + /assertValidConfig\(\{/, + 'the adapter must validate the mapped environment (assertValidConfig) before returning it', + ); + assert.match( + src, + /function resolvePort\(raw: string \| undefined\): number/, + 'the adapter must own the PORT resolution (resolvePort, the pre-adapter server behavior)', + ); +} + +/** + * Asserts the committed server loads its startup configuration through the + * environment adapter and reads no `process.env` itself: it imports + * `loadConfigFromEnv` from `@personal-blog/config`, calls it before binding, + * and binds/takes every setting from the validated config (`config.port`, + * `config.databaseUrl`). + */ +function assertServerSource(src) { + assert.match( + src, + /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, + 'the server must import the environment adapter from the config package', + ); + assert.match( + src, + /const config = loadConfigFromEnv\(\);/, + 'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())', + ); + assert.match( + src, + /server\.listen\(config\.port/, + 'the server must bind the port from the validated configuration (config.port)', + ); + assert.match( + src, + /const databaseUrl = config\.databaseUrl;/, + 'the server must take the databaseUrl from the validated configuration (config.databaseUrl)', + ); + assert.doesNotMatch( + src, + /process\.env\.[A-Z_]+/, + 'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)', + ); + const callIndex = src.indexOf('loadConfigFromEnv('); + const listenIndex = src.indexOf('server.listen('); + assert.ok( + callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex, + 'the adapter call must run before the server binds (server.listen)', + ); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the environment adapter exists and is the config package\'s single owner of process.env reads', () => { + assert.ok(existsSyncProbe(ADAPTER_REL), `committed ${ADAPTER_REL} must exist`); + assertAdapterSource(read(ADAPTER_REL)); +}); + +test('the package boundary re-exports the environment adapter', () => { + const src = read(INDEX_SRC); + assert.match( + src, + /export \{ loadConfigFromEnv \} from '\.\/env\.js'/, + 'the boundary must re-export the environment adapter (loadConfigFromEnv)', + ); +}); + +test('the server loads its configuration through the adapter and reads no process.env', () => { + assert.ok(existsSyncProbe(SERVER_SRC), `committed ${SERVER_SRC} must exist`); + assertServerSource(read(SERVER_SRC)); +}); + +test('no module reads process.env directly except the configuration adapter (workspace scan)', async () => { + // The issue's test plan: "static check confirms only the adapter reads + // process.env". Every source file under apps/, packages/ and extensions/ + // (comments stripped) is scanned; the only `process.env` references may + // live in packages/config/src/env.ts. + const files = await collectSourceFiles(REPO_ROOT); + const usages = findProcessEnvUsages(files); + + // Non-vacuous: the adapter really does read process.env. + const adapterUsages = usages.filter((u) => isInsideAdapter(u.relPath)); + assert.ok( + adapterUsages.length > 0, + 'the adapter must read process.env (the workspace\'s single owner)', + ); + + const misplaced = usages.filter((u) => !isInsideAdapter(u.relPath)); + assert.deepEqual( + misplaced, + [], + `no module may read process.env except the configuration adapter:\n${misplaced.map(formatUsage).join('\n')}`, + ); +}); + +test('the config-env-adapter criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/config-env-adapter.test.mjs`), + `CI must run the config-env-adapter suite (job "${CI_JOB}") on every PR`, + ); + assert.ok( + workflow.includes( + 'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build', + ), + 'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)', + ); +}); + +// --------------------------------------------------------------------------- +// Mutation probes — the static assertions are non-vacuous +// --------------------------------------------------------------------------- + +test('injecting a direct process.env read into the server fails the workspace scan (mutation probe)', async () => { + const dir = copyCommittedTree(); + try { + injectEnvRead(dir, SERVER_SRC, "const PORT = process.env.PORT;"); + const misplaced = await scanMisplacedEnvReads(dir); + assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatUsage).join('\n')}`); + assert.equal(misplaced[0].relPath, SERVER_SRC); + assert.equal(misplaced[0].line, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('moving the process.env read out of the adapter (into startup.ts) fails the workspace scan (mutation probe)', async () => { + const dir = copyCommittedTree(); + try { + injectEnvRead(dir, 'packages/config/src/startup.ts', "const PORT = process.env.PORT;"); + const misplaced = await scanMisplacedEnvReads(dir); + assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatUsage).join('\n')}`); + assert.equal(misplaced[0].relPath, 'packages/config/src/startup.ts'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a copy of the committed tree passes the workspace scan (probe sanity)', async () => { + const dir = copyCommittedTree(); + try { + const misplaced = await scanMisplacedEnvReads(dir); + assert.deepEqual( + misplaced, + [], + `a clean copy of the committed tree must pass the scan:\n${misplaced.map(formatUsage).join('\n')}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('comment stripping ignores commented-out process.env references (unit probe)', () => { + const src = [ + "// const PORT = process.env.PORT;", + "/* const HOST = process.env.HOST; */", + "const DATABASE_URL = process.env.DATABASE_URL;", + '', + ].join('\n'); + const usages = findProcessEnvUsages([{ relPath: 'apps/server/src/index.ts', content: src }]); + assert.equal(usages.length, 1); + assert.equal(usages[0].relPath, 'apps/server/src/index.ts'); + assert.equal(usages[0].line, 3, 'the stripped line number must match the original source'); +}); + +test('dropping the adapter call in the server fails the server wiring assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();'); + assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring'); + assert.throws(() => assertServerSource(withoutCall), /must load its startup configuration/); +}); + +test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const directRead = src.replace( + 'const config = loadConfigFromEnv();', + 'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;', + ); + assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read'); + assert.throws(() => assertServerSource(directRead), /must not read process\.env/); +}); + +test('removing the process.env default from the adapter fails the single-owner assertion (mutation probe)', () => { + const src = read(ADAPTER_REL); + const noDefault = src.replace( + 'export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config {', + 'export function loadConfigFromEnv(env: NodeJS.ProcessEnv): Config {', + ); + assert.notEqual(noDefault, src, 'the mutation must actually drop the process.env default'); + assert.throws(() => assertAdapterSource(noDefault), /reading process\.env by default/); +}); + +test('the adapter not validating the mapped environment fails the validation assertion (mutation probe)', () => { + const src = read(ADAPTER_REL); + const noValidation = src.replace('return assertValidConfig({', 'return {'); + assert.notEqual(noValidation, src, 'the mutation must actually bypass assertValidConfig'); + assert.throws(() => assertAdapterSource(noValidation), /must validate the mapped environment/); +}); + +test('dropping the adapter export from the package boundary fails the boundary assertion (mutation probe)', () => { + const src = read(INDEX_SRC); + const dropped = src.replace("export { loadConfigFromEnv } from './env.js';", ''); + assert.notEqual(dropped, src, 'the mutation must actually drop the loadConfigFromEnv export'); + assert.throws(() => assertBoundary(dropped), /must re-export the environment adapter/); +}); + +/** Asserts the boundary re-exports the environment adapter (shared with the mutation probe). */ +function assertBoundary(src) { + assert.match( + src, + /export \{ loadConfigFromEnv \} from '\.\/env\.js'/, + 'the boundary must re-export the environment adapter (loadConfigFromEnv)', + ); +} + +// --------------------------------------------------------------------------- +// Deterministic behavioral probe — the compiled @personal-blog/config boundary +// --------------------------------------------------------------------------- + +/** + * How the current Node executes TypeScript sources: `default` (>= 23.6, type + * stripping on by default), `strip-types-flag` (>= 22.6 via + * `--experimental-strip-types`) or `null` (cannot run .ts at all). The + * workspace pins engines.node to 24.x, where type stripping is stable. + */ +function tsExecMode() { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major > 23 || (major === 23 && minor >= 6)) return 'default'; + if (major === 22 && minor >= 6) return 'strip-types-flag'; + return null; +} + +/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */ +const TS_STRIPPING = tsExecMode() !== null; + +/** The compiled config package boundary the probes import (built by the CI job first). */ +const CONFIG_DIST = existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js')); +/** The compiled database-postgres package the booted server also imports. */ +const DATABASE_POSTGRES_DIST = existsSync( + path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'), +); + +/** Why the boot probes may be skipped on a clean clone without a build step. */ +const BUILD_HINT = + 'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)'; + +/** True when a repo-relative path exists. */ +function existsSyncProbe(relPath) { + return existsSync(path.join(REPO_ROOT, relPath)); +} + +/** + * The boundary probe source: exercises the compiled `@personal-blog/config` + * environment adapter (`loadConfigFromEnv`) exactly as the server consumes + * it — a full environment maps onto the validated config, missing settings + * fall back to the committed defaults (host 0.0.0.0, port 3000, no + * databaseUrl), a bad `PORT` falls back to 3000, a missing required secret + * throws `MissingRequiredSettingError` naming the field, an empty + * `DATABASE_URL` throws `ConfigStartupError`, and the no-argument call reads + * the real `process.env`. Written to a temp file inside `packages/config/` so + * `ajv`/`@sinclair/typebox` resolve through the package's own dependency + * links, then removed. + */ +const PROBE_SOURCE = ` +import { loadConfigFromEnv, MissingRequiredSettingError, ConfigStartupError } from './dist/index.js'; + +const SECRET = '${SECRET}'; +const URL = '${DATABASE_URL}'; + +const capture = (fn) => { + try { + return { threw: false, value: fn() }; + } catch (error) { + return { + threw: true, + name: error && typeof error === 'object' ? error.name : String(error), + message: error instanceof Error ? error.message : String(error), + missingField: error && typeof error === 'object' ? error.missingField : undefined, + isMissingRequired: error instanceof MissingRequiredSettingError, + isConfigStartup: error instanceof ConfigStartupError, + }; + } +}; + +// A full environment maps onto the validated config. +const full = loadConfigFromEnv({ + HOST: '127.0.0.1', + PORT: '4123', + DATABASE_URL: URL, + EPPP_SESSION_SECRET: SECRET, +}); + +// Missing optional settings fall back to the committed defaults. +const defaults = loadConfigFromEnv({ EPPP_SESSION_SECRET: SECRET }); + +// A bad PORT falls back to the default 3000 (the pre-adapter server behavior). +const badPort = loadConfigFromEnv({ PORT: 'abc', EPPP_SESSION_SECRET: SECRET }).port; +const zeroPort = loadConfigFromEnv({ PORT: '0', EPPP_SESSION_SECRET: SECRET }).port; +const highPort = loadConfigFromEnv({ PORT: '65536', EPPP_SESSION_SECRET: SECRET }).port; +const maxPort = loadConfigFromEnv({ PORT: '65535', EPPP_SESSION_SECRET: SECRET }).port; +const minPort = loadConfigFromEnv({ PORT: '1', EPPP_SESSION_SECRET: SECRET }).port; + +// A missing required secret is a field-specific startup error naming it. +const missingSecret = capture(() => loadConfigFromEnv({})); + +// An empty DATABASE_URL violates the schema (must be non-empty when present). +const emptyDb = capture(() => loadConfigFromEnv({ EPPP_SESSION_SECRET: SECRET, DATABASE_URL: '' })); + +// The no-argument call reads the real process.env (the adapter is the single +// owner of process.env reads). +process.env.EPPP_SESSION_SECRET = SECRET; +process.env.PORT = '5243'; +const fromRealEnv = loadConfigFromEnv(); + +const result = { + full: { host: full.host, port: full.port, databaseUrl: full.databaseUrl, sessionSecret: full.sessionSecret }, + defaults: { host: defaults.host, port: defaults.port, hasDatabaseUrl: defaults.databaseUrl !== undefined }, + badPort, zeroPort, highPort, maxPort, minPort, + missingSecret, + emptyDb, + fromRealEnv: { port: fromRealEnv.port, sessionSecret: fromRealEnv.sessionSecret }, +}; + +console.log('CONFIG_ENV_ADAPTER_PROBE_RESULT ' + JSON.stringify(result)); +`; + +test('the compiled environment adapter maps the environment onto the validated config (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => { + const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-env-adapter-probe-${process.pid}.mjs`); + try { + writeFileSync(probeFile, PROBE_SOURCE); + const run = spawnSync(process.execPath, [path.basename(probeFile)], { + cwd: path.join(REPO_ROOT, CONFIG_DIR), + encoding: 'utf8', + timeout: 60_000, + }); + assert.equal( + run.status, + 0, + `the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`, + ); + const match = run.stdout.match(/CONFIG_ENV_ADAPTER_PROBE_RESULT (\{.*\})/); + assert.ok(match, `the probe must print CONFIG_ENV_ADAPTER_PROBE_RESULT:\n${run.stdout.trim()}`); + const result = JSON.parse(match[1]); + + // A full environment maps onto the validated config. + assert.deepEqual( + result.full, + { host: '127.0.0.1', port: 4123, databaseUrl: DATABASE_URL, sessionSecret: SECRET }, + `a full env must map onto the validated config (got: ${JSON.stringify(result.full)})`, + ); + + // Missing optional settings fall back to the committed defaults. + assert.deepEqual( + result.defaults, + { host: '0.0.0.0', port: 3000, hasDatabaseUrl: false }, + `the defaults must be host 0.0.0.0 / port 3000 / no databaseUrl (got: ${JSON.stringify(result.defaults)})`, + ); + + // A bad PORT falls back to the default; the range edges hold. + assert.equal(result.badPort, 3000, 'a non-numeric PORT must fall back to 3000'); + assert.equal(result.zeroPort, 3000, 'PORT 0 must fall back to 3000 (out of the 1-65535 range)'); + assert.equal(result.highPort, 3000, 'PORT above 65535 must fall back to 3000'); + assert.equal(result.maxPort, 65535, 'PORT 65535 is the upper edge of the valid range'); + assert.equal(result.minPort, 1, 'PORT 1 is the lower edge of the valid range'); + + // A missing required secret is a field-specific startup error naming it. + assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw'); + assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError'); + assert.equal(result.missingSecret.isConfigStartup, true, 'MissingRequiredSettingError must be a ConfigStartupError'); + assert.equal( + result.missingSecret.missingField, + 'sessionSecret', + `MissingRequiredSettingError must carry the missing field name (got: ${JSON.stringify(result.missingSecret)})`, + ); + assert.match( + result.missingSecret.message, + /missing required setting: sessionSecret/, + `the startup error must name the missing field (got: ${JSON.stringify(result.missingSecret)})`, + ); + + // An empty DATABASE_URL violates the schema. + assert.equal(result.emptyDb.threw, true, 'an empty DATABASE_URL must throw'); + assert.equal(result.emptyDb.isConfigStartup, true, 'an empty DATABASE_URL must throw ConfigStartupError'); + assert.match( + result.emptyDb.message, + /databaseUrl/, + `the startup error must name the violating field (got: ${JSON.stringify(result.emptyDb)})`, + ); + + // The no-argument call reads the real process.env. + assert.equal(result.fromRealEnv.port, 5243, 'the no-argument call must read PORT from the real process.env'); + assert.equal(result.fromRealEnv.sessionSecret, SECRET, 'the no-argument call must read EPPP_SESSION_SECRET from the real process.env'); + } finally { + rmSync(probeFile, { force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Server-boot probes — the issue's test plan executed against the real +// committed server: settings flow through the adapter +// --------------------------------------------------------------------------- + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Reserves an ephemeral TCP port, then releases it for the child to bind. */ +function reservePort() { + return new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} + +/** + * Boots the committed server source on `port` with the given env overrides + * (merged over `process.env`; `DATABASE_URL` and `EPPP_SESSION_SECRET` are + * stripped unless explicitly provided so the probes are deterministic). + * Returns `{ child, stdout, stderr }` with closures for the captured output. + */ +function bootServer(port, envOverrides = {}) { + const args = + tsExecMode() === 'strip-types-flag' + ? ['--experimental-strip-types', SERVER_SRC] + : [SERVER_SRC]; + const env = { ...process.env, PORT: String(port) }; + delete env.DATABASE_URL; + delete env.EPPP_SESSION_SECRET; + Object.assign(env, envOverrides); + const child = spawn(process.execPath, args, { + cwd: REPO_ROOT, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + return { child, stdout: () => stdout, stderr: () => stderr }; +} + +/** + * Polls `GET /health` until the server answers with ANY status, the child + * exits, or the deadline passes. Returns the fetch Response. + */ +async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) { + const deadline = Date.now() + deadlineMs; + let lastError = ''; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`, + ); + } + try { + return await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(1_000), + }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + await delay(100); + } + } + throw new Error( + `GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`, + ); +} + +/** Waits for the child to exit (it fails fast on a startup error). */ +function waitForExit(child, deadlineMs = 10_000) { + return new Promise((resolve, reject) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + const timer = setTimeout( + () => reject(new Error('the server did not exit within the deadline (expected a startup error)')), + deadlineMs, + ); + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); +} + +/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */ +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([once(child, 'exit'), delay(2_000)]); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); +} + +test('a PORT/HOST override flows through the adapter into the resolved configuration (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { + // "All settings flow through the adapter": booting the committed server + // with PORT/HOST overrides must surface those values in the resolved + // configuration the server logs at startup — the settings reach the app + // through loadConfigFromEnv, not through direct process.env reads. + const port = await reservePort(); + const { child, stdout, stderr } = bootServer(port, { + EPPP_SESSION_SECRET: SECRET, + HOST: '127.0.0.1', + }); + try { + const response = await waitForAnswer(port, child, stderr); + assert.equal( + response.status, + 200, + `the server must boot to GET /health 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`, + ); + assert.match( + stdout(), + /\[config\] resolved configuration:/, + `the server must log its resolved configuration at startup (got: ${stdout().trim()})`, + ); + assert.ok( + stdout().includes(`"port":${port}`), + `the resolved configuration must show the PORT override (got: ${stdout().trim()})`, + ); + assert.ok( + stdout().includes('"host":"127.0.0.1"'), + `the resolved configuration must show the HOST override (got: ${stdout().trim()})`, + ); + assert.ok( + stdout().includes('"sessionSecret":"[REDACTED]"'), + `the resolved configuration must still redact the secret (got: ${stdout().trim()})`, + ); + } finally { + await stopChild(child); + } +}); + +test('starting without the required secret exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { + // The adapter validates the mapped environment with assertValidConfig + // (E00-S04-T02), so a missing required setting is still a startup error + // through the adapter path: booting without EPPP_SESSION_SECRET must exit + // non-zero naming the missing field. + const port = await reservePort(); + const { child, stdout, stderr } = bootServer(port); // EPPP_SESSION_SECRET stripped + const { code, signal } = await waitForExit(child); + assert.notEqual( + code, + 0, + `the server must exit non-zero when a required setting is missing (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`, + ); + assert.match( + stderr() + stdout(), + /missing required setting: sessionSecret/, + `the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`, + ); +}); From 3214807c9dcbb7607cc20efb745a590f59d87586 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:22 +0000 Subject: [PATCH 4/9] test: update suites locked to the old direct process.env wiring for the adapter (E00-S04-T04) --- tests/app-readiness.test.mjs | 4 +- tests/config-log-redaction.test.mjs | 27 +++++++---- tests/config-startup-error.test.mjs | 75 +++++++++++++++++------------ tests/health-endpoint.test.mjs | 17 ++++++- 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/tests/app-readiness.test.mjs b/tests/app-readiness.test.mjs index fccde91..f173b68 100644 --- a/tests/app-readiness.test.mjs +++ b/tests/app-readiness.test.mjs @@ -157,7 +157,7 @@ function assertReadinessGate(src) { * after migrations finish. */ function assertReadyAfterRun(src) { - const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL'); + const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl'); assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path'); const elseStart = src.indexOf('} else {', dbUrlIndex); assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)'); @@ -191,7 +191,7 @@ function assertReadyAfterRun(src) { * non-container path and the E00-S02-T03 health endpoint working). */ function assertNoDatabaseUrlPath(src) { - const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL')); + const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl')); assert.match( startup, /if \(databaseUrl === undefined\) \{/, diff --git a/tests/config-log-redaction.test.mjs b/tests/config-log-redaction.test.mjs index 60aa1c6..ffc49e3 100644 --- a/tests/config-log-redaction.test.mjs +++ b/tests/config-log-redaction.test.mjs @@ -159,8 +159,8 @@ function assertServerSource(src) { // package's scrubber before it reaches stdout/stderr. assert.match( src, - /import \{ assertValidConfig \} from '@personal-blog\/config'/, - 'the server must import the startup validation entry point from the config package', + /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, + 'the server must import the environment adapter (loadConfigFromEnv) from the config package', ); assert.match( src, @@ -188,12 +188,13 @@ function assertServerSource(src) { 'error lines must be written to stderr', ); - // The wiring — the server keeps its validated config, creates the logger - // with it and logs the resolved configuration redacted. + // The wiring — the server keeps its validated config (loaded through the + // environment adapter, E00-S04-T04), creates the logger with it and logs + // the resolved configuration redacted. assert.match( src, - /const config = assertValidConfig\(\{/, - 'the server must keep its validated configuration (const config = assertValidConfig(...))', + /const config = loadConfigFromEnv\(\);/, + 'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)', ); assert.match( src, @@ -215,13 +216,19 @@ function assertServerSource(src) { /console\.(log|error)\(/, 'the server must not write log output with bare console.log/console.error (they would bypass the redaction)', ); - // The startup validation runs before the logger is created, so a missing - // required setting still fails fast (E00-S04-T02) before any log output. - const validationIndex = src.indexOf('assertValidConfig({'); + assert.doesNotMatch( + src, + /process\.env\.[A-Z_]+/, + 'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)', + ); + // The startup configuration loads through the adapter (which validates it) + // before the logger is created, so a missing required setting still fails + // fast (E00-S04-T02) before any log output. + const validationIndex = src.indexOf('loadConfigFromEnv('); const loggerIndex = src.indexOf('createLogger(config)'); assert.ok( validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex, - 'the startup validation must run before the logger is created (a missing required setting is still a startup error)', + 'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)', ); } diff --git a/tests/config-startup-error.test.mjs b/tests/config-startup-error.test.mjs index 69a831c..03d6d15 100644 --- a/tests/config-startup-error.test.mjs +++ b/tests/config-startup-error.test.mjs @@ -6,17 +6,20 @@ * - "missing required setting gives a field-specific startup error" → the * `packages/config` package exposes the startup validation entry point * (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema) - * and the committed `apps/server/src/index.ts` calls it before the server + * and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the + * config package's single owner of `process.env` reads) validates the + * mapped environment through it; the committed `apps/server/src/index.ts` + * loads its startup configuration through the adapter 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 instead of * booting with an invalid configuration. Locked in statically (mutation - * probes prove non-vacuity: dropping the startup validation call, - * moving it after the bind, or dropping the compose/Dockerfile support - * all fail) and behaviorally by the deterministic probes (the issue's - * test plan: "start with a missing required field and confirm the error - * names it" — booting the committed server without `EPPP_SESSION_SECRET` - * exits non-zero with the error naming the missing field). + * probes prove non-vacuity: dropping the adapter call, moving it after + * the bind, or dropping the compose/Dockerfile support all fail) and + * behaviorally by the deterministic probes (the issue's test plan: "start + * with a missing required field and confirm the error names it" — booting + * the committed server without `EPPP_SESSION_SECRET` exits non-zero with + * the error naming the missing field). * - "the error names the missing field" → a missing required setting throws * `MissingRequiredSettingError` whose message and `missingField` name the * missing field (e.g. `"missing required setting: sessionSecret"`); other @@ -149,33 +152,35 @@ function assertStartupErrorSource(src) { } /** - * Asserts the committed server validates the required settings at startup: - * it imports `assertValidConfig` from `@personal-blog/config` and calls it - * with the parsed startup configuration (including the required - * `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required - * setting is a startup error, never a silently-booted invalid configuration. + * Asserts the committed server loads its startup configuration through the + * environment adapter (E00-S04-T04) before it binds: it imports + * `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter + * validates the mapped environment with `assertValidConfig`, including the + * required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing + * required setting is a startup error, never a silently-booted invalid + * configuration, and the server itself never reads `process.env` directly. */ function assertServerStartupValidation(src) { assert.match( src, - /import \{ assertValidConfig \} from '@personal-blog\/config'/, - 'the server must import the startup validation entry point from the config package', + /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, + 'the server must import the environment adapter (loadConfigFromEnv) from the config package', ); assert.match( src, - /assertValidConfig\(\{/, - 'the server must call assertValidConfig with its startup configuration', + /const config = loadConfigFromEnv\(\);/, + 'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())', ); - assert.match( + assert.doesNotMatch( src, - /sessionSecret: process\.env\.EPPP_SESSION_SECRET/, - 'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation', + /process\.env\.[A-Z_]+/, + 'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)', ); - const callIndex = src.indexOf('assertValidConfig({'); + const callIndex = src.indexOf('loadConfigFromEnv('); const listenIndex = src.indexOf('server.listen('); assert.ok( callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex, - 'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error', + 'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error', ); } @@ -274,25 +279,35 @@ test('the config-startup-error criterion is enforced in CI', () => { // Mutation probes — the static assertions are non-vacuous // --------------------------------------------------------------------------- -test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => { +test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => { const src = read(SERVER_SRC); - const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n'); - assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call'); - assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/); + const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();'); + assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring'); + assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/); }); -test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => { +test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => { const src = read(SERVER_SRC); const moved = src - .replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '') + .replace('const config = loadConfigFromEnv();\n', '') .replace( - 'server.listen(PORT, () => {', - 'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });', + 'server.listen(config.port, () => {', + 'server.listen(config.port, () => {\n const config = loadConfigFromEnv();', ); - assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind'); + assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind'); assert.throws(() => assertServerStartupValidation(moved), /before the server binds/); }); +test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const directRead = src.replace( + 'const config = loadConfigFromEnv();', + 'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;', + ); + assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read'); + assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/); +}); + test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => { const src = read(STARTUP_SRC); const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting'); diff --git a/tests/health-endpoint.test.mjs b/tests/health-endpoint.test.mjs index 27c4f59..f8d04ba 100644 --- a/tests/health-endpoint.test.mjs +++ b/tests/health-endpoint.test.mjs @@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) { ); assert.match( src, - /3000/, - 'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)', + /server\.listen\(config\.port/, + 'the server must bind the port from the validated configuration (config.port, E00-S04-T04)', ); } @@ -189,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {" ); }); +test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => { + // The server binds `config.port` (asserted above); the port default (3000, + // matching the Dockerfile EXPOSE / compose :3000) and the PORT override + // live in the config package's environment adapter — the single owner of + // process.env reads. + const envSrc = read('packages/config/src/env.ts'); + assert.match( + envSrc, + /3000/, + 'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)', + ); +}); + test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => { if (!tsExecMode()) { t.skip( From b9345e205c66b47ae1e6a7df7b76263406d546ad Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:22 +0000 Subject: [PATCH 5/9] docs: document the environment adapter in the non-container guide (E00-S04-T04) --- docs/development/non-container.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 9dba5c0..196de6d 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups: | Group | Path | Purpose | | --- | --- | --- | -| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), and redacts secret values from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story. | +| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the `.env.example` template (E00-S04-T05) land in later tasks. | +| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04); the `.env.example` template (E00-S04-T05) lands in a later task. | | `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -119,6 +119,13 @@ compiled application entrypoint. Three things to know: "sessionSecret":"[REDACTED]"}`, and every log line passes through the redacting logger — the admin-session secret and the password embedded in a `DATABASE_URL` connection string never appear in the log output. +5. Since [E00-S04-T04], **all settings flow through the config package's + environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` — + the workspace's single owner of `process.env` reads): `HOST`, `PORT`, + `DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated + config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`) + and validated at startup — no module outside the config package reads + `process.env` directly. To run the compiled output of any other workspace package directly: From 73a1ae88cd2c67f5b543f975bcb452d4288c0478 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:18:11 +0000 Subject: [PATCH 6/9] chore: drop unused mkdirSync import in the env-adapter suite (E00-S04-T04) --- tests/config-env-adapter.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config-env-adapter.test.mjs b/tests/config-env-adapter.test.mjs index 130b4bf..e931dca 100644 --- a/tests/config-env-adapter.test.mjs +++ b/tests/config-env-adapter.test.mjs @@ -41,7 +41,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, readdirSync, writeFileSync, mkdirSync, cpSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, writeFileSync, cpSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; From 345ceccfad25a24efe2e0ed613d72be8dec7b13e Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:41:53 +0000 Subject: [PATCH 7/9] feat: HOST validated at the adapter boundary and controls the actual bind interface (E00-S04-T04) 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). --- apps/server/package.json | 2 +- apps/server/src/index.ts | 12 +++++++-- packages/config/package.json | 2 +- packages/config/src/env.ts | 51 +++++++++++++++++++++++++++++++++--- packages/config/src/index.ts | 7 +++-- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 9a1d8c1..27dc383 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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), 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.", + "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 and binds the validated HOST interface (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", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 838562d..393a27d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -33,7 +33,11 @@ * (`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. + * 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 @@ -199,7 +203,11 @@ if (databaseUrl === undefined) { }); } -server.listen(config.port, () => { +// 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)`); }); diff --git a/packages/config/package.json b/packages/config/package.json index 49c69c2..8e8ad19 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index bca077c..03cbc10 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -16,7 +16,13 @@ * `schema.ts`): * * - `HOST` → `host` — the interface the HTTP server binds, default `0.0.0.0` - * (the container default). + * (the container default). The value is validated at this adapter boundary + * as a hostname or IP address (IPv4/IPv6) before it is used for binding or + * logged: an invalid `HOST` throws a field-specific `ConfigStartupError` + * naming `host`, so arbitrary env content is never echoed verbatim into the + * startup log (the issue's acceptance criterion: "HOST is validated at the + * adapter boundary as a hostname or IP address before it is used for + * binding or logged"). * - `PORT` → `port` — integer in the valid TCP port range (1–65535), default * `3000` (the Dockerfile `EXPOSE 3000` / compose `:3000` container port). A * non-numeric or out-of-range override falls back to the default so a bad @@ -39,7 +45,9 @@ * `process.env`. */ -import { assertValidConfig } from './startup.js'; +import { isIP } from 'node:net'; + +import { assertValidConfig, ConfigStartupError } from './startup.js'; import type { Config } from './schema.js'; /** @@ -53,6 +61,43 @@ function resolvePort(raw: string | undefined): number { return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000; } +/** + * One RFC 1123 hostname label: 1–63 alphanumerics/hyphens, not starting or + * ending with a hyphen. + */ +const HOSTNAME_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; + +/** + * A hostname: dot-separated RFC 1123 labels (e.g. `localhost`, `db`, + * `api.internal.example`), at most 253 characters total. + */ +const HOSTNAME_PATTERN = new RegExp(`^(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}$`); + +/** + * Resolves the bind interface from `HOST` (default `0.0.0.0` — the container + * default). The value is validated at this adapter boundary as a hostname or + * IP address (IPv4/IPv6, via `node:net` `isIP` or the RFC 1123 hostname + * pattern) BEFORE it can be used for binding or logged: an invalid value + * throws a field-specific `ConfigStartupError` naming `host`, so arbitrary + * `HOST` content is never echoed verbatim into the startup log (the issue's + * acceptance criterion — the server passes the validated value to + * `server.listen`, and the startup log reflects the actual bind interface). + * + * Unlike `resolvePort` (which falls back to the default on a bad value), an + * invalid `HOST` fails startup: an operator who sets `HOST=127.0.0.1` to + * restrict network exposure must never silently get a different interface. + */ +function resolveHost(raw: string | undefined): string { + if (raw === undefined) return '0.0.0.0'; + if (isIP(raw) !== 0 || (raw.length <= 253 && HOSTNAME_PATTERN.test(raw))) { + return raw; + } + throw new ConfigStartupError( + 'invalid configuration: host: must be a valid hostname or IP address', + ['host: must be a valid hostname or IP address'], + ); +} + /** * Maps the environment onto the validated configuration — the E00-S04-T04 * environment adapter. @@ -70,7 +115,7 @@ function resolvePort(raw: string | undefined): number { */ export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config { return assertValidConfig({ - host: env.HOST ?? '0.0.0.0', + host: resolveHost(env.HOST), port: resolvePort(env.PORT), databaseUrl: env.DATABASE_URL, sessionSecret: env.EPPP_SESSION_SECRET, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index dce9fcc..7658be0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -23,8 +23,11 @@ * It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) * onto the validated config shape and validates it with `assertValidConfig` * at startup, so every setting flows through the adapter and no other module - * reads `process.env` directly. The `.env.example` template (E00-S04-T05) - * builds on this boundary in a later task. + * reads `process.env` directly. `HOST` is validated at the adapter boundary + * as a hostname or IP address before it is used for binding or logged, so + * arbitrary env content is never echoed verbatim into the startup log. The + * `.env.example` template (E00-S04-T05) builds on this boundary in a later + * task. */ export { configSchema } from './schema.js'; From 408f33e4e9ffcd47baf67453e60b03dd5032a755 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:42:03 +0000 Subject: [PATCH 8/9] test: lock in HOST validation and bind-interface control (E00-S04-T04) Extend the env-adapter suite to the issue's reworked acceptance criteria: - static assertions: the adapter resolves HOST via resolveHost (hostname/IP at the adapter boundary) and the server passes config.host to server.listen - deterministic boundary probe: valid HOST forms (IPv4/IPv6/hostname) pass, invalid HOST forms throw ConfigStartupError naming host - boot probes: HOST=127.0.0.1 binds loopback only (no answer on a non-loopback interface) with the startup log reflecting the actual bind; an invalid HOST exits non-zero naming the field without echoing the raw value - mutation probes: bypassing resolveHost or dropping config.host from server.listen both fail - config-startup-error: update the order-asserion mutation probe for the new server.listen(config.port, config.host, ...) signature --- tests/config-env-adapter.test.mjs | 252 +++++++++++++++++++++++++--- tests/config-startup-error.test.mjs | 4 +- 2 files changed, 234 insertions(+), 22 deletions(-) diff --git a/tests/config-env-adapter.test.mjs b/tests/config-env-adapter.test.mjs index e931dca..726e8ee 100644 --- a/tests/config-env-adapter.test.mjs +++ b/tests/config-env-adapter.test.mjs @@ -17,20 +17,41 @@ * - "all settings flow through the adapter" → the committed * `apps/server/src/index.ts` imports `loadConfigFromEnv` from * `@personal-blog/config` and loads its startup configuration through it - * (binding `config.port`, taking `config.databaseUrl`, never reading - * `process.env` itself); the adapter maps `HOST`/`PORT`/`DATABASE_URL`/ - * `EPPP_SESSION_SECRET` onto the validated config shape and validates it - * with `assertValidConfig` (E00-S04-T02) before returning. Locked in - * statically and behaviorally: the deterministic boundary probe exercises - * the compiled adapter exactly as the server consumes it (full env, - * defaults, bad-`PORT` fallback, missing required secret → - * `MissingRequiredSettingError` naming `sessionSecret`, empty - * `DATABASE_URL` → `ConfigStartupError`, and the no-argument call reading - * the real `process.env`), and the server-boot probes execute the issue's - * test plan against the committed server (a `PORT`/`HOST` override shows - * up in the resolved-configuration log — the settings really flow through - * the adapter — and a missing required secret still fails startup naming - * the missing field). + * (binding `config.port`/`config.host`, taking `config.databaseUrl`, never + * reading `process.env` itself); the adapter maps `HOST`/`PORT`/ + * `DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config shape + * and validates it with `assertValidConfig` (E00-S04-T02) before + * returning. Locked in statically and behaviorally: the deterministic + * boundary probe exercises the compiled adapter exactly as the server + * consumes it (full env, defaults, bad-`PORT` fallback, valid + * `HOST` hostname/IP forms, invalid `HOST` → `ConfigStartupError` naming + * `host`, missing required secret → `MissingRequiredSettingError` naming + * `sessionSecret`, empty `DATABASE_URL` → `ConfigStartupError`, and the + * no-argument call reading the real `process.env`), and the server-boot + * probes execute the issue's test plan against the committed server (a + * `PORT`/`HOST` override shows up in the resolved-configuration log — the + * settings really flow through the adapter; a missing required secret + * still fails startup naming the missing field; `HOST=127.0.0.1` binds + * loopback only, not all interfaces, and the startup log reflects the + * actual bind; an invalid `HOST` fails startup naming the field without + * ever echoing the raw value). + * - "the HOST setting controls the actual bind interface" → the committed + * 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. + * Locked in statically (the server source must pass `config.host` to + * `server.listen`) and by the boot probes (with `HOST=127.0.0.1` the + * server answers on loopback and does not answer on a non-loopback + * interface; the startup log shows `http://127.0.0.1:`). + * - "HOST is validated at the adapter boundary as a hostname or IP address + * before it is used for binding or logged" → the adapter resolves `HOST` + * with `resolveHost`, which accepts hostnames (RFC 1123) and IPv4/IPv6 + * addresses and throws a field-specific `ConfigStartupError` naming + * `host` for anything else — so arbitrary env content is never echoed + * verbatim into logs. Locked in by the deterministic boundary probe + * (valid forms pass, invalid forms throw naming `host`) and by a boot + * probe (an invalid `HOST` exits non-zero naming the field, and the raw + * value never appears in the process output). * * Run: `node --test tests/config-env-adapter.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched. @@ -45,7 +66,7 @@ import { readFileSync, readdirSync, writeFileSync, cpSync, mkdtempSync, rmSync, import { readdir } from 'node:fs/promises'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; -import { createServer as createNetServer } from 'node:net'; +import { createServer as createNetServer, connect as netConnect } from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -324,7 +345,7 @@ function injectEnvRead(rootDir, relPath, line) { function assertAdapterSource(src) { assert.match( src, - /import \{ assertValidConfig \} from '\.\/startup\.js'/, + /import \{ assertValidConfig(?:, ConfigStartupError)? \} from '\.\/startup\.js'/, 'the adapter must validate the mapped environment with assertValidConfig (the E00-S04-T02 startup validation)', ); assert.match( @@ -337,11 +358,26 @@ function assertAdapterSource(src) { /export function loadConfigFromEnv\(env: NodeJS\.ProcessEnv = process\.env\): Config/, 'the adapter must export loadConfigFromEnv reading process.env by default (the workspace\'s single owner of process.env reads)', ); + assert.match( + src, + /import \{ isIP \} from 'node:net'/, + 'the adapter must validate HOST as an IP address via node:net isIP', + ); assert.match( src, /env\.HOST/, 'the adapter must map HOST onto the validated config (host)', ); + assert.match( + src, + /resolveHost\(env\.HOST\)/, + 'the adapter must map HOST onto the validated config (host, resolved by resolveHost)', + ); + assert.match( + src, + /function resolveHost\(raw: string \| undefined\): string/, + 'the adapter must own the HOST resolution (resolveHost, validating hostname/IP at the adapter boundary)', + ); assert.match( src, /resolvePort\(env\.PORT\)/, @@ -389,8 +425,8 @@ function assertServerSource(src) { ); assert.match( src, - /server\.listen\(config\.port/, - 'the server must bind the port from the validated configuration (config.port)', + /server\.listen\(config\.port, config\.host/, + 'the server must pass the validated bind interface to server.listen (config.host), so a configured HOST binds exactly that interface', ); assert.match( src, @@ -568,6 +604,20 @@ test('the adapter not validating the mapped environment fails the validation ass assert.throws(() => assertAdapterSource(noValidation), /must validate the mapped environment/); }); +test('the adapter mapping HOST without resolveHost fails the HOST-validation assertion (mutation probe)', () => { + const src = read(ADAPTER_REL); + const noResolve = src.replace('host: resolveHost(env.HOST),', "host: env.HOST ?? '0.0.0.0',"); + assert.notEqual(noResolve, src, 'the mutation must actually bypass the resolveHost validation'); + assert.throws(() => assertAdapterSource(noResolve), /resolveHost/); +}); + +test('the server binding without config.host fails the bind-interface assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const noHost = src.replace('server.listen(config.port, config.host,', 'server.listen(config.port,'); + assert.notEqual(noHost, src, 'the mutation must actually drop config.host from server.listen'); + assert.throws(() => assertServerSource(noHost), /must pass the validated bind interface/); +}); + test('dropping the adapter export from the package boundary fails the boundary assertion (mutation probe)', () => { const src = read(INDEX_SRC); const dropped = src.replace("export { loadConfigFromEnv } from './env.js';", ''); @@ -625,8 +675,10 @@ function existsSyncProbe(relPath) { * environment adapter (`loadConfigFromEnv`) exactly as the server consumes * it — a full environment maps onto the validated config, missing settings * fall back to the committed defaults (host 0.0.0.0, port 3000, no - * databaseUrl), a bad `PORT` falls back to 3000, a missing required secret - * throws `MissingRequiredSettingError` naming the field, an empty + * databaseUrl), a bad `PORT` falls back to 3000, valid `HOST` forms + * (hostnames and IPv4/IPv6) pass while an invalid `HOST` throws a + * field-specific `ConfigStartupError` naming `host`, a missing required + * secret throws `MissingRequiredSettingError` naming the field, an empty * `DATABASE_URL` throws `ConfigStartupError`, and the no-argument call reads * the real `process.env`. Written to a temp file inside `packages/config/` so * `ajv`/`@sinclair/typebox` resolve through the package's own dependency @@ -671,6 +723,21 @@ const highPort = loadConfigFromEnv({ PORT: '65536', EPPP_SESSION_SECRET: SECRET const maxPort = loadConfigFromEnv({ PORT: '65535', EPPP_SESSION_SECRET: SECRET }).port; const minPort = loadConfigFromEnv({ PORT: '1', EPPP_SESSION_SECRET: SECRET }).port; +// Valid HOST forms pass the adapter-boundary validation: IPv4, IPv6, +// single-label and dotted hostnames (RFC 1123). +const hostForms = [ + '127.0.0.1', '0.0.0.0', '10.0.0.7', + '::1', '::', 'fe80::1', + 'localhost', 'db', 'api.internal.example', +].map((h) => [h, loadConfigFromEnv({ HOST: h, EPPP_SESSION_SECRET: SECRET }).host]); + +// An invalid HOST fails at the adapter boundary: a field-specific startup +// error naming host, so arbitrary env content is never used for binding or +// echoed verbatim into logs. +const invalidHost = capture(() => loadConfigFromEnv({ HOST: 'not a host!', EPPP_SESSION_SECRET: SECRET })); +const invalidHostPort = capture(() => loadConfigFromEnv({ HOST: '127.0.0.1:3000', EPPP_SESSION_SECRET: SECRET })); +const invalidHostDash = capture(() => loadConfigFromEnv({ HOST: '-bad', EPPP_SESSION_SECRET: SECRET })); + // A missing required secret is a field-specific startup error naming it. const missingSecret = capture(() => loadConfigFromEnv({})); @@ -687,6 +754,8 @@ const result = { full: { host: full.host, port: full.port, databaseUrl: full.databaseUrl, sessionSecret: full.sessionSecret }, defaults: { host: defaults.host, port: defaults.port, hasDatabaseUrl: defaults.databaseUrl !== undefined }, badPort, zeroPort, highPort, maxPort, minPort, + hostForms, + invalidHost, invalidHostPort, invalidHostDash, missingSecret, emptyDb, fromRealEnv: { port: fromRealEnv.port, sessionSecret: fromRealEnv.sessionSecret }, @@ -734,6 +803,36 @@ test('the compiled environment adapter maps the environment onto the validated c assert.equal(result.maxPort, 65535, 'PORT 65535 is the upper edge of the valid range'); assert.equal(result.minPort, 1, 'PORT 1 is the lower edge of the valid range'); + // Valid HOST forms pass the adapter-boundary validation unchanged. + assert.deepEqual( + result.hostForms, + [ + ['127.0.0.1', '127.0.0.1'], + ['0.0.0.0', '0.0.0.0'], + ['10.0.0.7', '10.0.0.7'], + ['::1', '::1'], + ['::', '::'], + ['fe80::1', 'fe80::1'], + ['localhost', 'localhost'], + ['db', 'db'], + ['api.internal.example', 'api.internal.example'], + ], + `valid HOST values (IPv4/IPv6/hostname) must pass through the adapter (got: ${JSON.stringify(result.hostForms)})`, + ); + + // An invalid HOST fails at the adapter boundary with a field-specific + // startup error naming host (so arbitrary env content is never used for + // binding or echoed verbatim into logs). + for (const invalid of [result.invalidHost, result.invalidHostPort, result.invalidHostDash]) { + assert.equal(invalid.threw, true, `an invalid HOST must throw (got: ${JSON.stringify(invalid)})`); + assert.equal(invalid.isConfigStartup, true, `an invalid HOST must throw ConfigStartupError (got: ${JSON.stringify(invalid)})`); + assert.match( + invalid.message, + /host/, + `the startup error must name the violating field host (got: ${JSON.stringify(invalid)})`, + ); + } + // A missing required secret is a field-specific startup error naming it. assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw'); assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError'); @@ -870,6 +969,49 @@ async function stopChild(child) { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); } +/** Waits until the captured log output matches `pattern` (or the deadline passes). */ +async function waitForLog(readOutput, pattern, deadlineMs = 5_000) { + const deadline = Date.now() + deadlineMs; + while (Date.now() < deadline) { + if (pattern.test(readOutput())) return true; + await delay(100); + } + return false; +} + +/** A non-internal IPv4 address of this host, or undefined when none exists. */ +function nonLoopbackIpv4() { + for (const addrs of Object.values(os.networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family === 'IPv4' && !addr.internal) return addr.address; + } + } + return undefined; +} + +/** + * Attempts a TCP connection; resolves true when it succeeds. A refused + * connection or a timeout (a dropped SYN) both resolve false — the only way + * this resolves true is a listening socket on that interface, which is + * exactly what the loopback-only probe must rule out. + */ +function canConnect(host, port, timeoutMs = 1_500) { + return new Promise((resolve) => { + const socket = netConnect({ host, port }); + let settled = false; + const finish = (ok) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false)); + socket.once('error', () => finish(false)); + }); +} + test('a PORT/HOST override flows through the adapter into the resolved configuration (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { // "All settings flow through the adapter": booting the committed server // with PORT/HOST overrides must surface those values in the resolved @@ -928,3 +1070,73 @@ test('starting without the required secret exits non-zero naming the missing fie `the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`, ); }); + +test('a configured HOST binds exactly that interface: HOST=127.0.0.1 answers on loopback only, not all interfaces (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async (t) => { + // The issue's test plan: "boot with HOST=127.0.0.1 and confirm the server + // binds loopback only, not all interfaces", and "startup log reflects the + // actual bind interface". The server passes config.host to server.listen, + // so with HOST=127.0.0.1 the server answers on loopback and does NOT listen + // on a non-loopback interface, and the startup log shows the loopback bind. + const port = await reservePort(); + const { child, stdout, stderr } = bootServer(port, { + EPPP_SESSION_SECRET: SECRET, + HOST: '127.0.0.1', + }); + try { + const response = await waitForAnswer(port, child, stderr); + assert.equal( + response.status, + 200, + `GET /health on loopback must answer 200 with HOST=127.0.0.1 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`, + ); + // The startup log reflects the actual bind interface. + assert.ok( + await waitForLog(stdout, new RegExp(`http://127\\.0\\.0\\.1:${port}`)), + `the startup log must show the loopback bind (http://127.0.0.1:${port}); got: ${stdout().trim()}`, + ); + // Loopback-only: no non-loopback interface may accept the connection. + const external = nonLoopbackIpv4(); + if (external === undefined) { + t.skip('this host has no non-loopback IPv4 interface; loopback-only binding is trivially satisfied'); + return; + } + assert.equal( + await canConnect(external, port), + false, + `with HOST=127.0.0.1 the server must not listen on the non-loopback interface ${external}:${port} (it would bind all interfaces)`, + ); + assert.equal(await canConnect('127.0.0.1', port), true, 'the loopback interface must still accept connections'); + } finally { + await stopChild(child); + } +}); + +test('an invalid HOST fails startup naming the field and never echoes the raw value (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { + // "HOST is validated at the adapter boundary as a hostname or IP address + // before it is used for binding or logged, so arbitrary env content is + // never echoed verbatim into logs": booting with an invalid HOST must exit + // non-zero with a field-specific startup error naming host — and the raw + // invalid value must never appear in the process output. + const port = await reservePort(); + const invalidHost = 'bogus host!'; + const { child, stdout, stderr } = bootServer(port, { + EPPP_SESSION_SECRET: SECRET, + HOST: invalidHost, + }); + const { code, signal } = await waitForExit(child); + const output = stdout() + stderr(); + assert.notEqual( + code, + 0, + `the server must exit non-zero when HOST is invalid (code ${code}, signal ${signal}); output: ${output.trim()}`, + ); + assert.match( + output, + /host/, + `the startup error must name the invalid field (host); got: ${output.trim()}`, + ); + assert.ok( + !output.includes(invalidHost), + `the raw invalid HOST value must never be echoed verbatim into the process output; got: ${output.trim()}`, + ); +}); diff --git a/tests/config-startup-error.test.mjs b/tests/config-startup-error.test.mjs index 03d6d15..65a395b 100644 --- a/tests/config-startup-error.test.mjs +++ b/tests/config-startup-error.test.mjs @@ -291,8 +291,8 @@ test('moving the adapter call after the server binds fails the order assertion ( const moved = src .replace('const config = loadConfigFromEnv();\n', '') .replace( - 'server.listen(config.port, () => {', - 'server.listen(config.port, () => {\n const config = loadConfigFromEnv();', + 'server.listen(config.port, config.host, () => {', + 'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();', ); assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind'); assert.throws(() => assertServerStartupValidation(moved), /before the server binds/); From ffda249617bcc3a940fc58b2809b76110804949b Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:42:06 +0000 Subject: [PATCH 9/9] docs: document HOST validation and bind control (E00-S04-T04) The non-container guide now notes that HOST is validated at the adapter boundary as a hostname/IP (invalid values fail startup naming the field) and that the server passes config.host to server.listen, so a configured HOST binds exactly that interface and the startup log reflects the actual bind. The config-env-adapter CI job comment is refreshed to describe the extended suite (HOST validation + loopback-only boot probe). --- .gitea/workflows/ci.yml | 14 +++++++++----- docs/development/non-container.md | 10 ++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 30916ad..230eaff 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -233,11 +233,15 @@ jobs: # validated config) with a comment-stripped workspace scan, mutation probes # (injecting a direct process.env read into any other module fails the scan), # a deterministic boundary probe (full env mapping, defaults, bad-PORT - # fallback, missing required secret -> MissingRequiredSettingError) and - # server-boot probes (a PORT/HOST override shows up in the resolved - # configuration; a missing required secret still fails startup). The job - # installs the frozen workspace and builds the config and database-postgres - # packages because the probes boot the committed server which imports them. + # fallback, HOST validated as hostname/IP with invalid values throwing a + # field-specific startup error, missing required secret -> + # MissingRequiredSettingError) and server-boot probes (a PORT/HOST override + # shows up in the resolved configuration; HOST=127.0.0.1 binds loopback only + # and the startup log reflects the actual bind; an invalid HOST fails startup + # naming the field without echoing the raw value; a missing required secret + # still fails startup). The job installs the frozen workspace and builds the + # config and database-postgres packages because the probes boot the committed + # server which imports them. config-env-adapter: name: Env adapter owns process.env (E00-S04-T04) runs-on: ubuntu-latest diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 196de6d..34e3f8e 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,7 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04); the `.env.example` template (E00-S04-T05) lands in a later task. | +| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the `.env.example` template (E00-S04-T05) lands in a later task. | | `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -125,7 +125,13 @@ compiled application entrypoint. Three things to know: `DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`) and validated at startup — no module outside the config package reads - `process.env` directly. + `process.env` directly. `HOST` is validated at the adapter boundary as a + hostname or IP address (an invalid value fails startup with a + field-specific error naming `host` instead of being logged) and the server + passes `config.host` to `server.listen`, so a configured `HOST` binds + exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and + the startup log line (`@personal-blog/server listening on + http://:`) reflects the actual bind. To run the compiled output of any other workspace package directly: