Compare commits
42
Commits
ecc945ce65
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5c77e21d | ||
|
|
893707604d | ||
|
|
c1020353a0 | ||
|
|
edbf8cb7ea | ||
|
|
693976c289 | ||
|
|
b6c3fd9a28 | ||
|
|
b1a1c9bc86 | ||
|
|
2a229bbf3f | ||
|
|
b8f0abdb0d | ||
|
|
768f009ee9 | ||
|
|
dbd42d394d | ||
|
|
ee0c094398 | ||
|
|
1fba3d9f95 | ||
|
|
372bf1f648 | ||
|
|
41bd4d78aa | ||
|
|
aaa7489566 | ||
|
|
10ea1ef3db | ||
|
|
d0c3b6a83d | ||
|
|
13b1548df8 | ||
|
|
f3cd70e45d | ||
|
|
89fe0b52e6 | ||
|
|
d9b493498e | ||
|
|
e6d28f94f0 | ||
|
|
dce6cac05b | ||
|
|
1e0f628651 | ||
|
|
ffda249617 | ||
|
|
408f33e4e9 | ||
|
|
345ceccfad | ||
|
|
73a1ae88cd | ||
|
|
b9345e205c | ||
|
|
3214807c9d | ||
|
|
20173a8241 | ||
|
|
1214a33adb | ||
|
|
7aeeeaaa97 | ||
|
|
072f5c5785 | ||
|
|
ae900589c3 | ||
|
|
60bd097b70 | ||
|
|
6458013306 | ||
|
|
ebb9d4f421 | ||
|
|
873264004a | ||
|
|
1a9fd592d9 | ||
|
|
0ce790fca3 |
@@ -0,0 +1,47 @@
|
||||
# EPPP configuration template — [E00-S04-T05]
|
||||
#
|
||||
# Copy this file to `.env` and fill in real values:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Every value in this file is a PLACEHOLDER — the template intentionally ships
|
||||
# no real secrets. Real `.env` files stay git-ignored (`.env`, `.env.*` in
|
||||
# `.gitignore`), so a committed example can never leak a local secret. Never
|
||||
# commit a real `.env`.
|
||||
|
||||
# --- Server configuration (read by @personal-blog/config, E00-S04-T04) -------
|
||||
|
||||
# Interface the HTTP server binds — a hostname or IPv4/IPv6 address.
|
||||
# Default: 0.0.0.0 (all interfaces — the container default).
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Port the HTTP server listens on — an integer in the valid TCP range
|
||||
# (1-65535). Default: 3000.
|
||||
PORT=3000
|
||||
|
||||
# PostgreSQL connection string (optional). When unset, the app reports ready
|
||||
# immediately and skips the startup migration run (the local non-container
|
||||
# developer path). When set, the shape is:
|
||||
# postgres://<user>:<password>@<host>:5432/<database>
|
||||
# (add your own credentials; a local no-credential default is shown below)
|
||||
DATABASE_URL=postgres://localhost:5432/eppp
|
||||
|
||||
# Admin-session secret — REQUIRED and at least 32 characters (the config
|
||||
# schema's required field; Security-and-Operations §32/§26). Generate a fresh
|
||||
# one with `openssl rand -hex 32` and replace the placeholder below. The
|
||||
# placeholder is intentionally SHORTER than the 32-character minimum, so an
|
||||
# unedited `cp .env.example .env` is rejected at startup (fails closed)
|
||||
# instead of booting with a publicly known secret.
|
||||
EPPP_SESSION_SECRET=change-me
|
||||
|
||||
# --- Docker Compose overrides (optional — compose.yaml has dev defaults) ------
|
||||
|
||||
# PostgreSQL database name / user / password and host port for the `db`
|
||||
# service (compose.yaml interpolates these with dev defaults).
|
||||
POSTGRES_DB=eppp
|
||||
POSTGRES_USER=eppp
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Host port for the `app` service. Default: 3000.
|
||||
APP_PORT=3000
|
||||
+182
-154
@@ -5,14 +5,48 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Minimal workflow token: the pipeline only reads repository contents
|
||||
# (checkout, frozen install, typecheck, lint, tests, build) — nothing writes
|
||||
# back, so the token is scoped to contents: read (E00-S05-T01).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# E00-S05-T01 — CI quality baseline (required PR stages).
|
||||
#
|
||||
# Every pull request runs the required quality stages in order, each gated on
|
||||
# the previous stage through `needs`:
|
||||
#
|
||||
# 1. frozen-install — the committed lockfile installs cleanly
|
||||
# 2. typecheck — every workspace package passes `tsc --noEmit`
|
||||
# 3. formatting-lint — the dependency-free formatting/lint policy (`pnpm lint`)
|
||||
# 4. unit — deterministic unit suites (health, config, env example…)
|
||||
# 5. architecture — static workspace/container structure and policy suites
|
||||
# 6. postgres-integration — PostgreSQL adapter suites (docker-gated real-stack
|
||||
# probes run where a Docker daemon is available and
|
||||
# skip cleanly otherwise)
|
||||
# 7. build-apps — builds the workspace applications (apps/*: server
|
||||
# today, admin when E06-S01 lands) and verifies the
|
||||
# compiled artifact
|
||||
#
|
||||
# The stage order, the `needs` chain and the tests/ coverage are locked in by
|
||||
# tests/ci-stages.test.mjs (architecture stage). Third-party actions
|
||||
# (actions/checkout, actions/setup-node) are pinned to full commit SHAs — no
|
||||
# floating tags — and the workflow token is scoped to `contents: read`
|
||||
# (E00-S05-T01 hardening). Container/Compose smoke on main/release branches
|
||||
# and the Docker Compose baseline stack (E00-S02) stay out of scope for this
|
||||
# stage list.
|
||||
|
||||
jobs:
|
||||
# Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the
|
||||
# committed lockfile must install cleanly and be up to date with the
|
||||
# manifests before any stage proceeds.
|
||||
frozen-install:
|
||||
name: Frozen lockfile install
|
||||
name: Stage 1 — Frozen lockfile install (E00-S05-T01)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
@@ -22,191 +56,185 @@ jobs:
|
||||
- name: Verify workspace groups
|
||||
run: pnpm -r list --depth -1
|
||||
|
||||
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
|
||||
# gate every PR (the docker-gated layer-scan probe inside the same file runs
|
||||
# where a Docker daemon is available and skips cleanly otherwise).
|
||||
secrets-not-embedded:
|
||||
name: Secrets not embedded (E00-S02-T08)
|
||||
# Stage 2 — typecheck (E00-S05-T01).
|
||||
typecheck:
|
||||
name: Stage 2 — Typecheck (E00-S05-T01)
|
||||
needs: frozen-install
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Run secrets-not-embedded test suite
|
||||
- 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: Typecheck every workspace package
|
||||
run: pnpm typecheck
|
||||
|
||||
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
|
||||
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
|
||||
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
|
||||
# newline, and valid JSON with 2-space indentation and no duplicate keys.
|
||||
formatting-lint:
|
||||
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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: Run the formatting/lint policy
|
||||
run: pnpm lint
|
||||
|
||||
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
|
||||
# PR without external services: the app health endpoint, the secrets scan
|
||||
# and the configuration service suites (schema, startup error, log
|
||||
# redaction, env adapter, .env.example). The config suites boot the
|
||||
# committed server, so the config and database-postgres packages are built
|
||||
# first.
|
||||
unit:
|
||||
name: Stage 4 — Unit tests (E00-S05-T01)
|
||||
needs: formatting-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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 the health-endpoint unit suite
|
||||
run: node --test tests/health-endpoint.test.mjs
|
||||
- name: Run the secrets-not-embedded unit suite
|
||||
run: node --test tests/secrets-not-embedded.test.mjs
|
||||
- name: Run the config-schema unit suite
|
||||
run: node --test tests/config-schema.test.mjs
|
||||
- name: Run the config-startup-error unit suite
|
||||
run: node --test tests/config-startup-error.test.mjs
|
||||
- name: Run the config-log-redaction unit suite
|
||||
run: node --test tests/config-log-redaction.test.mjs
|
||||
- name: Run the config-env-adapter unit suite
|
||||
run: node --test tests/config-env-adapter.test.mjs
|
||||
- name: Run the env-example unit suite
|
||||
run: node --test tests/env-example.test.mjs
|
||||
|
||||
# E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs
|
||||
# gate every PR — the scan proves pg/Kysely imports live only in
|
||||
# packages/database-postgres and the mutation probes prove the scan catches
|
||||
# a driver import injected into any other package.
|
||||
database-postgres-imports:
|
||||
name: Database-postgres import isolation (E00-S03-T02)
|
||||
# Stage 5 — architecture tests (E00-S05-T01). Static structure and policy
|
||||
# suites: dependency boundaries, workspace layout/configuration, strict
|
||||
# TypeScript base, engine/TypeScript pins, root commands, frozen-install
|
||||
# clean clone, container definition structure, and the CI baseline itself.
|
||||
architecture:
|
||||
name: Stage 5 — Architecture tests (E00-S05-T01)
|
||||
needs: unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Run database-postgres import isolation suite
|
||||
- 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 (strict-tsconfig typechecks apps/server which imports them)
|
||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||
- name: Run the architecture-import suite
|
||||
run: node --test tests/architecture-import.test.mjs
|
||||
- name: Run the no-core-extension-imports suite
|
||||
run: node --test tests/no-core-extension-imports.test.mjs
|
||||
- name: Run the workspace-layout suite
|
||||
run: node --test tests/workspace-layout.test.mjs
|
||||
- name: Run the workspace-config suite
|
||||
run: node --test tests/workspace-config.test.mjs
|
||||
- name: Run the strict-tsconfig suite
|
||||
run: node --test tests/strict-tsconfig.test.mjs
|
||||
- name: Run the typescript-pin suite
|
||||
run: node --test tests/typescript-pin.test.mjs
|
||||
- name: Run the node-engine suite
|
||||
run: node --test tests/node-engine.test.mjs
|
||||
- name: Run the frozen-install suite
|
||||
run: node --test tests/frozen-install.test.mjs
|
||||
- name: Run the root-commands suite
|
||||
run: node --test tests/root-commands.test.mjs
|
||||
- name: Run the compose-config suite
|
||||
run: node --test tests/compose-config.test.mjs
|
||||
- name: Run the build-targets suite
|
||||
run: node --test tests/build-targets.test.mjs
|
||||
- name: Run the non-root-user suite
|
||||
run: node --test tests/non-root-user.test.mjs
|
||||
- name: Run the readonly-rootfs suite
|
||||
run: node --test tests/readonly-rootfs.test.mjs
|
||||
- name: Run the database-postgres-imports suite
|
||||
run: node --test tests/database-postgres-imports.test.mjs
|
||||
- name: Run the ci-stages baseline suite
|
||||
run: node --test tests/ci-stages.test.mjs
|
||||
|
||||
# E00-S03-T03: the static assertions of tests/database-postgres-ledger.test.mjs
|
||||
# gate every PR — the suite locks in the migration ledger (schema_migrations
|
||||
# table DDL, idempotent parameterized record, driver-boundary re-export)
|
||||
# with mutation probes, and the docker-gated real-stack probe (migrate an
|
||||
# empty database and confirm the ledger exists) runs where a Docker daemon
|
||||
# is available and skips cleanly otherwise. The job installs the frozen
|
||||
# workspace because the real-stack probe executes the committed ledger
|
||||
# module from the host (it imports `pg` through the package's own links).
|
||||
database-postgres-ledger:
|
||||
name: Migration ledger (E00-S03-T03)
|
||||
# Stage 6 — PostgreSQL integration tests (E00-S05-T01). The PostgreSQL
|
||||
# adapter suites (migration ledger, advisory lock, failure diagnostic) and
|
||||
# the app readiness suite: their docker-gated real-stack probes (migrate an
|
||||
# empty database, hold/release the advisory lock, readiness waits on the
|
||||
# startup migration run) run where a Docker daemon is available and skip
|
||||
# cleanly otherwise; the static and deterministic probes always gate. The
|
||||
# app-readiness probes boot the committed server, so the config and
|
||||
# database-postgres packages are built first.
|
||||
postgres-integration:
|
||||
name: Stage 6 — PostgreSQL integration tests (E00-S05-T01)
|
||||
needs: architecture
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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: Run migration ledger test suite
|
||||
- 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 the database-postgres-ledger suite
|
||||
run: node --test tests/database-postgres-ledger.test.mjs
|
||||
|
||||
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
|
||||
# gate every PR — the suite locks in the migration advisory lock (session-
|
||||
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
|
||||
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
|
||||
# acquire() calls share one connection, driver-boundary re-export) with
|
||||
# mutation probes, and the docker-gated real-stack concurrent probe (a
|
||||
# second runner waits or fails while the first holds the lock; concurrent
|
||||
# acquire() checks out exactly one connection; the lock releases when the
|
||||
# holding session ends) runs where a Docker daemon is available and skips
|
||||
# cleanly otherwise. The job installs the frozen workspace because the
|
||||
# real-stack probe executes the committed lock module from the host (it
|
||||
# imports `pg` through the package's own links).
|
||||
database-postgres-lock:
|
||||
name: Migration advisory lock (E00-S03-T04)
|
||||
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: Run migration advisory lock test suite
|
||||
- name: Run the database-postgres-lock suite
|
||||
run: node --test tests/database-postgres-lock.test.mjs
|
||||
|
||||
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
|
||||
# gate every PR — the suite locks in the migration failure diagnostic (a
|
||||
# structured MigrationFailedError whose diagnostic identifies the failing
|
||||
# migration, the failure phase, the underlying cause, and the applied/pending
|
||||
# ledger state, serializable via toJSON) with mutation probes, and a
|
||||
# deterministic stub-pool behavioral probe (intentionally failing migration
|
||||
# fixture -> structured diagnostic naming the failing migration) runs on
|
||||
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
|
||||
# intentionally failing migration fixture and confirm the diagnostic") runs
|
||||
# where a Docker daemon is available and skips cleanly otherwise. The job
|
||||
# installs the frozen workspace because the probes execute the committed
|
||||
# runner module from the host (it imports `pg` through the package's own
|
||||
# links).
|
||||
database-postgres-diagnostic:
|
||||
name: Migration failure diagnostic (E00-S03-T05)
|
||||
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: Run migration failure diagnostic test suite
|
||||
- name: Run the database-postgres-diagnostic suite
|
||||
run: node --test tests/database-postgres-diagnostic.test.mjs
|
||||
|
||||
# E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate
|
||||
# every PR — the suite locks in the readiness gate (the app answers
|
||||
# GET /health with 503 {"status":"not ready"} until the startup migration
|
||||
# run completes, then 200 {"status":"ok"}) with mutation probes, the
|
||||
# deterministic probes (boot the committed server: no DATABASE_URL ->
|
||||
# ready immediately; unreachable DATABASE_URL -> stays not-ready) run on
|
||||
# Node 24, and the docker-gated real-stack probe (the issue's test plan:
|
||||
# "start with pending migrations and confirm readiness waits" — the app's
|
||||
# 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).
|
||||
app-readiness:
|
||||
name: App readiness after migrations (E00-S03-T06)
|
||||
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 database-postgres package (the probes boot the committed server which imports it)
|
||||
run: pnpm --filter @personal-blog/database-postgres build
|
||||
- name: Run app readiness test suite
|
||||
- name: Run the app-readiness suite
|
||||
run: node --test tests/app-readiness.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 +
|
||||
# ajv@8.20.0) with mutation probes, and the deterministic probe executes
|
||||
# the issue's test plan ("validate a full config against the TypeBox/Ajv
|
||||
# schema") against the committed schema through Ajv. The job installs the
|
||||
# frozen workspace and builds the config package because the probe also
|
||||
# exercises the compiled package boundary (@personal-blog/config) exactly
|
||||
# as the later configuration adapter will consume it.
|
||||
config-schema:
|
||||
name: TypeBox/Ajv config schema (E00-S04-T01)
|
||||
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
|
||||
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
|
||||
# — the pnpm apps-group glob picks it up automatically) and verifies the
|
||||
# compiled server artifact.
|
||||
build-apps:
|
||||
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
|
||||
needs: postgres-integration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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 package (the probe exercises the compiled package boundary)
|
||||
run: pnpm --filter @personal-blog/config build
|
||||
- name: Run config schema test suite
|
||||
run: node --test tests/config-schema.test.mjs
|
||||
|
||||
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
||||
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
||||
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
||||
# the same file run where a Docker daemon is available and skip cleanly
|
||||
# otherwise).
|
||||
compose-config:
|
||||
name: Compose config (E00-S03-T01)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Run compose-config test suite
|
||||
run: node --test tests/compose-config.test.mjs
|
||||
- name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands)
|
||||
run: pnpm --filter "./apps/**" run build
|
||||
- name: Verify the compiled server application artifact
|
||||
run: test -f apps/server/dist/index.js
|
||||
|
||||
+12
-1
@@ -6,9 +6,12 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Local environment files (a committed .env.example lands in E00-S04)
|
||||
# Local environment files: real .env files are ignored, while the committed
|
||||
# .env.example template (E00-S04-T05) is explicitly un-ignored so it stays
|
||||
# tracked.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -23,5 +26,13 @@ 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
|
||||
|
||||
# Transient host-side probe file written by the config-env-adapter test
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-env-adapter-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), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly and binds the validated HOST interface (E00-S04-T04); 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": {
|
||||
|
||||
+102
-20
@@ -18,17 +18,64 @@
|
||||
* 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.
|
||||
*
|
||||
* [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
* package's environment adapter (`loadConfigFromEnv` from
|
||||
* `@personal-blog/config`) — the workspace's single owner of `process.env`
|
||||
* reads — so this module (and every other module outside the config package)
|
||||
* never reads `process.env` directly. The adapter maps the environment
|
||||
* (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated
|
||||
* config shape and validates it with `assertValidConfig` (E00-S04-T02) before
|
||||
* the server binds, so a missing required setting is still a startup error
|
||||
* naming the missing field. `HOST` is validated at the adapter boundary as a
|
||||
* hostname or IP address and the server passes `config.host` to
|
||||
* `server.listen`, so a configured `HOST` binds exactly that interface and
|
||||
* the startup log reflects the actual bind — it never claims a bind the
|
||||
* process does not enforce, and never echoes unvalidated env content.
|
||||
*
|
||||
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
|
||||
* logger (`createLogger`, defined below — every line is scrubbed of the
|
||||
* config's secret values before it reaches stdout/stderr), so secret values —
|
||||
* the admin-session secret and the password in a `DATABASE_URL` connection
|
||||
* string — automatically redact from logs. The server logs its resolved
|
||||
* configuration at startup through `redactConfig` (the issue's test plan:
|
||||
* "log configuration and confirm secret values are redacted"), so operators
|
||||
* see the effective settings with every secret value replaced by
|
||||
* `[REDACTED]` and no secret value reaches the log output.
|
||||
*
|
||||
* 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 { loadConfigFromEnv } from '@personal-blog/config';
|
||||
import { redactConfig, redactText, type Config } 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';
|
||||
|
||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||
const PORT = resolvePort(process.env.PORT);
|
||||
// [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
// package's adapter — the workspace's single owner of process.env reads — so
|
||||
// the server never reads process.env directly. The adapter maps the
|
||||
// environment onto the validated config shape and validates it with
|
||||
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
|
||||
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
|
||||
// startup with an error naming the missing field — never boots with an
|
||||
// invalid configuration.
|
||||
const config = loadConfigFromEnv();
|
||||
|
||||
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
||||
// logger, seeded with the validated config's secrets — and the resolved
|
||||
// configuration is logged redacted, so operators see the effective settings
|
||||
// while secret values stay out of the log output.
|
||||
const logger = createLogger(config);
|
||||
logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));
|
||||
|
||||
/** Health payload — reported once the startup migration run completes. */
|
||||
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
||||
@@ -56,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
|
||||
*/
|
||||
let migrationsComplete = false;
|
||||
|
||||
/**
|
||||
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
||||
* out-of-range override falls back to the default so a bad `PORT` value cannot
|
||||
* crash the process at startup.
|
||||
*/
|
||||
function resolvePort(raw: string | undefined): number {
|
||||
const port = Number(raw ?? 3000);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
|
||||
}
|
||||
|
||||
/** Writes a JSON response with an explicit content-length. */
|
||||
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.writeHead(statusCode, {
|
||||
@@ -76,6 +112,47 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
/** The server's logger: `log` writes to stdout, `error` writes to stderr — both redacted. */
|
||||
interface ServerLogger {
|
||||
log(...args: unknown[]): void;
|
||||
error(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the redacting logger for the validated configuration (E00-S04-T03):
|
||||
* each argument is serialized (strings verbatim, errors by message, other
|
||||
* values as JSON) and the joined line is scrubbed of the config's secret
|
||||
* values — the admin-session secret and the password embedded in a
|
||||
* `DATABASE_URL` connection string — before it is written, so no secret value
|
||||
* can reach the log output. The server uses this logger for ALL of its
|
||||
* output; a bare `console.log`/`console.error` would bypass the redaction and
|
||||
* is rejected by the test suite.
|
||||
*/
|
||||
function createLogger(config: Config): ServerLogger {
|
||||
const write = (stream: NodeJS.WriteStream, args: unknown[]): void => {
|
||||
stream.write(`${redactText(args.map(serialize).join(' '), config)}\n`);
|
||||
};
|
||||
return {
|
||||
log: (...args) => write(process.stdout, args),
|
||||
error: (...args) => write(process.stderr, args),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializes one log argument: strings verbatim, errors by message, objects as JSON. */
|
||||
function serialize(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value instanceof Error) return String(value);
|
||||
if (typeof value === 'undefined') return 'undefined';
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one request. The application only serves the health endpoint at this
|
||||
* stage; anything else is a 404 so misconfiguration is loud. The health route
|
||||
@@ -96,13 +173,13 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databaseUrl = config.databaseUrl;
|
||||
|
||||
if (databaseUrl === undefined) {
|
||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||
// migrations to run, so the app reports ready from the start.
|
||||
migrationsComplete = true;
|
||||
console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
|
||||
logger.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
|
||||
} else {
|
||||
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
@@ -111,7 +188,7 @@ if (databaseUrl === undefined) {
|
||||
.run()
|
||||
.then((result) => {
|
||||
migrationsComplete = true;
|
||||
console.log(
|
||||
logger.log(
|
||||
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
|
||||
);
|
||||
})
|
||||
@@ -120,13 +197,18 @@ if (databaseUrl === undefined) {
|
||||
// runner already throws a serializable MigrationFailedError. The app
|
||||
// logs the failure and stays not-ready, so a deployment with failed
|
||||
// migrations is surfaced by the readiness probe instead of
|
||||
// crash-looping.
|
||||
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
|
||||
// crash-looping. The redacting logger scrubs any secret value (e.g.
|
||||
// the database password) the error text may embed.
|
||||
logger.error('[migrate] startup migration run failed; app stays not-ready:', error);
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||
// The server binds the validated bind interface: `config.host` (default
|
||||
// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to
|
||||
// `server.listen`, so a configured `HOST` binds exactly that interface and
|
||||
// the startup log reflects the actual bind.
|
||||
server.listen(config.port, config.host, () => {
|
||||
logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`);
|
||||
});
|
||||
|
||||
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
|
||||
|
||||
+11
-1
@@ -49,7 +49,13 @@
|
||||
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
|
||||
#
|
||||
# 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 (the committed .env.example template, E00-S04-T05,
|
||||
# lists the overridable variables with placeholder values).
|
||||
# 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 +99,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
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-001: Modular monolith
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), Architecture wiki (sections 1, 4, 9)
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a greenfield personal blogging platform. The first visible release
|
||||
(v0.1) is intentionally small: editable site identity, editable Home page,
|
||||
ordered list of published posts, individual post pages, an administration
|
||||
interface and the Amber theme. It must be small because optional features are
|
||||
absent, not because extensibility is absent — the codebase ships explicit,
|
||||
versioned boundaries from day one for content types, content blocks, page
|
||||
sections, themes, extensions, extension settings, navigation, visitor
|
||||
preferences, database migrations, media storage, background jobs, events and
|
||||
rendering.
|
||||
|
||||
The platform is owned by a single small stream, operates a single canonical
|
||||
PostgreSQL database, and has no v1 requirement for independent service
|
||||
deployment, service discovery, message brokers, distributed transactions or
|
||||
cross-service schema versioning. The workspace is a pnpm monorepo whose
|
||||
dependency boundaries are already CI-enforced (FIT-001, FIT-010, FIT-011), so
|
||||
module boundaries can be structural, explicit and testable rather than purely
|
||||
organisational.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP is a **modular monolith**. One application image contains the public
|
||||
server, admin API, rendering pipeline, extension runtime, core services, built
|
||||
admin assets and first-party extensions, deployed as a single deployable unit;
|
||||
an optional worker uses the same image with a different command (`eppp serve`
|
||||
/ `eppp worker`). Runtime is Node.js 24 LTS with Fastify 5, PostgreSQL 18 as
|
||||
the sole canonical database, React 19 for server-rendered public components
|
||||
and a Vite admin, and Docker Compose as the primary runtime model.
|
||||
|
||||
Modules are separated in the workspace layout (`apps/`, `packages/`,
|
||||
`extensions/`) and their import edges are enforced by
|
||||
`dependency-boundaries.json` in CI. Every module boundary is an explicit,
|
||||
versioned contract; no module may reach into another module's internals. The
|
||||
decision text — **Modular monolith** — matches the ADR index entry (ADR-001,
|
||||
section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Microservices / distributed system** — rejected: no v1 requirement
|
||||
justifies distributed transactions, service discovery, brokers,
|
||||
multi-service deployment or cross-service schema changes; the operational
|
||||
and cognitive cost is disproportionate for a small platform stream, and it
|
||||
would split one canonical database into many.
|
||||
- **Classic (unstructured) monolith** — rejected: it would undermine the
|
||||
extension architecture, which depends on stable versioned contracts between
|
||||
core, extensions and themes, and it would make the CI-enforced dependency
|
||||
boundaries impossible to honour in practice.
|
||||
- **Serverless / FaaS** — rejected: it conflicts with the chosen long-lived
|
||||
runtime model (Fastify 5 process, background jobs, connection-backed
|
||||
PostgreSQL access) and the single-image deployment model.
|
||||
- **Modular monolith with future extraction** — chosen: module boundaries
|
||||
exist now; any module can be extracted into a service later on evidence
|
||||
without a rewrite of the whole system.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one deployable unit means simpler operations, atomic deploys and a
|
||||
single image to build, scan and promote; all modules share one transaction
|
||||
and consistency boundary via the canonical database; a small team can own
|
||||
the entire platform; explicit boundaries force API discipline and keep the
|
||||
extension contracts honest.
|
||||
- Negative: the single process only stays modular through discipline — a
|
||||
boundary violation degrades it toward a big ball of mud, which is exactly
|
||||
what the CI dependency-boundary tests guard against; scaling is vertical
|
||||
until a module is extracted; deploys are all-or-nothing; a fault in one
|
||||
module can affect the whole process (mitigated by the optional worker
|
||||
separation for background jobs and by health/readiness gates).
|
||||
- Neutral: extraction of a module into a service remains possible and is a
|
||||
deliberate, evidence-based decision rather than a default; the monorepo
|
||||
layout already supports it because modules are physically separated.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- One application image/container for the server plus an optional worker
|
||||
container running the same image with a different command; no service
|
||||
discovery, message broker or per-service observability requirements at v1.
|
||||
- Single PostgreSQL 18 instance as the sole canonical database with one core
|
||||
migration chain; extension-owned tables live in independent chains
|
||||
(`eppp_extension_migrations`), all run behind the advisory migration lock at
|
||||
startup.
|
||||
- The health/readiness endpoint gates on migration completion; readiness is
|
||||
reported only when the single deployable unit is fully initialised.
|
||||
- Deploy is build-one-image-and-run-Compose; rollback is redeploying the
|
||||
previous image. Horizontal scaling means running more instances of the same
|
||||
image behind a proxy for stateless work and more workers for background
|
||||
jobs; the database remains the single shared store.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit this ADR when a module's change frequency, team ownership or scaling
|
||||
needs diverge enough that a single deployable unit becomes a bottleneck —
|
||||
for example, when a module needs an independent deploy cadence, independent
|
||||
scaling or a different runtime — and there is evidence (per the architecture
|
||||
review gates, ADR index section 68) that extracting that module into a
|
||||
service would help.
|
||||
- Revisit if the platform grows to multiple independent products or teams
|
||||
requiring distributed transactions or independent data stores, or if the
|
||||
v1.1 boundary contract (ADR-027 to ADR-032) pressures the single-image
|
||||
model.
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-002: Node.js 24 LTS runtime
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), Technology stack wiki (sections 5.2, 6, 7, 8), ADR-001
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a greenfield personal blogging platform (ADR-001) whose runtime is a
|
||||
single modular-monolith application plus an optional worker, deployed as one
|
||||
or more containers of one image. The whole platform — public server, admin
|
||||
API, rendering pipeline, extension runtime, core services and background
|
||||
jobs — executes on one runtime, so the runtime choice is a platform-wide,
|
||||
hard-to-reverse decision with security, tooling and operational reach.
|
||||
|
||||
The workspace is a pnpm monorepo (pnpm 11.23.0) whose root manifest already
|
||||
declares `engines.node: ">=24.0.0 <25.0.0"` with `engineStrict: true`, and
|
||||
the CI pipeline installs and runs on Node 24 (E00-S01-T08). The technology
|
||||
stack wiki classifies Node.js as support class A — a fixed upstream EOL date
|
||||
— and pins the golden compatibility tuple to Node 24.19.0 LTS (Krypton), with
|
||||
`node:24.19.0-bookworm-slim` as the application container base image.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP runs on the **Node.js 24 LTS** runtime line, exact-pinned to Node 24.19.0
|
||||
LTS (Krypton) in the golden compatibility tuple and the container image. The
|
||||
workspace root manifest restricts the engine to 24.x
|
||||
(`engines.node: ">=24.0.0 <25.0.0"`), pnpm enforces it at install time
|
||||
(`engineStrict: true`), and CI installs and runs on Node 24. The runtime is a
|
||||
class A dependency: its upstream EOL (2028-04-30) drives the upgrade
|
||||
schedule, and a move to a later major line (for example Node 26) is only
|
||||
evaluated once that line is LTS and the compatibility suite passes, and then
|
||||
only as a deliberate, ADR-recorded change. The decision text — **Node.js 24
|
||||
LTS runtime** — matches the ADR index entry (ADR-002, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Node 22 LTS (Jod)** — rejected: it is the previous LTS line and reaches
|
||||
EOL before Node 24 (2027-04-30), shortening the runway for a platform whose
|
||||
v1.1 boundary contracts (ADR-027 to ADR-032) extend past that date.
|
||||
- **Node 26 (current, non-LTS at decision time)** — rejected: not LTS when
|
||||
the decision was made; running a non-LTS line contradicts the class A
|
||||
support posture (fixed EOL, security patching on an LTS cadence).
|
||||
- **Node 24 with no exact pin (floating latest 24.x)** — rejected: a floating
|
||||
minor would break the reproducibility of the golden compatibility tuple and
|
||||
the container image; the line is pinned and minors move deliberately.
|
||||
- **Node 24 LTS, exact-pinned 24.19.0** — chosen: an LTS major with a fixed
|
||||
EOL, exact-pinned in the image and tuple, engine-restricted in the
|
||||
manifest, and enforced in CI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: an LTS line with a fixed upstream EOL (2028-04-30) gives a
|
||||
predictable security-patching and upgrade horizon; the exact pin makes
|
||||
builds and containers reproducible; the engine restriction rejects
|
||||
unsupported Node versions at install time instead of failing at runtime.
|
||||
- Negative: Node 24 API and behaviour become the platform floor — anything
|
||||
needing a newer Node feature waits for the deliberate, ADR-recorded major
|
||||
upgrade; minor upgrades inside 24.x still need the weekly dependency sweep
|
||||
and full CI.
|
||||
- Neutral: the runtime is shared by every module, so a future extraction
|
||||
(ADR-001) keeps running on the same Node line until that module's runtime
|
||||
needs diverge.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- Application and worker containers run `node:24.19.0-bookworm-slim`; release
|
||||
automation records the immutable image digest in the SBOM/release manifest.
|
||||
- The supported runtime is enforced at install (`engineStrict: true` fails
|
||||
installs on unsupported Node) and at test time (the node-engine suite
|
||||
asserts the current runtime satisfies the 24.x range).
|
||||
- CI installs and runs every stage on Node 24, so the committed pipeline is
|
||||
the operational proof that the platform runs on the chosen line.
|
||||
- Node 24 is patched on the LTS cadence; security-emergency updates go
|
||||
through the focused lane (immediate, full CI), and the EOL date is the
|
||||
deadline for the next major-line ADR.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit when Node 26 becomes LTS and passes the compatibility suite, per
|
||||
the LTS strategy — the decision to move majors is a new ADR, not a patch.
|
||||
- Revisit on a security-emergency or patch-lane event that forces a minor
|
||||
upgrade outside the weekly sweep, or if upstream announces a change to the
|
||||
Node 24 support horizon.
|
||||
- Revisit if a module's runtime needs (ADR-001 extraction) diverge from the
|
||||
shared Node line and a second runtime enters the platform.
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-003: TypeScript 6.0.3 language decision
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), Technology stack wiki (sections 5.2, 6, 7, 8), ADR-001
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a modular monolith (ADR-001) written in TypeScript across every
|
||||
module boundary: `apps/`, `packages/` and `extensions/` are all compiled from
|
||||
strict TypeScript against a shared base tsconfig (E00-S01-T10). The language
|
||||
version is therefore a platform-wide decision: it fixes the type-system
|
||||
features, the compiler behaviour and the toolchain (editor, build, CI
|
||||
typecheck) every module sees.
|
||||
|
||||
The workspace already pins TypeScript exactly: the root manifest declares
|
||||
`devDependencies.typescript: "6.0.3"` (a bare MAJOR.MINOR.PATCH, no semver
|
||||
range), the committed lockfile resolves exactly one `typescript@6.0.3`, and
|
||||
every workspace package resolves `Version 6.0.3` (E00-S01-T09). The
|
||||
technology stack wiki classifies TypeScript as support class C — rolling,
|
||||
exact-pinned, tested and upgraded deliberately — and pins 6.0.3 in the golden
|
||||
compatibility tuple. TypeScript 7.0 reached GA in 2026-07 without a stable
|
||||
programmatic API before 7.1, making 6.0 the bridge release.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP uses **TypeScript 6.0.3** as its language and compiler, exact-pinned in
|
||||
the root manifest and the lockfile so every workspace package and every CI
|
||||
typecheck runs the identical compiler. 6.0.3 is the baseline; a formal review
|
||||
happens after TS 7.1 is stable (per the technology stack LTS strategy), and
|
||||
any move to the 7.x line is a deliberate, ADR-recorded change. The decision
|
||||
text — **TypeScript 6.0.3 pending TS7.1 ecosystem review** — matches the ADR
|
||||
index entry (ADR-003, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **TypeScript 7.0 at GA (2026-07)** — rejected: it shipped without a stable
|
||||
programmatic API before 7.1, which would put the compiler toolchain on an
|
||||
unstable surface; 6.0 is the documented bridge.
|
||||
- **TypeScript 6.x floating (caret/range)** — rejected: a range could resolve
|
||||
to a different compiler than the one the golden tuple was tested with; the
|
||||
exact pin is what makes typecheck deterministic across modules and CI.
|
||||
- **TypeScript 5.x (previous major)** — rejected: it predates the 6.0 type
|
||||
system and ecosystem position the platform was bootstrapped on; staying on
|
||||
an older major only delays the bridge.
|
||||
- **TypeScript 6.0.3 exact-pinned** — chosen: the bridge release, exact-pinned
|
||||
and CI-verified, with a formal re-review once TS 7.1 stabilises the
|
||||
programmatic API.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one exact compiler version across apps, packages and extensions
|
||||
makes typecheck results reproducible locally and in CI; the 6.0 bridge is a
|
||||
known-good stepping stone to the 7.x line; strict mode across the workspace
|
||||
stays uniform.
|
||||
- Negative: the language feature set is fixed at 6.0.3 until the reviewed
|
||||
upgrade; any tooling that needs the 7.x programmatic API waits for the TS
|
||||
7.1 formal review.
|
||||
- Neutral: upgrades inside the pinned major remain controlled by the
|
||||
dependency sweep; the language choice is invisible to the deployed runtime
|
||||
(TypeScript compiles away) but is enforced at build and typecheck time.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- `pnpm install --frozen-lockfile` resolves exactly `typescript@6.0.3`; the
|
||||
lockfile contains one resolved TypeScript entry, so no package can drift
|
||||
onto another version.
|
||||
- Every workspace `build`/`typecheck` script invokes the pinned compiler
|
||||
(`pnpm exec tsc`), and the typescript-pin suite asserts each package
|
||||
resolves `Version 6.0.3`.
|
||||
- CI stage 2 (typecheck) runs `pnpm typecheck` across the workspace, so the
|
||||
language pin is continuously verified on every pull request.
|
||||
- A TypeScript upgrade is a class C lane change: patch/minor through the
|
||||
sweep with full CI; the 7.x major is a programme item with its own ADR.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit after TypeScript 7.1 is stable: the formal review (per the LTS
|
||||
strategy) decides whether the platform moves to the 7.x line, recorded as a
|
||||
new ADR.
|
||||
- Revisit if a workspace package needs a type-system feature or toolchain
|
||||
capability that 6.0.3 cannot provide, or if the ecosystem (editors, tools,
|
||||
type packages) leaves the 6.0 bridge unsupported.
|
||||
- Revisit if a module boundary contract (ADR-027 to ADR-032) becomes
|
||||
unrepresentable in the pinned type system.
|
||||
@@ -0,0 +1,136 @@
|
||||
# ADR-004: Fastify 5 HTTP runtime
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), Technology stack wiki (sections 5.2, 6,
|
||||
7, 8), Architecture wiki (sections 1, 19, 42), ADR-001, ADR-002
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a modular monolith (ADR-001) whose single application image contains
|
||||
the public server, admin API, rendering pipeline, extension runtime and core
|
||||
services. Every inbound request — public pages, the admin API and the
|
||||
health/readiness endpoints — enters the platform through one HTTP runtime, so
|
||||
the runtime choice is a platform-wide, hard-to-reverse decision with security,
|
||||
tooling and operational reach.
|
||||
|
||||
The public rendering pipeline is already documented as starting at the HTTP
|
||||
layer — `Fastify route → SiteResolver → VisitorPreferenceResolver →
|
||||
ContentService → PageComposition + BlockRegistry → ThemeResolver → React DOM
|
||||
server renderer → HTML` (Architecture wiki §19) — and the v0.1 public surface
|
||||
(§42) exposes `GET /`, `GET /posts/:slug`, `GET /assets/*`, `GET /media/*`,
|
||||
`GET /health/live`, `GET /health/ready` and `GET /admin/*`, all served by the
|
||||
HTTP runtime.
|
||||
|
||||
The choice is already recorded higher up: ADR-001 fixes the runtime as
|
||||
"Node.js 24 LTS with Fastify 5" and ADR-002 records the Node line. The
|
||||
technology stack wiki pins **Fastify 5.12.1** in the golden compatibility
|
||||
tuple (§7) as support class B — a documented support policy with no fixed
|
||||
multi-year EOL date (§5.2) — and lists the approved Fastify lifecycle
|
||||
plugins: `@fastify/cookie` 11.1.2, `helmet` 13.1.1, `@fastify/rate-limit`
|
||||
11.2.0, `@fastify/static` 10.1.3, `@fastify/csrf-protection` 8.0.1,
|
||||
`@fastify/swagger` 9.8.1, with `@fastify/multipart` 10.1.1 behind a CI gate
|
||||
(§5.2).
|
||||
|
||||
The workspace currently boots a minimal `node:http` server for the health
|
||||
endpoint (E00-S02-T03) — explicitly documented as a bootstrap until "the
|
||||
Fastify 5 application shell (and the real HTTP API) lands in a later story" —
|
||||
so this ADR formalises the committed runtime decision ahead of that shell.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP serves all inbound HTTP on the **Fastify 5 HTTP runtime**, exact-pinned
|
||||
to Fastify 5.12.1 in the golden compatibility tuple (Technology stack §7).
|
||||
One Fastify 5 application hosts the public routes, the admin API under
|
||||
`/api/admin/v1/*` and the health/readiness endpoints; the temporary
|
||||
`node:http` bootstrap is replaced when the Fastify 5 application shell lands
|
||||
in a later story.
|
||||
|
||||
Fastify's plugin encapsulation matches the platform's module-boundary
|
||||
discipline (ADR-001): the approved plugin set is the Fastify lifecycle list
|
||||
in the technology stack (§5.2) — cookie, helmet, rate-limit, static,
|
||||
csrf-protection, swagger, with multipart gated in CI before enabling — and
|
||||
Fastify's native Pino 10.3.1 logger is the platform logger (the existing
|
||||
secret-redaction requirement, E00-S04-T03, carries over).
|
||||
|
||||
Fastify is support class B: it has no fixed upstream EOL, and an upgrade to
|
||||
Fastify 6 is a deliberate, ADR-recorded major change gated on a stable v6,
|
||||
all required plugins compatible, the full suite green, and extension
|
||||
contracts stable/migrated (§6). The decision text — **Fastify 5 HTTP
|
||||
runtime** — matches the ADR index entry (ADR-004, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Express (4.x/5.x)** — rejected: the middleware-chain model has no
|
||||
first-class request/response schema validation or serialization, which
|
||||
conflicts with the JSON Schema + TypeBox + Ajv validation decision
|
||||
(ADR-011); plugin encapsulation and lifecycle hooks are weaker than
|
||||
Fastify's, and the community middleware surface is less uniform to pin.
|
||||
- **Hono** — rejected: it is oriented toward edge/serverless runtimes, which
|
||||
conflicts with the long-lived process model, background jobs and
|
||||
connection-backed PostgreSQL access chosen in ADR-001; the golden tuple and
|
||||
extension contracts were bootstrapped on Fastify.
|
||||
- **Koa** — rejected: the minimal core requires assembling routing, body
|
||||
parsing, validation, security headers and logging by hand, adding glue code
|
||||
with no schema-based validation story.
|
||||
- **Bare `node:http`** — rejected for the real API: it is only the temporary
|
||||
health bootstrap (E00-S02-T03); it provides no routing, plugin lifecycle,
|
||||
validation or the ecosystem the v0.1 public surface (§42) needs.
|
||||
- **Fastify 5** — chosen: schema-based validation and serialization aligns
|
||||
with ADR-011, plugin encapsulation matches the module boundaries (ADR-001),
|
||||
Pino logging is native, TypeScript support is first-class, and the
|
||||
lifecycle plugin set is already pinned in the stack (§5.2).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: schema-based request/response validation and serialization aligns
|
||||
with the ADR-011 decision (JSON Schema + TypeBox + Ajv); plugin
|
||||
encapsulation gives each plugin an isolated scope, matching the
|
||||
module-boundary discipline of ADR-001; the runtime is already the documented
|
||||
choice in ADR-001 and the golden tuple, so no rework is needed when the
|
||||
application shell lands; native Pino 10.3.1 logging integrates with the
|
||||
existing redaction requirement (E00-S04-T03); the pinned lifecycle plugin
|
||||
set covers the v0.1 needs — cookies/opaque DB-backed sessions (ADR-019),
|
||||
security headers, rate limiting, static assets/media and swagger docs.
|
||||
- Negative: Fastify 5 is class B — no fixed upstream EOL date, so the support
|
||||
horizon is policy-defined rather than date-defined; the exact pin (5.12.1)
|
||||
must move deliberately through the dependency lanes; the application shell
|
||||
does not exist yet, so this ADR commits a decision whose implementation
|
||||
lands in a later story (the `node:http` bootstrap remains until then).
|
||||
- Neutral: Fastify is an in-process library, so a future module extraction
|
||||
(ADR-001) does not change the shared HTTP runtime unless that module needs
|
||||
its own runtime; plugin majors move through the update lanes like any class
|
||||
B dependency.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- One Fastify 5 process serves the public routes, admin API and
|
||||
health/readiness endpoints; it binds the validated `HOST`/`PORT` from the
|
||||
config adapter (E00-S04-T04).
|
||||
- Health/readiness remain part of the v0.1 surface (`GET /health/live`,
|
||||
`GET /health/ready`, Architecture wiki §42) with readiness gated on
|
||||
migration completion (E00-S03-T06).
|
||||
- All log output continues through the redacting-logger pattern (E00-S04-T03);
|
||||
Fastify's native Pino logger is configured with the same redaction.
|
||||
- The security posture comes from the approved plugin set: helmet (headers),
|
||||
csrf-protection (state-changing requests), rate-limit (abuse), cookie
|
||||
(opaque DB-backed admin sessions, ADR-019), static (assets/media) and
|
||||
swagger (API docs).
|
||||
- Deploy/rollback is unchanged: the HTTP runtime lives inside the single
|
||||
application image (ADR-001), so rollback is redeploying the previous image.
|
||||
- Fastify patch/minor upgrades go through the weekly dependency sweep with
|
||||
full CI; a Fastify 6 major is a programme item (ADR + compatibility + full
|
||||
suite + plugin migration, §6/§8).
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit when Fastify 6 is stable, all required lifecycle plugins are
|
||||
compatible, the full suite is green and the extension contracts are
|
||||
stable/migrated — per the LTS strategy (§6) the move to v6 is a new ADR, not
|
||||
a patch.
|
||||
- Revisit if the Fastify 5 support policy changes (class B has no fixed EOL
|
||||
date), or if a required plugin forces a Fastify major earlier than planned.
|
||||
- Revisit if the Fastify 5 application shell, when it lands, cannot satisfy
|
||||
the v0.1 public surface (§42) or the v1.1 boundary contracts (ADR-027 to
|
||||
ADR-032) — for example, a hard requirement Fastify 5 cannot meet.
|
||||
@@ -0,0 +1,131 @@
|
||||
# ADR-005: PostgreSQL 18 sole canonical DB
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), Architecture wiki (sections 1, 4, 9),
|
||||
Technology-Stack wiki (sections 5.2, 5.4, 6, 7), Engineering-Standards wiki
|
||||
(sections 24, 25, 58)
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a greenfield personal blogging platform owned by a single small
|
||||
stream. The first visible release (v0.1) is intentionally small, but the
|
||||
codebase ships explicit, versioned boundaries from day one — content types,
|
||||
content blocks, page sections, themes, extensions, extension settings,
|
||||
navigation, visitor preferences, database migrations, media storage,
|
||||
background jobs, events and rendering — and all of these need durable,
|
||||
transactional storage.
|
||||
|
||||
ADR-001 already records that the platform operates a single canonical
|
||||
PostgreSQL database within a modular monolith; this ADR is the focused record
|
||||
of the database choice itself. There is no v1 requirement for distributed
|
||||
transactions, service discovery, message brokers or independent data stores,
|
||||
so the database can be one engine, one instance, one consistency boundary.
|
||||
|
||||
The choice is already operationalised in the workspace:
|
||||
|
||||
- `compose.yaml` pins `postgres:18.6-bookworm` as the documented runtime
|
||||
target (Technology-Stack §5.2/§5.4, golden tuple §7; E00-S03-T01), with a
|
||||
`pg_isready` health gate and a named `db-data` volume for persistence.
|
||||
- `packages/database-postgres` is the single workspace package allowed to
|
||||
import `pg` and Kysely (dependency direction `database-postgres → core
|
||||
ports → Kysely/pg`, Engineering-Standards §24); domain and extension
|
||||
packages never import the driver.
|
||||
- The migration runner coordinates on a PostgreSQL advisory lock
|
||||
(`pg_advisory_lock`, E00-S03-T04) and gates app readiness on migration
|
||||
completion (E00-S03-T06).
|
||||
- Extensions are the growth mechanism; extension-owned tables and migrations
|
||||
live in independent chains (`eppp_extension_migrations`) sharing the same
|
||||
canonical database (ADR-001, Engineering-Standards §25).
|
||||
|
||||
PostgreSQL 18 is Class A in the runtime stack (fixed upstream EOL date,
|
||||
Technology-Stack §5.1) with a documented support window to 2030-11-14; the
|
||||
LTS strategy mandates always running the current minor, with a major upgrade
|
||||
as a separate operator procedure (Technology-Stack §6).
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP uses **PostgreSQL 18 as the sole canonical database**. One PostgreSQL 18
|
||||
instance is the only database engine in the platform; it holds all core data,
|
||||
and extension-owned tables live in independent migration chains inside the
|
||||
same instance. `pg`/Kysely imports are isolated to `packages/database-postgres`
|
||||
— the adapter boundary every database access passes through — and no other
|
||||
database engine is introduced at v1.
|
||||
The decision text — **PostgreSQL 18 sole canonical DB** — matches the ADR
|
||||
index entry (ADR-005, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **SQLite** — rejected: the embedded, file-backed, single-writer model does
|
||||
not fit the long-lived connection-backed server plus optional worker and
|
||||
background jobs, and it offers no advisory-lock coordination for concurrent
|
||||
migration runners; operational tooling for the Compose deployment model is
|
||||
weaker than PostgreSQL's.
|
||||
- **MySQL / MariaDB** — rejected: no v1 requirement needs their
|
||||
differentiators; PostgreSQL provides the standards compliance, constraints,
|
||||
JSONB and advisory locks that the migration runner and the versioned
|
||||
contract model rely on, and a second SQL dialect would add cost without
|
||||
benefit.
|
||||
- **MongoDB / document store** — rejected: no v1 need for schemaless
|
||||
documents; the platform's explicit versioned contracts (content types,
|
||||
blocks, schemas validated by TypeBox/Ajv) benefit from a relational,
|
||||
constraint-enforcing store.
|
||||
- **Polyglot persistence (multiple engines)** — rejected: no v1 requirement
|
||||
justifies the operational and cognitive cost; a single canonical database
|
||||
keeps one transaction and consistency boundary (ADR-001).
|
||||
- **Cloud-managed database (e.g. RDS/Aurora)** — rejected for v1: the primary
|
||||
runtime model is Docker Compose with local parity; a managed service can be
|
||||
revisited on evidence if hosting needs change.
|
||||
- **Older PostgreSQL major / unpinned minor** — rejected: 18 is the
|
||||
documented golden tuple; pinning the exact 18.6 minor makes the database
|
||||
container reproducible (E00-S03-T01).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one transaction and consistency boundary across the whole
|
||||
platform; mature operational tooling (standard backups, `pg_isready` health
|
||||
gates already in `compose.yaml`); advisory locks coordinate concurrent
|
||||
migration runners; constraints and JSONB support the versioned contract
|
||||
model; the adapter boundary keeps engine specifics contained in one package.
|
||||
- Negative: a heavier operational footprint than embedded options — a
|
||||
dedicated service with credentials, volume persistence and health/readiness
|
||||
gating; schema changes demand migration discipline (expand/contract, never
|
||||
edit a released migration — Engineering-Standards §25); the single canonical
|
||||
database is a single point of failure (mitigated by volume persistence,
|
||||
health gates and standard backup tooling).
|
||||
- Neutral: the engine is encapsulated behind core ports, so moving to a
|
||||
different engine later is a deliberate, evidence-based change rather than a
|
||||
default; extensions share the canonical database with their own migration
|
||||
chains.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- A dedicated `db` service in `compose.yaml`, pinned to
|
||||
`postgres:18.6-bookworm`, with a `pg_isready` healthcheck that gates app
|
||||
start and a named `db-data` volume that persists across restart/recreate
|
||||
(`docker compose down -v` resets it).
|
||||
- Credentials come from the environment (`DATABASE_URL`, Compose
|
||||
`POSTGRES_*` defaults), never embedded in the image (E00-S02-T08).
|
||||
- One core migration chain plus extension-owned chains
|
||||
(`eppp_extension_migrations`) run behind the advisory migration lock at
|
||||
startup; readiness is reported only after migrations complete (E00-S03-T06).
|
||||
- Backups use standard PostgreSQL tooling against the volume; version policy
|
||||
follows the LTS strategy — always run the current minor, treat a major
|
||||
upgrade as a separate operator procedure (Technology-Stack §6).
|
||||
- At v1 there is no replication, multi-instance or sharding; scaling stays
|
||||
vertical and the database remains the single shared store.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit this ADR when a module or extension needs a genuinely different
|
||||
data model (graph, search index, time-series) and there is evidence that
|
||||
PostgreSQL features are insufficient — per the architecture review gates
|
||||
(ADR index section 68); the adapter boundary keeps such a change contained.
|
||||
- Revisit if the platform grows to multiple independent products or teams
|
||||
requiring independent data stores or distributed transactions (shared with
|
||||
the ADR-001 revisit trigger).
|
||||
- Revisit if the golden tuple or Technology-Stack changes the database choice
|
||||
or its support window — for example when PostgreSQL 18 EOL (2030-11-14)
|
||||
approaches and a major upgrade becomes a scheduled operator procedure, or a
|
||||
new major becomes the documented runtime target.
|
||||
@@ -0,0 +1,118 @@
|
||||
# ADR-006: Kysely contained inside DB adapter
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR-001, ADR-005, Architecture wiki (sections 1, 4, 9),
|
||||
Engineering-Standards wiki (sections 24, 25, 57)
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a modular monolith (ADR-001) that keeps every module boundary
|
||||
explicit, versioned and CI-enforced. ADR-005 already records the database
|
||||
choice — PostgreSQL 18 as the sole canonical database — and with it the rule
|
||||
that the `pg` driver and the Kysely query builder are isolated to
|
||||
`packages/database-postgres`, the adapter boundary every database access
|
||||
passes through. This ADR is the focused record of where the query builder
|
||||
itself may live: the containment decision.
|
||||
|
||||
Kysely is a type-safe SQL query builder, not an ORM. It gives typed queries
|
||||
and composable expressions on top of `pg`, but it still speaks SQL and a
|
||||
dialect, and every package that imports it is coupled to that dialect and to
|
||||
the adapter's implementation choices. The workspace already depends on the
|
||||
containment: `packages/database-postgres` declares `kysely` 0.29.4 and `pg`
|
||||
8.22.0 as its exact-pinned runtime dependencies (its only ones), imports them
|
||||
in its source and re-exports the pieces the adapter is built on (E00-S03-T02),
|
||||
and is the single workspace package whose source may import the driver — a
|
||||
rule locked in by `tests/database-postgres-imports.test.mjs` in the
|
||||
`database-postgres-imports` CI job and by the architecture fitness tests
|
||||
FIT-010 (browser bundles cannot import server/DB packages) and FIT-011 (UI
|
||||
cannot import Kysely/pg).
|
||||
|
||||
The dependency direction is already fixed: `database-postgres → core ports →
|
||||
Kysely/pg` (Architecture wiki §9.1, Engineering-Standards §24). Domain and
|
||||
extension packages talk to the database through core repository/port
|
||||
interfaces, never through the query builder. What remains to be recorded is
|
||||
that this is a standing architectural decision, not a temporary arrangement:
|
||||
Kysely stays inside the adapter, and no other package may grow a Kysely or
|
||||
`pg` import surface.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP keeps **Kysely contained inside DB adapter**. Kysely is an
|
||||
implementation detail of `packages/database-postgres` — the single workspace
|
||||
package allowed to import `pg`/Kysely (E00-S03-T02) — and the rest of the
|
||||
workspace reaches the database exclusively through the adapter's and core
|
||||
ports' APIs. No other workspace package may declare `kysely` or `pg` in its
|
||||
manifest or import them directly in its source; the `database-postgres-imports`
|
||||
CI gate plus FIT-010/FIT-011 enforce this on every PR. Kysely exists only
|
||||
where the adapter lives — it is contained, not distributed, and the containment
|
||||
is a package-level rule, not a type-level one.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Kysely imported directly by domain/extension packages** — rejected: it
|
||||
would leak SQL-dialect and query-builder concerns into domain and extension
|
||||
code, defeat the single-owner boundary (E00-S03-T02), make FIT-011
|
||||
impossible to honour, and couple business logic to the adapter's
|
||||
implementation.
|
||||
- **A dedicated shared query-layer package** — rejected: it would create a
|
||||
second import surface for the driver stack, split ownership of the dialect,
|
||||
and add a package whose only purpose is to widen the boundary; the adapter
|
||||
already owns the typed surface callers need.
|
||||
- **Full ORM (e.g. Prisma/Drizzle) instead of a query builder** — rejected:
|
||||
schema is owned by the adapter's migration chains (ADR-005,
|
||||
Engineering-Standards §25), and an ORM adds code generation and a schema
|
||||
file that would couple domain models to storage; Kysely's typed builder is
|
||||
sufficient and stays contained.
|
||||
- **Raw `pg` everywhere / no query builder** — rejected: hand-written SQL
|
||||
loses type safety and composability while still requiring the driver;
|
||||
containing a query builder is strictly better than containing SQL strings.
|
||||
- **Adapter re-exports Kysely for other packages to build queries** — rejected
|
||||
(variant of the first alternative): even when routed through the adapter,
|
||||
letting other packages compose Kysely queries would leak the dialect and
|
||||
bypass the port/repository API that keeps domain code storage-agnostic.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one owner for the whole driver/query-builder stack — upgrade,
|
||||
dialect and typing decisions live in `packages/database-postgres` and are
|
||||
exact-pinned single-manifest changes, so replacing or upgrading Kysely and
|
||||
`pg` touches exactly one manifest; domain and extension code stays
|
||||
driver-free, so FIT-010/FIT-011 hold; replacing Kysely or the dialect later
|
||||
is a contained change behind the adapter boundary (consistent with the
|
||||
ADR-005 encapsulation).
|
||||
- Negative: the adapter's port/repository API must be designed well enough
|
||||
that domain code never needs the query builder — the port API is mandatory,
|
||||
not optional; Kysely conveniences (expression builders, plugin features) are
|
||||
not available to callers outside the adapter; the adapter package grows as
|
||||
the query surface grows.
|
||||
- Neutral: Kysely remains a dependency of the adapter package only; contracts
|
||||
between core ports and the adapter are plain TypeScript interfaces, so the
|
||||
containment stays a package-level rule and never becomes a type-level one.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- `kysely` and `pg` appear in exactly one manifest
|
||||
(`packages/database-postgres/package.json`) and in exactly one package's
|
||||
source; a static scan in `tests/database-postgres-imports.test.mjs`
|
||||
(`database-postgres-imports` CI job) proves it on every PR, including a
|
||||
mutation probe that fails when a driver import is injected elsewhere.
|
||||
- Upgrading Kysely or `pg` is a single-manifest change inside the adapter,
|
||||
reviewed under the workspace's dependency/upgrade lane; no other package's
|
||||
manifest or code moves.
|
||||
- No new runtime services, no schema changes, no deployment or configuration
|
||||
impact: this ADR is documentation of an already-enforced boundary.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit this ADR when a non-adapter package demonstrably needs
|
||||
query-builder features that cannot be expressed through the adapter/port
|
||||
API, and there is evidence (per the architecture review gates) that the
|
||||
boundary costs more than containment saves.
|
||||
- Revisit if the platform grows additional data stores or engines (shared with
|
||||
the ADR-005 revisit trigger): a second engine may need its own contained
|
||||
adapter, and the query-builder containment rule would extend per adapter.
|
||||
- Revisit if the dependency-boundary enforcement changes — for example if
|
||||
FIT-010/FIT-011 or the `database-postgres-imports` gate are relaxed or
|
||||
re-scoped.
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR-007: React SSR for public rendering
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70), ADR-001, Architecture wiki (sections 1, 19, 42),
|
||||
Technology-Stack wiki (sections 5.2, 6, 7, 8)
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a modular monolith (ADR-001) whose single application image contains the
|
||||
public server, admin API, rendering pipeline, extension runtime and core services.
|
||||
The public rendering pipeline is already documented: Fastify route → SiteResolver →
|
||||
VisitorPreferenceResolver → ContentService → PageComposition + BlockRegistry →
|
||||
ThemeResolver → React DOM server renderer → HTML (Architecture wiki §19), and the
|
||||
v0.1 public surface exposes `GET /`, `GET /posts/:slug`, `GET /assets/*`,
|
||||
`GET /media/*`, `GET /health/live`, `GET /health/ready` and `GET /admin/*` (§42).
|
||||
This ADR is the focused record of the rendering choice at the end of that pipeline.
|
||||
|
||||
React is already the pinned rendering technology in the golden compatibility tuple:
|
||||
React / React DOM 19.2.8, class C, in the runtime stack and golden tuple
|
||||
(Technology-Stack §5.2, §7), and ADR-001 fixes the runtime as "Node.js 24 LTS with
|
||||
Fastify 5, PostgreSQL 18, React 19 for server-rendered public components and a Vite
|
||||
admin". The LTS strategy keeps React exact-pinned at 19.2.8, keeps React Server
|
||||
Components out of v1, and keeps React out of persisted content formats
|
||||
(Technology-Stack §6).
|
||||
|
||||
The workspace does not implement the public renderer yet — `apps/server` is the bare
|
||||
Fastify health shell — so this ADR records the decision ahead of the code that
|
||||
implements it, formalising what the Architecture and Technology-Stack wikis already
|
||||
mandate: public pages are server-rendered first, React is a server-rendering detail,
|
||||
and the site is not hydrating (§19, §19.1).
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP renders all public pages with **React SSR for public rendering**: server-side
|
||||
rendering through the React DOM server renderer, React 19.2.8 exact-pinned (class C,
|
||||
golden tuple). Public pages are server-rendered first (§19); the pipeline terminates
|
||||
in the React DOM server renderer producing HTML, with no client-side hydration of
|
||||
core public pages — React is a server-rendering detail and the site is not hydrating
|
||||
(§19.1). Core public Home/article pages target 0 bytes of EPPP JavaScript, and only
|
||||
truly interactive features register client islands with an explicit activation mode
|
||||
(§19.2). React Server Components are out of scope for v1, and React stays out of
|
||||
persisted content formats (Technology-Stack §6).
|
||||
The decision text — **React SSR for public rendering** — matches the ADR index entry
|
||||
(ADR-007, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Client-side rendering (SPA)** — rejected: public pages must work with JavaScript
|
||||
disabled (§19.1 zero-JavaScript baseline); a browser-rendered SPA delivers no HTML
|
||||
to first paint and contradicts the server-rendered-first pipeline (§19).
|
||||
- **Static site generation (build-time SSG)** — rejected: content is connection-backed
|
||||
PostgreSQL 18 (ADR-005) resolved per request with visitor preferences and
|
||||
extension-owned content; build-time HTML would go stale against the canonical DB and
|
||||
add a rebuild/redeploy cycle per content change.
|
||||
- **SSR with full client hydration** — rejected for v1: core public pages target
|
||||
0 bytes of EPPP JavaScript (§19.1); hydration would ship a client bundle to every
|
||||
visitor when only a few interactive features need JS (§19.2 islands).
|
||||
- **React Server Components (RSC)** — rejected for v1: the LTS strategy keeps RSC out
|
||||
of v1 and out of persisted content formats (Technology-Stack §6); under the
|
||||
zero-JavaScript baseline there is no client component tree to serve.
|
||||
- **Template engine / string templating (e.g. Pug or Handlebars via @fastify/view)** —
|
||||
rejected: the pipeline already terminates in the React DOM server renderer (§19),
|
||||
themes resolve through the ThemeResolver into that renderer, and the golden tuple
|
||||
pins React 19.2.8; a second rendering technology would duplicate work with no
|
||||
benefit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: server-rendered HTML works with JavaScript disabled, meeting the
|
||||
zero-JavaScript baseline (§19.1); public pages get a fast first paint with no client
|
||||
bootstrapping; metadata/SEO output is plain HTML; React is already in the golden
|
||||
tuple (class C) so no new dependency or stack choice; one rendering path serves all
|
||||
public pages, and themes/extensions compose through the documented pipeline
|
||||
(PageComposition, BlockRegistry, ThemeResolver).
|
||||
- Negative: SSR costs per-request CPU in the Node.js process; the React runtime must
|
||||
load in the server process; rendering errors surface at request time rather than at
|
||||
build time; public pages have no client interactivity without registered islands.
|
||||
- Neutral: React stays a server-rendering detail — the site is not hydrating (§19.1);
|
||||
browser JavaScript exists only on registered islands (§19.2); the decision constrains
|
||||
the later client-side decisions (zero-JS public baseline and the client-island model,
|
||||
ADR-009 and ADR-010).
|
||||
|
||||
## Operational impact
|
||||
|
||||
- One Node.js 24 process renders public HTML in-process via the React DOM server
|
||||
renderer inside the single application image (ADR-001); no separate rendering
|
||||
service or runtime build step.
|
||||
- Public routes in the v0.1 surface (§42) return server-rendered HTML; core
|
||||
Home/article pages ship 0 bytes of EPPP JavaScript (§19.1) — a measurable CI
|
||||
invariant once the renderer lands.
|
||||
- Rendering is stateless: horizontal scaling means more instances of the same image
|
||||
behind the optional edge proxy; rendering load is part of the application process.
|
||||
- React 19.2.8 is exact-pinned and class C (Technology-Stack §5.2, §7); upgrades flow
|
||||
through the dependency lanes (ADR-026), and a React major is an ADR-recorded upgrade
|
||||
program item (§7/§8).
|
||||
- The renderer is not implemented yet — `apps/server` is the Fastify health shell — so
|
||||
today rollback is reverting this documentation commit; once the renderer lands,
|
||||
rollback is redeploying the previous image.
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit when a v0.1 public page cannot meet the zero-JavaScript baseline (§19.1)
|
||||
with server rendering alone, or a required feature needs browser-side rendering at
|
||||
scale.
|
||||
- Revisit when React Server Components or full hydration is proposed for v1 —
|
||||
Technology-Stack §6 explicitly keeps RSC out of v1.
|
||||
- Revisit when the golden tuple's React pin moves to a new major (an ADR-recorded
|
||||
upgrade program item, §7/§8) or React's support class changes.
|
||||
- Revisit when visitor preferences or the v1.1 boundary contracts (ADR-027 to ADR-032)
|
||||
demand a different rendering model — consistent with the Architecture §19 gates.
|
||||
@@ -0,0 +1,136 @@
|
||||
# ADR-008: React/Vite admin
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-31
|
||||
- Deciders: platform stream
|
||||
- References: ADR index (section 70, `docs/adr/README.md`), ADR-001, ADR-007,
|
||||
Architecture wiki (sections 1, 4, 9, 11, 20, 42), Technology-Stack wiki
|
||||
(sections 5.2, 5.3, 6, 7, 8)
|
||||
|
||||
## Context
|
||||
|
||||
EPPP is a modular monolith (ADR-001) whose single application image contains the
|
||||
public server, admin API, rendering pipeline, extension runtime, core services,
|
||||
built admin assets and first-party extensions (Architecture §4). The v0.1 scope
|
||||
explicitly includes an "administration interface" (§1), and Architecture §20
|
||||
already fixes its shape: a **React client app built with Vite**, served by the
|
||||
same image, with `/admin/*` talking to the admin API under `/api/admin/v1/*` and
|
||||
an initial IA of Dashboard, Content→Posts, Home, Navigation, Appearance (active
|
||||
theme, theme settings), Extensions and Settings→Site. ADR-001 fixes the runtime
|
||||
as "Node.js 24 LTS with Fastify 5, PostgreSQL 18, React 19 for server-rendered
|
||||
public components and a Vite admin".
|
||||
|
||||
React and Vite are already the pinned toolchain in the golden compatibility
|
||||
tuple. React / React DOM 19.2.8 (class C, exact-pinned) is in the runtime stack
|
||||
and golden tuple (Technology-Stack §5.2, §7), and the build/admin/test toolchain
|
||||
is pinned to Vite 8.2.2, `@vitejs/plugin-react` 6.1.0, pnpm 11.23.0, Vitest
|
||||
4.1.10 and Playwright 1.62.1 (§5.3). The LTS strategy keeps React exact-pinned
|
||||
at 19.2.8, keeps React Server Components out of v1 and keeps React out of
|
||||
persisted content formats (§6); direct platform dependencies are exact-pinned
|
||||
with a committed lockfile (§8).
|
||||
|
||||
The workspace does not implement the admin app yet — `apps/server` is the bare
|
||||
Fastify health shell and there is no `apps/admin` (Architecture §9; the CI
|
||||
`build-apps` stage already globs `apps/**` so the admin is picked up when it
|
||||
lands). This ADR therefore records the decision ahead of the code that
|
||||
implements it, formalising what the Architecture and Technology-Stack wikis
|
||||
already mandate: the admin frontend is a React client app built with Vite and
|
||||
served by the same application image.
|
||||
|
||||
## Decision
|
||||
|
||||
EPPP builds the admin frontend with **React/Vite admin**: a React client
|
||||
application built with Vite — React 19.2.8 and Vite 8.2.2, exact-pinned class C
|
||||
dependencies from the golden compatibility tuple (Technology-Stack §5.2, §5.3,
|
||||
§7) — served by the same application image (ADR-001, Architecture §4). The admin
|
||||
lives under `/admin/*` and calls the admin API at `/api/admin/v1/*` (§20, §42).
|
||||
It is deliberately a separate concern from the public rendering pipeline:
|
||||
public pages stay server-rendered with no client hydration (ADR-007, §19.1),
|
||||
while the admin is a client-rendered SPA — the interactive management surface
|
||||
where JavaScript is expected and required. React Server Components stay out of
|
||||
v1 and React stays out of persisted content formats (Technology-Stack §6).
|
||||
The decision text — **React/Vite admin** — matches the ADR index entry
|
||||
(ADR-008, section 70).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Server-rendered admin through the public React SSR pipeline** — rejected:
|
||||
the admin is an interactive management surface where JavaScript is expected;
|
||||
routing it through the zero-JavaScript, non-hydrated SSR pipeline (ADR-007,
|
||||
§19.1) would buy nothing — every admin page needs client interactivity — and
|
||||
would couple admin rendering to the public pipeline's per-request server
|
||||
rendering.
|
||||
- **Separate admin framework (e.g. Next.js, Angular, Vue SPA)** — rejected: the
|
||||
golden tuple pins React 19.2.8 (Technology-Stack §5.2, §7) and ADR-001 already
|
||||
records "a Vite admin"; a second frontend ecosystem would split the admin UI
|
||||
from the public components with no v1 requirement justifying it.
|
||||
- **Framework-less Vite / hand-rolled DOM** — rejected: the admin IA (§20) needs
|
||||
a component model for the content, theme, extension and settings screens;
|
||||
re-implementing state management and composition by hand duplicates what React
|
||||
already provides, and the golden tuple already pins React.
|
||||
- **Build-time static-site-generated admin** — rejected: the admin is an
|
||||
authenticated, connection-backed interface over the canonical database
|
||||
(ADR-005, ADR-019); build-time HTML would go stale against the canonical DB
|
||||
and add a rebuild/redeploy cycle per content change.
|
||||
- **Admin as a separate service or image** — rejected: ADR-001 fixes one
|
||||
application image containing built admin assets; a separate admin deployment
|
||||
would break the single-deployable-unit model and add service boundaries with
|
||||
no v1 benefit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one frontend technology — React 19.2.8 — spans the public SSR
|
||||
pipeline (ADR-007) and the admin SPA, so patterns, components and the golden
|
||||
tuple pin are shared; Vite gives fast HMR during admin development and a
|
||||
static build that ships inside the same image (ADR-001); the admin is
|
||||
explicitly outside the public zero-JavaScript baseline (§19.1), so its
|
||||
interactivity does not conflict with the public surface; the exact-pinned
|
||||
toolchain (Vite 8.2.2, React 19.2.8, committed lockfile) keeps admin builds
|
||||
reproducible under the ADR-026 upgrade lanes.
|
||||
- Negative: the admin ships client JavaScript and a client runtime to
|
||||
authenticated admin users; a client-side build step joins the CI pipeline
|
||||
(the `build-apps` stage globs `apps/**`) and brings its own test tooling
|
||||
(Vitest, Playwright — Technology-Stack §5.3, §7); two rendering paths — public
|
||||
SSR and admin SPA — must be maintained, and the client/server contract at
|
||||
`/api/admin/v1/*` must stay versioned; the admin is an authenticated security
|
||||
surface (opaque DB-backed sessions, ADR-019) that the security baseline must
|
||||
keep covering (CSRF, session handling, authz).
|
||||
- Neutral: Vite is a build-time detail — what ships is static assets served
|
||||
behind `/admin/*` from the same image; the decision constrains the later
|
||||
client-side decisions (zero-JS public baseline and the client-island model,
|
||||
ADR-009 and ADR-010) to the public surface, not the admin.
|
||||
|
||||
## Operational impact
|
||||
|
||||
- `apps/admin` builds with Vite into static assets that are served by the same
|
||||
application image behind `/admin/*` (Architecture §20); there is no separate
|
||||
admin service, container or deployment (ADR-001).
|
||||
- `/admin/*` serves the SPA shell; the SPA reads and writes through the admin
|
||||
API at `/api/admin/v1/*` (§42), secured by the Fastify lifecycle and opaque
|
||||
DB-backed sessions (ADR-019).
|
||||
- The admin build runs in the CI `build-apps` stage — the pnpm `apps/**` glob
|
||||
picks `apps/admin` up automatically when the app lands — and admin behaviour
|
||||
is covered by Vitest unit suites and Playwright e2e (Technology-Stack §5.3,
|
||||
§7).
|
||||
- Admin traffic is authenticated and internal; the admin is not part of the
|
||||
public zero-JavaScript surface (§19.1) — client JavaScript is expected and
|
||||
required there — so no byte-budget applies to admin assets.
|
||||
- The admin is not implemented yet — today rollback is reverting this
|
||||
documentation commit; once the admin lands, rollback is redeploying the
|
||||
previous image (built admin assets live inside the image, so there is no
|
||||
separate asset deploy to coordinate).
|
||||
|
||||
## Revisit trigger
|
||||
|
||||
- Revisit when the admin grows a requirement the React/Vite SPA model cannot
|
||||
meet — for example a genuinely different client architecture or offline
|
||||
capability — with evidence per the architecture review gates (ADR index
|
||||
section 68).
|
||||
- Revisit when the golden tuple moves React or Vite to a new major — an
|
||||
ADR-recorded upgrade program item (Technology-Stack §7/§8, ADR-026 lane) — or
|
||||
changes the React pin's support class.
|
||||
- Revisit when the public client-side decisions (ADR-009, ADR-010) change the
|
||||
boundary between the public surface and the admin, or the admin API contract
|
||||
at `/api/admin/v1/*` needs a different shape.
|
||||
- Revisit when the single-image model (ADR-001) changes such that built admin
|
||||
assets must be served or deployed separately.
|
||||
@@ -0,0 +1,65 @@
|
||||
# ADR index (§70)
|
||||
|
||||
Repository copy of the ADR index from the wiki ADR-Index page (§70): the
|
||||
canonical list of architectural decisions, committed or planned. Every ADR
|
||||
committed to `docs/adr/` must record a decision that matches its row in
|
||||
section 70; the wiki page remains the canonical index.
|
||||
|
||||
| ADR | Decision |
|
||||
|---|---|
|
||||
| ADR-001 | Modular monolith |
|
||||
| ADR-002 | Node.js 24 LTS runtime |
|
||||
| ADR-003 | TypeScript 6.0.3 pending TS7.1 ecosystem review |
|
||||
| ADR-004 | Fastify 5 HTTP runtime |
|
||||
| ADR-005 | PostgreSQL 18 sole canonical DB |
|
||||
| ADR-006 | Kysely contained inside DB adapter |
|
||||
| ADR-007 | React SSR for public rendering |
|
||||
| ADR-008 | React/Vite admin |
|
||||
| ADR-009 | Zero-JS public baseline |
|
||||
| ADR-010 | Client-island model for optional public interactivity |
|
||||
| ADR-011 | JSON Schema + TypeBox + Ajv validation |
|
||||
| ADR-012 | EPPP Extension API hides framework internals |
|
||||
| ADR-013 | Node/Amber is a theme extension |
|
||||
| ADR-014 | Blog is a first-party content extension |
|
||||
| ADR-015 | Versioned block documents |
|
||||
| ADR-016 | Versioned page composition |
|
||||
| ADR-017 | Extension-owned migrations/tables |
|
||||
| ADR-018 | Docker Compose primary installation |
|
||||
| ADR-019 | Opaque DB-backed admin sessions |
|
||||
| ADR-020 | Separate anonymous preference identity |
|
||||
| ADR-021 | Local media storage through storage port |
|
||||
| ADR-022 | PostgreSQL jobs before external broker |
|
||||
| ADR-023 | No Redis initially |
|
||||
| ADR-024 | No microservices initially |
|
||||
| ADR-025 | Trusted build-time executable extensions in v1 |
|
||||
| ADR-026 | Exact dependency pinning + controlled upgrade lanes |
|
||||
| ADR-027 (v1.1) | `core.markdown` block restores Markdown authoring inside the block model |
|
||||
| ADR-028 (v1.1) | Hardened outbound fetch as a core service; extensions never fetch directly |
|
||||
| ADR-029 (v1.1) | Embed provider allowlist enforced in renderer and generated CSP |
|
||||
| ADR-030 (v1.1) | Native server-side SVG charts and diagrams with an accessibility contract |
|
||||
| ADR-031 (v1.1) | Theme renaming replaces trademark references |
|
||||
| ADR-032 (v1.1) | Day-one byte budgets; latency targets from measurement |
|
||||
|
||||
Every ADR contains: Context, Decision, Alternatives, Consequences, Operational
|
||||
impact, Revisit trigger (§46 E01-S01).
|
||||
|
||||
## Architectural fitness tests (§71)
|
||||
|
||||
- **Add Ledger/Paper:** create `theme-paper` extension → register
|
||||
manifest/tokens/assets → optionally override renderer slots → tests →
|
||||
include in build. Failure = editing Home/Post domain, core DB, auth, or
|
||||
`if (theme === "paper")` in core.
|
||||
- **Add Reading:** create `org.eppp.reading` → migrations → public route →
|
||||
admin contribution → Home section → settings → job(s) → content/block
|
||||
contributions. Failure = core learning seam/half-life/bookmark/link-health
|
||||
semantics.
|
||||
|
||||
## Architecture review gates (§68)
|
||||
|
||||
Gate A (end Sprint 1): publish a real post without core becoming
|
||||
blog/Amber-specific. Gate B (end Sprint 3): add a Home feature as an extension
|
||||
with no core edits. Gate C (end Sprint 4): a radically different theme runs
|
||||
without changing content/business logic. Gate D (end Sprint 5): visitor
|
||||
preference persists without coupling to auth or theme storage. Gate E (before
|
||||
public SDK): Extension API v1 proven enough to maintain. Gate F (Reading):
|
||||
Reading owns its whole domain without `if (readingEnabled)` in core.
|
||||
@@ -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), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); 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), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the committed `.env.example` template (E00-S04-T05) documents every variable with placeholder values only. |
|
||||
| `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 copied from the committed
|
||||
`.env.example` template (E00-S04-T05).
|
||||
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
|
||||
@@ -105,6 +113,25 @@ compiled application entrypoint. Two things to know:
|
||||
answers 200 `{"status":"ok"}` from the start. The real Fastify 5
|
||||
application shell — which turns this into the full serving API — lands in
|
||||
a later story; the `start` command shape stays the same once it does.
|
||||
4. Since [E00-S04-T03], the server **logs its resolved configuration at
|
||||
startup with secret values redacted**: the first log line is
|
||||
`[config] resolved configuration: {"host":"0.0.0.0","port":3000, ...,
|
||||
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||
redacting logger — the admin-session secret and the password embedded in a
|
||||
`DATABASE_URL` connection string never appear in the log output.
|
||||
5. Since [E00-S04-T04], **all settings flow through the config package's
|
||||
environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` —
|
||||
the workspace's single owner of `process.env` reads): `HOST`, `PORT`,
|
||||
`DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated
|
||||
config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`)
|
||||
and validated at startup — no module outside the config package reads
|
||||
`process.env` directly. `HOST` is validated at the adapter boundary as a
|
||||
hostname or IP address (an invalid value fails startup with a
|
||||
field-specific error naming `host` instead of being logged) and the server
|
||||
passes `config.host` to `server.listen`, so a configured `HOST` binds
|
||||
exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and
|
||||
the startup log line (`@personal-blog/server listening on
|
||||
http://<host>:<port>`) reflects the actual bind.
|
||||
|
||||
To run the compiled output of any other workspace package directly:
|
||||
|
||||
@@ -116,11 +143,22 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
`pnpm test` runs the `node:test` suites under `tests/` (currently
|
||||
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
|
||||
This is also the suite that enforces the dependency-boundary rule.
|
||||
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
|
||||
dependencies (this is also the suite that enforces the dependency-boundary
|
||||
rule). `pnpm lint` runs the formatting/lint policy suite
|
||||
(`tests/formatting-policy.test.mjs`): every tracked text file must use LF
|
||||
line endings, no BOM, no trailing whitespace, no tab indentation and exactly
|
||||
one final newline; JSON files must additionally parse, carry no duplicate
|
||||
keys and use 2-space indentation.
|
||||
|
||||
Pull requests run these checks as CI stages, in order — frozen lockfile
|
||||
install → typecheck → formatting/lint → unit → architecture → PostgreSQL
|
||||
integration → build of the applications (E00-S05-T01); the stage order,
|
||||
the `needs` chain and the `tests/` coverage are locked in by
|
||||
`tests/ci-stages.test.mjs`.
|
||||
|
||||
## Smoke check from a clean clone
|
||||
|
||||
@@ -130,8 +168,9 @@ corepack enable
|
||||
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 lint # formatting/lint policy passes, exit 0
|
||||
pnpm test # all node:test suites pass, exit 0
|
||||
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -142,7 +181,8 @@ 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)). |
|
||||
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
|
||||
| `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; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"lint": "node --test tests/formatting-policy.test.mjs",
|
||||
"test": "node --test \"tests/**/*.test.mjs\"",
|
||||
"typecheck": "pnpm -r run typecheck"
|
||||
},
|
||||
|
||||
@@ -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), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
@@ -12,6 +12,9 @@
|
||||
"@sinclair/typebox": "0.34.52",
|
||||
"ajv": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* EPPP configuration environment adapter — [E00-S04-T04] no module reads
|
||||
* `process.env` except the configuration adapter.
|
||||
*
|
||||
* This module is the workspace's single owner of `process.env` reads.
|
||||
* `loadConfigFromEnv` maps the environment onto the validated config shape
|
||||
* (the E00-S04-T01 TypeBox/Ajv schema) and validates it with
|
||||
* `assertValidConfig` (the E00-S04-T02 startup validation) before returning
|
||||
* it, so every setting the application uses — `host`, `port`, `databaseUrl`,
|
||||
* `sessionSecret` — flows through the adapter and no other module reads
|
||||
* `process.env` directly (the issue's acceptance criteria: "no module reads
|
||||
* `process.env` directly except the configuration adapter", "all settings
|
||||
* flow through the adapter").
|
||||
*
|
||||
* The environment mapping (per the schema's documented environment sources,
|
||||
* `schema.ts`):
|
||||
*
|
||||
* - `HOST` → `host` — the interface the HTTP server binds, default `0.0.0.0`
|
||||
* (the container default). The value is validated at this adapter boundary
|
||||
* as a hostname or IP address (IPv4/IPv6) before it is used for binding or
|
||||
* logged: an invalid `HOST` throws a field-specific `ConfigStartupError`
|
||||
* naming `host`, so arbitrary env content is never echoed verbatim into the
|
||||
* startup log (the issue's acceptance criterion: "HOST is validated at the
|
||||
* adapter boundary as a hostname or IP address before it is used for
|
||||
* binding or logged").
|
||||
* - `PORT` → `port` — integer in the valid TCP port range (1–65535), default
|
||||
* `3000` (the Dockerfile `EXPOSE 3000` / compose `:3000` container port). A
|
||||
* non-numeric or out-of-range override falls back to the default so a bad
|
||||
* `PORT` cannot crash the process at startup (the behavior the server's
|
||||
* entrypoint had before this adapter existed).
|
||||
* - `DATABASE_URL` → `databaseUrl` — optional; when absent the app has no
|
||||
* startup migration run to wait for and reports ready immediately (the
|
||||
* local non-container developer path, E00-S01-T06/E00-S03-T06).
|
||||
* - `EPPP_SESSION_SECRET` → `sessionSecret` — the required admin-session
|
||||
* secret (Security-and-Operations §32/§26, ≥ 32 characters); a missing or
|
||||
* too-short value fails startup with the field-specific error from
|
||||
* `assertValidConfig`.
|
||||
*
|
||||
* `assertValidConfig` is what makes a missing required setting a startup
|
||||
* error: the adapter hands the mapped value to it, and it throws
|
||||
* `MissingRequiredSettingError` naming the missing field — the application
|
||||
* never boots with an invalid configuration.
|
||||
*
|
||||
* Rollback note from the issue: revert any module changes that read
|
||||
* `process.env`.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { assertValidConfig, ConfigStartupError } from './startup.js';
|
||||
import type { Config } from './schema.js';
|
||||
|
||||
/**
|
||||
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
||||
* out-of-range override falls back to the default so a bad `PORT` value cannot
|
||||
* crash the process at startup.
|
||||
*/
|
||||
function resolvePort(raw: string | undefined): number {
|
||||
const port = Number(raw ?? 3000);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
|
||||
}
|
||||
|
||||
/**
|
||||
* One RFC 1123 hostname label: 1–63 alphanumerics/hyphens, not starting or
|
||||
* ending with a hyphen.
|
||||
*/
|
||||
const HOSTNAME_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
|
||||
|
||||
/**
|
||||
* A hostname: dot-separated RFC 1123 labels (e.g. `localhost`, `db`,
|
||||
* `api.internal.example`), at most 253 characters total.
|
||||
*/
|
||||
const HOSTNAME_PATTERN = new RegExp(`^(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}$`);
|
||||
|
||||
/**
|
||||
* Resolves the bind interface from `HOST` (default `0.0.0.0` — the container
|
||||
* default). The value is validated at this adapter boundary as a hostname or
|
||||
* IP address (IPv4/IPv6, via `node:net` `isIP` or the RFC 1123 hostname
|
||||
* pattern) BEFORE it can be used for binding or logged: an invalid value
|
||||
* throws a field-specific `ConfigStartupError` naming `host`, so arbitrary
|
||||
* `HOST` content is never echoed verbatim into the startup log (the issue's
|
||||
* acceptance criterion — the server passes the validated value to
|
||||
* `server.listen`, and the startup log reflects the actual bind interface).
|
||||
*
|
||||
* Unlike `resolvePort` (which falls back to the default on a bad value), an
|
||||
* invalid `HOST` fails startup: an operator who sets `HOST=127.0.0.1` to
|
||||
* restrict network exposure must never silently get a different interface.
|
||||
*/
|
||||
function resolveHost(raw: string | undefined): string {
|
||||
if (raw === undefined) return '0.0.0.0';
|
||||
if (isIP(raw) !== 0 || (raw.length <= 253 && HOSTNAME_PATTERN.test(raw))) {
|
||||
return raw;
|
||||
}
|
||||
throw new ConfigStartupError(
|
||||
'invalid configuration: host: must be a valid hostname or IP address',
|
||||
['host: must be a valid hostname or IP address'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the environment onto the validated configuration — the E00-S04-T04
|
||||
* environment adapter.
|
||||
*
|
||||
* Reads every setting from the given environment (defaulting to `process.env`
|
||||
* — this module is the workspace's single owner of `process.env` reads) and
|
||||
* returns the validated `Config`; an invalid environment fails fast with the
|
||||
* field-specific startup error, so a missing or malformed setting is a
|
||||
* startup error, never a silently-booted invalid configuration.
|
||||
*
|
||||
* @param env - the environment to read (defaults to `process.env`)
|
||||
* @returns the validated configuration
|
||||
* @throws {MissingRequiredSettingError} when a required setting is missing
|
||||
* @throws {ConfigStartupError} when the configuration violates the schema
|
||||
*/
|
||||
export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
return assertValidConfig({
|
||||
host: resolveHost(env.HOST),
|
||||
port: resolvePort(env.PORT),
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
sessionSecret: env.EPPP_SESSION_SECRET,
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,37 @@
|
||||
*
|
||||
* [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.
|
||||
*
|
||||
* [E00-S04-T03] Secret redaction: the boundary also exposes the redaction
|
||||
* layer (`redactConfig` — a config value with every secret replaced by
|
||||
* `[REDACTED]`, for logging the resolved configuration — and `redactText` —
|
||||
* scrubbing free-form log text of the config's secret values), which the
|
||||
* server's redacting logger applies to every log line, so secrets
|
||||
* automatically redact from logs.
|
||||
*
|
||||
* [E00-S04-T04] Environment adapter: the boundary also exposes
|
||||
* `loadConfigFromEnv` — the workspace's single owner of `process.env` reads.
|
||||
* It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`)
|
||||
* onto the validated config shape and validates it with `assertValidConfig`
|
||||
* at startup, so every setting flows through the adapter and no other module
|
||||
* reads `process.env` directly. `HOST` is validated at the adapter boundary
|
||||
* as a hostname or IP address before it is used for binding or logged, so
|
||||
* arbitrary env content is never echoed verbatim into the startup log. The
|
||||
* committed `.env.example` template (E00-S04-T05) documents the same
|
||||
* variables with placeholder values only.
|
||||
*/
|
||||
|
||||
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';
|
||||
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
||||
export { loadConfigFromEnv } from './env.js';
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* EPPP secret redaction — [E00-S04-T03] secrets automatically redact from
|
||||
* logs.
|
||||
*
|
||||
* The config package owns which configuration fields are secrets, so the
|
||||
* redaction layer lives here (the environment adapter, E00-S04-T04, feeds
|
||||
* the validated config into it via the app's logger):
|
||||
*
|
||||
* - `redactConfig(config)` — a copy of a config value with every secret
|
||||
* replaced by `[REDACTED]`: the secret fields by name (see
|
||||
* `SECRET_FIELD_NAMES`) and the password embedded in a `databaseUrl`
|
||||
* connection string, masked in place. The app logs its resolved
|
||||
* configuration through this (the issue's test plan: "log configuration
|
||||
* and confirm secret values are redacted").
|
||||
* - `redactText(text, config)` — scrubs every occurrence of the config's
|
||||
* secret values from arbitrary text, so a free-form log line that embeds
|
||||
* a secret value (e.g. an error message carrying a connection string) is
|
||||
* redacted even when the value was not redacted by field.
|
||||
*
|
||||
* Both feed the server's redacting logger (the `createLogger` in
|
||||
* `apps/server/src/index.ts`), so secret values never reach stdout/stderr —
|
||||
* the acceptance criteria: "secrets automatically redact from logs", "log
|
||||
* output contains no secret values".
|
||||
*
|
||||
* Rollback note from the issue: revert the redaction changes.
|
||||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { Config } from './schema.js';
|
||||
|
||||
/** The placeholder every redacted secret value is replaced with. */
|
||||
export const REDACTED = '[REDACTED]';
|
||||
|
||||
/**
|
||||
* The config fields whose values are secrets, derived from the E00-S04-T01
|
||||
* schema: `sessionSecret` is the story's secret field — the admin-session
|
||||
* secret (Security-and-Operations §32/§26), required and at least 32
|
||||
* characters. The password embedded in a `databaseUrl` connection string is a
|
||||
* credential too, but it is not a config field of its own, so it is redacted
|
||||
* separately (see `redactDatabaseUrl` / `secretValuesOf`).
|
||||
*/
|
||||
export const SECRET_FIELD_NAMES: readonly string[] = ['sessionSecret'];
|
||||
|
||||
/**
|
||||
* A copy of a config value with every secret replaced by `[REDACTED]` — for
|
||||
* logging the resolved configuration. Secret fields are replaced by name; the
|
||||
* `databaseUrl` password is masked in place (`scheme://user:[REDACTED]@host`).
|
||||
* A `databaseUrl` that cannot be parsed as a URL is replaced wholesale (its
|
||||
* password cannot be isolated, so the whole value must not be logged).
|
||||
*/
|
||||
export function redactConfig(config: Config): Config {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(config)) {
|
||||
const value = (config as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string' && SECRET_FIELD_NAMES.includes(key)) {
|
||||
redacted[key] = REDACTED;
|
||||
} else if (key === 'databaseUrl' && typeof value === 'string') {
|
||||
redacted[key] = redactDatabaseUrl(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted as Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrubs every occurrence of the config's secret values from `text`,
|
||||
* replacing each with `[REDACTED]` — for free-form log lines (e.g. an error
|
||||
* message that embeds a connection string). Non-secret text passes through
|
||||
* unchanged.
|
||||
*/
|
||||
export function redactText(text: string, config: Config): string {
|
||||
let redacted = text;
|
||||
for (const value of secretValuesOf(config)) {
|
||||
if (value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
redacted = redacted.split(value).join(REDACTED);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw secret values of a config value — what must never appear in log
|
||||
* output: the values of the secret fields plus the password embedded in
|
||||
* `databaseUrl`. A `databaseUrl` that cannot be parsed as a URL (so its
|
||||
* password cannot be isolated) is included whole, keeping the credential
|
||||
* inside it redactable from free text. Empty values are never collected
|
||||
* (scrubbing an empty string would redact nothing).
|
||||
*/
|
||||
function secretValuesOf(config: Config): readonly string[] {
|
||||
const values: string[] = [];
|
||||
for (const field of SECRET_FIELD_NAMES) {
|
||||
const value = (config as Record<string, unknown>)[field];
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
if (config.databaseUrl !== undefined) {
|
||||
const password = databaseUrlPassword(config.databaseUrl);
|
||||
if (password === null) {
|
||||
values.push(config.databaseUrl);
|
||||
} else if (password.length > 0) {
|
||||
values.push(password);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `databaseUrl` with its password masked in place; the whole value is
|
||||
* replaced when it cannot be parsed as a URL (its password cannot be
|
||||
* isolated, so the raw value must never be logged).
|
||||
*/
|
||||
function redactDatabaseUrl(databaseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(databaseUrl);
|
||||
if (url.password === '') {
|
||||
return databaseUrl;
|
||||
}
|
||||
return `${url.protocol}//${encodeURIComponent(url.username)}:${REDACTED}@${url.host}${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
|
||||
/** The password embedded in a connection string, or `null` when it is not a parseable URL. */
|
||||
function databaseUrlPassword(databaseUrl: string): string | null {
|
||||
try {
|
||||
return new URL(databaseUrl).password;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
|
||||
* port range (1–65535), default `3000` (the container default, matching
|
||||
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
|
||||
* `PORT` is read today, E00-S02-T03).
|
||||
* `PORT` is read by the environment adapter, E00-S04-T04).
|
||||
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
|
||||
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
||||
* startup migration run to wait for and reports ready immediately (the
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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, `env.ts`) maps the environment
|
||||
* onto the validated config shape and passes it to `assertValidConfig` at
|
||||
* startup; the secret redaction layer (E00-S04-T03) builds on the same
|
||||
* boundary.
|
||||
*
|
||||
* 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';
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Generated
+7
@@ -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
|
||||
@@ -32,6 +35,10 @@ importers:
|
||||
ajv:
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 24.13.3
|
||||
version: 24.13.3
|
||||
|
||||
packages/core: {}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
|
||||
* after migrations finish.
|
||||
*/
|
||||
function assertReadyAfterRun(src) {
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL');
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl');
|
||||
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
||||
@@ -191,7 +191,7 @@ function assertReadyAfterRun(src) {
|
||||
* non-container path and the E00-S02-T03 health endpoint working).
|
||||
*/
|
||||
function assertNoDatabaseUrlPath(src) {
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL'));
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl'));
|
||||
assert.match(
|
||||
startup,
|
||||
/if \(databaseUrl === undefined\) \{/,
|
||||
@@ -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,370 @@
|
||||
/**
|
||||
* CI quality baseline test — locks in the [E00-S05-T01] required PR stages of
|
||||
* the committed workflow (`.gitea/workflows/ci.yml`).
|
||||
*
|
||||
* The baseline (issue #187 acceptance criteria):
|
||||
* - "CI runs frozen install before later stages" → `frozen-install` is the
|
||||
* first job and every later stage declares `needs` on its predecessor, so
|
||||
* the pipeline runs the required stages strictly in order and nothing
|
||||
* proceeds past a failed stage.
|
||||
* - "CI runs typecheck, formatting/lint, unit, architecture and PostgreSQL
|
||||
* integration tests" → the five stage jobs exist with the expected
|
||||
* commands: `pnpm typecheck`, `pnpm lint`, and the unit / architecture /
|
||||
* postgres-integration `node --test` suite runs.
|
||||
* - "CI builds the admin and server applications" → the `build-apps` stage
|
||||
* compiles the whole apps group (`./apps/**` — apps/server today, the
|
||||
* admin app when E06-S01 lands) and verifies the compiled server
|
||||
* artifact.
|
||||
* - "CI workflow pins third-party actions (actions/checkout,
|
||||
* actions/setup-node) to full commit SHAs, not floating tags" → every
|
||||
* `uses:` ref is a 40-char commit SHA pinned to the committed
|
||||
* PINNED_ACTIONS values — no `@v4`-style floating tags.
|
||||
* - "CI workflow declares minimal permissions (`permissions: contents:
|
||||
* read`) at the workflow level" → the workflow declares a top-level
|
||||
* `permissions:` block granting exactly `contents: read`.
|
||||
* - every committed test suite under tests/ is wired into exactly one stage
|
||||
* (`pnpm lint` runs the formatting-policy suite; every other suite is
|
||||
* named by a `node --test` run in the unit, architecture or
|
||||
* postgres-integration stage).
|
||||
*
|
||||
* Mutation probes prove the assertions are non-vacuous: removing a stage,
|
||||
* breaking the `needs` chain, or dropping a suite from its stage all fail.
|
||||
*
|
||||
* Run: `node --test tests/ci-stages.test.mjs`
|
||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
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 workflow file under test. */
|
||||
const WORKFLOW = '.gitea/workflows/ci.yml';
|
||||
|
||||
/** The required PR stages, in the order the pipeline must run them. */
|
||||
const REQUIRED_STAGES = [
|
||||
'frozen-install',
|
||||
'typecheck',
|
||||
'formatting-lint',
|
||||
'unit',
|
||||
'architecture',
|
||||
'postgres-integration',
|
||||
'build-apps',
|
||||
];
|
||||
|
||||
/** The root lint command the formatting-lint stage must run. */
|
||||
const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs';
|
||||
|
||||
/**
|
||||
* The third-party actions the workflow may use, pinned to the full commit
|
||||
* SHA of a released version (E00-S05-T01 hardening). Updating a pin means
|
||||
* updating this table and the workflow together, in the same PR.
|
||||
*/
|
||||
const PINNED_ACTIONS = {
|
||||
'actions/checkout': '11bd71901bbe5b1630ceea73d27597364c9af683', // v4.2.2
|
||||
'actions/setup-node': '1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a', // v4.2.0
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the workflow's `jobs:` section (the committed file is 2-space
|
||||
* indented YAML) into `{ order, jobs }` where `order` lists job keys in
|
||||
* document order and each job carries its `needs` value and `run:` commands.
|
||||
* Comments and blank lines are skipped; unknown keys under a job are ignored.
|
||||
*/
|
||||
function parseWorkflowJobs(yamlText) {
|
||||
const lines = yamlText.split('\n');
|
||||
const jobsIndex = lines.findIndex((line) => line === 'jobs:');
|
||||
assert.ok(jobsIndex >= 0, 'the workflow must declare a top-level jobs: section');
|
||||
|
||||
const order = [];
|
||||
const jobs = {};
|
||||
let current = null;
|
||||
|
||||
for (let i = jobsIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||
const indent = line.length - line.trimStart().length;
|
||||
if (indent === 2) {
|
||||
const key = /^([A-Za-z0-9_-]+):/.exec(trimmed);
|
||||
assert.ok(key, `unexpected jobs: entry at indent 2: "${line}"`);
|
||||
current = key[1];
|
||||
order.push(current);
|
||||
jobs[current] = { needs: null, runs: [] };
|
||||
continue;
|
||||
}
|
||||
if (current && indent > 2) {
|
||||
const needs = /^needs:\s*(.+)$/.exec(trimmed);
|
||||
if (needs) jobs[current].needs = needs[1].trim().replace(/^\[|\]$/g, '');
|
||||
const run = /^run:\s*(.+)$/.exec(trimmed);
|
||||
if (run) jobs[current].runs.push(run[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return { order, jobs };
|
||||
}
|
||||
|
||||
/** Extracts the tests/*.test.mjs suite names named by `node --test` runs. */
|
||||
function suitesNamedInRuns(runs) {
|
||||
const out = [];
|
||||
for (const run of runs) {
|
||||
for (const token of run.split(/\s+/)) {
|
||||
if (token.startsWith('tests/') && token.endsWith('.test.mjs')) {
|
||||
out.push(token.slice('tests/'.length));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@<sha>". */
|
||||
function usesRefs(yamlText) {
|
||||
const refs = [];
|
||||
for (const line of yamlText.split('\n')) {
|
||||
// `uses:` appears either as a bare key or as a sequence item ("- uses:").
|
||||
const match = /^\s*(?:-\s+)?uses:\s*(\S+)/.exec(line);
|
||||
if (match) refs.push(match[1]);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the E00-S05-T01 hardening criteria: every third-party `uses:` ref
|
||||
* is pinned to a full 40-char commit SHA (exactly the committed PINNED_ACTIONS
|
||||
* values — no floating tags) and the workflow declares `permissions:
|
||||
* contents: read` at the top level. Throws an AssertionError describing the
|
||||
* first violated invariant.
|
||||
*/
|
||||
function assertHardening(yamlText) {
|
||||
const refs = usesRefs(yamlText);
|
||||
assert.ok(refs.length > 0, 'the workflow must use at least one third-party action');
|
||||
for (const ref of refs) {
|
||||
const match = /^([\w.-]+\/[\w.-]+)@([0-9a-f]{40})$/.exec(ref);
|
||||
assert.ok(
|
||||
match,
|
||||
`every third-party action must be pinned to a full 40-char commit SHA, not a floating tag (got "${ref}")`,
|
||||
);
|
||||
assert.ok(
|
||||
Object.hasOwn(PINNED_ACTIONS, match[1]),
|
||||
`unexpected third-party action "${match[1]}" — add it to the PINNED_ACTIONS policy table if it is approved`,
|
||||
);
|
||||
assert.equal(
|
||||
match[2],
|
||||
PINNED_ACTIONS[match[1]],
|
||||
`"${match[1]}" must be pinned to the committed full commit SHA ${PINNED_ACTIONS[match[1]]} (got ${match[2]})`,
|
||||
);
|
||||
}
|
||||
for (const action of Object.keys(PINNED_ACTIONS)) {
|
||||
assert.ok(
|
||||
refs.some((ref) => ref.startsWith(`${action}@`)),
|
||||
`the workflow must use "${action}" pinned to a full commit SHA`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
yamlText,
|
||||
/^permissions:\n[ \t]+contents: read$/m,
|
||||
'the workflow must declare a top-level "permissions: contents: read" block',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an
|
||||
* AssertionError describing the first violated invariant.
|
||||
*/
|
||||
function assertBaseline({ order, jobs }) {
|
||||
// Every required stage exists.
|
||||
for (const stage of REQUIRED_STAGES) {
|
||||
assert.ok(jobs[stage], `required CI stage "${stage}" is missing from the workflow`);
|
||||
}
|
||||
|
||||
// The required stages run in order (their relative order is preserved).
|
||||
const present = order.filter((name) => REQUIRED_STAGES.includes(name));
|
||||
assert.deepEqual(
|
||||
present,
|
||||
REQUIRED_STAGES,
|
||||
`the required CI stages must run in order: ${REQUIRED_STAGES.join(' -> ')}`,
|
||||
);
|
||||
|
||||
// Frozen install runs before later stages: every later stage gates on its
|
||||
// predecessor, so the pipeline is strictly ordered.
|
||||
for (let i = 1; i < REQUIRED_STAGES.length; i++) {
|
||||
assert.equal(
|
||||
jobs[REQUIRED_STAGES[i]].needs,
|
||||
REQUIRED_STAGES[i - 1],
|
||||
`stage "${REQUIRED_STAGES[i]}" must gate on the previous stage "${REQUIRED_STAGES[i - 1]}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Stage commands.
|
||||
assert.ok(
|
||||
jobs['frozen-install'].runs.some((run) => run.includes('pnpm install --frozen-lockfile')),
|
||||
'frozen-install must run the frozen lockfile install',
|
||||
);
|
||||
assert.ok(
|
||||
jobs['typecheck'].runs.some((run) => run.includes('pnpm typecheck')),
|
||||
'the typecheck stage must run pnpm typecheck',
|
||||
);
|
||||
assert.ok(
|
||||
jobs['formatting-lint'].runs.some((run) => run.includes('pnpm lint')),
|
||||
'the formatting-lint stage must run pnpm lint',
|
||||
);
|
||||
|
||||
// The build stage compiles the apps group and verifies the server artifact.
|
||||
const buildRuns = jobs['build-apps'].runs;
|
||||
assert.ok(
|
||||
buildRuns.some((run) => run.includes('run build') && run.includes('./apps/**')),
|
||||
'build-apps must build the apps group (pnpm --filter "./apps/**" run build)',
|
||||
);
|
||||
assert.ok(
|
||||
buildRuns.some((run) => run.includes('apps/server/dist/index.js')),
|
||||
'build-apps must verify the compiled server application artifact',
|
||||
);
|
||||
|
||||
// Every committed test suite is wired into exactly one stage.
|
||||
const staged = [
|
||||
...suitesNamedInRuns(jobs['unit'].runs),
|
||||
...suitesNamedInRuns(jobs['architecture'].runs),
|
||||
...suitesNamedInRuns(jobs['postgres-integration'].runs),
|
||||
];
|
||||
const allSuites = readdirSync(path.join(REPO_ROOT, 'tests'))
|
||||
.filter((file) => file.endsWith('.test.mjs'))
|
||||
.sort();
|
||||
// The formatting-policy suite is run by the formatting-lint stage via the
|
||||
// root lint script rather than by a node --test run in a test stage.
|
||||
const expected = allSuites.filter((file) => file !== 'formatting-policy.test.mjs');
|
||||
assert.equal(
|
||||
staged.length,
|
||||
expected.length,
|
||||
'each test suite must be wired into exactly one CI stage run ' +
|
||||
`(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(staged)].sort(),
|
||||
expected,
|
||||
'every committed test suite must be wired into exactly one CI stage ' +
|
||||
`(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Removes the whole ` <jobName>:` block from a workflow text (probe helper). */
|
||||
function removeJobBlock(yamlText, jobName) {
|
||||
const lines = yamlText.split('\n');
|
||||
const start = lines.findIndex((line) => line === ` ${jobName}:`);
|
||||
assert.ok(start >= 0, `job ${jobName} must exist in the workflow text`);
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (
|
||||
trimmed !== '' &&
|
||||
!trimmed.startsWith('#') &&
|
||||
lines[i].length - lines[i].trimStart().length === 2 &&
|
||||
/^[A-Za-z0-9_-]+:/.test(trimmed)
|
||||
) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-workflow baseline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the committed CI workflow runs the required PR stages in order', () => {
|
||||
assertBaseline(parseWorkflowJobs(read(WORKFLOW)));
|
||||
});
|
||||
|
||||
test('the workflow triggers on pull requests and pushes to main', () => {
|
||||
const text = read(WORKFLOW);
|
||||
assert.match(text, /^on:$/m, 'the workflow must declare an on: trigger block');
|
||||
assert.match(text, /pull_request:/, 'PRs must trigger the CI workflow');
|
||||
assert.match(text, /push:/, 'pushes must trigger the CI workflow');
|
||||
assert.match(text, /branches:\s*\[main\]/, 'the push trigger must cover main');
|
||||
});
|
||||
|
||||
test('the root lint script runs the formatting-policy suite', () => {
|
||||
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
||||
assert.equal(
|
||||
scripts.lint,
|
||||
LINT_SCRIPT,
|
||||
`root package.json must declare scripts.lint exactly as "${LINT_SCRIPT}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the workflow pins third-party actions to full commit SHAs and declares minimal permissions', () => {
|
||||
assertHardening(read(WORKFLOW));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation probes — the baseline assertions are non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('removing a required stage fails the baseline (mutation probe)', () => {
|
||||
const mutated = removeJobBlock(read(WORKFLOW), 'unit');
|
||||
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /required CI stage "unit"/);
|
||||
});
|
||||
|
||||
test('breaking the needs chain fails the baseline (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
const mutated = text.replace('needs: formatting-lint', 'needs: typecheck');
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(
|
||||
() => assertBaseline(parseWorkflowJobs(mutated)),
|
||||
/must gate on the previous stage/,
|
||||
);
|
||||
});
|
||||
|
||||
test('dropping a suite from its stage fails the coverage assertion (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
const mutated = text.replace('tests/config-schema.test.mjs', '');
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(
|
||||
() => assertBaseline(parseWorkflowJobs(mutated)),
|
||||
/must be wired into exactly one CI stage/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reordering the stages fails the baseline (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
// Swap the typecheck and formatting-lint job blocks so their document order
|
||||
// no longer matches the required stage order.
|
||||
const typecheckBlock = text.slice(text.indexOf(' typecheck:'), text.indexOf(' formatting-lint:'));
|
||||
const lintBlock = text.slice(text.indexOf(' formatting-lint:'), text.indexOf(' unit:'));
|
||||
const mutated = text.replace(typecheckBlock + lintBlock, lintBlock + typecheckBlock);
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/);
|
||||
});
|
||||
|
||||
test('reverting an action pin to a floating tag fails the hardening assertion (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
const mutated = text.replace(
|
||||
`actions/checkout@${PINNED_ACTIONS['actions/checkout']}`,
|
||||
'actions/checkout@v4',
|
||||
);
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(() => assertHardening(mutated), /full 40-char commit SHA/);
|
||||
});
|
||||
|
||||
test('changing a pinned action SHA fails the hardening assertion (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
const mutated = text.replace(
|
||||
`actions/setup-node@${PINNED_ACTIONS['actions/setup-node']}`,
|
||||
`actions/setup-node@${'a'.repeat(40)}`,
|
||||
);
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(() => assertHardening(mutated), /must be pinned to the committed full commit SHA/);
|
||||
});
|
||||
|
||||
test('removing the workflow-level permissions block fails the hardening assertion (mutation probe)', () => {
|
||||
const text = read(WORKFLOW);
|
||||
const mutated = text.replace(/^permissions:\n contents: read\n\n/m, '');
|
||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||
assert.throws(() => assertHardening(mutated), /permissions: contents: read/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* Config log redaction test — locks in the [E00-S04-T03] guarantee that
|
||||
* secrets automatically redact from logs: the app's log output contains no
|
||||
* secret values.
|
||||
*
|
||||
* Acceptance criteria covered (each test fails without the committed state):
|
||||
* - "secrets automatically redact from logs" → `packages/config` exposes the
|
||||
* redaction layer (`redactConfig` — a config value with every secret
|
||||
* replaced by `[REDACTED]`, for logging the resolved configuration — and
|
||||
* `redactText` — scrubbing free-form log text of the config's secret
|
||||
* values), and the committed server writes ALL of its log output through
|
||||
* the redacting logger (`createLogger` in the server entrypoint, seeded
|
||||
* with the validated config). Locked in statically (mutation probes prove
|
||||
* non-vacuity: renaming the exports, dropping the split/join scrub,
|
||||
* unmasking the databaseUrl password, or reintroducing a bare
|
||||
* `console.log`/`console.error` all fail) and behaviorally by the
|
||||
* deterministic probes.
|
||||
* - "log output contains no secret values" → the deterministic probes
|
||||
* execute the issue's test plan ("log configuration and confirm secret
|
||||
* values are redacted") against the committed code: the compiled
|
||||
* `@personal-blog/config` boundary redacts the admin-session secret and
|
||||
* the password embedded in a `DATABASE_URL` connection string, and
|
||||
* booting the committed server logs its resolved configuration with
|
||||
* every secret value replaced by `[REDACTED]` — the booted server's
|
||||
* stdout/stderr contain no secret value.
|
||||
*
|
||||
* Run: `node --test tests/config-log-redaction.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 redaction layer, package boundary and server entrypoint under test. */
|
||||
const REDACT_SRC = 'packages/config/src/redact.ts';
|
||||
const INDEX_SRC = 'packages/config/src/index.ts';
|
||||
const SERVER_SRC = 'apps/server/src/index.ts';
|
||||
|
||||
/** 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 log-redaction criterion on every PR. */
|
||||
const CI_JOB = 'config-log-redaction';
|
||||
|
||||
/** A distinctive >= 32-char admin-session secret the probes must never leak. */
|
||||
const SECRET = 'redact-me-0123456789abcdefghijklmnopqrstuv';
|
||||
|
||||
/** A connection string whose password the probes must never leak. */
|
||||
const DATABASE_URL = 'postgres://redact-user:redact-password@db:5432/redact-db';
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static assertions on the committed sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Asserts the config package exposes the redaction layer: `redactConfig`
|
||||
* (a config value with every secret replaced by `[REDACTED]` — the secret
|
||||
* fields by name and the `databaseUrl` password masked in place) and
|
||||
* `redactText` (free-form log text scrubbed of the config's secret values).
|
||||
* Fails fast on a deviation; the mutation probes below prove the assertions
|
||||
* are non-vacuous.
|
||||
*/
|
||||
function assertRedactionSource(src) {
|
||||
// The placeholder and the schema-derived secret field names.
|
||||
assert.match(
|
||||
src,
|
||||
/export const REDACTED = '\[REDACTED\]'/,
|
||||
'the redaction module must export the [REDACTED] placeholder',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/export const SECRET_FIELD_NAMES/,
|
||||
'the redaction module must export the secret field names (SECRET_FIELD_NAMES)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/SECRET_FIELD_NAMES: readonly string\[\] = \['sessionSecret'\]/,
|
||||
"the secret field names must name the schema's secret field (sessionSecret, E00-S04-T01)",
|
||||
);
|
||||
|
||||
// redactConfig — a config copy with every secret replaced by the placeholder.
|
||||
assert.match(
|
||||
src,
|
||||
/export function redactConfig\(config: Config\): Config/,
|
||||
'the redaction module must export redactConfig (a redacted copy of a config value, for logging the resolved configuration)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/SECRET_FIELD_NAMES\.includes\(key\)/,
|
||||
'redactConfig must replace the secret fields by name (SECRET_FIELD_NAMES)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/redacted\[key\] = REDACTED/,
|
||||
'redactConfig must replace secret field values with the [REDACTED] placeholder',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/redactDatabaseUrl\(value\)/,
|
||||
'redactConfig must mask the databaseUrl password (redactDatabaseUrl)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/:\$\{REDACTED\}@/,
|
||||
'redactDatabaseUrl must mask the password in place (scheme://user:[REDACTED]@host)',
|
||||
);
|
||||
|
||||
// redactText — free-form log text scrubbed of the config's secret values.
|
||||
assert.match(
|
||||
src,
|
||||
/export function redactText\(text: string, config: Config\): string/,
|
||||
'the redaction module must export redactText (scrub free-form log text of the config\'s secret values)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/\.split\(value\)\.join\(REDACTED\)/,
|
||||
'redactText must replace every occurrence of a secret value with the [REDACTED] placeholder',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the package boundary re-exports the redaction layer.
|
||||
*/
|
||||
function assertBoundary(src) {
|
||||
assert.match(
|
||||
src,
|
||||
/export \{ REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText \} from '\.\/redact\.js'/,
|
||||
'the package boundary must re-export the redaction layer (REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the committed server logs through a redacting logger only: it
|
||||
* imports the redaction entry points from `@personal-blog/config`, defines
|
||||
* `createLogger(config)` which scrubs every joined log line with the
|
||||
* validated config's secret values before writing it to stdout/stderr, and
|
||||
* logs its resolved configuration at startup via `redactConfig` (the issue's
|
||||
* test plan: "log configuration and confirm secret values are redacted"). A
|
||||
* bare `console.log`/`console.error` would bypass the redaction and is
|
||||
* rejected.
|
||||
*/
|
||||
function assertServerSource(src) {
|
||||
// The redacting logger — every log line passes through the config
|
||||
// package's scrubber before it reaches stdout/stderr.
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ redactConfig, redactText, type Config \} from '@personal-blog\/config'/,
|
||||
'the server must import the redaction entry points (redactConfig, redactText) and the Config type from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/function createLogger\(config: Config\): ServerLogger/,
|
||||
'the server must define the redacting logger (createLogger, seeded with the validated configuration)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/redactText\(args\.map\(serialize\)\.join\(' '\), config\)/,
|
||||
'every log line must pass through redactText (the config package\'s scrubber) before it is written',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/write\(process\.stdout, args\)/,
|
||||
'log lines must be written to stdout',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/write\(process\.stderr, args\)/,
|
||||
'error lines must be written to stderr',
|
||||
);
|
||||
|
||||
// The wiring — the server keeps its validated config (loaded through the
|
||||
// environment adapter, E00-S04-T04), creates the logger with it and logs
|
||||
// the resolved configuration redacted.
|
||||
assert.match(
|
||||
src,
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const logger = createLogger\(config\)/,
|
||||
'the server must create the redacting logger seeded with its validated configuration',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/resolved configuration/,
|
||||
'the server must log its resolved configuration at startup (the issue\'s test plan: "log configuration and confirm secret values are redacted")',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/redactConfig\(config\)/,
|
||||
'the configuration log must be redacted (redactConfig) so secret values never reach the log output',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/console\.(log|error)\(/,
|
||||
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
);
|
||||
// The startup configuration loads through the adapter (which validates it)
|
||||
// before the logger is created, so a missing required setting still fails
|
||||
// fast (E00-S04-T02) before any log output.
|
||||
const validationIndex = src.indexOf('loadConfigFromEnv(');
|
||||
const loggerIndex = src.indexOf('createLogger(config)');
|
||||
assert.ok(
|
||||
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
||||
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the config package exposes the secret redaction layer (redactConfig + redactText)', () => {
|
||||
assert.ok(existsSync(path.join(REPO_ROOT, REDACT_SRC)), `committed ${REDACT_SRC} must exist`);
|
||||
assertRedactionSource(read(REDACT_SRC));
|
||||
});
|
||||
|
||||
test('the package boundary re-exports the redaction layer', () => {
|
||||
assertBoundary(read(INDEX_SRC));
|
||||
});
|
||||
|
||||
test('the server logs through the redacting logger and logs its resolved configuration redacted', () => {
|
||||
assertServerSource(read(SERVER_SRC));
|
||||
});
|
||||
|
||||
test('the config-log-redaction 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-log-redaction.test.mjs`),
|
||||
`CI must run the config-log-redaction 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('renaming the redactText export fails the redaction assertion (mutation probe)', () => {
|
||||
const src = read(REDACT_SRC);
|
||||
const renamed = src.replace('export function redactText(', 'export function redactTextX(');
|
||||
assert.notEqual(renamed, src, 'the mutation must actually rename the redactText export');
|
||||
assert.throws(() => assertRedactionSource(renamed), /must export redactText/);
|
||||
});
|
||||
|
||||
test('changing the [REDACTED] placeholder fails the redaction assertion (mutation probe)', () => {
|
||||
const src = read(REDACT_SRC);
|
||||
const noPlaceholder = src.replace("export const REDACTED = '[REDACTED]';", "export const REDACTED = '***';");
|
||||
assert.notEqual(noPlaceholder, src, 'the mutation must actually change the placeholder');
|
||||
assert.throws(() => assertRedactionSource(noPlaceholder), /\[REDACTED\] placeholder/);
|
||||
});
|
||||
|
||||
test('replacing every occurrence instead of scrubbing the whole value fails the redaction assertion (mutation probe)', () => {
|
||||
const src = read(REDACT_SRC);
|
||||
const partial = src.replace('.split(value).join(REDACTED)', '.replace(value, REDACTED)');
|
||||
assert.notEqual(partial, src, 'the mutation must actually change the scrubbing');
|
||||
assert.throws(() => assertRedactionSource(partial), /every occurrence/);
|
||||
});
|
||||
|
||||
test('unmasking the databaseUrl password fails the redaction assertion (mutation probe)', () => {
|
||||
const src = read(REDACT_SRC);
|
||||
const unmasked = src.replace('redactDatabaseUrl(value)', 'value');
|
||||
assert.notEqual(unmasked, src, 'the mutation must actually drop the databaseUrl password masking');
|
||||
assert.throws(() => assertRedactionSource(unmasked), /must mask the databaseUrl password/);
|
||||
});
|
||||
|
||||
test('dropping a redaction export from the package boundary fails the boundary assertion (mutation probe)', () => {
|
||||
const src = read(INDEX_SRC);
|
||||
const dropped = src.replace('REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText', 'REDACTED, SECRET_FIELD_NAMES, redactConfig');
|
||||
assert.notEqual(dropped, src, 'the mutation must actually drop the redactText export');
|
||||
assert.throws(() => assertBoundary(dropped), /must re-export the redaction layer/);
|
||||
});
|
||||
|
||||
test('renaming createLogger fails the logger assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const renamed = src.replace('function createLogger(', 'function createLoggerX(');
|
||||
assert.notEqual(renamed, src, 'the mutation must actually rename the createLogger function');
|
||||
assert.throws(() => assertServerSource(renamed), /must define the redacting logger/);
|
||||
});
|
||||
|
||||
test('writing a log line without redacting it fails the logger assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const unredacted = src.replace("redactText(args.map(serialize).join(' '), config)", "args.map(serialize).join(' ')");
|
||||
assert.notEqual(unredacted, src, 'the mutation must actually drop the redactText pass-through');
|
||||
assert.throws(() => assertServerSource(unredacted), /must pass through redactText/);
|
||||
});
|
||||
|
||||
test('reintroducing a bare console.log/console.error in the server fails the wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const bareConsole = src.replaceAll('logger.log(', 'console.log(').replaceAll('logger.error(', 'console.error(');
|
||||
assert.notEqual(bareConsole, src, 'the mutation must actually replace the logger calls with bare console calls');
|
||||
assert.throws(() => assertServerSource(bareConsole), /console\.(log|error)/);
|
||||
});
|
||||
|
||||
test('dropping the resolved-configuration log fails the wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const noConfigLog = src.replace(
|
||||
"logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));",
|
||||
'',
|
||||
);
|
||||
assert.notEqual(noConfigLog, src, 'the mutation must actually drop the resolved-configuration log');
|
||||
assert.throws(() => assertServerSource(noConfigLog), /resolved configuration/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic behavioral probe — the compiled @personal-blog/config boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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, 'packages/config', '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`
|
||||
* redaction boundary (`redactConfig` + `redactText`) exactly as the server's
|
||||
* logger consumes it — the admin-session secret is replaced by `[REDACTED]`,
|
||||
* the `databaseUrl` password is masked in place (and an unparseable
|
||||
* `databaseUrl` is replaced wholesale), free-form text is scrubbed of the
|
||||
* secret values, and non-secret text passes through unchanged. 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 { REDACTED, redactConfig, redactText } from './dist/index.js';
|
||||
|
||||
const SECRET = '${SECRET}';
|
||||
const URL = '${DATABASE_URL}';
|
||||
|
||||
const result = {
|
||||
placeholder: REDACTED,
|
||||
redactedSecret: redactConfig({ sessionSecret: SECRET }).sessionSecret,
|
||||
maskedUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: URL }).databaseUrl,
|
||||
unparseableUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: 'not a url' }).databaseUrl,
|
||||
nonSecretKept: redactConfig({ sessionSecret: SECRET, host: '0.0.0.0', port: 3000 }),
|
||||
scrubText: redactText('connecting with ' + SECRET + ' now', { sessionSecret: SECRET }),
|
||||
scrubUrl: redactText('failed at ' + URL, { sessionSecret: SECRET, databaseUrl: URL }),
|
||||
untouched: redactText('no secrets here', { sessionSecret: SECRET }),
|
||||
};
|
||||
|
||||
console.log('CONFIG_REDACTION_PROBE_RESULT ' + JSON.stringify(result));
|
||||
`;
|
||||
|
||||
test('the compiled boundary redacts secret values from config and text (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
|
||||
const probeFile = path.join(REPO_ROOT, 'packages/config', `.config-redaction-probe-${process.pid}.mjs`);
|
||||
try {
|
||||
writeFileSync(probeFile, PROBE_SOURCE);
|
||||
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
|
||||
cwd: path.join(REPO_ROOT, 'packages/config'),
|
||||
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_REDACTION_PROBE_RESULT (\{.*\})/);
|
||||
assert.ok(match, `the probe must print CONFIG_REDACTION_PROBE_RESULT:\n${run.stdout.trim()}`);
|
||||
const result = JSON.parse(match[1]);
|
||||
|
||||
// The placeholder and the redacted admin-session secret.
|
||||
assert.equal(result.placeholder, '[REDACTED]', 'REDACTED must be the [REDACTED] placeholder');
|
||||
assert.equal(
|
||||
result.redactedSecret,
|
||||
'[REDACTED]',
|
||||
'redactConfig must replace the admin-session secret with [REDACTED]',
|
||||
);
|
||||
|
||||
// The databaseUrl password is masked in place; an unparseable databaseUrl
|
||||
// is replaced wholesale (its password cannot be isolated).
|
||||
assert.equal(
|
||||
result.maskedUrl,
|
||||
'postgres://redact-user:[REDACTED]@db:5432/redact-db',
|
||||
`redactConfig must mask the databaseUrl password in place (got: ${JSON.stringify(result.maskedUrl)})`,
|
||||
);
|
||||
assert.equal(
|
||||
result.unparseableUrl,
|
||||
'[REDACTED]',
|
||||
'redactConfig must replace an unparseable databaseUrl wholesale (its password cannot be isolated)',
|
||||
);
|
||||
|
||||
// Non-secret fields pass through unchanged.
|
||||
assert.equal(result.nonSecretKept.host, '0.0.0.0', 'non-secret fields must pass through unchanged');
|
||||
assert.equal(result.nonSecretKept.port, 3000, 'non-secret fields must pass through unchanged');
|
||||
assert.equal(result.nonSecretKept.sessionSecret, '[REDACTED]', 'the secret field must still be redacted');
|
||||
|
||||
// Free-form text is scrubbed of the config's secret values.
|
||||
assert.equal(
|
||||
result.scrubText,
|
||||
'connecting with [REDACTED] now',
|
||||
`redactText must scrub the admin-session secret from free text (got: ${JSON.stringify(result.scrubText)})`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.scrubText.includes(SECRET),
|
||||
'redactText output must not contain the admin-session secret value',
|
||||
);
|
||||
assert.equal(
|
||||
result.scrubUrl,
|
||||
'failed at postgres://redact-user:[REDACTED]@db:5432/redact-db',
|
||||
`redactText must scrub the database password from free text (got: ${JSON.stringify(result.scrubUrl)})`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.scrubUrl.includes('redact-password'),
|
||||
'redactText output must not contain the database password',
|
||||
);
|
||||
assert.equal(result.untouched, 'no secrets here', 'redactText must leave non-secret text unchanged');
|
||||
} finally {
|
||||
rmSync(probeFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-boot probes — the issue's test plan executed against the real
|
||||
// committed server: "log configuration and confirm secret values are
|
||||
// redacted"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 secret must be deterministic) are stripped
|
||||
* unless explicitly provided. Returns `{ child, stdout, stderr }` with
|
||||
* closures for the captured output.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits until the captured log output matches `pattern` (or the deadline passes). */
|
||||
async function waitForLog(readOutput, pattern, deadlineMs = 5_000) {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (pattern.test(readOutput())) return true;
|
||||
await delay(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 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('booting the server logs the resolved configuration with secret values redacted (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||
// The issue's test plan: "log configuration and confirm secret values are
|
||||
// redacted". The committed server logs its resolved configuration at
|
||||
// startup through the redacting logger — the admin-session secret must
|
||||
// appear as [REDACTED], and the secret value must not appear in the log
|
||||
// output at all.
|
||||
const port = await reservePort();
|
||||
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET }); // DATABASE_URL stripped
|
||||
try {
|
||||
const response = await waitForAnswer(port, child, stderr);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`the server must boot to GET /health 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
// The resolved configuration is logged, with the secret redacted.
|
||||
assert.match(
|
||||
stdout(),
|
||||
/\[config\] resolved configuration:/,
|
||||
`the server must log its resolved configuration at startup (got: ${stdout().trim()})`,
|
||||
);
|
||||
assert.match(
|
||||
stdout(),
|
||||
/"sessionSecret":"\[REDACTED\]"/,
|
||||
`the resolved-configuration log must show the admin-session secret redacted (got: ${stdout().trim()})`,
|
||||
);
|
||||
// No secret value in the log output (the acceptance criterion).
|
||||
assert.ok(
|
||||
!(stdout() + stderr()).includes(SECRET),
|
||||
`the log output must not contain the admin-session secret value (got: ${stdout().trim()} ${stderr().trim()})`,
|
||||
);
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
test('the log output contains no database password when a DATABASE_URL is configured (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||
// With a DATABASE_URL whose password must never leak, the startup
|
||||
// migration run cannot complete (nothing listens on the dead port), so the
|
||||
// app stays not-ready — and the log output (the resolved-configuration log
|
||||
// AND the migration-failure log) must contain the masked URL, never the
|
||||
// password and never the raw connection string.
|
||||
const port = await reservePort();
|
||||
const deadPort = await reservePort(); // reserved then released: nothing listens
|
||||
const databaseUrl = `postgres://redact-user:redact-password@127.0.0.1:${deadPort}/redact-db`;
|
||||
const { child, stdout, stderr } = bootServer(port, {
|
||||
EPPP_SESSION_SECRET: SECRET,
|
||||
DATABASE_URL: databaseUrl,
|
||||
});
|
||||
try {
|
||||
const response = await waitForAnswer(port, child, stderr);
|
||||
assert.equal(
|
||||
response.status,
|
||||
503,
|
||||
`the app must stay not-ready while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||
);
|
||||
// The resolved-configuration log masks the databaseUrl password in place.
|
||||
assert.match(
|
||||
stdout(),
|
||||
new RegExp(`postgres://redact-user:${'\\[REDACTED\\]'}@127\\.0\\.0\\.1:`),
|
||||
`the resolved-configuration log must show the databaseUrl password masked in place (got: ${stdout().trim()})`,
|
||||
);
|
||||
// Wait for the migration-failure log (also written through the redacting logger).
|
||||
assert.ok(
|
||||
await waitForLog(() => stdout() + stderr(), /startup migration run failed; app stays not-ready/),
|
||||
`the app must log the failed startup migration run (got: ${stdout().trim()} ${stderr().trim()})`,
|
||||
);
|
||||
const output = stdout() + stderr();
|
||||
assert.ok(
|
||||
!output.includes('redact-password'),
|
||||
`the log output must not contain the database password (got: ${output.trim()})`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.includes('postgres://redact-user:redact-password@'),
|
||||
`the log output must not contain the raw connection string with its password (got: ${output.trim()})`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.includes(SECRET),
|
||||
`the log output must not contain the admin-session secret value (got: ${output.trim()})`,
|
||||
);
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* 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 environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the
|
||||
* config package's single owner of `process.env` reads) validates the
|
||||
* mapped environment through it; the committed `apps/server/src/index.ts`
|
||||
* loads its startup configuration through the adapter 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 adapter 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 loads its startup configuration through the
|
||||
* environment adapter (E00-S04-T04) before it binds: it imports
|
||||
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
|
||||
* validates the mapped environment with `assertValidConfig`, including the
|
||||
* required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing
|
||||
* required setting is a startup error, never a silently-booted invalid
|
||||
* configuration, and the server itself never reads `process.env` directly.
|
||||
*/
|
||||
function assertServerStartupValidation(src) {
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
);
|
||||
const callIndex = src.indexOf('loadConfigFromEnv(');
|
||||
const listenIndex = src.indexOf('server.listen(');
|
||||
assert.ok(
|
||||
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
||||
'the startup configuration must load through the adapter 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 adapter call fails the server wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
|
||||
});
|
||||
|
||||
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const moved = src
|
||||
.replace('const config = loadConfigFromEnv();\n', '')
|
||||
.replace(
|
||||
'server.listen(config.port, config.host, () => {',
|
||||
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
|
||||
);
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
|
||||
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
||||
});
|
||||
|
||||
test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const directRead = src.replace(
|
||||
'const config = loadConfigFromEnv();',
|
||||
'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;',
|
||||
);
|
||||
assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read');
|
||||
assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* .env.example test — locks in the [E00-S04-T05] guarantee that the committed
|
||||
* `.env.example` template at the repo root contains placeholders only and no
|
||||
* real secret values.
|
||||
*
|
||||
* Acceptance criteria covered (each test fails without the committed state):
|
||||
* - ".env.example contains placeholders only" → the file exists at the repo
|
||||
* root and every assignment value is a placeholder (an explicit
|
||||
* `change-me`/`<…>`-style marker) or a benign non-secret default (bind
|
||||
* address, port, local database/user name); no value is a long
|
||||
* random-looking token without a placeholder marker, no value embeds a
|
||||
* credential URI, every line is a comment, a blank line, or a well-formed
|
||||
* `KEY=value` assignment, no variable is repeated, and the file documents
|
||||
* every configuration environment source (`HOST`/`PORT`/`DATABASE_URL`/
|
||||
* `EPPP_SESSION_SECRET`).
|
||||
* - "no real secret values appear in the example file" → the same value
|
||||
* predicate rejects secret-shaped values, the compose dev-default
|
||||
* credential value appears nowhere in the file (values or comments), and
|
||||
* `.gitignore` keeps real `.env` files ignored while un-ignoring the
|
||||
* committed `.env.example`. The mutation probes below prove the
|
||||
* assertions are non-vacuous (a secret-looking value, a credential URI, a
|
||||
* compose default credential, a missing required variable, a dropped
|
||||
* `!.env.example` negation, or a malformed line all break the criterion).
|
||||
* - "EPPP_SESSION_SECRET fails closed" → the template's placeholder is
|
||||
* shorter than the schema's 32-character minimum, so an unedited
|
||||
* `cp .env.example .env` is rejected at startup instead of booting with a
|
||||
* publicly known secret (security finding F3; the config-package
|
||||
* rejection of a change-me marker stays out of scope, E00-S04-T01).
|
||||
* - "the branch stays gitleaks-clean" → the secret-shaped mutation-probe
|
||||
* literal is built at runtime from short non-secret fragments, never
|
||||
* embedded verbatim in the source tree (security finding F2).
|
||||
* - "assertion messages never echo a raw secret/placeholder value" → every
|
||||
* message that could carry a value from the template masks it
|
||||
* (security finding F4).
|
||||
*
|
||||
* Run: `node --test tests/env-example.test.mjs`
|
||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
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');
|
||||
const exists = (relPath) => existsSync(path.join(REPO_ROOT, relPath));
|
||||
|
||||
const ENV_EXAMPLE_PATH = '.env.example';
|
||||
|
||||
/** The config schema's environment sources (E00-S04-T01/T04) the template must document. */
|
||||
const REQUIRED_VARS = ['HOST', 'PORT', 'DATABASE_URL', 'EPPP_SESSION_SECRET'];
|
||||
|
||||
/** Compose dev-default credential values that must never appear in the template. */
|
||||
const FORBIDDEN_VALUES = ['postgres://eppp:eppp@db:5432/eppp'];
|
||||
|
||||
/** A long random-looking value (JWT/API-key/token-shaped literal). */
|
||||
const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/;
|
||||
|
||||
/**
|
||||
* A long random-looking, secret-shaped value used as a mutation probe. Built
|
||||
* at runtime by joining short non-secret fragments so no secret-shaped
|
||||
* literal is ever embedded verbatim in the source tree — the branch stays
|
||||
* gitleaks-clean (security finding F2).
|
||||
*/
|
||||
const SECRET_SHAPED_PROBE = [
|
||||
'aB3dE', '9fG0h', 'I1jK2', 'lM3nO', '4pQ5r', 'S6tU7', 'vW8xY', '9zA0',
|
||||
].join('');
|
||||
|
||||
/** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */
|
||||
const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/;
|
||||
|
||||
/** Explicit placeholder markers a value may carry. */
|
||||
const PLACEHOLDER_MARKERS = [
|
||||
'change-me',
|
||||
'changeme',
|
||||
'change_me',
|
||||
'your-',
|
||||
'replace',
|
||||
'example',
|
||||
'xxx',
|
||||
'<',
|
||||
'>',
|
||||
];
|
||||
|
||||
/** Benign non-secret defaults the template may show as values. */
|
||||
const BENIGN_VALUES = new Set([
|
||||
'0.0.0.0', // HOST container default
|
||||
'3000', // PORT / APP_PORT default
|
||||
'5432', // POSTGRES_PORT default
|
||||
'localhost', // local bind/db host
|
||||
'eppp', // local database/user name (non-secret)
|
||||
'postgres://localhost:5432/eppp', // DATABASE_URL shape without embedded credentials
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when an assignment value is a placeholder, never a real secret.
|
||||
*
|
||||
* A value is a placeholder when it is a benign non-secret default or carries
|
||||
* an explicit placeholder marker; a marked value is still rejected when it
|
||||
* embeds a credential URI or reproduces a compose default credential. Any
|
||||
* other value — including a long random-looking token — is not a placeholder.
|
||||
*/
|
||||
function isPlaceholderValue(value) {
|
||||
if (value === '') return false;
|
||||
if (BENIGN_VALUES.has(value)) return true;
|
||||
if (PLACEHOLDER_MARKERS.some((marker) => value.includes(marker))) {
|
||||
return !CREDENTIAL_URI_RE.test(value) && !FORBIDDEN_VALUES.includes(value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a value for an assertion message (security finding F4): a failing
|
||||
* assertion echoes a truncated value with its length, never the raw string,
|
||||
* so a real secret that ever lands in the template cannot leak into CI logs.
|
||||
*/
|
||||
function maskValue(value) {
|
||||
const s = String(value);
|
||||
if (s.length <= 8) return '<masked>';
|
||||
return `${s.slice(0, 4)}...<${s.length} chars>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the template into `{ key, value }` assignments, ignoring blank lines
|
||||
* and `#` comment lines. Throws a descriptive Error on a malformed line so a
|
||||
* stray non-assignment line cannot silently pass.
|
||||
*/
|
||||
function parseAssignments(content) {
|
||||
const assignments = [];
|
||||
content.split(/\r?\n/).forEach((raw, index) => {
|
||||
const line = raw.trim();
|
||||
if (line === '' || line.startsWith('#')) return;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${maskValue(raw)}"`,
|
||||
);
|
||||
}
|
||||
assignments.push({ key: match[1], value: match[2] });
|
||||
});
|
||||
return assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the acceptance criteria for the given template content — used by
|
||||
* the committed-state test and by the mutation probes (which must make it
|
||||
* throw).
|
||||
*/
|
||||
function assertTemplateHasPlaceholdersOnly(content) {
|
||||
for (const forbidden of FORBIDDEN_VALUES) {
|
||||
assert.ok(
|
||||
!content.includes(forbidden),
|
||||
`the template must not contain the compose default credential value "${maskValue(forbidden)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const assignments = parseAssignments(content);
|
||||
assert.ok(assignments.length > 0, 'the template must contain at least one assignment');
|
||||
|
||||
const keys = assignments.map(({ key }) => key);
|
||||
assert.equal(new Set(keys).size, keys.length, 'the template must not repeat a variable');
|
||||
|
||||
for (const required of REQUIRED_VARS) {
|
||||
assert.ok(
|
||||
keys.includes(required),
|
||||
`the template must document the required variable "${required}"`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { key, value } of assignments) {
|
||||
assert.ok(
|
||||
isPlaceholderValue(value),
|
||||
`"${key}" must be a placeholder value, got: "${maskValue(value)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a committed .env.example exists at the repo root', () => {
|
||||
assert.ok(exists(ENV_EXAMPLE_PATH), `"${ENV_EXAMPLE_PATH}" must exist at the repo root`);
|
||||
});
|
||||
|
||||
test('.gitignore keeps real .env files ignored while un-ignoring the committed example', () => {
|
||||
const gitignore = read('.gitignore');
|
||||
assert.ok(gitignore.includes('.env'), 'real .env files must stay git-ignored');
|
||||
assert.ok(gitignore.includes('.env.*'), 'real .env.* files must stay git-ignored');
|
||||
assert.ok(
|
||||
gitignore.includes('!.env.example'),
|
||||
'the committed .env.example must be un-ignored (negation pattern)',
|
||||
);
|
||||
});
|
||||
|
||||
test('.env.example contains placeholders only and no real secret values', () => {
|
||||
assertTemplateHasPlaceholdersOnly(read(ENV_EXAMPLE_PATH));
|
||||
});
|
||||
|
||||
test("EPPP_SESSION_SECRET's placeholder is shorter than the schema's 32-character minimum (fails closed)", () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const match = /^EPPP_SESSION_SECRET=(.*)$/m.exec(content);
|
||||
assert.ok(match, 'the template must document EPPP_SESSION_SECRET');
|
||||
assert.ok(
|
||||
match[1].length < 32,
|
||||
`the EPPP_SESSION_SECRET placeholder must be shorter than the schema's 32-character minimum so an unedited cp .env.example .env is rejected at startup (got ${match[1].length} chars)`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-vacuous probes — the assertions above really do fail on violations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a compose default credential value in the template fails the criterion (mutation probe)', () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const mutated = content.replace(
|
||||
'DATABASE_URL=postgres://localhost:5432/eppp',
|
||||
'DATABASE_URL=postgres://eppp:eppp@db:5432/eppp',
|
||||
);
|
||||
assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value');
|
||||
assert.throws(
|
||||
() => assertTemplateHasPlaceholdersOnly(mutated),
|
||||
/compose default credential|placeholder/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a long secret-looking value fails the criterion (mutation probe)', () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const mutated = content.replace(
|
||||
'EPPP_SESSION_SECRET=change-me',
|
||||
`EPPP_SESSION_SECRET=${SECRET_SHAPED_PROBE}`,
|
||||
);
|
||||
assert.notEqual(mutated, content, 'the mutation must actually replace the session secret');
|
||||
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
|
||||
});
|
||||
|
||||
test('a credential URI value fails the criterion (mutation probe)', () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const mutated = content.replace(
|
||||
'DATABASE_URL=postgres://localhost:5432/eppp',
|
||||
'DATABASE_URL=postgres://alice:supersecret@db.example.com:5432/eppp',
|
||||
);
|
||||
assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value');
|
||||
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
|
||||
});
|
||||
|
||||
test('a missing required variable fails the criterion (mutation probe)', () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const mutated = content.replace(/^EPPP_SESSION_SECRET=.*$/m, '');
|
||||
assert.notEqual(
|
||||
mutated,
|
||||
content,
|
||||
'the mutation must actually remove the EPPP_SESSION_SECRET assignment',
|
||||
);
|
||||
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /EPPP_SESSION_SECRET/);
|
||||
});
|
||||
|
||||
test('dropping the !.env.example negation fails the criterion (mutation probe)', () => {
|
||||
const gitignore = read('.gitignore');
|
||||
const mutated = gitignore.replace('!.env.example', '');
|
||||
assert.notEqual(mutated, gitignore, 'the mutation must actually drop the negation pattern');
|
||||
assert.throws(
|
||||
() => {
|
||||
assert.ok(mutated.includes('!.env.example'), 'the committed .env.example must be un-ignored');
|
||||
},
|
||||
/un-ignored/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed non-assignment line fails the criterion (mutation probe)', () => {
|
||||
const content = read(ENV_EXAMPLE_PATH);
|
||||
const mutated = `${content}\nTHIS IS NOT A VALID LINE\n`;
|
||||
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /not a comment/);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Formatting/lint policy test — locks in the workspace formatting and lint
|
||||
* policy (E00-S05-T01, CI stage 3: formatting/lint).
|
||||
*
|
||||
* Policy (every file tracked by git, i.e. every committed text file):
|
||||
* - LF line endings: no carriage returns (no CRLF, no lone CR)
|
||||
* - no UTF-8 byte-order mark
|
||||
* - no trailing whitespace on any line
|
||||
* - no tab characters anywhere (indentation is spaces)
|
||||
* - exactly one final newline: the file must end with `\n`, with no blank
|
||||
* line left at the end of the file
|
||||
* JSON files additionally must:
|
||||
* - parse as strict JSON (no trailing commas, no comments)
|
||||
* - contain no duplicate object keys
|
||||
* - use 2-space indentation (every line's leading spaces are an even count)
|
||||
*
|
||||
* The scan is scoped to files tracked by git (`git ls-files`), so ignored and
|
||||
* generated files (node_modules/, dist/, .env, probe scratch files) never
|
||||
* enter the policy. Binary files (containing a NUL byte) are skipped.
|
||||
*
|
||||
* Mutation probes prove every rule is non-vacuous: each violation below is
|
||||
* injected into a temp file and must be reported.
|
||||
*
|
||||
* Run: `pnpm lint` (== `node --test tests/formatting-policy.test.mjs`)
|
||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
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');
|
||||
|
||||
/** True for binary files: the policy applies to text files only. */
|
||||
const isBinary = (content) => content.includes('\0');
|
||||
|
||||
/**
|
||||
* Returns the list of tracked files (git ls-files, NUL-delimited) under the
|
||||
* given repo root, or fails the suite when git is unavailable.
|
||||
*/
|
||||
function trackedFiles(root = REPO_ROOT) {
|
||||
const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, `git ls-files must succeed in ${root}`);
|
||||
return result.stdout.split('\0').filter((p) => p.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the duplicate object keys of a JSON document. JSON.parse collapses
|
||||
* duplicate keys (last value wins) before any reviver or post-parse walker can
|
||||
* see them, so this scans the raw text: a string literal immediately followed
|
||||
* by `:` is an object key, and keys are tracked per enclosing `{…}` frame, so
|
||||
* same-named keys in different objects stay legal while a repeated key inside
|
||||
* one object is reported.
|
||||
*/
|
||||
function collectDuplicateKeys(text, out) {
|
||||
const frames = [];
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < n && text[j] !== '"') {
|
||||
if (text[j] === '\\') j += 1;
|
||||
j += 1;
|
||||
}
|
||||
const key = text.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
let k = j + 1;
|
||||
while (k < n && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1;
|
||||
if (text[k] === ':' && frames.length > 0) {
|
||||
const seen = frames[frames.length - 1];
|
||||
if (seen.has(key)) out.push(`duplicate JSON key: ${key}`);
|
||||
seen.add(key);
|
||||
}
|
||||
i = k;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') {
|
||||
frames.push(new Set());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}') {
|
||||
frames.pop();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the formatting/lint violations for one file relative to `root`, or
|
||||
* [] when the file conforms. Binary files are out of policy scope.
|
||||
*/
|
||||
function violationsForFile(root, relPath) {
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(path.join(root, relPath), 'utf8');
|
||||
} catch (err) {
|
||||
return [`${relPath}: unreadable: ${err.message}`];
|
||||
}
|
||||
if (isBinary(content)) return [];
|
||||
|
||||
const label = (message) => `${relPath}: ${message}`;
|
||||
const out = [];
|
||||
|
||||
if (content.includes('\r')) out.push(label('carriage return (use LF line endings)'));
|
||||
if (content.startsWith('\uFEFF')) out.push(label('UTF-8 byte-order mark'));
|
||||
if (content.includes('\t')) out.push(label('tab character (indentation must be spaces)'));
|
||||
|
||||
const lines = content.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (/[ \t]+$/.test(line)) out.push(label(`trailing whitespace on line ${i + 1}`));
|
||||
});
|
||||
|
||||
if (content.length > 0) {
|
||||
if (lines[lines.length - 1] !== '') out.push(label('missing final newline'));
|
||||
else if (lines[lines.length - 2] === '') out.push(label('blank line at end of file'));
|
||||
}
|
||||
|
||||
if (relPath.endsWith('.json')) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (err) {
|
||||
out.push(label(`invalid JSON: ${err.message}`));
|
||||
return out;
|
||||
}
|
||||
const duplicateKeys = [];
|
||||
collectDuplicateKeys(content, duplicateKeys);
|
||||
for (const message of duplicateKeys) out.push(label(message));
|
||||
lines.forEach((line, i) => {
|
||||
const indent = /^([ ]*)\S/.exec(line);
|
||||
if (indent && indent[1].length % 2 !== 0) {
|
||||
out.push(label(`JSON indentation must be 2 spaces per level (line ${i + 1})`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Writes a probe file into a fresh temp dir and returns its violations. */
|
||||
function probeViolations(filename, content) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-format-policy-'));
|
||||
try {
|
||||
writeFileSync(path.join(dir, filename), content);
|
||||
return violationsForFile(dir, filename);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-tree scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('every tracked text file conforms to the formatting/lint policy', () => {
|
||||
const files = trackedFiles();
|
||||
assert.ok(
|
||||
files.length >= 25,
|
||||
`expected a meaningful tracked file set to scan (got ${files.length})`,
|
||||
);
|
||||
const violations = [];
|
||||
for (const file of files) violations.push(...violationsForFile(REPO_ROOT, file));
|
||||
assert.deepEqual(violations, [], `formatting/lint violations:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('the scan covers the workspace source, docs, manifests and workflow', () => {
|
||||
const files = trackedFiles();
|
||||
for (const expected of [
|
||||
'apps/server/src/index.ts',
|
||||
'packages/config/src/env.ts',
|
||||
'docs/development/non-container.md',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'compose.yaml',
|
||||
'.gitea/workflows/ci.yml',
|
||||
]) {
|
||||
assert.ok(files.includes(expected), `tracked file set must include ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation probes — every rule is non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a trailing whitespace is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line with trailing space \n');
|
||||
assert.ok(violations.some((v) => /trailing whitespace/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a tab character is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\twith tab\n');
|
||||
assert.ok(violations.some((v) => /tab character/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a CRLF line ending is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\r\n');
|
||||
assert.ok(violations.some((v) => /carriage return/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a UTF-8 byte-order mark is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', '\uFEFFline\n');
|
||||
assert.ok(violations.some((v) => /byte-order mark/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a missing final newline is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'no final newline');
|
||||
assert.ok(violations.some((v) => /missing final newline/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a blank line at the end of the file is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\n\n');
|
||||
assert.ok(violations.some((v) => /blank line at end of file/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('invalid JSON is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{"a": 1,}\n');
|
||||
assert.ok(violations.some((v) => /invalid JSON/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a duplicate JSON key is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{"a": 1, "a": 2}\n');
|
||||
assert.ok(violations.some((v) => /duplicate JSON key/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('odd JSON indentation is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{\n "a": 1,\n "b": 2\n}\n');
|
||||
assert.ok(violations.some((v) => /2 spaces per level/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('binary files are out of policy scope (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.bin', 'a\0b');
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('a conforming file yields no violations (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{\n "a": 1\n}\n');
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
@@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) {
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/3000/,
|
||||
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
/server\.listen\(config\.port/,
|
||||
'the server must bind the port from the validated configuration (config.port, E00-S04-T04)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -181,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {"
|
||||
);
|
||||
});
|
||||
|
||||
test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => {
|
||||
// The server binds `config.port` (asserted above); the port default (3000,
|
||||
// matching the Dockerfile EXPOSE / compose :3000) and the PORT override
|
||||
// live in the config package's environment adapter — the single owner of
|
||||
// process.env reads.
|
||||
const envSrc = read('packages/config/src/env.ts');
|
||||
assert.match(
|
||||
envSrc,
|
||||
/3000/,
|
||||
'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
);
|
||||
});
|
||||
|
||||
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
|
||||
if (!tsExecMode()) {
|
||||
t.skip(
|
||||
|
||||
Reference in New Issue
Block a user