[E00-S04-T02] Missing required setting gives field-specific startup error #397

Merged
kpcto merged 3 commits from feature/183 into main 2026-08-30 03:32:24 +00:00
9 changed files with 200 additions and 23 deletions
Showing only changes of commit 0ce790fca3 - Show all commits
+19 -13
View File
@@ -21,14 +21,17 @@
# T05 the runtime stage drops root privileges (runs as the image's non-root # T05 the runtime stage drops root privileges (runs as the image's non-root
# `node` user). # `node` user).
# #
# The app now depends on the `database-postgres` workspace package (the single # The app now depends on the `config` and `database-postgres` workspace
# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also # packages (the configuration service — E00-S04-T02 validates the required
# installs/builds that package — the server's `build`/`typecheck` scripts # settings at startup — and the single owner of the pg/Kysely driver,
# build their workspace dependency first (`pnpm --filter # E00-S03-T02). The build stage therefore also installs/builds those
# @personal-blog/database-postgres build`), and the runtime stage ships the # packages — the server's `build`/`typecheck` scripts build their workspace
# compiled `packages/database-postgres/dist` next to the copied workspace # dependencies first (`pnpm --filter @personal-blog/config build` and
# node_modules links so the server's `@personal-blog/database-postgres` import # `pnpm --filter @personal-blog/database-postgres build`), and the runtime
# resolves at run time. # stage ships the compiled `packages/config/dist` and
# `packages/database-postgres/dist` next to the copied workspace node_modules
# links so the server's `@personal-blog/config` and
# `@personal-blog/database-postgres` imports resolve at run time.
# #
# T08: the image embeds no secrets. The Dockerfile declares no secret-bearing # T08: the image embeds no secrets. The Dockerfile declares no secret-bearing
# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY # ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY
@@ -68,10 +71,11 @@ COPY extensions/example/package.json extensions/example/package.json
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The # Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The
# server's build script builds its workspace dependency first (the # server's build script builds its workspace dependencies first (the `config`
# `database-postgres` package, whose compiled dist the server imports), so a # and `database-postgres` packages, whose compiled dists the server imports),
# single command produces both dists in the right order. # so a single command produces all dists in the right order.
COPY apps/server apps/server COPY apps/server apps/server
COPY packages/config packages/config
COPY packages/database-postgres packages/database-postgres COPY packages/database-postgres packages/database-postgres
RUN pnpm --filter @personal-blog/server build RUN pnpm --filter @personal-blog/server build
@@ -81,11 +85,13 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# The workspace install (devDependencies included — image-size pruning is a # The workspace install (devDependencies included — image-size pruning is a
# later E00-S02 concern) plus the compiled server output, the compiled # later E00-S02 concern) plus the compiled server output, the compiled config
# database-postgres output the server imports, and the package manifests. # and database-postgres outputs the server imports, and the package manifests.
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/server/dist ./apps/server/dist COPY --from=build /app/apps/server/dist ./apps/server/dist
COPY --from=build /app/apps/server/package.json ./apps/server/package.json COPY --from=build /app/apps/server/package.json ./apps/server/package.json
COPY --from=build /app/packages/config/dist ./packages/config/dist
COPY --from=build /app/packages/config/package.json ./packages/config/package.json
COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist
COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json
+4 -3
View File
@@ -3,13 +3,14 @@
"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); 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); the Fastify 5 application shell lands in a later story.",
"scripts": { "scripts": {
"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/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",
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"@personal-blog/config": "workspace:*",
"@personal-blog/database-postgres": "workspace:*" "@personal-blog/database-postgres": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
+22
View File
@@ -18,11 +18,22 @@
* E00-S01-T06) there are no migrations to run, so the app reports ready * E00-S01-T06) there are no migrations to run, so the app reports ready
* immediately. * immediately.
* *
* [E00-S04-T02] field-specific startup error: the required settings are
* validated 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
* 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.
*
* The Fastify 5 application shell (and the real HTTP API) lands in a later * The Fastify 5 application shell (and the real HTTP API) lands in a later
* story; this bootstrap keeps the application health-checkable until then. * story; this bootstrap keeps the application health-checkable until then.
*/ */
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 { 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';
@@ -30,6 +41,17 @@ import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */ /** Port the server listens on; `PORT` overrides the container default (3000). */
const PORT = resolvePort(process.env.PORT); 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.
assertValidConfig({
host: '0.0.0.0',
port: PORT,
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.EPPP_SESSION_SECRET,
});
/** Health payload — reported once the startup migration run completes. */ /** Health payload — reported once the startup migration run completes. */
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
+9
View File
@@ -50,6 +50,11 @@
# #
# All values have defaults so `docker compose up -d` works from a clean clone # All values have defaults so `docker compose up -d` works from a clean clone
# without a .env file (a committed .env.example template lands in E00-S04). # without a .env file (a committed .env.example template lands in E00-S04).
# Since E00-S04-T02 the app validates its required settings at startup: the
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
# Security-and-Operations §32/§26) is provided here with a dev-only default —
# override it via a `.env` file / shell environment for anything beyond local
# development.
services: services:
db: db:
@@ -93,6 +98,10 @@ services:
- linux/arm64 - linux/arm64
environment: environment:
DATABASE_URL: postgres://eppp:eppp@db:5432/eppp DATABASE_URL: postgres://eppp:eppp@db:5432/eppp
# E00-S04-T02: the app's required admin-session secret (the schema's
# required field, EPPP_SESSION_SECRET per Security-and-Operations
# §32/§26) — dev-only default (>= 32 chars), override via .env / shell.
EPPP_SESSION_SECRET: ${EPPP_SESSION_SECRET:-eppp-local-session-secret-change-me-0123456789}
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
# T02: start only once the database reports healthy (service_healthy), so # T02: start only once the database reports healthy (service_healthy), so
+1 -1
View File
@@ -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 environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02), secret redaction (E00-S04-T03) 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) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
+11 -3
View File
@@ -3,12 +3,20 @@
* *
* [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the * [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the
* configuration schema (`configSchema`, defined with TypeBox) and the Ajv * configuration schema (`configSchema`, defined with TypeBox) and the Ajv
* schema-validation entry point (`validateConfig`). The environment adapter * schema-validation entry point (`validateConfig`).
* (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret *
* redaction (E00-S04-T03) build on this boundary in later tasks. * [E00-S04-T02] Field-specific startup error: the boundary also exposes the
* startup validation entry point (`assertValidConfig`) and its field-specific
* errors (`MissingRequiredSettingError` — names the missing required setting —
* and `ConfigStartupError` — names each violating field), so the application
* fails fast at startup when a required setting is missing.
*
* The environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03)
* build on this boundary in later tasks.
*/ */
export { configSchema } from './schema.js'; export { configSchema } from './schema.js';
export type { Config } from './schema.js'; export type { Config } from './schema.js';
export { validateConfig } from './validate.js'; export { validateConfig } from './validate.js';
export type { ConfigValidationResult } from './validate.js'; export type { ConfigValidationResult } from './validate.js';
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
+128
View File
@@ -0,0 +1,128 @@
/**
* EPPP configuration startup validation — [E00-S04-T02] missing required
* setting gives a field-specific startup error.
*
* Builds on the E00-S04-T01 boundary (`configSchema` from `schema.ts`,
* validated with Ajv): `assertValidConfig` is the startup entry point the
* application calls with its parsed configuration before it binds — when a
* required setting is missing it throws `MissingRequiredSettingError`, whose
* message and `missingField` name the missing field (the issue's acceptance:
* "missing required setting gives a field-specific startup error", "the error
* names the missing field"); any other schema violation throws a
* `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.
*
* Rollback note from the issue: revert the validation error handling.
*/
import { Ajv, type ErrorObject } from 'ajv';
import { configSchema, type Config } from './schema.js';
/** Ajv instance for the config schema — `allErrors` reports every violation. */
const ajv = new Ajv({ allErrors: true });
/** The compiled validator — TypeBox schemas are JSON Schema, so Ajv compiles them directly. */
const validateConfigValue = ajv.compile(configSchema);
/**
* The field-specific startup error thrown when a configuration value is
* invalid at startup (any schema violation). `violations` holds one
* field-prefixed message per violation (e.g. `"sessionSecret: must NOT have
* fewer than 32 characters"`), so the error names the offending field(s).
*/
export class ConfigStartupError extends Error {
/** Field-prefixed messages naming each violation (never empty). */
readonly violations: ReadonlyArray<string>;
constructor(message: string, violations: readonly string[]) {
super(message);
this.name = 'ConfigStartupError';
this.violations = violations;
}
}
/**
* The error thrown when a required setting is missing — the E00-S04-T02
* field-specific startup error. `missingField` and the message name the
* missing field (e.g. `"missing required setting: sessionSecret"`), so an
* operator starting the app with an incomplete configuration sees exactly
* which setting to provide.
*/
export class MissingRequiredSettingError extends ConfigStartupError {
/** The name of the required setting that is missing. */
readonly missingField: string;
constructor(missingField: string) {
super(`missing required setting: ${missingField}`, [`missing required setting: ${missingField}`]);
this.name = 'MissingRequiredSettingError';
this.missingField = missingField;
}
}
/**
* Formats one Ajv violation as a field-specific message: the field named by
* the error's `instancePath` (e.g. `/sessionSecret`) prefixes the Ajv
* message, so every startup error names the offending setting — never just a
* bare schema message. An `additionalProperties` violation points at the
* object (empty `instancePath`), so its offending key (Ajv
* `params.additionalProperty`) is used as the field instead.
*/
function formatViolation(error: ErrorObject): string {
const field = error.instancePath.replace(/^\//, '');
const message = error.message ?? 'invalid';
if (field !== '') {
return `${field}: ${message}`;
}
const extra = (error.params as { additionalProperty?: unknown } | undefined)?.additionalProperty;
return typeof extra === 'string' && extra.length > 0 ? `${extra}: ${message}` : message;
}
/**
* Validates a configuration value at startup and returns it as the typed
* `Config` — or throws a field-specific startup error:
*
* - a missing required setting throws `MissingRequiredSettingError` naming
* the missing field (the issue's acceptance criteria);
* - any other schema violation throws `ConfigStartupError` whose message
* names the violating field(s).
*
* The application calls this before it starts serving, so an invalid
* configuration fails fast at startup with a clear, field-specific error
* instead of booting with a silently-wrong setting.
*
* @param value - the parsed configuration value (the T04 adapter will hand
* this the mapped environment)
* @returns the validated configuration
* @throws {MissingRequiredSettingError} when a required setting is missing
* @throws {ConfigStartupError} when the configuration violates the schema
*/
export function assertValidConfig(value: unknown): Config {
const valid = validateConfigValue(value);
if (valid) {
return value as Config;
}
const errors = validateConfigValue.errors ?? [];
// Missing required settings get the dedicated field-specific error — the
// Ajv `required` keyword error carries the missing property name, which is
// exactly the field the acceptance criteria require the error to name.
const missingFields = errors
.filter((error) => error.keyword === 'required')
.map((error) => {
const missing = (error.params as { missingProperty?: unknown } | undefined)?.missingProperty;
return typeof missing === 'string' ? missing : '';
})
.filter((field) => field.length > 0);
if (missingFields.length > 0) {
throw new MissingRequiredSettingError(missingFields.join(', '));
}
const violations = errors.map(formatViolation);
throw new ConfigStartupError(`invalid configuration: ${violations.join('; ')}`, violations);
}
+3 -3
View File
@@ -8,9 +8,9 @@
* *
* This is deliberately NOT the E00-S04-T02 field-specific startup error: * This is deliberately NOT the E00-S04-T02 field-specific startup error:
* `validateConfig` returns the raw schema-validation outcome (valid or not, * `validateConfig` returns the raw schema-validation outcome (valid or not,
* with the Ajv messages) and performs no startup wiring — the adapter * with the Ajv messages) and performs no startup wiring — the startup error
* (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it * formatting (E00-S04-T02, `startup.ts`) and the environment adapter
* in later tasks. * (E00-S04-T04) build on this raw outcome in their own modules.
*/ */
import { Ajv } from 'ajv'; import { Ajv } from 'ajv';
+3
View File
@@ -14,6 +14,9 @@ importers:
apps/server: apps/server:
dependencies: dependencies:
'@personal-blog/config':
specifier: workspace:*
version: link:../../packages/config
'@personal-blog/database-postgres': '@personal-blog/database-postgres':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database-postgres version: link:../../packages/database-postgres