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
|
- name: Run config log redaction test suite
|
||||||
run: node --test tests/config-log-redaction.test.mjs
|
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
|
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
||||||
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
||||||
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
||||||
|
|||||||
@@ -27,5 +27,9 @@ coverage/
|
|||||||
# suite into the package (removed in its finally block)
|
# suite into the package (removed in its finally block)
|
||||||
.config-startup-probe-*.mjs
|
.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
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
"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",
|
"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
|
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
|
||||||
* required field, Security-and-Operations §32/§26) fails fast at startup
|
* required field, Security-and-Operations §32/§26) fails fast at startup
|
||||||
* with an error naming the missing field instead of booting with an invalid
|
* with an error naming the missing field instead of booting with an invalid
|
||||||
* configuration. The parsed config keeps the committed defaults for
|
* configuration.
|
||||||
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
|
*
|
||||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
* [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||||
* `MissingRequiredSettingError` naming the missing field.
|
* 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
|
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
|
||||||
* logger (`createLogger`, defined below — every line is scrubbed of the
|
* logger (`createLogger`, defined below — every line is scrubbed of the
|
||||||
@@ -43,25 +54,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
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 { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||||
import { Pool } from '@personal-blog/database-postgres';
|
import { Pool } from '@personal-blog/database-postgres';
|
||||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||||
import type { Migration } from '@personal-blog/database-postgres';
|
import type { Migration } from '@personal-blog/database-postgres';
|
||||||
|
|
||||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
// [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||||
const PORT = resolvePort(process.env.PORT);
|
// package's adapter — the workspace's single owner of process.env reads — so
|
||||||
|
// the server never reads process.env directly. The adapter maps the
|
||||||
// [E00-S04-T02] field-specific startup error: validate the startup
|
// environment onto the validated config shape and validates it with
|
||||||
// configuration before anything else, so a missing required setting (e.g.
|
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
|
||||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
|
||||||
// the missing field — never boots with an invalid configuration.
|
// startup with an error naming the missing field — never boots with an
|
||||||
const config = assertValidConfig({
|
// invalid configuration.
|
||||||
host: '0.0.0.0',
|
const config = loadConfigFromEnv();
|
||||||
port: PORT,
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
sessionSecret: process.env.EPPP_SESSION_SECRET,
|
|
||||||
});
|
|
||||||
|
|
||||||
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
||||||
// logger, seeded with the validated config's secrets — and the resolved
|
// logger, seeded with the validated config's secrets — and the resolved
|
||||||
@@ -96,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
|
|||||||
*/
|
*/
|
||||||
let migrationsComplete = false;
|
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. */
|
/** Writes a JSON response with an explicit content-length. */
|
||||||
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
@@ -177,7 +173,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
|
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = config.databaseUrl;
|
||||||
|
|
||||||
if (databaseUrl === undefined) {
|
if (databaseUrl === undefined) {
|
||||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||||
@@ -207,8 +203,12 @@ if (databaseUrl === undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// The server binds the validated bind interface: `config.host` (default
|
||||||
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
// `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
|
// `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 |
|
| 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/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). |
|
| `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. |
|
| `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
|
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||||
redacting logger — the admin-session secret and the password embedded in a
|
redacting logger — the admin-session secret and the password embedded in a
|
||||||
`DATABASE_URL` connection string never appear in the log output.
|
`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:
|
To run the compiled output of any other workspace package directly:
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"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
|
* server's redacting logger applies to every log line, so secrets
|
||||||
* automatically redact from logs.
|
* 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.
|
* task.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -28,3 +36,4 @@ export { validateConfig } from './validate.js';
|
|||||||
export type { ConfigValidationResult } from './validate.js';
|
export type { ConfigValidationResult } from './validate.js';
|
||||||
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
||||||
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
||||||
|
export { loadConfigFromEnv } from './env.js';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* logs.
|
* logs.
|
||||||
*
|
*
|
||||||
* The config package owns which configuration fields are secrets, so the
|
* 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):
|
* the validated config into it via the app's logger):
|
||||||
*
|
*
|
||||||
* - `redactConfig(config)` — a copy of a config value with every secret
|
* - `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` — the port the HTTP server listens on. Integer in the valid TCP
|
||||||
* port range (1–65535), default `3000` (the container default, matching
|
* port range (1–65535), default `3000` (the container default, matching
|
||||||
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
|
* 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`
|
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
|
||||||
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
||||||
* startup migration run to wait for and reports ready immediately (the
|
* startup migration run to wait for and reports ready immediately (the
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
* `ConfigStartupError` whose message names each violating field too.
|
* `ConfigStartupError` whose message names each violating field too.
|
||||||
*
|
*
|
||||||
* This is deliberately NOT the T04 environment adapter: nothing here reads
|
* This is deliberately NOT the T04 environment adapter: nothing here reads
|
||||||
* `process.env`. The adapter (E00-S04-T04) maps the environment onto the
|
* `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment
|
||||||
* validated config shape and passes it to `assertValidConfig` at startup;
|
* onto the validated config shape and passes it to `assertValidConfig` at
|
||||||
* secret redaction (E00-S04-T03) builds on the same boundary in a later task.
|
* startup; the secret redaction layer (E00-S04-T03) builds on the same
|
||||||
|
* boundary.
|
||||||
*
|
*
|
||||||
* Rollback note from the issue: revert the validation error handling.
|
* Rollback note from the issue: revert the validation error handling.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
|
|||||||
* after migrations finish.
|
* after migrations finish.
|
||||||
*/
|
*/
|
||||||
function assertReadyAfterRun(src) {
|
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');
|
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||||
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||||
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
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).
|
* non-container path and the E00-S02-T03 health endpoint working).
|
||||||
*/
|
*/
|
||||||
function assertNoDatabaseUrlPath(src) {
|
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(
|
assert.match(
|
||||||
startup,
|
startup,
|
||||||
/if \(databaseUrl === undefined\) \{/,
|
/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.
|
// package's scrubber before it reaches stdout/stderr.
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||||
'the server must import the startup validation entry point from the config package',
|
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
@@ -188,12 +188,13 @@ function assertServerSource(src) {
|
|||||||
'error lines must be written to stderr',
|
'error lines must be written to stderr',
|
||||||
);
|
);
|
||||||
|
|
||||||
// The wiring — the server keeps its validated config, creates the logger
|
// The wiring — the server keeps its validated config (loaded through the
|
||||||
// with it and logs the resolved configuration redacted.
|
// environment adapter, E00-S04-T04), creates the logger with it and logs
|
||||||
|
// the resolved configuration redacted.
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/const config = assertValidConfig\(\{/,
|
/const config = loadConfigFromEnv\(\);/,
|
||||||
'the server must keep its validated configuration (const config = assertValidConfig(...))',
|
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
@@ -215,13 +216,19 @@ function assertServerSource(src) {
|
|||||||
/console\.(log|error)\(/,
|
/console\.(log|error)\(/,
|
||||||
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
'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
|
assert.doesNotMatch(
|
||||||
// required setting still fails fast (E00-S04-T02) before any log output.
|
src,
|
||||||
const validationIndex = src.indexOf('assertValidConfig({');
|
/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)');
|
const loggerIndex = src.indexOf('createLogger(config)');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
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
|
* - "missing required setting gives a field-specific startup error" → the
|
||||||
* `packages/config` package exposes the startup validation entry point
|
* `packages/config` package exposes the startup validation entry point
|
||||||
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
* (`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
|
* binds, so a deployment missing a required setting (the admin-session
|
||||||
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
||||||
* Security-and-Operations §32/§26) fails fast at startup instead of
|
* Security-and-Operations §32/§26) fails fast at startup instead of
|
||||||
* booting with an invalid configuration. Locked in statically (mutation
|
* booting with an invalid configuration. Locked in statically (mutation
|
||||||
* probes prove non-vacuity: dropping the startup validation call,
|
* probes prove non-vacuity: dropping the adapter call, moving it after
|
||||||
* moving it after the bind, or dropping the compose/Dockerfile support
|
* the bind, or dropping the compose/Dockerfile support all fail) and
|
||||||
* all fail) and behaviorally by the deterministic probes (the issue's
|
* behaviorally by the deterministic probes (the issue's test plan: "start
|
||||||
* test plan: "start with a missing required field and confirm the error
|
* with a missing required field and confirm the error names it" — booting
|
||||||
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
|
||||||
* exits non-zero with the error naming the missing field).
|
* the error naming the missing field).
|
||||||
* - "the error names the missing field" → a missing required setting throws
|
* - "the error names the missing field" → a missing required setting throws
|
||||||
* `MissingRequiredSettingError` whose message and `missingField` name the
|
* `MissingRequiredSettingError` whose message and `missingField` name the
|
||||||
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
* 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:
|
* Asserts the committed server loads its startup configuration through the
|
||||||
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
* environment adapter (E00-S04-T04) before it binds: it imports
|
||||||
* with the parsed startup configuration (including the required
|
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
|
||||||
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
* validates the mapped environment with `assertValidConfig`, including the
|
||||||
* setting is a startup error, never a silently-booted invalid configuration.
|
* 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) {
|
function assertServerStartupValidation(src) {
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||||
'the server must import the startup validation entry point from the config package',
|
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/assertValidConfig\(\{/,
|
/const config = loadConfigFromEnv\(\);/,
|
||||||
'the server must call assertValidConfig with its startup configuration',
|
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.doesNotMatch(
|
||||||
src,
|
src,
|
||||||
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
/process\.env\.[A-Z_]+/,
|
||||||
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
'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(');
|
const listenIndex = src.indexOf('server.listen(');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
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
|
// 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 src = read(SERVER_SRC);
|
||||||
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
|
||||||
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
|
||||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
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 src = read(SERVER_SRC);
|
||||||
const moved = 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(
|
.replace(
|
||||||
'server.listen(PORT, () => {',
|
'server.listen(config.port, config.host, () => {',
|
||||||
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
'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/);
|
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)', () => {
|
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
||||||
const src = read(STARTUP_SRC);
|
const src = read(STARTUP_SRC);
|
||||||
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/3000/,
|
/server\.listen\(config\.port/,
|
||||||
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
|
'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) => {
|
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
|
||||||
if (!tsExecMode()) {
|
if (!tsExecMode()) {
|
||||||
t.skip(
|
t.skip(
|
||||||
|
|||||||
Reference in New Issue
Block a user