Merge pull request '[E00-S04-T04] No module reads process.env except configuration adapter' (#403) from feature/185 into main
CI / Frozen lockfile install (push) Successful in 44s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m4s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m12s
CI / Env adapter owns process.env (E00-S04-T04) (push) Successful in 1m9s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 29s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 25s
CI / Migration ledger (E00-S03-T03) (push) Successful in 46s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 57s
CI / Secret redaction from logs (E00-S04-T03) (push) Successful in 1m4s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 53s
CI / Compose config (E00-S03-T01) (push) Successful in 35s
CI / Frozen lockfile install (push) Successful in 44s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m4s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m12s
CI / Env adapter owns process.env (E00-S04-T04) (push) Successful in 1m9s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 29s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 25s
CI / Migration ledger (E00-S03-T03) (push) Successful in 46s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 57s
CI / Secret redaction from logs (E00-S04-T03) (push) Successful in 1m4s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 53s
CI / Compose config (E00-S03-T01) (push) Successful in 35s
This commit was merged in pull request #403.
This commit is contained in:
@@ -226,6 +226,40 @@ jobs:
|
||||
- name: Run config log redaction test suite
|
||||
run: node --test tests/config-log-redaction.test.mjs
|
||||
|
||||
# E00-S04-T04: the static assertions of tests/config-env-adapter.test.mjs
|
||||
# gate every PR — the suite locks in the environment adapter (packages/config
|
||||
# is the single owner of process.env reads; the server and every other module
|
||||
# read no process.env, all settings flow through loadConfigFromEnv into the
|
||||
# validated config) with a comment-stripped workspace scan, mutation probes
|
||||
# (injecting a direct process.env read into any other module fails the scan),
|
||||
# a deterministic boundary probe (full env mapping, defaults, bad-PORT
|
||||
# fallback, HOST validated as hostname/IP with invalid values throwing a
|
||||
# field-specific startup error, missing required secret ->
|
||||
# MissingRequiredSettingError) and server-boot probes (a PORT/HOST override
|
||||
# shows up in the resolved configuration; HOST=127.0.0.1 binds loopback only
|
||||
# and the startup log reflects the actual bind; an invalid HOST fails startup
|
||||
# naming the field without echoing the raw value; a missing required secret
|
||||
# still fails startup). The job installs the frozen workspace and builds the
|
||||
# config and database-postgres packages because the probes boot the committed
|
||||
# server which imports them.
|
||||
config-env-adapter:
|
||||
name: Env adapter owns process.env (E00-S04-T04)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||
- name: Run config env adapter test suite
|
||||
run: node --test tests/config-env-adapter.test.mjs
|
||||
|
||||
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
||||
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
||||
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
||||
|
||||
@@ -27,5 +27,9 @@ coverage/
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-startup-probe-*.mjs
|
||||
|
||||
# Transient host-side probe file written by the config-env-adapter test
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-env-adapter-probe-*.mjs
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
@@ -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) and automatic secret redaction from all log output (E00-S04-T03); 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",
|
||||
|
||||
+32
-32
@@ -23,10 +23,21 @@
|
||||
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
|
||||
* required field, Security-and-Operations §32/§26) fails fast at startup
|
||||
* with an error naming the missing field instead of booting with an invalid
|
||||
* configuration. The parsed config keeps the committed defaults for
|
||||
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
|
||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
||||
* `MissingRequiredSettingError` naming the missing field.
|
||||
* configuration.
|
||||
*
|
||||
* [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
* package's environment adapter (`loadConfigFromEnv` from
|
||||
* `@personal-blog/config`) — the workspace's single owner of `process.env`
|
||||
* reads — so this module (and every other module outside the config package)
|
||||
* never reads `process.env` directly. The adapter maps the environment
|
||||
* (`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. `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
|
||||
@@ -43,25 +54,21 @@
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { assertValidConfig } from '@personal-blog/config';
|
||||
import { loadConfigFromEnv } from '@personal-blog/config';
|
||||
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||
import { Pool } from '@personal-blog/database-postgres';
|
||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||
import type { Migration } from '@personal-blog/database-postgres';
|
||||
|
||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||
const PORT = resolvePort(process.env.PORT);
|
||||
|
||||
// [E00-S04-T02] field-specific startup error: validate the startup
|
||||
// configuration before anything else, so a missing required setting (e.g.
|
||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
||||
// the missing field — never boots with an invalid configuration.
|
||||
const config = assertValidConfig({
|
||||
host: '0.0.0.0',
|
||||
port: PORT,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
sessionSecret: process.env.EPPP_SESSION_SECRET,
|
||||
});
|
||||
// [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
// package's adapter — the workspace's single owner of process.env reads — so
|
||||
// the server never reads process.env directly. The adapter maps the
|
||||
// environment onto the validated config shape and validates it with
|
||||
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
|
||||
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
|
||||
// startup with an error naming the missing field — never boots with an
|
||||
// invalid configuration.
|
||||
const config = loadConfigFromEnv();
|
||||
|
||||
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
||||
// logger, seeded with the validated config's secrets — and the resolved
|
||||
@@ -96,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
|
||||
*/
|
||||
let migrationsComplete = false;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Writes a JSON response with an explicit content-length. */
|
||||
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.writeHead(statusCode, {
|
||||
@@ -177,7 +173,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databaseUrl = config.databaseUrl;
|
||||
|
||||
if (databaseUrl === undefined) {
|
||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||
@@ -207,8 +203,12 @@ if (databaseUrl === undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||
// 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)`);
|
||||
});
|
||||
|
||||
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
|
||||
|
||||
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
|
||||
|
||||
| Group | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), and redacts secret values from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story. |
|
||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. |
|
||||
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
||||
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (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. |
|
||||
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (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 workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the `.env.example` template (E00-S04-T05) lands in a later task. |
|
||||
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
||||
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||
|
||||
@@ -119,6 +119,19 @@ compiled application entrypoint. Three things to know:
|
||||
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||
redacting logger — the admin-session secret and the password embedded in a
|
||||
`DATABASE_URL` connection string never appear in the log output.
|
||||
5. Since [E00-S04-T04], **all settings flow through the config package's
|
||||
environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` —
|
||||
the workspace's single owner of `process.env` reads): `HOST`, `PORT`,
|
||||
`DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated
|
||||
config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`)
|
||||
and validated at startup — no module outside the config package reads
|
||||
`process.env` directly. `HOST` is validated at the adapter boundary as a
|
||||
hostname or IP address (an invalid value fails startup with a
|
||||
field-specific error naming `host` instead of being logged) and the server
|
||||
passes `config.host` to `server.listen`, so a configured `HOST` binds
|
||||
exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and
|
||||
the startup log line (`@personal-blog/server listening on
|
||||
http://<host>:<port>`) reflects the actual bind.
|
||||
|
||||
To run the compiled output of any other workspace package directly:
|
||||
|
||||
|
||||
@@ -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, 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"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,15 @@
|
||||
* 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
|
||||
* [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. `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.
|
||||
*/
|
||||
|
||||
@@ -28,3 +36,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
|
||||
* after migrations finish.
|
||||
*/
|
||||
function assertReadyAfterRun(src) {
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL');
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl');
|
||||
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
||||
@@ -191,7 +191,7 @@ function assertReadyAfterRun(src) {
|
||||
* non-container path and the E00-S02-T03 health endpoint working).
|
||||
*/
|
||||
function assertNoDatabaseUrlPath(src) {
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL'));
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl'));
|
||||
assert.match(
|
||||
startup,
|
||||
/if \(databaseUrl === undefined\) \{/,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -159,8 +159,8 @@ function assertServerSource(src) {
|
||||
// package's scrubber before it reaches stdout/stderr.
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||
'the server must import the startup validation entry point from the config package',
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
@@ -188,12 +188,13 @@ function assertServerSource(src) {
|
||||
'error lines must be written to stderr',
|
||||
);
|
||||
|
||||
// The wiring — the server keeps its validated config, creates the logger
|
||||
// with it and logs the resolved configuration redacted.
|
||||
// The wiring — the server keeps its validated config (loaded through the
|
||||
// environment adapter, E00-S04-T04), creates the logger with it and logs
|
||||
// the resolved configuration redacted.
|
||||
assert.match(
|
||||
src,
|
||||
/const config = assertValidConfig\(\{/,
|
||||
'the server must keep its validated configuration (const config = assertValidConfig(...))',
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
@@ -215,13 +216,19 @@ function assertServerSource(src) {
|
||||
/console\.(log|error)\(/,
|
||||
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
||||
);
|
||||
// The startup validation runs before the logger is created, so a missing
|
||||
// required setting still fails fast (E00-S04-T02) before any log output.
|
||||
const validationIndex = src.indexOf('assertValidConfig({');
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
);
|
||||
// The startup configuration loads through the adapter (which validates it)
|
||||
// before the logger is created, so a missing required setting still fails
|
||||
// fast (E00-S04-T02) before any log output.
|
||||
const validationIndex = src.indexOf('loadConfigFromEnv(');
|
||||
const loggerIndex = src.indexOf('createLogger(config)');
|
||||
assert.ok(
|
||||
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
||||
'the startup validation must run before the logger is created (a missing required setting is still a startup error)',
|
||||
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,20 @@
|
||||
* - "missing required setting gives a field-specific startup error" → the
|
||||
* `packages/config` package exposes the startup validation entry point
|
||||
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
||||
* and the committed `apps/server/src/index.ts` calls it before the server
|
||||
* and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the
|
||||
* config package's single owner of `process.env` reads) validates the
|
||||
* mapped environment through it; the committed `apps/server/src/index.ts`
|
||||
* loads its startup configuration through the adapter before the server
|
||||
* binds, so a deployment missing a required setting (the admin-session
|
||||
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
||||
* Security-and-Operations §32/§26) fails fast at startup instead of
|
||||
* booting with an invalid configuration. Locked in statically (mutation
|
||||
* probes prove non-vacuity: dropping the startup validation call,
|
||||
* moving it after the bind, or dropping the compose/Dockerfile support
|
||||
* all fail) and behaviorally by the deterministic probes (the issue's
|
||||
* test plan: "start with a missing required field and confirm the error
|
||||
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
||||
* exits non-zero with the error naming the missing field).
|
||||
* probes prove non-vacuity: dropping the adapter call, moving it after
|
||||
* the bind, or dropping the compose/Dockerfile support all fail) and
|
||||
* behaviorally by the deterministic probes (the issue's test plan: "start
|
||||
* with a missing required field and confirm the error names it" — booting
|
||||
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
|
||||
* the error naming the missing field).
|
||||
* - "the error names the missing field" → a missing required setting throws
|
||||
* `MissingRequiredSettingError` whose message and `missingField` name the
|
||||
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
||||
@@ -149,33 +152,35 @@ function assertStartupErrorSource(src) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the committed server validates the required settings at startup:
|
||||
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
||||
* with the parsed startup configuration (including the required
|
||||
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
||||
* setting is a startup error, never a silently-booted invalid configuration.
|
||||
* Asserts the committed server loads its startup configuration through the
|
||||
* environment adapter (E00-S04-T04) before it binds: it imports
|
||||
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
|
||||
* validates the mapped environment with `assertValidConfig`, including the
|
||||
* required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing
|
||||
* required setting is a startup error, never a silently-booted invalid
|
||||
* configuration, and the server itself never reads `process.env` directly.
|
||||
*/
|
||||
function assertServerStartupValidation(src) {
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||
'the server must import the startup validation entry point from the config package',
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/assertValidConfig\(\{/,
|
||||
'the server must call assertValidConfig with its startup configuration',
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
|
||||
);
|
||||
assert.match(
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
||||
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
);
|
||||
const callIndex = src.indexOf('assertValidConfig({');
|
||||
const callIndex = src.indexOf('loadConfigFromEnv(');
|
||||
const listenIndex = src.indexOf('server.listen(');
|
||||
assert.ok(
|
||||
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
||||
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
|
||||
'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,25 +279,35 @@ test('the config-startup-error criterion is enforced in CI', () => {
|
||||
// Mutation probes — the static assertions are non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
|
||||
test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
||||
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
|
||||
});
|
||||
|
||||
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
|
||||
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const moved = src
|
||||
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
|
||||
.replace('const config = loadConfigFromEnv();\n', '')
|
||||
.replace(
|
||||
'server.listen(PORT, () => {',
|
||||
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
||||
'server.listen(config.port, config.host, () => {',
|
||||
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
|
||||
);
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
|
||||
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
||||
});
|
||||
|
||||
test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const directRead = src.replace(
|
||||
'const config = loadConfigFromEnv();',
|
||||
'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;',
|
||||
);
|
||||
assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read');
|
||||
assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/);
|
||||
});
|
||||
|
||||
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
||||
const src = read(STARTUP_SRC);
|
||||
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
||||
|
||||
@@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) {
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/3000/,
|
||||
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
/server\.listen\(config\.port/,
|
||||
'the server must bind the port from the validated configuration (config.port, E00-S04-T04)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {"
|
||||
);
|
||||
});
|
||||
|
||||
test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => {
|
||||
// The server binds `config.port` (asserted above); the port default (3000,
|
||||
// matching the Dockerfile EXPOSE / compose :3000) and the PORT override
|
||||
// live in the config package's environment adapter — the single owner of
|
||||
// process.env reads.
|
||||
const envSrc = read('packages/config/src/env.ts');
|
||||
assert.match(
|
||||
envSrc,
|
||||
/3000/,
|
||||
'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
);
|
||||
});
|
||||
|
||||
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
|
||||
if (!tsExecMode()) {
|
||||
t.skip(
|
||||
|
||||
Reference in New Issue
Block a user