[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
Member

What changed

  • packages/config — the environment adapter (new src/env.ts, re-exported from the boundary as loadConfigFromEnv) is the workspace's single owner of process.env reads. It maps HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET onto the validated config shape and validates it with assertValidConfig (E00-S04-T02) before returning: a missing required setting (EPPP_SESSION_SECRET) is still a field-specific startup error naming the field, and a bad PORT keeps the pre-adapter fallback-to-3000 behavior.
  • HOST is validated at the adapter boundary (resolveHost): it must be a hostname (RFC 1123) or an IP address (IPv4/IPv6 via 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 (resolves security review SEC-3).
  • apps/server loads all of its settings through the adapter (const config = loadConfigFromEnv(), binding config.port, taking config.databaseUrl) and no longer reads process.env at all. The server passes config.host to server.listen(config.port, config.host, ...), 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 (resolves security review SEC-2).
  • Tests — tests/config-env-adapter.test.mjs extended (HOST validation in the deterministic boundary probe; boot probes for loopback-only binding with HOST=127.0.0.1 and for an invalid HOST failing startup without echoing the raw value; mutation probes for resolveHost and config.host); suites locked to the old direct-read wiring updated (config-startup-error, config-log-redaction, app-readiness, health-endpoint).
  • CI — the config-env-adapter job (E00-S04-T04) gates the static process.env check on every PR; docs — non-container guide updated.

Criterion → test mapping

Criterion (issue) Test
no module reads process.env directly except the configuration adapter config-env-adapter: comment-stripped workspace scan over apps/+packages/+extensions/ proves every process.env reference lives in packages/config/src/env.ts (issue test plan: "static check confirms only the adapter reads process.env"); mutation probes inject a direct read into the server / move it into startup.ts in a temp tree copy and the scan fails naming the file; clean-copy sanity passes
all settings flow through the adapter config-env-adapter: static assertions on env.ts (maps HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET, validates via assertValidConfig) and on the server (imports loadConfigFromEnv, binds config.port/config.host, takes config.databaseUrl, no process.env); deterministic boundary probe (full env mapping, defaults 0.0.0.0/3000, bad-PORT fallback, missing secret → MissingRequiredSettingError naming sessionSecret, empty DATABASE_URL → ConfigStartupError, no-arg call reads the real process.env); server-boot probes (a PORT/HOST override appears in the resolved-configuration log; missing secret still exits non-zero naming the field)
the HOST setting controls the actual bind interface: the server passes config.host to server.listen, and the startup log never claims a bind the process does not enforce config-env-adapter: static assertion that the server passes config.host to server.listen(config.port, config.host, ...) (mutation probe: dropping config.host fails); boot probe: with HOST=127.0.0.1 the server answers GET /health on loopback, does not answer on a non-loopback interface (canConnect to the host's non-internal IPv4 fails), and the startup log shows http://127.0.0.1:<port> — the log reflects the actual bind (issue test plan: "boot with HOST=127.0.0.1 and confirm the server binds loopback only, not all interfaces"; "startup log reflects the actual bind interface")
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 logs config-env-adapter: static assertions on resolveHost in env.ts; deterministic boundary probe (valid forms pass: IPv4/IPv6/hostnames incl. 127.0.0.1, 0.0.0.0, ::1, localhost, db; invalid forms — not a host!, 127.0.0.1:3000, -bad — throw ConfigStartupError naming host); boot probe: an invalid HOST exits non-zero naming the field and the raw value never appears in the process output (mutation probe: bypassing resolveHost fails)
the config-env-adapter job in .gitea/workflows/ci.yml is an intended, in-scope part of this task's test plan (it implements the static process.env check); additive only, alters no existing gating step config-env-adapter: "the config-env-adapter criterion is enforced in CI" — the root scripts.test glob picks up the suite and the CI job runs node --test tests/config-env-adapter.test.mjs after building the config/database-postgres packages; the workflow diff is purely additive (+job insert, no existing job removed or weakened) — per the issue body, this job is the intended test-plan vehicle (evidence for the SEC-1 human decision)
regression: field-specific startup error still fires through the adapter config-startup-error: updated static wiring assertions + mutation probes (dropping/moving the adapter call, server reading process.env directly) + boot probes unchanged
regression: secret redaction from logs unaffected config-log-redaction: updated wiring assertions (adapter-loaded config seeds the redacting logger) + boot probes unchanged
regression: readiness gate + no-database path unaffected app-readiness: updated source anchors (config.databaseUrl) + static/deterministic/docker probes unchanged
regression: health endpoint + port default health-endpoint: server binds config.port; the 3000 default now lives in the adapter

Risks

  • The server's resolved-configuration log shape is unchanged (defaults 0.0.0.0/3000, redacted secret); env-derived values (HOST/PORT) now appear from the adapter instead of hardcoded/read-inline.
  • HOST is now a read setting (default 0.0.0.0, validated as hostname/IP at the adapter boundary) — behavior identical when unset, matching the schema's documented environment source. An invalid HOST now fails startup with a field-specific error instead of being logged; this is the intended stricter behavior (SEC-2/SEC-3).
  • Out of scope per issue: secret redaction (T03) and .env.example placeholders (T05).
## What changed - **`packages/config` — the environment adapter (new `src/env.ts`, re-exported from the boundary as `loadConfigFromEnv`)** is the workspace's **single owner of `process.env` reads**. It maps `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config shape and validates it with `assertValidConfig` (E00-S04-T02) before returning: a missing required setting (`EPPP_SESSION_SECRET`) is still a field-specific startup error naming the field, and a bad `PORT` keeps the pre-adapter fallback-to-3000 behavior. - **`HOST` is validated at the adapter boundary** (`resolveHost`): it must be a hostname (RFC 1123) or an IP address (IPv4/IPv6 via `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 (resolves security review SEC-3). - **`apps/server`** loads all of its settings through the adapter (`const config = loadConfigFromEnv()`, binding `config.port`, taking `config.databaseUrl`) and no longer reads `process.env` at all. **The server passes `config.host` to `server.listen(config.port, config.host, ...)`**, 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 (resolves security review SEC-2). - **Tests** — `tests/config-env-adapter.test.mjs` extended (HOST validation in the deterministic boundary probe; boot probes for loopback-only binding with `HOST=127.0.0.1` and for an invalid `HOST` failing startup without echoing the raw value; mutation probes for `resolveHost` and `config.host`); suites locked to the old direct-read wiring updated (`config-startup-error`, `config-log-redaction`, `app-readiness`, `health-endpoint`). - **CI** — the `config-env-adapter` job (E00-S04-T04) gates the static `process.env` check on every PR; **docs** — non-container guide updated. ## Criterion → test mapping | Criterion (issue) | Test | | --- | --- | | no module reads `process.env` directly except the configuration adapter | `config-env-adapter`: comment-stripped workspace scan over `apps/`+`packages/`+`extensions/` proves every `process.env` reference lives in `packages/config/src/env.ts` (issue test plan: "static check confirms only the adapter reads `process.env`"); mutation probes inject a direct read into the server / move it into `startup.ts` in a temp tree copy and the scan fails naming the file; clean-copy sanity passes | | all settings flow through the adapter | `config-env-adapter`: static assertions on `env.ts` (maps HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET, validates via `assertValidConfig`) and on the server (imports `loadConfigFromEnv`, binds `config.port`/`config.host`, takes `config.databaseUrl`, no `process.env`); deterministic boundary probe (full env mapping, defaults `0.0.0.0`/3000, bad-`PORT` fallback, missing secret → `MissingRequiredSettingError` naming `sessionSecret`, empty `DATABASE_URL` → `ConfigStartupError`, no-arg call reads the real `process.env`); server-boot probes (a `PORT`/`HOST` override appears in the resolved-configuration log; missing secret still exits non-zero naming the field) | | the `HOST` setting controls the actual bind interface: the server passes `config.host` to `server.listen`, and the startup log never claims a bind the process does not enforce | `config-env-adapter`: static assertion that the server passes `config.host` to `server.listen(config.port, config.host, ...)` (mutation probe: dropping `config.host` fails); boot probe: with `HOST=127.0.0.1` the server answers `GET /health` on loopback, does **not** answer on a non-loopback interface (`canConnect` to the host's non-internal IPv4 fails), and the startup log shows `http://127.0.0.1:<port>` — the log reflects the actual bind (issue test plan: "boot with `HOST=127.0.0.1` and confirm the server binds loopback only, not all interfaces"; "startup log reflects the actual bind interface") | | `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 logs | `config-env-adapter`: static assertions on `resolveHost` in `env.ts`; deterministic boundary probe (valid forms pass: IPv4/IPv6/hostnames incl. `127.0.0.1`, `0.0.0.0`, `::1`, `localhost`, `db`; invalid forms — `not a host!`, `127.0.0.1:3000`, `-bad` — throw `ConfigStartupError` naming `host`); boot probe: an invalid `HOST` exits non-zero naming the field and the raw value never appears in the process output (mutation probe: bypassing `resolveHost` fails) | | the `config-env-adapter` job in `.gitea/workflows/ci.yml` is an intended, in-scope part of this task's test plan (it implements the static `process.env` check); additive only, alters no existing gating step | `config-env-adapter`: "the config-env-adapter criterion is enforced in CI" — the root `scripts.test` glob picks up the suite and the CI job runs `node --test tests/config-env-adapter.test.mjs` after building the config/database-postgres packages; the workflow diff is purely additive (+job insert, no existing job removed or weakened) — per the issue body, this job is the intended test-plan vehicle (evidence for the SEC-1 human decision) | | regression: field-specific startup error still fires through the adapter | `config-startup-error`: updated static wiring assertions + mutation probes (dropping/moving the adapter call, server reading `process.env` directly) + boot probes unchanged | | regression: secret redaction from logs unaffected | `config-log-redaction`: updated wiring assertions (adapter-loaded config seeds the redacting logger) + boot probes unchanged | | regression: readiness gate + no-database path unaffected | `app-readiness`: updated source anchors (`config.databaseUrl`) + static/deterministic/docker probes unchanged | | regression: health endpoint + port default | `health-endpoint`: server binds `config.port`; the 3000 default now lives in the adapter | ## Risks - The server's resolved-configuration log shape is unchanged (defaults `0.0.0.0`/3000, redacted secret); env-derived values (`HOST`/`PORT`) now appear from the adapter instead of hardcoded/read-inline. - `HOST` is now a read setting (default `0.0.0.0`, validated as hostname/IP at the adapter boundary) — behavior identical when unset, matching the schema's documented environment source. An invalid `HOST` now fails startup with a field-specific error instead of being logged; this is the intended stricter behavior (SEC-2/SEC-3). - Out of scope per issue: secret redaction (T03) and `.env.example` placeholders (T05).
bot-implementer added 5 commits 2026-08-30 04:17:40 +00:00
docs: document the environment adapter in the non-container guide (E00-S04-T04)
CI / Frozen lockfile install (pull_request) Successful in 46s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 24s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 32s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 50s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 48s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 45s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m20s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m20s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m11s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m48s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 1m21s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 31s
b9345e205c
bot-implementer added 1 commit 2026-08-30 04:18:15 +00:00
chore: drop unused mkdirSync import in the env-adapter suite (E00-S04-T04)
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 55s
CI / Frozen lockfile install (pull_request) Successful in 46s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 28s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 1m5s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m32s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m41s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m10s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m12s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 1m6s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 35s
73a1ae88cd
bot-implementer added 3 commits 2026-08-30 04:42:13 +00:00
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).
Extend the env-adapter suite to the issue's reworked acceptance criteria:
- static assertions: the adapter resolves HOST via resolveHost (hostname/IP at
  the adapter boundary) and the server passes config.host to server.listen
- deterministic boundary probe: valid HOST forms (IPv4/IPv6/hostname) pass,
  invalid HOST forms throw ConfigStartupError naming host
- boot probes: HOST=127.0.0.1 binds loopback only (no answer on a
  non-loopback interface) with the startup log reflecting the actual bind;
  an invalid HOST exits non-zero naming the field without echoing the raw
  value
- mutation probes: bypassing resolveHost or dropping config.host from
  server.listen both fail
- config-startup-error: update the order-asserion mutation probe for the new
  server.listen(config.port, config.host, ...) signature
docs: document HOST validation and bind control (E00-S04-T04)
CI / Frozen lockfile install (pull_request) Successful in 54s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 34s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 30s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 1m16s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 54s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m6s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m2s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m1s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 50s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m5s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
ffda249617
The non-container guide now notes that HOST is validated at the adapter
boundary as a hostname/IP (invalid values fail startup naming the field) and
that the server passes config.host to server.listen, so a configured HOST
binds exactly that interface and the startup log reflects the actual bind.
The config-env-adapter CI job comment is refreshed to describe the extended
suite (HOST validation + loopback-only boot probe).
kpcto merged commit 1e0f628651 into main 2026-08-30 05:02:49 +00:00
kpcto deleted branch feature/185 2026-08-30 05:02:50 +00:00
Sign in to join this conversation.