diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index f2050e5..f8a1f85 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -21,14 +21,17 @@ # T05 the runtime stage drops root privileges (runs as the image's non-root # `node` user). # -# The app now depends on the `database-postgres` workspace package (the single -# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also -# installs/builds that package — the server's `build`/`typecheck` scripts -# build their workspace dependency first (`pnpm --filter -# @personal-blog/database-postgres build`), and the runtime stage ships the -# compiled `packages/database-postgres/dist` next to the copied workspace -# node_modules links so the server's `@personal-blog/database-postgres` import -# resolves at run time. +# The app now depends on the `config` and `database-postgres` workspace +# packages (the configuration service — E00-S04-T02 validates the required +# settings at startup — and the single owner of the pg/Kysely driver, +# E00-S03-T02). The build stage therefore also installs/builds those +# packages — the server's `build`/`typecheck` scripts build their workspace +# dependencies first (`pnpm --filter @personal-blog/config build` and +# `pnpm --filter @personal-blog/database-postgres build`), and the runtime +# 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 # 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 # Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The -# server's build script builds its workspace dependency first (the -# `database-postgres` package, whose compiled dist the server imports), so a -# single command produces both dists in the right order. +# server's build script builds its workspace dependencies first (the `config` +# and `database-postgres` packages, whose compiled dists the server imports), +# so a single command produces all dists in the right order. COPY apps/server apps/server +COPY packages/config packages/config COPY packages/database-postgres packages/database-postgres RUN pnpm --filter @personal-blog/server build @@ -81,11 +85,13 @@ WORKDIR /app ENV NODE_ENV=production # The workspace install (devDependencies included — image-size pruning is a -# later E00-S02 concern) plus the compiled server output, the compiled -# database-postgres output the server imports, and the package manifests. +# later E00-S02 concern) plus the compiled server output, the compiled config +# and database-postgres outputs the server imports, and the package manifests. COPY --from=build /app/node_modules ./node_modules 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/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/package.json ./packages/database-postgres/package.json diff --git a/apps/server/package.json b/apps/server/package.json index 026f594..56506f2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,13 +3,14 @@ "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); 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": { - "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", + "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", "start": "node dist/index.js" }, "dependencies": { + "@personal-blog/config": "workspace:*", "@personal-blog/database-postgres": "workspace:*" }, "devDependencies": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c99ebaf..f258caf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -18,11 +18,22 @@ * E00-S01-T06) there are no migrations to run, so the app reports ready * 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 * story; this bootstrap keeps the application health-checkable until then. */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { assertValidConfig } 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'; @@ -30,6 +41,17 @@ 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. +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. */ const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); diff --git a/compose.yaml b/compose.yaml index 06aa600..856db96 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,6 +50,11 @@ # # 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). +# 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: db: @@ -93,6 +98,10 @@ services: - linux/arm64 environment: 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: - "${APP_PORT:-3000}:3000" # T02: start only once the database reports healthy (service_healthy), so diff --git a/packages/config/package.json b/packages/config/package.json index 700a3da..5cddb6c 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -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 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": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1bc4602..b6cc631 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -3,12 +3,20 @@ * * [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the * configuration schema (`configSchema`, defined with TypeBox) and the Ajv - * schema-validation entry point (`validateConfig`). The environment adapter - * (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret - * redaction (E00-S04-T03) build on this boundary in later tasks. + * schema-validation entry point (`validateConfig`). + * + * [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 type { Config } from './schema.js'; export { validateConfig } from './validate.js'; export type { ConfigValidationResult } from './validate.js'; +export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js'; diff --git a/packages/config/src/startup.ts b/packages/config/src/startup.ts new file mode 100644 index 0000000..f4953c2 --- /dev/null +++ b/packages/config/src/startup.ts @@ -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; + + 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); +} diff --git a/packages/config/src/validate.ts b/packages/config/src/validate.ts index 81435d2..ef2bf52 100644 --- a/packages/config/src/validate.ts +++ b/packages/config/src/validate.ts @@ -8,9 +8,9 @@ * * This is deliberately NOT the E00-S04-T02 field-specific startup error: * `validateConfig` returns the raw schema-validation outcome (valid or not, - * with the Ajv messages) and performs no startup wiring — the adapter - * (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it - * in later tasks. + * with the Ajv messages) and performs no startup wiring — the startup error + * formatting (E00-S04-T02, `startup.ts`) and the environment adapter + * (E00-S04-T04) build on this raw outcome in their own modules. */ import { Ajv } from 'ajv'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3669a69..6511571 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/server: dependencies: + '@personal-blog/config': + specifier: workspace:* + version: link:../../packages/config '@personal-blog/database-postgres': specifier: workspace:* version: link:../../packages/database-postgres