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

Merged
kpcto merged 9 commits from feature/185 into main 2026-08-30 05:02:49 +00:00
16 changed files with 1447 additions and 86 deletions
+34
View File
@@ -226,6 +226,40 @@ 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, 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
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 +
+4
View File
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02) and automatic secret redaction from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story.",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly 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",
+32 -32
View File
@@ -23,10 +23,21 @@
* 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. `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
@@ -43,25 +54,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 +103,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 +173,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 +203,12 @@ if (databaseUrl === undefined) {
});
}
server.listen(PORT, () => {
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
// The server binds the validated bind interface: `config.host` (default
// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to
// `server.listen`, so a configured `HOST` binds exactly that interface and
// the startup log reflects the actual bind.
server.listen(config.port, config.host, () => {
logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`);
});
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
+15 -2
View File
@@ -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) 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. |
@@ -119,6 +119,19 @@ 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. `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://<host>:<port>`) reflects the actual bind.
To run the compiled output of any other workspace package directly:
+1 -1
View File
@@ -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, 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"
+123
View File
@@ -0,0 +1,123 @@
/**
* 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). 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
* `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 { isIP } from 'node:net';
import { assertValidConfig, ConfigStartupError } 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;
}
/**
* 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.
*
* 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: resolveHost(env.HOST),
port: resolvePort(env.PORT),
databaseUrl: env.DATABASE_URL,
sessionSecret: env.EPPP_SESSION_SECRET,
});
}
+10 -1
View File
@@ -18,7 +18,15 @@
* 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
* [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. `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.
*/
@@ -28,3 +36,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';
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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.
*/
+2 -2
View File
@@ -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\) \{/,
File diff suppressed because it is too large Load Diff
+17 -10
View File
@@ -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)',
);
}
+45 -30
View File
@@ -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, config.host, () => {',
'server.listen(config.port, config.host, () => {\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');
+15 -2
View File
@@ -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(