[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
5 changed files with 65 additions and 9 deletions
Showing only changes of commit 345ceccfad - Show all commits
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), 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",
+10 -2
View File
@@ -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)`);
});
+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), 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"
+48 -3
View File
@@ -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,
+5 -2
View File
@@ -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';