Compare commits
35
Commits
ae900589c3
...
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 |
@@ -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
|
||||||
+177
-208
@@ -5,14 +5,48 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
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:
|
frozen-install:
|
||||||
name: Frozen lockfile install
|
name: Stage 1 — Frozen lockfile install (E00-S05-T01)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
@@ -22,141 +56,59 @@ jobs:
|
|||||||
- name: Verify workspace groups
|
- name: Verify workspace groups
|
||||||
run: pnpm -r list --depth -1
|
run: pnpm -r list --depth -1
|
||||||
|
|
||||||
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
|
# Stage 2 — typecheck (E00-S05-T01).
|
||||||
# gate every PR (the docker-gated layer-scan probe inside the same file runs
|
typecheck:
|
||||||
# where a Docker daemon is available and skips cleanly otherwise).
|
name: Stage 2 — Typecheck (E00-S05-T01)
|
||||||
secrets-not-embedded:
|
needs: frozen-install
|
||||||
name: Secrets not embedded (E00-S02-T08)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- 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
|
|
||||||
run: node --test tests/secrets-not-embedded.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)
|
|
||||||
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 database-postgres import isolation suite
|
|
||||||
run: node --test tests/database-postgres-imports.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)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Install dependencies (frozen lockfile)
|
- name: Install dependencies (frozen lockfile)
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run migration ledger test suite
|
- name: Typecheck every workspace package
|
||||||
run: node --test tests/database-postgres-ledger.test.mjs
|
run: pnpm typecheck
|
||||||
|
|
||||||
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
|
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
|
||||||
# gate every PR — the suite locks in the migration advisory lock (session-
|
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
|
||||||
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
|
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
|
||||||
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
|
# newline, and valid JSON with 2-space indentation and no duplicate keys.
|
||||||
# acquire() calls share one connection, driver-boundary re-export) with
|
formatting-lint:
|
||||||
# mutation probes, and the docker-gated real-stack concurrent probe (a
|
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
|
||||||
# second runner waits or fails while the first holds the lock; concurrent
|
needs: typecheck
|
||||||
# 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Install dependencies (frozen lockfile)
|
- name: Install dependencies (frozen lockfile)
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run migration advisory lock test suite
|
- name: Run the formatting/lint policy
|
||||||
run: node --test tests/database-postgres-lock.test.mjs
|
run: pnpm lint
|
||||||
|
|
||||||
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
|
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
|
||||||
# gate every PR — the suite locks in the migration failure diagnostic (a
|
# PR without external services: the app health endpoint, the secrets scan
|
||||||
# structured MigrationFailedError whose diagnostic identifies the failing
|
# and the configuration service suites (schema, startup error, log
|
||||||
# migration, the failure phase, the underlying cause, and the applied/pending
|
# redaction, env adapter, .env.example). The config suites boot the
|
||||||
# ledger state, serializable via toJSON) with mutation probes, and a
|
# committed server, so the config and database-postgres packages are built
|
||||||
# deterministic stub-pool behavioral probe (intentionally failing migration
|
# first.
|
||||||
# fixture -> structured diagnostic naming the failing migration) runs on
|
unit:
|
||||||
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
|
name: Stage 4 — Unit tests (E00-S05-T01)
|
||||||
# intentionally failing migration fixture and confirm the diagnostic") runs
|
needs: formatting-lint
|
||||||
# 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- 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 failure diagnostic test 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 config
|
|
||||||
# and database-postgres packages because the probes boot the committed
|
|
||||||
# server from the host (it imports @personal-blog/config and
|
|
||||||
# @personal-blog/database-postgres through the packages' own links; the
|
|
||||||
# required EPPP_SESSION_SECRET is provided by the probe's boot env).
|
|
||||||
app-readiness:
|
|
||||||
name: App readiness after migrations (E00-S03-T06)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
@@ -165,107 +117,124 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
- 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
|
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||||
- name: Run app readiness test suite
|
- 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
|
||||||
|
|
||||||
|
# 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@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 (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
|
||||||
|
|
||||||
|
# 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@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 database-postgres-ledger suite
|
||||||
|
run: node --test tests/database-postgres-ledger.test.mjs
|
||||||
|
- name: Run the database-postgres-lock suite
|
||||||
|
run: node --test tests/database-postgres-lock.test.mjs
|
||||||
|
- name: Run the database-postgres-diagnostic suite
|
||||||
|
run: node --test tests/database-postgres-diagnostic.test.mjs
|
||||||
|
- name: Run the app-readiness suite
|
||||||
run: node --test tests/app-readiness.test.mjs
|
run: node --test tests/app-readiness.test.mjs
|
||||||
|
|
||||||
# E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs
|
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
|
||||||
# gate every PR — the suite locks in the field-specific startup error (a
|
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
|
||||||
# missing required setting fails startup with an error naming the missing
|
# — the pnpm apps-group glob picks it up automatically) and verifies the
|
||||||
# field: packages/config's MissingRequiredSettingError/assertValidConfig,
|
# compiled server artifact.
|
||||||
# wired into the committed server before it binds) with mutation probes, and
|
build-apps:
|
||||||
# the deterministic probes execute the issue's test plan ("start with a
|
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
|
||||||
# missing required field and confirm the error names it"): booting the
|
needs: postgres-integration
|
||||||
# committed server without EPPP_SESSION_SECRET exits non-zero naming
|
|
||||||
# sessionSecret, while a valid secret boots to GET /health 200. The job
|
|
||||||
# installs the frozen workspace and builds the config and database-postgres
|
|
||||||
# packages because the probes boot the committed server which imports them.
|
|
||||||
config-startup-error:
|
|
||||||
name: Field-specific startup errors (E00-S04-T02)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Install dependencies (frozen lockfile)
|
- name: Install dependencies (frozen lockfile)
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
- name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands)
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
run: pnpm --filter "./apps/**" run build
|
||||||
- name: Run config startup error test suite
|
- name: Verify the compiled server application artifact
|
||||||
run: node --test tests/config-startup-error.test.mjs
|
run: test -f apps/server/dist/index.js
|
||||||
|
|
||||||
# E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs
|
|
||||||
# gate every PR — the suite locks in automatic secret redaction from logs
|
|
||||||
# (packages/config's redactConfig/redactText + the server's redacting
|
|
||||||
# logger: every log line is scrubbed of the config's secret values) with
|
|
||||||
# mutation probes, and the deterministic probes execute the issue's test
|
|
||||||
# plan ("log configuration and confirm secret values are redacted"):
|
|
||||||
# booting the committed server logs its resolved configuration with the
|
|
||||||
# secret values replaced by [REDACTED], and no secret value appears in the
|
|
||||||
# log output. The job installs the frozen workspace and builds the config
|
|
||||||
# and database-postgres packages because the probes boot the committed
|
|
||||||
# server which imports them.
|
|
||||||
config-log-redaction:
|
|
||||||
name: Secret redaction from logs (E00-S04-T03)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
|
||||||
- name: Run config log redaction test suite
|
|
||||||
run: node --test tests/config-log-redaction.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)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config 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
|
|
||||||
|
|||||||
+8
-1
@@ -6,9 +6,12 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
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.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -27,5 +30,9 @@ coverage/
|
|||||||
# suite into the package (removed in its finally block)
|
# suite into the package (removed in its finally block)
|
||||||
.config-startup-probe-*.mjs
|
.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
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02) and automatic secret redaction from all log output (E00-S04-T03); 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": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
||||||
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
|
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
|
||||||
|
|||||||
+32
-32
@@ -23,10 +23,21 @@
|
|||||||
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
|
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
|
||||||
* required field, Security-and-Operations §32/§26) fails fast at startup
|
* required field, Security-and-Operations §32/§26) fails fast at startup
|
||||||
* with an error naming the missing field instead of booting with an invalid
|
* with an error naming the missing field instead of booting with an invalid
|
||||||
* configuration. The parsed config keeps the committed defaults for
|
* configuration.
|
||||||
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
|
*
|
||||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
* [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||||
* `MissingRequiredSettingError` naming the missing field.
|
* 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
|
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
|
||||||
* logger (`createLogger`, defined below — every line is scrubbed of the
|
* logger (`createLogger`, defined below — every line is scrubbed of the
|
||||||
@@ -43,25 +54,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { assertValidConfig } from '@personal-blog/config';
|
import { loadConfigFromEnv } from '@personal-blog/config';
|
||||||
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||||
import { Pool } from '@personal-blog/database-postgres';
|
import { Pool } from '@personal-blog/database-postgres';
|
||||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||||
import type { Migration } from '@personal-blog/database-postgres';
|
import type { Migration } from '@personal-blog/database-postgres';
|
||||||
|
|
||||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
// [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||||
const PORT = resolvePort(process.env.PORT);
|
// package's adapter — the workspace's single owner of process.env reads — so
|
||||||
|
// the server never reads process.env directly. The adapter maps the
|
||||||
// [E00-S04-T02] field-specific startup error: validate the startup
|
// environment onto the validated config shape and validates it with
|
||||||
// configuration before anything else, so a missing required setting (e.g.
|
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
|
||||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
|
||||||
// the missing field — never boots with an invalid configuration.
|
// startup with an error naming the missing field — never boots with an
|
||||||
const config = assertValidConfig({
|
// invalid configuration.
|
||||||
host: '0.0.0.0',
|
const config = loadConfigFromEnv();
|
||||||
port: PORT,
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
sessionSecret: process.env.EPPP_SESSION_SECRET,
|
|
||||||
});
|
|
||||||
|
|
||||||
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
// [E00-S04-T03] secret redaction: every log line goes through the redacting
|
||||||
// logger, seeded with the validated config's secrets — and the resolved
|
// logger, seeded with the validated config's secrets — and the resolved
|
||||||
@@ -96,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
|
|||||||
*/
|
*/
|
||||||
let migrationsComplete = false;
|
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. */
|
/** Writes a JSON response with an explicit content-length. */
|
||||||
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
@@ -177,7 +173,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
|
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = config.databaseUrl;
|
||||||
|
|
||||||
if (databaseUrl === undefined) {
|
if (databaseUrl === undefined) {
|
||||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||||
@@ -207,8 +203,12 @@ if (databaseUrl === undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// The server binds the validated bind interface: `config.host` (default
|
||||||
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
// `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
|
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
|
||||||
|
|||||||
+2
-1
@@ -49,7 +49,8 @@
|
|||||||
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
|
# 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
|
# 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
|
# Since E00-S04-T02 the app validates its required settings at startup: the
|
||||||
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
|
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
|
||||||
# Security-and-Operations §32/§26) is provided here with a dev-only default —
|
# Security-and-Operations §32/§26) is provided here with a dev-only default —
|
||||||
|
|||||||
@@ -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 |
|
| 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), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), and redacts secret values from all log output (E00-S04-T03); 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/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 field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the `.env.example` template (E00-S04-T05) 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). |
|
| `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. |
|
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ compiled application entrypoint. Three things to know:
|
|||||||
must be set in the environment — if it is missing, the process fails fast
|
must be set in the environment — if it is missing, the process fails fast
|
||||||
with a field-specific startup error (`missing required setting:
|
with a field-specific startup error (`missing required setting:
|
||||||
sessionSecret`) that names the missing field instead of booting. Provide it
|
sessionSecret`) that names the missing field instead of booting. Provide it
|
||||||
in your shell or a local `.env` file (the `.env.example` template lands in
|
in your shell or a local `.env` file copied from the committed
|
||||||
E00-S04).
|
`.env.example` template (E00-S04-T05).
|
||||||
3. Since [E00-S02-T03], `apps/server` serves the **application health
|
3. Since [E00-S02-T03], `apps/server` serves the **application health
|
||||||
endpoint**: starting it opens an HTTP server on port 3000 answering
|
endpoint**: starting it opens an HTTP server on port 3000 answering
|
||||||
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
|
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
|
||||||
@@ -119,6 +119,19 @@ compiled application entrypoint. Three things to know:
|
|||||||
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||||
redacting logger — the admin-session secret and the password embedded in a
|
redacting logger — the admin-session secret and the password embedded in a
|
||||||
`DATABASE_URL` connection string never appear in the log output.
|
`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:
|
To run the compiled output of any other workspace package directly:
|
||||||
|
|
||||||
@@ -130,11 +143,22 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm test
|
pnpm test
|
||||||
|
pnpm lint
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm test` runs the `node:test` suites under `tests/` (currently
|
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
|
||||||
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
|
dependencies (this is also the suite that enforces the dependency-boundary
|
||||||
This is also the suite that enforces the dependency-boundary rule.
|
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
|
## Smoke check from a clean clone
|
||||||
|
|
||||||
@@ -144,7 +168,8 @@ corepack enable
|
|||||||
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
||||||
pnpm build # 5/5 packages emit dist/, exit 0
|
pnpm build # 5/5 packages emit dist/, exit 0
|
||||||
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
||||||
pnpm test # 10/10 pass, exit 0
|
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
|
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,7 +182,7 @@ pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see
|
|||||||
| `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). |
|
| `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
|
||||||
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
|
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
|
||||||
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
|
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
|
||||||
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
|
| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm -r run build",
|
"build": "pnpm -r run build",
|
||||||
|
"lint": "node --test tests/formatting-policy.test.mjs",
|
||||||
"test": "node --test \"tests/**/*.test.mjs\"",
|
"test": "node --test \"tests/**/*.test.mjs\"",
|
||||||
"typecheck": "pnpm -r run typecheck"
|
"typecheck": "pnpm -r run typecheck"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) 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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,8 +18,16 @@
|
|||||||
* server's redacting logger applies to every log line, so secrets
|
* server's redacting logger applies to every log line, so secrets
|
||||||
* automatically redact from logs.
|
* automatically redact from logs.
|
||||||
*
|
*
|
||||||
* The environment adapter (E00-S04-T04) builds on this boundary in a later
|
* [E00-S04-T04] Environment adapter: the boundary also exposes
|
||||||
* task.
|
* `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 { configSchema } from './schema.js';
|
||||||
@@ -28,3 +36,4 @@ export { validateConfig } from './validate.js';
|
|||||||
export type { ConfigValidationResult } from './validate.js';
|
export type { ConfigValidationResult } from './validate.js';
|
||||||
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
||||||
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
||||||
|
export { loadConfigFromEnv } from './env.js';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* logs.
|
* logs.
|
||||||
*
|
*
|
||||||
* The config package owns which configuration fields are secrets, so the
|
* The config package owns which configuration fields are secrets, so the
|
||||||
* redaction layer lives here (the environment adapter, E00-S04-T04, will feed
|
* redaction layer lives here (the environment adapter, E00-S04-T04, feeds
|
||||||
* the validated config into it via the app's logger):
|
* the validated config into it via the app's logger):
|
||||||
*
|
*
|
||||||
* - `redactConfig(config)` — a copy of a config value with every secret
|
* - `redactConfig(config)` — a copy of a config value with every secret
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
|
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
|
||||||
* port range (1–65535), default `3000` (the container default, matching
|
* port range (1–65535), default `3000` (the container default, matching
|
||||||
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
|
* 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`
|
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
|
||||||
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
||||||
* startup migration run to wait for and reports ready immediately (the
|
* startup migration run to wait for and reports ready immediately (the
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
* `ConfigStartupError` whose message names each violating field too.
|
* `ConfigStartupError` whose message names each violating field too.
|
||||||
*
|
*
|
||||||
* This is deliberately NOT the T04 environment adapter: nothing here reads
|
* This is deliberately NOT the T04 environment adapter: nothing here reads
|
||||||
* `process.env`. The adapter (E00-S04-T04) maps the environment onto the
|
* `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment
|
||||||
* validated config shape and passes it to `assertValidConfig` at startup;
|
* onto the validated config shape and passes it to `assertValidConfig` at
|
||||||
* secret redaction (E00-S04-T03) builds on the same boundary in a later task.
|
* startup; the secret redaction layer (E00-S04-T03) builds on the same
|
||||||
|
* boundary.
|
||||||
*
|
*
|
||||||
* Rollback note from the issue: revert the validation error handling.
|
* Rollback note from the issue: revert the validation error handling.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
|
|||||||
* after migrations finish.
|
* after migrations finish.
|
||||||
*/
|
*/
|
||||||
function assertReadyAfterRun(src) {
|
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');
|
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||||
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||||
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
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).
|
* non-container path and the E00-S02-T03 health endpoint working).
|
||||||
*/
|
*/
|
||||||
function assertNoDatabaseUrlPath(src) {
|
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(
|
assert.match(
|
||||||
startup,
|
startup,
|
||||||
/if \(databaseUrl === undefined\) \{/,
|
/if \(databaseUrl === undefined\) \{/,
|
||||||
|
|||||||
@@ -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
@@ -159,8 +159,8 @@ function assertServerSource(src) {
|
|||||||
// package's scrubber before it reaches stdout/stderr.
|
// package's scrubber before it reaches stdout/stderr.
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||||
'the server must import the startup validation entry point from the config package',
|
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
@@ -188,12 +188,13 @@ function assertServerSource(src) {
|
|||||||
'error lines must be written to stderr',
|
'error lines must be written to stderr',
|
||||||
);
|
);
|
||||||
|
|
||||||
// The wiring — the server keeps its validated config, creates the logger
|
// The wiring — the server keeps its validated config (loaded through the
|
||||||
// with it and logs the resolved configuration redacted.
|
// environment adapter, E00-S04-T04), creates the logger with it and logs
|
||||||
|
// the resolved configuration redacted.
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/const config = assertValidConfig\(\{/,
|
/const config = loadConfigFromEnv\(\);/,
|
||||||
'the server must keep its validated configuration (const config = assertValidConfig(...))',
|
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
@@ -215,13 +216,19 @@ function assertServerSource(src) {
|
|||||||
/console\.(log|error)\(/,
|
/console\.(log|error)\(/,
|
||||||
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
||||||
);
|
);
|
||||||
// The startup validation runs before the logger is created, so a missing
|
assert.doesNotMatch(
|
||||||
// required setting still fails fast (E00-S04-T02) before any log output.
|
src,
|
||||||
const validationIndex = src.indexOf('assertValidConfig({');
|
/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)');
|
const loggerIndex = src.indexOf('createLogger(config)');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
||||||
'the startup validation must run before the logger is created (a missing required setting is still a startup error)',
|
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,20 @@
|
|||||||
* - "missing required setting gives a field-specific startup error" → the
|
* - "missing required setting gives a field-specific startup error" → the
|
||||||
* `packages/config` package exposes the startup validation entry point
|
* `packages/config` package exposes the startup validation entry point
|
||||||
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
||||||
* and the committed `apps/server/src/index.ts` calls it before the server
|
* 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
|
* binds, so a deployment missing a required setting (the admin-session
|
||||||
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
||||||
* Security-and-Operations §32/§26) fails fast at startup instead of
|
* Security-and-Operations §32/§26) fails fast at startup instead of
|
||||||
* booting with an invalid configuration. Locked in statically (mutation
|
* booting with an invalid configuration. Locked in statically (mutation
|
||||||
* probes prove non-vacuity: dropping the startup validation call,
|
* probes prove non-vacuity: dropping the adapter call, moving it after
|
||||||
* moving it after the bind, or dropping the compose/Dockerfile support
|
* the bind, or dropping the compose/Dockerfile support all fail) and
|
||||||
* all fail) and behaviorally by the deterministic probes (the issue's
|
* behaviorally by the deterministic probes (the issue's test plan: "start
|
||||||
* test plan: "start with a missing required field and confirm the error
|
* with a missing required field and confirm the error names it" — booting
|
||||||
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
|
||||||
* exits non-zero with the error naming the missing field).
|
* the error naming the missing field).
|
||||||
* - "the error names the missing field" → a missing required setting throws
|
* - "the error names the missing field" → a missing required setting throws
|
||||||
* `MissingRequiredSettingError` whose message and `missingField` name the
|
* `MissingRequiredSettingError` whose message and `missingField` name the
|
||||||
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
||||||
@@ -149,33 +152,35 @@ function assertStartupErrorSource(src) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts the committed server validates the required settings at startup:
|
* Asserts the committed server loads its startup configuration through the
|
||||||
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
* environment adapter (E00-S04-T04) before it binds: it imports
|
||||||
* with the parsed startup configuration (including the required
|
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
|
||||||
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
* validates the mapped environment with `assertValidConfig`, including the
|
||||||
* setting is a startup error, never a silently-booted invalid configuration.
|
* 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) {
|
function assertServerStartupValidation(src) {
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||||
'the server must import the startup validation entry point from the config package',
|
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/assertValidConfig\(\{/,
|
/const config = loadConfigFromEnv\(\);/,
|
||||||
'the server must call assertValidConfig with its startup configuration',
|
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.doesNotMatch(
|
||||||
src,
|
src,
|
||||||
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
/process\.env\.[A-Z_]+/,
|
||||||
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||||
);
|
);
|
||||||
const callIndex = src.indexOf('assertValidConfig({');
|
const callIndex = src.indexOf('loadConfigFromEnv(');
|
||||||
const listenIndex = src.indexOf('server.listen(');
|
const listenIndex = src.indexOf('server.listen(');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
||||||
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
|
'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,25 +279,35 @@ test('the config-startup-error criterion is enforced in CI', () => {
|
|||||||
// Mutation probes — the static assertions are non-vacuous
|
// Mutation probes — the static assertions are non-vacuous
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
|
test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => {
|
||||||
const src = read(SERVER_SRC);
|
const src = read(SERVER_SRC);
|
||||||
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
|
||||||
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
|
||||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
|
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
|
||||||
const src = read(SERVER_SRC);
|
const src = read(SERVER_SRC);
|
||||||
const moved = src
|
const moved = src
|
||||||
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
|
.replace('const config = loadConfigFromEnv();\n', '')
|
||||||
.replace(
|
.replace(
|
||||||
'server.listen(PORT, () => {',
|
'server.listen(config.port, config.host, () => {',
|
||||||
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
|
||||||
);
|
);
|
||||||
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
|
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
|
||||||
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
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)', () => {
|
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
||||||
const src = read(STARTUP_SRC);
|
const src = read(STARTUP_SRC);
|
||||||
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
||||||
|
|||||||
@@ -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(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/3000/,
|
/server\.listen\(config\.port/,
|
||||||
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
|
'the server must bind the port from the validated configuration (config.port, E00-S04-T04)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,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) => {
|
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
|
||||||
if (!tsExecMode()) {
|
if (!tsExecMode()) {
|
||||||
t.skip(
|
t.skip(
|
||||||
|
|||||||
Reference in New Issue
Block a user