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).
124 lines
5.7 KiB
TypeScript
124 lines
5.7 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
}
|