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:
implementer
2026-08-30 03:00:40 +00:00
parent ecc945ce65
commit 0ce790fca3
9 changed files with 200 additions and 23 deletions
+19 -13
View File
@@ -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
+4 -3
View File
@@ -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": {
+22
View File
@@ -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' });