Compare commits
4
Commits
ecc945ce65
...
ebb9d4f421
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb9d4f421 | ||
|
|
873264004a | ||
|
|
1a9fd592d9 | ||
|
|
0ce790fca3 |
+36
-6
@@ -145,10 +145,11 @@ jobs:
|
||||
# migration run is blocked behind a held ACCESS EXCLUSIVE lock on the
|
||||
# migration ledger, /health stays not-ready, then flips ready once the lock
|
||||
# releases) runs where a Docker daemon is available and skips cleanly
|
||||
# otherwise. The job installs the frozen workspace and builds the
|
||||
# database-postgres package because the probes boot the committed server
|
||||
# from the host (it imports @personal-blog/database-postgres through the
|
||||
# package's own links).
|
||||
# otherwise. The job installs the frozen workspace and builds the config
|
||||
# and database-postgres packages because the probes boot the committed
|
||||
# server from the host (it imports @personal-blog/config and
|
||||
# @personal-blog/database-postgres through the packages' own links; the
|
||||
# required EPPP_SESSION_SECRET is provided by the probe's boot env).
|
||||
app-readiness:
|
||||
name: App readiness after migrations (E00-S03-T06)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -162,11 +163,40 @@ jobs:
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build the database-postgres package (the probes boot the committed server which imports it)
|
||||
run: pnpm --filter @personal-blog/database-postgres build
|
||||
- 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 app readiness test suite
|
||||
run: node --test tests/app-readiness.test.mjs
|
||||
|
||||
# E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs
|
||||
# gate every PR — the suite locks in the field-specific startup error (a
|
||||
# missing required setting fails startup with an error naming the missing
|
||||
# field: packages/config's MissingRequiredSettingError/assertValidConfig,
|
||||
# wired into the committed server before it binds) with mutation probes, and
|
||||
# the deterministic probes execute the issue's test plan ("start with a
|
||||
# missing required field and confirm the error names it"): booting the
|
||||
# committed server without EPPP_SESSION_SECRET exits non-zero naming
|
||||
# sessionSecret, while a valid secret boots to GET /health 200. 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-startup-error:
|
||||
name: Field-specific startup errors (E00-S04-T02)
|
||||
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 startup error test suite
|
||||
run: node --test tests/config-startup-error.test.mjs
|
||||
|
||||
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
||||
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
||||
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
||||
|
||||
@@ -23,5 +23,9 @@ coverage/
|
||||
# the package (removed in its finally block)
|
||||
.config-schema-probe-*.mjs
|
||||
|
||||
# Transient host-side probe file written by the config-startup-error test
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-startup-probe-*.mjs
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
+19
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
|
||||
|
||||
| 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); 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), and fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02); 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/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret redaction (E00-S04-T03) 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) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03) land in later tasks. |
|
||||
| `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. |
|
||||
|
||||
@@ -85,15 +85,23 @@ Expected result: `apps/server/dist/`, `packages/core/dist/`,
|
||||
|
||||
```sh
|
||||
# Run the compiled public server entrypoint
|
||||
pnpm --filter @personal-blog/server start
|
||||
EPPP_SESSION_SECRET='change-me-0123456789abcdefghijklmnopqrstuvwxyz' pnpm --filter @personal-blog/server start
|
||||
```
|
||||
|
||||
This runs the `start` script of `apps/server` (`node dist/index.js`), i.e. the
|
||||
compiled application entrypoint. Two things to know:
|
||||
compiled application entrypoint. Three things to know:
|
||||
|
||||
1. The command **must follow `pnpm build`** — the `start` script executes the
|
||||
compiled artifact in `dist/`, it does not compile first.
|
||||
2. Since [E00-S02-T03], `apps/server` serves the **application health
|
||||
2. Since [E00-S04-T02] the server validates its **required settings at
|
||||
startup**: the admin-session secret `EPPP_SESSION_SECRET` (the config
|
||||
schema's required field, ≥ 32 characters, Security-and-Operations §32/§26)
|
||||
must be set in the environment — if it is missing, the process fails fast
|
||||
with a field-specific startup error (`missing required setting:
|
||||
sessionSecret`) that names the missing field instead of booting. Provide it
|
||||
in your shell or a local `.env` file (the `.env.example` template lands in
|
||||
E00-S04).
|
||||
3. Since [E00-S02-T03], `apps/server` serves the **application health
|
||||
endpoint**: starting it opens an HTTP server on port 3000 answering
|
||||
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
|
||||
is the **readiness probe**: when a `DATABASE_URL` is configured, the app
|
||||
@@ -131,7 +139,7 @@ pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
||||
pnpm build # 5/5 packages emit dist/, exit 0
|
||||
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
||||
pnpm test # 10/10 pass, exit 0
|
||||
pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up
|
||||
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -142,6 +150,7 @@ pnpm --filter @personal-blog/server start # serves GET /health on port 3000, s
|
||||
| `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. |
|
||||
| `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
|
||||
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
|
||||
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
|
||||
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
* `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';
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -246,7 +246,11 @@ function reservePort() {
|
||||
/**
|
||||
* Boots the committed server source on `port` with the given env overrides
|
||||
* (merged over `process.env`; `DATABASE_URL` is stripped unless explicitly
|
||||
* provided). Returns `{ child, stderr, stdout }`; the child writes its
|
||||
* provided, and a valid `EPPP_SESSION_SECRET` is provided unless explicitly
|
||||
* overridden — since E00-S04-T02 the required admin-session secret is
|
||||
* validated at startup, and a missing secret is the field-specific
|
||||
* startup-error path locked in by tests/config-startup-error.test.mjs).
|
||||
* Returns `{ child, stderr, stdout }`; the child writes its
|
||||
* stdout/stderr into closures for diagnostics.
|
||||
*/
|
||||
function bootServer(port, envOverrides = {}) {
|
||||
@@ -254,7 +258,12 @@ function bootServer(port, envOverrides = {}) {
|
||||
tsExecMode() === 'strip-types-flag'
|
||||
? ['--experimental-strip-types', SERVER_SRC]
|
||||
: [SERVER_SRC];
|
||||
const env = { ...process.env, PORT: String(port), ...envOverrides };
|
||||
// Strip an inherited DATABASE_URL unless the caller explicitly provides one
|
||||
// (the no-database path must be deterministic), and provide the required
|
||||
// admin-session secret (E00-S04-T02) unless the caller overrides it.
|
||||
const env = { ...process.env, PORT: String(port) };
|
||||
delete env.DATABASE_URL;
|
||||
Object.assign(env, { EPPP_SESSION_SECRET: 's'.repeat(32) }, envOverrides);
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: REPO_ROOT,
|
||||
env,
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Config startup error test — locks in the [E00-S04-T02] guarantee that a
|
||||
* missing required setting gives a field-specific startup error.
|
||||
*
|
||||
* Acceptance criteria covered (each test fails without the committed state):
|
||||
* - "missing required setting gives a field-specific startup error" → the
|
||||
* `packages/config` package exposes the startup validation entry point
|
||||
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
||||
* and the committed `apps/server/src/index.ts` calls it 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 instead of
|
||||
* booting with an invalid configuration. Locked in statically (mutation
|
||||
* probes prove non-vacuity: dropping the startup validation call,
|
||||
* moving it after the bind, or dropping the compose/Dockerfile support
|
||||
* all fail) and behaviorally by the deterministic probes (the issue's
|
||||
* test plan: "start with a missing required field and confirm the error
|
||||
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
||||
* exits non-zero with the error naming the missing field).
|
||||
* - "the error names the missing field" → a missing required setting throws
|
||||
* `MissingRequiredSettingError` whose message and `missingField` name the
|
||||
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
||||
* schema violations throw `ConfigStartupError` whose message names the
|
||||
* violating field too. Locked in statically and by the deterministic
|
||||
* probes (boundary + booted server).
|
||||
* - the compose stack and the server image stay runnable with the required
|
||||
* secret: `compose.yaml` provides `EPPP_SESSION_SECRET` for the `app`
|
||||
* service (dev-only default, ≥ 32 chars — override via .env / shell) and
|
||||
* the `apps/server/Dockerfile` ships the compiled `packages/config` next
|
||||
* to the other workspace deps, so the app container boots (the
|
||||
* deterministic probe boots with a valid secret and `GET /health` answers
|
||||
* 200 — a valid startup still works).
|
||||
*
|
||||
* Run: `node --test tests/config-startup-error.test.mjs`
|
||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.
|
||||
* The deterministic probes boot the committed server, which imports
|
||||
* `@personal-blog/config` and `@personal-blog/database-postgres` — build those
|
||||
* packages first, exactly as the CI job does.)
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||
|
||||
/** The committed config package and server entrypoint under test. */
|
||||
const CONFIG_DIR = 'packages/config';
|
||||
const STARTUP_SRC = `${CONFIG_DIR}/src/startup.ts`;
|
||||
const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`;
|
||||
const SERVER_SRC = 'apps/server/src/index.ts';
|
||||
const COMPOSE_PATH = 'compose.yaml';
|
||||
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
|
||||
|
||||
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
|
||||
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
|
||||
|
||||
/** The CI job that gates the startup-error criterion on every PR. */
|
||||
const CI_JOB = 'config-startup-error';
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static assertions on the committed sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Asserts the config package exposes the field-specific startup error: the
|
||||
* startup module compiles the TypeBox `configSchema` with Ajv and exports
|
||||
* `assertValidConfig` plus the field-specific errors — `ConfigStartupError`
|
||||
* for any schema violation, and `MissingRequiredSettingError` (whose message
|
||||
* and `missingField` name the missing field, e.g.
|
||||
* `"missing required setting: sessionSecret"`) for a missing required
|
||||
* setting. Fails fast on a deviation; the mutation probes below prove the
|
||||
* assertions are non-vacuous.
|
||||
*/
|
||||
function assertStartupErrorSource(src) {
|
||||
// Built on the E00-S04-T01 schema boundary: the module compiles the
|
||||
// TypeBox configSchema with Ajv (same golden-tuple validator as
|
||||
// validate.ts) and derives the missing-field names from the Ajv `required`
|
||||
// keyword errors.
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ Ajv, type ErrorObject \} from 'ajv'/,
|
||||
'the startup module must import Ajv (the golden-tuple validator) and the ErrorObject type',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ configSchema, type Config \} from '\.\/schema\.js'/,
|
||||
'the startup module must build on the committed configSchema (import from ./schema.js)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/new Ajv\(\{ allErrors: true \}\)/,
|
||||
'the startup module must create the Ajv instance with allErrors (report every violation)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/\.compile\(configSchema\)/,
|
||||
'the startup module must compile the TypeBox configSchema with Ajv',
|
||||
);
|
||||
|
||||
// The field-specific errors.
|
||||
assert.match(
|
||||
src,
|
||||
/export class ConfigStartupError extends Error/,
|
||||
'the startup module must export ConfigStartupError (any invalid configuration is a field-specific startup error)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/export class MissingRequiredSettingError extends ConfigStartupError/,
|
||||
'the startup module must export MissingRequiredSettingError (a missing required setting is the field-specific startup error)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/missing required setting: \$\{missingField\}/,
|
||||
'the MissingRequiredSettingError message must name the missing field ("missing required setting: <field>")',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/readonly missingField: string/,
|
||||
'MissingRequiredSettingError must carry the missing field name (missingField)',
|
||||
);
|
||||
|
||||
// The startup entry point: validates and throws the field-specific error
|
||||
// for a missing required setting (Ajv `required` keyword -> missingProperty).
|
||||
assert.match(
|
||||
src,
|
||||
/export function assertValidConfig\(value: unknown\): Config/,
|
||||
'the startup module must export assertValidConfig (the startup validation entry point)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/error\.keyword === 'required'/,
|
||||
'assertValidConfig must detect missing required settings from the Ajv "required" keyword errors',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/throw new MissingRequiredSettingError\(missingFields\.join\(', '\)\)/,
|
||||
'assertValidConfig must throw MissingRequiredSettingError naming the missing field(s)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the committed server validates the required settings at startup:
|
||||
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
||||
* with the parsed startup configuration (including the required
|
||||
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
||||
* setting is a startup error, never a silently-booted invalid configuration.
|
||||
*/
|
||||
function assertServerStartupValidation(src) {
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||
'the server must import the startup validation entry point from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/assertValidConfig\(\{/,
|
||||
'the server must call assertValidConfig with its startup configuration',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
||||
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
||||
);
|
||||
const callIndex = src.indexOf('assertValidConfig({');
|
||||
const listenIndex = src.indexOf('server.listen(');
|
||||
assert.ok(
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the compose app service provides the required admin-session secret
|
||||
* (`EPPP_SESSION_SECRET`) with a dev-only default of at least 32 characters
|
||||
* (the schema's required field, Security-and-Operations §32/§26), so
|
||||
* `docker compose up -d` keeps working from a clean clone while the app's
|
||||
* startup validation has the secret it requires.
|
||||
*/
|
||||
function assertComposeSecret(composeText) {
|
||||
// The app service block runs from the top-level " app:" key to the
|
||||
// top-level "volumes:" map (the db service has its own nested "volumes:"
|
||||
// key earlier, so slice to the root-level one).
|
||||
const appBlock = composeText.slice(composeText.indexOf(' app:'), composeText.indexOf('\nvolumes:'));
|
||||
assert.match(
|
||||
appBlock,
|
||||
/EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]{32,}\}/,
|
||||
'the app service must provide EPPP_SESSION_SECRET with a >= 32 char dev-only default (${EPPP_SESSION_SECRET:-...}), so the app boots with the required secret from a clean clone',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the server image ships the config package: the build stage copies
|
||||
* `packages/config` source (the server's build compiles it) and the runtime
|
||||
* stage copies the compiled `packages/config/dist` + manifest next to the
|
||||
* other workspace deps the server imports.
|
||||
*/
|
||||
function assertDockerfileConfig(dockerfile) {
|
||||
assert.match(
|
||||
dockerfile,
|
||||
/COPY packages\/config packages\/config/,
|
||||
'the build stage must copy the config package source (the server build compiles its workspace dependency)',
|
||||
);
|
||||
assert.match(
|
||||
dockerfile,
|
||||
/COPY --from=build \/app\/packages\/config\/dist \.\/packages\/config\/dist/,
|
||||
'the runtime stage must ship the compiled config package (packages/config/dist) so the server import resolves in the image',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the config package exposes the field-specific startup error (MissingRequiredSettingError + assertValidConfig)', () => {
|
||||
assert.ok(existsSync(path.join(REPO_ROOT, STARTUP_SRC)), `committed ${STARTUP_SRC} must exist`);
|
||||
assertStartupErrorSource(read(STARTUP_SRC));
|
||||
});
|
||||
|
||||
test('the package boundary re-exports the startup validation entry point and its errors', () => {
|
||||
const src = read(INDEX_SRC);
|
||||
assert.match(
|
||||
src,
|
||||
/export \{ assertValidConfig, ConfigStartupError, MissingRequiredSettingError \} from '\.\/startup\.js'/,
|
||||
'the boundary must re-export assertValidConfig and the field-specific startup errors',
|
||||
);
|
||||
});
|
||||
|
||||
test('the server validates the required settings at startup, before it binds', () => {
|
||||
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
|
||||
assertServerStartupValidation(read(SERVER_SRC));
|
||||
});
|
||||
|
||||
test('the compose app service provides the required admin-session secret (EPPP_SESSION_SECRET)', () => {
|
||||
assertComposeSecret(read(COMPOSE_PATH));
|
||||
});
|
||||
|
||||
test('the server image ships the config package (build source + runtime dist)', () => {
|
||||
assertDockerfileConfig(read(DOCKERFILE_PATH));
|
||||
});
|
||||
|
||||
test('the config-startup-error criterion is enforced in CI', () => {
|
||||
// Picked up by the root test command (root `scripts.test` glob).
|
||||
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
||||
assert.equal(
|
||||
scripts.test,
|
||||
`node --test "${ROOT_TEST_GLOB}"`,
|
||||
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
|
||||
);
|
||||
// And a dedicated CI job gates it on every PR.
|
||||
const workflow = read('.gitea/workflows/ci.yml');
|
||||
assert.ok(
|
||||
workflow.includes(`node --test tests/config-startup-error.test.mjs`),
|
||||
`CI must run the config-startup-error suite (job "${CI_JOB}") on every PR`,
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes(
|
||||
'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build',
|
||||
),
|
||||
'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation probes — the static assertions are non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
||||
});
|
||||
|
||||
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_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(
|
||||
'server.listen(PORT, () => {',
|
||||
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
||||
);
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
|
||||
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
||||
});
|
||||
|
||||
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
||||
const src = read(STARTUP_SRC);
|
||||
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
||||
assert.notEqual(noField, src, 'the mutation must actually drop the field name from the message');
|
||||
assert.throws(() => assertStartupErrorSource(noField), /must name the missing field/);
|
||||
});
|
||||
|
||||
test('dropping MissingRequiredSettingError fails the field-specific error assertion (mutation probe)', () => {
|
||||
const src = read(STARTUP_SRC);
|
||||
const noError = src.replace('export class MissingRequiredSettingError extends ConfigStartupError', 'export class MissingRequiredSettingErrorX extends ConfigStartupError');
|
||||
assert.notEqual(noError, src, 'the mutation must actually rename the error class');
|
||||
assert.throws(() => assertStartupErrorSource(noError), /must export MissingRequiredSettingError/);
|
||||
});
|
||||
|
||||
test('dropping the missing-field detection fails the required-setting assertion (mutation probe)', () => {
|
||||
const src = read(STARTUP_SRC);
|
||||
const noDetection = src.replace("error.keyword === 'required'", "error.keyword === 'minLength'");
|
||||
assert.notEqual(noDetection, src, 'the mutation must actually change the missing-field detection');
|
||||
assert.throws(() => assertStartupErrorSource(noDetection), /"required" keyword/);
|
||||
});
|
||||
|
||||
test('removing EPPP_SESSION_SECRET from the compose app service fails the compose assertion (mutation probe)', () => {
|
||||
const composeText = read(COMPOSE_PATH);
|
||||
const withoutSecret = composeText.replace(
|
||||
/ EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]*\}\n/,
|
||||
'',
|
||||
);
|
||||
assert.notEqual(withoutSecret, composeText, 'the mutation must actually remove the secret env entry');
|
||||
assert.throws(() => assertComposeSecret(withoutSecret), /EPPP_SESSION_SECRET/);
|
||||
});
|
||||
|
||||
test('dropping the config package from the image fails the Dockerfile assertion (mutation probe)', () => {
|
||||
const dockerfile = read(DOCKERFILE_PATH);
|
||||
const withoutDist = dockerfile.replace(
|
||||
'COPY --from=build /app/packages/config/dist ./packages/config/dist\n',
|
||||
'',
|
||||
);
|
||||
assert.notEqual(withoutDist, dockerfile, 'the mutation must actually drop the runtime dist copy');
|
||||
assert.throws(() => assertDockerfileConfig(withoutDist), /runtime stage must ship the compiled config package/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic behavioral probe — the issue's test plan: "start with a
|
||||
// missing required field and confirm the error names it"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
|
||||
* stripping on by default), `strip-types-flag` (>= 22.6 via
|
||||
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
|
||||
* workspace pins engines.node to 24.x, where type stripping is stable.
|
||||
*/
|
||||
function tsExecMode() {
|
||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
|
||||
if (major === 22 && minor >= 6) return 'strip-types-flag';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
|
||||
const TS_STRIPPING = tsExecMode() !== null;
|
||||
|
||||
/** The compiled config package boundary the probes import (built by the CI job first). */
|
||||
const CONFIG_DIST = existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'));
|
||||
/** The compiled database-postgres package the booted server also imports. */
|
||||
const DATABASE_POSTGRES_DIST = existsSync(
|
||||
path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'),
|
||||
);
|
||||
|
||||
/** Why the boot probes may be skipped on a clean clone without a build step. */
|
||||
const BUILD_HINT =
|
||||
'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)';
|
||||
|
||||
/**
|
||||
* The boundary probe source: exercises the compiled `@personal-blog/config`
|
||||
* startup boundary (`assertValidConfig` + the field-specific errors) exactly
|
||||
* as the server consumes it — a valid configuration passes (including the
|
||||
* no-database local path), a missing required setting throws
|
||||
* `MissingRequiredSettingError` naming the field, and other violations throw
|
||||
* `ConfigStartupError` naming the violating field. Written to a temp file
|
||||
* inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve through the
|
||||
* package's own dependency links, then removed.
|
||||
*/
|
||||
const PROBE_SOURCE = `
|
||||
import { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './dist/index.js';
|
||||
|
||||
const capture = (fn) => {
|
||||
try {
|
||||
return { threw: false, value: fn() };
|
||||
} catch (error) {
|
||||
return {
|
||||
threw: true,
|
||||
name: error && typeof error === 'object' ? error.name : String(error),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
missingField: error && typeof error === 'object' ? error.missingField : undefined,
|
||||
isMissingRequired: error instanceof MissingRequiredSettingError,
|
||||
isConfigStartup: error instanceof ConfigStartupError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const result = {
|
||||
// A valid configuration passes and is returned — including the local
|
||||
// non-container path with no databaseUrl (host/port/databaseUrl optional).
|
||||
validReturnsConfig: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32) })).threw === false,
|
||||
fullValid: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000, databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', sessionSecret: 's'.repeat(32) })).threw === false,
|
||||
// The issue's test plan: start with a missing required field — the error
|
||||
// must be MissingRequiredSettingError and name the missing field.
|
||||
missingSecret: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000 })),
|
||||
// Other violations are field-specific too (the message names the field).
|
||||
shortSecret: capture(() => assertValidConfig({ sessionSecret: 'short' })),
|
||||
unknownProperty: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32), extra: true })),
|
||||
};
|
||||
|
||||
console.log('CONFIG_STARTUP_PROBE_RESULT ' + JSON.stringify(result));
|
||||
`;
|
||||
|
||||
test('the compiled startup boundary throws a field-specific error naming the missing required setting (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
|
||||
const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-startup-probe-${process.pid}.mjs`);
|
||||
try {
|
||||
writeFileSync(probeFile, PROBE_SOURCE);
|
||||
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
|
||||
cwd: path.join(REPO_ROOT, CONFIG_DIR),
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
});
|
||||
assert.equal(
|
||||
run.status,
|
||||
0,
|
||||
`the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`,
|
||||
);
|
||||
const match = run.stdout.match(/CONFIG_STARTUP_PROBE_RESULT (\{.*\})/);
|
||||
assert.ok(match, `the probe must print CONFIG_STARTUP_PROBE_RESULT:\n${run.stdout.trim()}`);
|
||||
const result = JSON.parse(match[1]);
|
||||
|
||||
// A valid configuration passes (including the no-database local path).
|
||||
assert.equal(result.validReturnsConfig, true, 'a valid config (only the required secret) must pass assertValidConfig');
|
||||
assert.equal(result.fullValid, true, 'a full valid config must pass assertValidConfig');
|
||||
|
||||
// Missing required setting: field-specific startup error naming the field.
|
||||
assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw');
|
||||
assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError');
|
||||
assert.equal(result.missingSecret.isConfigStartup, true, 'MissingRequiredSettingError must be a ConfigStartupError');
|
||||
assert.equal(
|
||||
result.missingSecret.missingField,
|
||||
'sessionSecret',
|
||||
`MissingRequiredSettingError must carry the missing field name (got: ${JSON.stringify(result.missingSecret)})`,
|
||||
);
|
||||
assert.match(
|
||||
result.missingSecret.message,
|
||||
/missing required setting: sessionSecret/,
|
||||
`the startup error must name the missing field (got: ${JSON.stringify(result.missingSecret)})`,
|
||||
);
|
||||
|
||||
// Other violations are field-specific too.
|
||||
assert.equal(result.shortSecret.threw, true, 'a too-short secret must throw');
|
||||
assert.equal(result.shortSecret.isMissingRequired, false, 'a too-short secret is not a missing required setting');
|
||||
assert.equal(result.shortSecret.isConfigStartup, true, 'a too-short secret must throw ConfigStartupError');
|
||||
assert.match(
|
||||
result.shortSecret.message,
|
||||
/sessionSecret/,
|
||||
`the startup error must name the violating field (got: ${JSON.stringify(result.shortSecret)})`,
|
||||
);
|
||||
assert.equal(result.unknownProperty.threw, true, 'an unknown property must throw');
|
||||
assert.match(
|
||||
result.unknownProperty.message,
|
||||
/extra/,
|
||||
`the startup error must name the unexpected field (got: ${JSON.stringify(result.unknownProperty)})`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(probeFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-boot probes — the issue's test plan executed against the real
|
||||
// committed startup: "start with a missing required field and confirm the
|
||||
// error names it"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createNetServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the committed server source on `port` with the given env overrides.
|
||||
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
|
||||
* and `EPPP_SESSION_SECRET` (the missing-secret case must be deterministic)
|
||||
* are stripped unless explicitly provided. Returns `{ child, stdout, stderr }`
|
||||
* with closures for diagnostics.
|
||||
*/
|
||||
function bootServer(port, envOverrides = {}) {
|
||||
const args =
|
||||
tsExecMode() === 'strip-types-flag'
|
||||
? ['--experimental-strip-types', SERVER_SRC]
|
||||
: [SERVER_SRC];
|
||||
const env = { ...process.env, PORT: String(port) };
|
||||
delete env.DATABASE_URL;
|
||||
delete env.EPPP_SESSION_SECRET;
|
||||
Object.assign(env, envOverrides);
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: REPO_ROOT,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
return { child, stdout: () => stdout, stderr: () => stderr };
|
||||
}
|
||||
|
||||
/** Waits for the child to exit (it fails fast on a startup error). */
|
||||
function waitForExit(child, deadlineMs = 10_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve({ code: child.exitCode, signal: child.signalCode });
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('the server did not exit within the deadline (expected a startup error)')),
|
||||
deadlineMs,
|
||||
);
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls `GET /health` until the server answers with ANY status, the child
|
||||
* exits, or the deadline passes. Returns the fetch Response.
|
||||
*/
|
||||
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
let lastError = '';
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill('SIGTERM');
|
||||
await Promise.race([once(child, 'exit'), delay(2_000)]);
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
|
||||
test('starting without the required setting exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||
// The issue's test plan: "start with a missing required field and confirm
|
||||
// the error names it". The committed server validates its required settings
|
||||
// at startup, so booting it without EPPP_SESSION_SECRET must fail fast —
|
||||
// the process exits non-zero and the error names the missing field.
|
||||
const port = await reservePort();
|
||||
const { child, stdout, stderr } = bootServer(port); // EPPP_SESSION_SECRET stripped
|
||||
const { code, signal } = await waitForExit(child);
|
||||
assert.notEqual(
|
||||
code,
|
||||
0,
|
||||
`the server must exit non-zero when a required setting is missing (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
assert.match(
|
||||
stderr() + stdout(),
|
||||
/missing required setting: sessionSecret/,
|
||||
`the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('starting with an invalid required secret exits non-zero naming the field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||
// A required setting that violates the schema (a too-short secret, < 32
|
||||
// chars) is also a field-specific startup error naming the field.
|
||||
const port = await reservePort();
|
||||
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 'short' });
|
||||
const { code, signal } = await waitForExit(child);
|
||||
assert.notEqual(
|
||||
code,
|
||||
0,
|
||||
`the server must exit non-zero when the required secret is invalid (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
assert.match(
|
||||
stderr() + stdout(),
|
||||
/sessionSecret/,
|
||||
`the startup error must name the invalid field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('starting with the required secret present boots to GET /health 200 (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||
// The startup validation must not reject a valid configuration: with the
|
||||
// required admin-session secret (and no DATABASE_URL — the local
|
||||
// non-container path) the server reports ready immediately and GET /health
|
||||
// answers 200 {"status":"ok"}.
|
||||
const port = await reservePort();
|
||||
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 's'.repeat(32) });
|
||||
try {
|
||||
const response = await waitForAnswer(port, child, stderr);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`GET /health with a valid required secret must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await response.json(),
|
||||
{ status: 'ok' },
|
||||
'the app must report a healthy application ({"status":"ok"}) when the required settings are valid',
|
||||
);
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
}
|
||||
});
|
||||
@@ -116,14 +116,22 @@ function reservePort() {
|
||||
* env): since E00-S03-T06 the health endpoint is the readiness probe, and the
|
||||
* no-DATABASE_URL path is the one with no startup migration run to wait for —
|
||||
* the server reports ready immediately, so this smoke test stays deterministic
|
||||
* and exercises exactly the committed no-migration readiness path.
|
||||
* and exercises exactly the committed no-migration readiness path. Since
|
||||
* E00-S04-T02 the required admin-session secret (EPPP_SESSION_SECRET, the
|
||||
* schema's required field) is validated at startup, so the boot provides a
|
||||
* valid one — a missing secret is the field-specific startup-error path
|
||||
* locked in by tests/config-startup-error.test.mjs.
|
||||
*/
|
||||
function bootServer(port) {
|
||||
const args =
|
||||
tsExecMode() === 'strip-types-flag'
|
||||
? ['--experimental-strip-types', SERVER_SRC]
|
||||
: [SERVER_SRC];
|
||||
const env = { ...process.env, PORT: String(port) };
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
EPPP_SESSION_SECRET: 's'.repeat(32),
|
||||
};
|
||||
delete env.DATABASE_URL;
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: REPO_ROOT,
|
||||
|
||||
Reference in New Issue
Block a user