/** * 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, }); }