feat: missing required setting fails startup with a field-specific error (E00-S04-T02)
- packages/config: add src/startup.ts exposing assertValidConfig (builds on the T01 TypeBox/Ajv schema) and the field-specific startup errors (MissingRequiredSettingError names the missing field; ConfigStartupError names each violating field); re-export from the package boundary - apps/server: validate the startup configuration (including the required EPPP_SESSION_SECRET) before the server binds, so a missing required setting crashes the process at startup naming the field; depends on @personal-blog/config - compose.yaml: provide EPPP_SESSION_SECRET for the app service (dev-only >= 32 char default; override via .env / shell) - Dockerfile: ship the compiled packages/config in the image (build source + runtime dist), matching the server's new workspace dependency - pnpm-lock.yaml: apps/server importer gains @personal-blog/config
This commit is contained in:
+19
-13
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user