feat: environment adapter is the single owner of process.env reads (E00-S04-T04)

This commit is contained in:
implementer
2026-08-30 04:17:19 +00:00
parent 072f5c5785
commit 7aeeeaaa97
6 changed files with 93 additions and 8 deletions
+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 (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"
+78
View File
@@ -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,
});
}
+8 -2
View File
@@ -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';
+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.
*/