Compare commits
1
Commits
main
..
25248461e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25248461e0 |
@@ -1,47 +0,0 @@
|
||||
# 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
|
||||
+211
-180
@@ -5,48 +5,14 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Minimal workflow token: the pipeline only reads repository contents
|
||||
# (checkout, frozen install, typecheck, lint, tests, build) — nothing writes
|
||||
# back, so the token is scoped to contents: read (E00-S05-T01).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# E00-S05-T01 — CI quality baseline (required PR stages).
|
||||
#
|
||||
# Every pull request runs the required quality stages in order, each gated on
|
||||
# the previous stage through `needs`:
|
||||
#
|
||||
# 1. frozen-install — the committed lockfile installs cleanly
|
||||
# 2. typecheck — every workspace package passes `tsc --noEmit`
|
||||
# 3. formatting-lint — the dependency-free formatting/lint policy (`pnpm lint`)
|
||||
# 4. unit — deterministic unit suites (health, config, env example…)
|
||||
# 5. architecture — static workspace/container structure and policy suites
|
||||
# 6. postgres-integration — PostgreSQL adapter suites (docker-gated real-stack
|
||||
# probes run where a Docker daemon is available and
|
||||
# skip cleanly otherwise)
|
||||
# 7. build-apps — builds the workspace applications (apps/*: server
|
||||
# today, admin when E06-S01 lands) and verifies the
|
||||
# compiled artifact
|
||||
#
|
||||
# The stage order, the `needs` chain and the tests/ coverage are locked in by
|
||||
# tests/ci-stages.test.mjs (architecture stage). Third-party actions
|
||||
# (actions/checkout, actions/setup-node) are pinned to full commit SHAs — no
|
||||
# floating tags — and the workflow token is scoped to `contents: read`
|
||||
# (E00-S05-T01 hardening). Container/Compose smoke on main/release branches
|
||||
# and the Docker Compose baseline stack (E00-S02) stay out of scope for this
|
||||
# stage list.
|
||||
|
||||
jobs:
|
||||
# Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the
|
||||
# committed lockfile must install cleanly and be up to date with the
|
||||
# manifests before any stage proceeds.
|
||||
frozen-install:
|
||||
name: Stage 1 — Frozen lockfile install (E00-S05-T01)
|
||||
name: Frozen lockfile install
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
@@ -56,149 +22,141 @@ jobs:
|
||||
- name: Verify workspace groups
|
||||
run: pnpm -r list --depth -1
|
||||
|
||||
# Stage 2 — typecheck (E00-S05-T01).
|
||||
typecheck:
|
||||
name: Stage 2 — Typecheck (E00-S05-T01)
|
||||
needs: frozen-install
|
||||
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
|
||||
# gate every PR (the docker-gated layer-scan probe inside the same file runs
|
||||
# where a Docker daemon is available and skips cleanly otherwise).
|
||||
secrets-not-embedded:
|
||||
name: Secrets not embedded (E00-S02-T08)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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: Typecheck every workspace package
|
||||
run: pnpm typecheck
|
||||
|
||||
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
|
||||
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
|
||||
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
|
||||
# newline, and valid JSON with 2-space indentation and no duplicate keys.
|
||||
formatting-lint:
|
||||
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run the formatting/lint policy
|
||||
run: pnpm lint
|
||||
|
||||
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
|
||||
# PR without external services: the app health endpoint, the secrets scan
|
||||
# and the configuration service suites (schema, startup error, log
|
||||
# redaction, env adapter, .env.example). The config suites boot the
|
||||
# committed server, so the config and database-postgres packages are built
|
||||
# first.
|
||||
unit:
|
||||
name: Stage 4 — Unit tests (E00-S05-T01)
|
||||
needs: formatting-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||
- name: Run the health-endpoint unit suite
|
||||
run: node --test tests/health-endpoint.test.mjs
|
||||
- name: Run the secrets-not-embedded unit suite
|
||||
- name: Run secrets-not-embedded test 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
|
||||
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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:
|
||||
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
|
||||
- name: Run migration ledger test suite
|
||||
run: node --test tests/database-postgres-ledger.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
|
||||
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
|
||||
# gate every PR — the suite locks in the migration advisory lock (session-
|
||||
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
|
||||
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
|
||||
# acquire() calls share one connection, driver-boundary re-export) with
|
||||
# mutation probes, and the docker-gated real-stack concurrent probe (a
|
||||
# second runner waits or fails while the first holds the lock; concurrent
|
||||
# acquire() checks out exactly one connection; the lock releases when the
|
||||
# holding session ends) runs where a Docker daemon is available and skips
|
||||
# cleanly otherwise. The job installs the frozen workspace because the
|
||||
# real-stack probe executes the committed lock module from the host (it
|
||||
# imports `pg` through the package's own links).
|
||||
database-postgres-lock:
|
||||
name: Migration advisory lock (E00-S03-T04)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run migration advisory lock test suite
|
||||
run: node --test tests/database-postgres-lock.test.mjs
|
||||
|
||||
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
|
||||
# gate every PR — the suite locks in the migration failure diagnostic (a
|
||||
# structured MigrationFailedError whose diagnostic identifies the failing
|
||||
# migration, the failure phase, the underlying cause, and the applied/pending
|
||||
# ledger state, serializable via toJSON) with mutation probes, and a
|
||||
# deterministic stub-pool behavioral probe (intentionally failing migration
|
||||
# fixture -> structured diagnostic naming the failing migration) runs on
|
||||
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
|
||||
# intentionally failing migration fixture and confirm the diagnostic") runs
|
||||
# where a Docker daemon is available and skips cleanly otherwise. The job
|
||||
# installs the frozen workspace because the probes execute the committed
|
||||
# runner module from the host (it imports `pg` through the package's own
|
||||
# links).
|
||||
database-postgres-diagnostic:
|
||||
name: Migration failure diagnostic (E00-S03-T05)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
run: corepack enable
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run migration failure diagnostic test suite
|
||||
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:
|
||||
node-version: '24'
|
||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||
@@ -207,34 +165,107 @@ jobs:
|
||||
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
|
||||
- name: Run app readiness test suite
|
||||
run: node --test tests/app-readiness.test.mjs
|
||||
|
||||
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
|
||||
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
|
||||
# — the pnpm apps-group glob picks it up automatically) and verifies the
|
||||
# compiled server artifact.
|
||||
build-apps:
|
||||
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
|
||||
needs: postgres-integration
|
||||
# E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs
|
||||
# gate every PR — the suite locks in the field-specific startup error (a
|
||||
# missing required setting fails startup with an error naming the missing
|
||||
# field: packages/config's MissingRequiredSettingError/assertValidConfig,
|
||||
# wired into the committed server before it binds) with mutation probes, and
|
||||
# the deterministic probes execute the issue's test plan ("start with a
|
||||
# missing required field and confirm the error names it"): booting the
|
||||
# committed server without EPPP_SESSION_SECRET exits non-zero naming
|
||||
# sessionSecret, while a valid secret boots to GET /health 200. The job
|
||||
# installs the frozen workspace and builds the config and database-postgres
|
||||
# packages because the probes boot the committed server which imports them.
|
||||
config-startup-error:
|
||||
name: Field-specific startup errors (E00-S04-T02)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
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 workspace applications (apps/* — server today, admin when E06-S01 lands)
|
||||
run: pnpm --filter "./apps/**" run build
|
||||
- name: Verify the compiled server application artifact
|
||||
run: test -f apps/server/dist/index.js
|
||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||
- name: Run config startup error test suite
|
||||
run: node --test tests/config-startup-error.test.mjs
|
||||
|
||||
# E00-S04-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
|
||||
|
||||
+1
-8
@@ -6,12 +6,9 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# 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.
|
||||
# Local environment files (a committed .env.example lands in E00-S04)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -30,9 +27,5 @@ coverage/
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-startup-probe-*.mjs
|
||||
|
||||
# Transient host-side probe file written by the config-env-adapter test
|
||||
# suite into the package (removed in its finally block)
|
||||
.config-env-adapter-probe-*.mjs
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), 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.",
|
||||
"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.",
|
||||
"scripts": {
|
||||
"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",
|
||||
|
||||
+32
-32
@@ -23,21 +23,10 @@
|
||||
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
|
||||
* required field, Security-and-Operations §32/§26) fails fast at startup
|
||||
* with an error naming the missing field instead of booting with an invalid
|
||||
* configuration.
|
||||
*
|
||||
* [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
* package's environment adapter (`loadConfigFromEnv` from
|
||||
* `@personal-blog/config`) — the workspace's single owner of `process.env`
|
||||
* reads — so this module (and every other module outside the config package)
|
||||
* never reads `process.env` directly. The adapter maps the environment
|
||||
* (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated
|
||||
* config shape and validates it with `assertValidConfig` (E00-S04-T02) before
|
||||
* the server binds, so a missing required setting is still a startup error
|
||||
* naming the missing field. `HOST` is validated at the adapter boundary as a
|
||||
* hostname or IP address and the server passes `config.host` to
|
||||
* `server.listen`, so a configured `HOST` binds exactly that interface and
|
||||
* the startup log reflects the actual bind — it never claims a bind the
|
||||
* process does not enforce, and never echoes unvalidated env content.
|
||||
* configuration. The parsed config keeps the committed defaults for
|
||||
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
|
||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
||||
* `MissingRequiredSettingError` naming the missing field.
|
||||
*
|
||||
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
|
||||
* logger (`createLogger`, defined below — every line is scrubbed of the
|
||||
@@ -54,21 +43,25 @@
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { loadConfigFromEnv } from '@personal-blog/config';
|
||||
import { assertValidConfig } from '@personal-blog/config';
|
||||
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||
import { Pool } from '@personal-blog/database-postgres';
|
||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||
import type { Migration } from '@personal-blog/database-postgres';
|
||||
|
||||
// [E00-S04-T04] environment adapter: ALL settings flow through the config
|
||||
// package's adapter — the workspace's single owner of process.env reads — so
|
||||
// the server never reads process.env directly. The adapter maps the
|
||||
// environment onto the validated config shape and validates it with
|
||||
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
|
||||
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
|
||||
// startup with an error naming the missing field — never boots with an
|
||||
// invalid configuration.
|
||||
const config = loadConfigFromEnv();
|
||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||
const PORT = resolvePort(process.env.PORT);
|
||||
|
||||
// [E00-S04-T02] field-specific startup error: validate the startup
|
||||
// configuration before anything else, so a missing required setting (e.g.
|
||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
||||
// the missing field — never boots with an invalid configuration.
|
||||
const config = assertValidConfig({
|
||||
host: '0.0.0.0',
|
||||
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
|
||||
// logger, seeded with the validated config's secrets — and the resolved
|
||||
@@ -103,6 +96,17 @@ const MIGRATIONS: readonly Migration[] = [];
|
||||
*/
|
||||
let migrationsComplete = false;
|
||||
|
||||
/**
|
||||
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
||||
* out-of-range override falls back to the default so a bad `PORT` value cannot
|
||||
* crash the process at startup.
|
||||
*/
|
||||
function resolvePort(raw: string | undefined): number {
|
||||
const port = Number(raw ?? 3000);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
|
||||
}
|
||||
|
||||
/** Writes a JSON response with an explicit content-length. */
|
||||
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.writeHead(statusCode, {
|
||||
@@ -173,7 +177,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
const databaseUrl = config.databaseUrl;
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (databaseUrl === undefined) {
|
||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||
@@ -203,12 +207,8 @@ if (databaseUrl === undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
// The server binds the validated bind interface: `config.host` (default
|
||||
// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to
|
||||
// `server.listen`, so a configured `HOST` binds exactly that interface and
|
||||
// the startup log reflects the actual bind.
|
||||
server.listen(config.port, config.host, () => {
|
||||
logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`);
|
||||
server.listen(PORT, () => {
|
||||
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||
});
|
||||
|
||||
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
|
||||
|
||||
+1
-2
@@ -49,8 +49,7 @@
|
||||
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
|
||||
#
|
||||
# All values have defaults so `docker compose up -d` works from a clean clone
|
||||
# without a .env file (the committed .env.example template, E00-S04-T05,
|
||||
# lists the overridable variables with placeholder values).
|
||||
# without a .env file (a committed .env.example template lands in E00-S04).
|
||||
# Since E00-S04-T02 the app validates its required settings at startup: the
|
||||
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
|
||||
# Security-and-Operations §32/§26) is provided here with a dev-only default —
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,86 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,136 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,131 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,136 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,65 +0,0 @@
|
||||
# ADR index (§70)
|
||||
|
||||
Repository copy of the ADR index from the wiki ADR-Index page (§70): the
|
||||
canonical list of architectural decisions, committed or planned. Every ADR
|
||||
committed to `docs/adr/` must record a decision that matches its row in
|
||||
section 70; the wiki page remains the canonical index.
|
||||
|
||||
| ADR | Decision |
|
||||
|---|---|
|
||||
| ADR-001 | Modular monolith |
|
||||
| ADR-002 | Node.js 24 LTS runtime |
|
||||
| ADR-003 | TypeScript 6.0.3 pending TS7.1 ecosystem review |
|
||||
| ADR-004 | Fastify 5 HTTP runtime |
|
||||
| ADR-005 | PostgreSQL 18 sole canonical DB |
|
||||
| ADR-006 | Kysely contained inside DB adapter |
|
||||
| ADR-007 | React SSR for public rendering |
|
||||
| ADR-008 | React/Vite admin |
|
||||
| ADR-009 | Zero-JS public baseline |
|
||||
| ADR-010 | Client-island model for optional public interactivity |
|
||||
| ADR-011 | JSON Schema + TypeBox + Ajv validation |
|
||||
| ADR-012 | EPPP Extension API hides framework internals |
|
||||
| ADR-013 | Node/Amber is a theme extension |
|
||||
| ADR-014 | Blog is a first-party content extension |
|
||||
| ADR-015 | Versioned block documents |
|
||||
| ADR-016 | Versioned page composition |
|
||||
| ADR-017 | Extension-owned migrations/tables |
|
||||
| ADR-018 | Docker Compose primary installation |
|
||||
| ADR-019 | Opaque DB-backed admin sessions |
|
||||
| ADR-020 | Separate anonymous preference identity |
|
||||
| ADR-021 | Local media storage through storage port |
|
||||
| ADR-022 | PostgreSQL jobs before external broker |
|
||||
| ADR-023 | No Redis initially |
|
||||
| ADR-024 | No microservices initially |
|
||||
| ADR-025 | Trusted build-time executable extensions in v1 |
|
||||
| ADR-026 | Exact dependency pinning + controlled upgrade lanes |
|
||||
| ADR-027 (v1.1) | `core.markdown` block restores Markdown authoring inside the block model |
|
||||
| ADR-028 (v1.1) | Hardened outbound fetch as a core service; extensions never fetch directly |
|
||||
| ADR-029 (v1.1) | Embed provider allowlist enforced in renderer and generated CSP |
|
||||
| ADR-030 (v1.1) | Native server-side SVG charts and diagrams with an accessibility contract |
|
||||
| ADR-031 (v1.1) | Theme renaming replaces trademark references |
|
||||
| ADR-032 (v1.1) | Day-one byte budgets; latency targets from measurement |
|
||||
|
||||
Every ADR contains: Context, Decision, Alternatives, Consequences, Operational
|
||||
impact, Revisit trigger (§46 E01-S01).
|
||||
|
||||
## Architectural fitness tests (§71)
|
||||
|
||||
- **Add Ledger/Paper:** create `theme-paper` extension → register
|
||||
manifest/tokens/assets → optionally override renderer slots → tests →
|
||||
include in build. Failure = editing Home/Post domain, core DB, auth, or
|
||||
`if (theme === "paper")` in core.
|
||||
- **Add Reading:** create `org.eppp.reading` → migrations → public route →
|
||||
admin contribution → Home section → settings → job(s) → content/block
|
||||
contributions. Failure = core learning seam/half-life/bookmark/link-health
|
||||
semantics.
|
||||
|
||||
## Architecture review gates (§68)
|
||||
|
||||
Gate A (end Sprint 1): publish a real post without core becoming
|
||||
blog/Amber-specific. Gate B (end Sprint 3): add a Home feature as an extension
|
||||
with no core edits. Gate C (end Sprint 4): a radically different theme runs
|
||||
without changing content/business logic. Gate D (end Sprint 5): visitor
|
||||
preference persists without coupling to auth or theme storage. Gate E (before
|
||||
public SDK): Extension API v1 proven enough to maintain. Gate F (Reading):
|
||||
Reading owns its whole domain without `if (readingEnabled)` in core.
|
||||
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
|
||||
|
||||
| Group | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), 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. |
|
||||
| `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. |
|
||||
| `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), 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/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/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. |
|
||||
|
||||
@@ -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
|
||||
with a field-specific startup error (`missing required setting:
|
||||
sessionSecret`) that names the missing field instead of booting. Provide it
|
||||
in your shell or a local `.env` file copied from the committed
|
||||
`.env.example` template (E00-S04-T05).
|
||||
in your shell or a local `.env` file (the `.env.example` template lands in
|
||||
E00-S04).
|
||||
3. Since [E00-S02-T03], `apps/server` serves the **application health
|
||||
endpoint**: starting it opens an HTTP server on port 3000 answering
|
||||
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
|
||||
@@ -119,19 +119,6 @@ compiled application entrypoint. Three things to know:
|
||||
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||
redacting logger — the admin-session secret and the password embedded in a
|
||||
`DATABASE_URL` connection string never appear in the log output.
|
||||
5. Since [E00-S04-T04], **all settings flow through the config package's
|
||||
environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` —
|
||||
the workspace's single owner of `process.env` reads): `HOST`, `PORT`,
|
||||
`DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated
|
||||
config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`)
|
||||
and validated at startup — no module outside the config package reads
|
||||
`process.env` directly. `HOST` is validated at the adapter boundary as a
|
||||
hostname or IP address (an invalid value fails startup with a
|
||||
field-specific error naming `host` instead of being logged) and the server
|
||||
passes `config.host` to `server.listen`, so a configured `HOST` binds
|
||||
exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and
|
||||
the startup log line (`@personal-blog/server listening on
|
||||
http://<host>:<port>`) reflects the actual bind.
|
||||
|
||||
To run the compiled output of any other workspace package directly:
|
||||
|
||||
@@ -143,22 +130,11 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
|
||||
dependencies (this is also the suite that enforces the dependency-boundary
|
||||
rule). `pnpm lint` runs the formatting/lint policy suite
|
||||
(`tests/formatting-policy.test.mjs`): every tracked text file must use LF
|
||||
line endings, no BOM, no trailing whitespace, no tab indentation and exactly
|
||||
one final newline; JSON files must additionally parse, carry no duplicate
|
||||
keys and use 2-space indentation.
|
||||
|
||||
Pull requests run these checks as CI stages, in order — frozen lockfile
|
||||
install → typecheck → formatting/lint → unit → architecture → PostgreSQL
|
||||
integration → build of the applications (E00-S05-T01); the stage order,
|
||||
the `needs` chain and the `tests/` coverage are locked in by
|
||||
`tests/ci-stages.test.mjs`.
|
||||
`pnpm test` runs the `node:test` suites under `tests/` (currently
|
||||
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
|
||||
This is also the suite that enforces the dependency-boundary rule.
|
||||
|
||||
## Smoke check from a clean clone
|
||||
|
||||
@@ -168,8 +144,7 @@ corepack enable
|
||||
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
||||
pnpm build # 5/5 packages emit dist/, exit 0
|
||||
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
||||
pnpm lint # formatting/lint policy passes, exit 0
|
||||
pnpm test # all node:test suites pass, exit 0
|
||||
pnpm test # 10/10 pass, exit 0
|
||||
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
||||
```
|
||||
|
||||
@@ -182,7 +157,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). |
|
||||
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
|
||||
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
|
||||
| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
|
||||
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"lint": "node --test tests/formatting-policy.test.mjs",
|
||||
"test": "node --test \"tests/**/*.test.mjs\"",
|
||||
"typecheck": "pnpm -r run typecheck"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the 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.",
|
||||
"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.",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 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,16 +18,8 @@
|
||||
* server's redacting logger applies to every log line, so secrets
|
||||
* automatically redact from logs.
|
||||
*
|
||||
* [E00-S04-T04] Environment adapter: the boundary also exposes
|
||||
* `loadConfigFromEnv` — the workspace's single owner of `process.env` reads.
|
||||
* It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`)
|
||||
* onto the validated config shape and validates it with `assertValidConfig`
|
||||
* at startup, so every setting flows through the adapter and no other module
|
||||
* reads `process.env` directly. `HOST` is validated at the adapter boundary
|
||||
* as a hostname or IP address before it is used for binding or logged, so
|
||||
* arbitrary env content is never echoed verbatim into the startup log. The
|
||||
* committed `.env.example` template (E00-S04-T05) documents the same
|
||||
* variables with placeholder values only.
|
||||
* The environment adapter (E00-S04-T04) builds on this boundary in a later
|
||||
* task.
|
||||
*/
|
||||
|
||||
export { configSchema } from './schema.js';
|
||||
@@ -36,4 +28,3 @@ export { validateConfig } from './validate.js';
|
||||
export type { ConfigValidationResult } from './validate.js';
|
||||
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
||||
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
||||
export { loadConfigFromEnv } from './env.js';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* logs.
|
||||
*
|
||||
* The config package owns which configuration fields are secrets, so the
|
||||
* redaction layer lives here (the environment adapter, E00-S04-T04, feeds
|
||||
* redaction layer lives here (the environment adapter, E00-S04-T04, will feed
|
||||
* the validated config into it via the app's logger):
|
||||
*
|
||||
* - `redactConfig(config)` — a copy of a config value with every secret
|
||||
@@ -17,10 +17,10 @@
|
||||
* a secret value (e.g. an error message carrying a connection string) is
|
||||
* redacted even when the value was not redacted by field.
|
||||
*
|
||||
* Both feed the server's redacting logger (the `createLogger` in
|
||||
* `apps/server/src/index.ts`), so secret values never reach stdout/stderr —
|
||||
* the acceptance criteria: "secrets automatically redact from logs", "log
|
||||
* output contains no secret values".
|
||||
* Both feed the server's redacting logger (`apps/server/src/logger.ts`), so
|
||||
* secret values never reach stdout/stderr — the acceptance criteria:
|
||||
* "secrets automatically redact from logs", "log output contains no secret
|
||||
* values".
|
||||
*
|
||||
* Rollback note from the issue: revert the redaction changes.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
|
||||
* port range (1–65535), default `3000` (the container default, matching
|
||||
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
|
||||
* `PORT` is read by the environment adapter, E00-S04-T04).
|
||||
* `PORT` is read today, E00-S02-T03).
|
||||
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
|
||||
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
||||
* startup migration run to wait for and reports ready immediately (the
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
* `ConfigStartupError` whose message names each violating field too.
|
||||
*
|
||||
* This is deliberately NOT the T04 environment adapter: nothing here reads
|
||||
* `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment
|
||||
* onto the validated config shape and passes it to `assertValidConfig` at
|
||||
* startup; the secret redaction layer (E00-S04-T03) builds on the same
|
||||
* boundary.
|
||||
* `process.env`. The adapter (E00-S04-T04) maps the environment onto the
|
||||
* validated config shape and passes it to `assertValidConfig` at startup;
|
||||
* secret redaction (E00-S04-T03) builds on the same boundary in a later task.
|
||||
*
|
||||
* Rollback note from the issue: revert the validation error handling.
|
||||
*/
|
||||
|
||||
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
|
||||
* after migrations finish.
|
||||
*/
|
||||
function assertReadyAfterRun(src) {
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl');
|
||||
const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL');
|
||||
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
||||
@@ -191,7 +191,7 @@ function assertReadyAfterRun(src) {
|
||||
* non-container path and the E00-S02-T03 health endpoint working).
|
||||
*/
|
||||
function assertNoDatabaseUrlPath(src) {
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl'));
|
||||
const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL'));
|
||||
assert.match(
|
||||
startup,
|
||||
/if \(databaseUrl === undefined\) \{/,
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||
'the server must import the startup validation entry point from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
@@ -188,13 +188,12 @@ function assertServerSource(src) {
|
||||
'error lines must be written to stderr',
|
||||
);
|
||||
|
||||
// The wiring — the server keeps its validated config (loaded through the
|
||||
// environment adapter, E00-S04-T04), creates the logger with it and logs
|
||||
// the resolved configuration redacted.
|
||||
// The wiring — the server keeps its validated config, creates the logger
|
||||
// with it and logs the resolved configuration redacted.
|
||||
assert.match(
|
||||
src,
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
|
||||
/const config = assertValidConfig\(\{/,
|
||||
'the server must keep its validated configuration (const config = assertValidConfig(...))',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
@@ -216,19 +215,13 @@ function assertServerSource(src) {
|
||||
/console\.(log|error)\(/,
|
||||
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
);
|
||||
// The startup configuration loads through the adapter (which validates it)
|
||||
// before the logger is created, so a missing required setting still fails
|
||||
// fast (E00-S04-T02) before any log output.
|
||||
const validationIndex = src.indexOf('loadConfigFromEnv(');
|
||||
// The startup validation runs before the logger is created, so a missing
|
||||
// required setting still fails fast (E00-S04-T02) before any log output.
|
||||
const validationIndex = src.indexOf('assertValidConfig({');
|
||||
const loggerIndex = src.indexOf('createLogger(config)');
|
||||
assert.ok(
|
||||
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
||||
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
|
||||
'the startup validation must run before the logger is created (a missing required setting is still a startup error)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,17 @@
|
||||
* - "missing required setting gives a field-specific startup error" → the
|
||||
* `packages/config` package exposes the startup validation entry point
|
||||
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
||||
* and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the
|
||||
* config package's single owner of `process.env` reads) validates the
|
||||
* mapped environment through it; the committed `apps/server/src/index.ts`
|
||||
* loads its startup configuration through the adapter before the server
|
||||
* and the committed `apps/server/src/index.ts` calls it before the server
|
||||
* binds, so a deployment missing a required setting (the admin-session
|
||||
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
||||
* Security-and-Operations §32/§26) fails fast at startup instead of
|
||||
* booting with an invalid configuration. Locked in statically (mutation
|
||||
* probes prove non-vacuity: dropping the adapter call, moving it after
|
||||
* the bind, or dropping the compose/Dockerfile support all fail) and
|
||||
* behaviorally by the deterministic probes (the issue's test plan: "start
|
||||
* with a missing required field and confirm the error names it" — booting
|
||||
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
|
||||
* the error naming the missing field).
|
||||
* probes prove non-vacuity: dropping the startup validation call,
|
||||
* moving it after the bind, or dropping the compose/Dockerfile support
|
||||
* all fail) and behaviorally by the deterministic probes (the issue's
|
||||
* test plan: "start with a missing required field and confirm the error
|
||||
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
||||
* exits non-zero with the error naming the missing field).
|
||||
* - "the error names the missing field" → a missing required setting throws
|
||||
* `MissingRequiredSettingError` whose message and `missingField` name the
|
||||
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
||||
@@ -152,35 +149,33 @@ function assertStartupErrorSource(src) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the committed server loads its startup configuration through the
|
||||
* environment adapter (E00-S04-T04) before it binds: it imports
|
||||
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
|
||||
* validates the mapped environment with `assertValidConfig`, including the
|
||||
* required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing
|
||||
* required setting is a startup error, never a silently-booted invalid
|
||||
* configuration, and the server itself never reads `process.env` directly.
|
||||
* Asserts the committed server validates the required settings at startup:
|
||||
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
||||
* with the parsed startup configuration (including the required
|
||||
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
||||
* setting is a startup error, never a silently-booted invalid configuration.
|
||||
*/
|
||||
function assertServerStartupValidation(src) {
|
||||
assert.match(
|
||||
src,
|
||||
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
|
||||
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
|
||||
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||
'the server must import the startup validation entry point from the config package',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const config = loadConfigFromEnv\(\);/,
|
||||
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
|
||||
/assertValidConfig\(\{/,
|
||||
'the server must call assertValidConfig with its startup configuration',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
src,
|
||||
/process\.env\.[A-Z_]+/,
|
||||
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
|
||||
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
||||
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
||||
);
|
||||
const callIndex = src.indexOf('loadConfigFromEnv(');
|
||||
const callIndex = src.indexOf('assertValidConfig({');
|
||||
const listenIndex = src.indexOf('server.listen(');
|
||||
assert.ok(
|
||||
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
||||
'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error',
|
||||
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,35 +274,25 @@ test('the config-startup-error criterion is enforced in CI', () => {
|
||||
// Mutation probes — the static assertions are non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => {
|
||||
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
|
||||
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
||||
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
||||
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
||||
});
|
||||
|
||||
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
|
||||
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const moved = src
|
||||
.replace('const config = loadConfigFromEnv();\n', '')
|
||||
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
|
||||
.replace(
|
||||
'server.listen(config.port, config.host, () => {',
|
||||
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
|
||||
'server.listen(PORT, () => {',
|
||||
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
||||
);
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
|
||||
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
|
||||
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
||||
});
|
||||
|
||||
test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => {
|
||||
const src = read(SERVER_SRC);
|
||||
const directRead = src.replace(
|
||||
'const config = loadConfigFromEnv();',
|
||||
'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;',
|
||||
);
|
||||
assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read');
|
||||
assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/);
|
||||
});
|
||||
|
||||
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
||||
const src = read(STARTUP_SRC);
|
||||
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* .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/);
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Formatting/lint policy test — locks in the workspace formatting and lint
|
||||
* policy (E00-S05-T01, CI stage 3: formatting/lint).
|
||||
*
|
||||
* Policy (every file tracked by git, i.e. every committed text file):
|
||||
* - LF line endings: no carriage returns (no CRLF, no lone CR)
|
||||
* - no UTF-8 byte-order mark
|
||||
* - no trailing whitespace on any line
|
||||
* - no tab characters anywhere (indentation is spaces)
|
||||
* - exactly one final newline: the file must end with `\n`, with no blank
|
||||
* line left at the end of the file
|
||||
* JSON files additionally must:
|
||||
* - parse as strict JSON (no trailing commas, no comments)
|
||||
* - contain no duplicate object keys
|
||||
* - use 2-space indentation (every line's leading spaces are an even count)
|
||||
*
|
||||
* The scan is scoped to files tracked by git (`git ls-files`), so ignored and
|
||||
* generated files (node_modules/, dist/, .env, probe scratch files) never
|
||||
* enter the policy. Binary files (containing a NUL byte) are skipped.
|
||||
*
|
||||
* Mutation probes prove every rule is non-vacuous: each violation below is
|
||||
* injected into a temp file and must be reported.
|
||||
*
|
||||
* Run: `pnpm lint` (== `node --test tests/formatting-policy.test.mjs`)
|
||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||
|
||||
/** True for binary files: the policy applies to text files only. */
|
||||
const isBinary = (content) => content.includes('\0');
|
||||
|
||||
/**
|
||||
* Returns the list of tracked files (git ls-files, NUL-delimited) under the
|
||||
* given repo root, or fails the suite when git is unavailable.
|
||||
*/
|
||||
function trackedFiles(root = REPO_ROOT) {
|
||||
const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, `git ls-files must succeed in ${root}`);
|
||||
return result.stdout.split('\0').filter((p) => p.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the duplicate object keys of a JSON document. JSON.parse collapses
|
||||
* duplicate keys (last value wins) before any reviver or post-parse walker can
|
||||
* see them, so this scans the raw text: a string literal immediately followed
|
||||
* by `:` is an object key, and keys are tracked per enclosing `{…}` frame, so
|
||||
* same-named keys in different objects stay legal while a repeated key inside
|
||||
* one object is reported.
|
||||
*/
|
||||
function collectDuplicateKeys(text, out) {
|
||||
const frames = [];
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < n && text[j] !== '"') {
|
||||
if (text[j] === '\\') j += 1;
|
||||
j += 1;
|
||||
}
|
||||
const key = text.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
let k = j + 1;
|
||||
while (k < n && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1;
|
||||
if (text[k] === ':' && frames.length > 0) {
|
||||
const seen = frames[frames.length - 1];
|
||||
if (seen.has(key)) out.push(`duplicate JSON key: ${key}`);
|
||||
seen.add(key);
|
||||
}
|
||||
i = k;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') {
|
||||
frames.push(new Set());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}') {
|
||||
frames.pop();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the formatting/lint violations for one file relative to `root`, or
|
||||
* [] when the file conforms. Binary files are out of policy scope.
|
||||
*/
|
||||
function violationsForFile(root, relPath) {
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(path.join(root, relPath), 'utf8');
|
||||
} catch (err) {
|
||||
return [`${relPath}: unreadable: ${err.message}`];
|
||||
}
|
||||
if (isBinary(content)) return [];
|
||||
|
||||
const label = (message) => `${relPath}: ${message}`;
|
||||
const out = [];
|
||||
|
||||
if (content.includes('\r')) out.push(label('carriage return (use LF line endings)'));
|
||||
if (content.startsWith('\uFEFF')) out.push(label('UTF-8 byte-order mark'));
|
||||
if (content.includes('\t')) out.push(label('tab character (indentation must be spaces)'));
|
||||
|
||||
const lines = content.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (/[ \t]+$/.test(line)) out.push(label(`trailing whitespace on line ${i + 1}`));
|
||||
});
|
||||
|
||||
if (content.length > 0) {
|
||||
if (lines[lines.length - 1] !== '') out.push(label('missing final newline'));
|
||||
else if (lines[lines.length - 2] === '') out.push(label('blank line at end of file'));
|
||||
}
|
||||
|
||||
if (relPath.endsWith('.json')) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (err) {
|
||||
out.push(label(`invalid JSON: ${err.message}`));
|
||||
return out;
|
||||
}
|
||||
const duplicateKeys = [];
|
||||
collectDuplicateKeys(content, duplicateKeys);
|
||||
for (const message of duplicateKeys) out.push(label(message));
|
||||
lines.forEach((line, i) => {
|
||||
const indent = /^([ ]*)\S/.exec(line);
|
||||
if (indent && indent[1].length % 2 !== 0) {
|
||||
out.push(label(`JSON indentation must be 2 spaces per level (line ${i + 1})`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Writes a probe file into a fresh temp dir and returns its violations. */
|
||||
function probeViolations(filename, content) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-format-policy-'));
|
||||
try {
|
||||
writeFileSync(path.join(dir, filename), content);
|
||||
return violationsForFile(dir, filename);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-tree scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('every tracked text file conforms to the formatting/lint policy', () => {
|
||||
const files = trackedFiles();
|
||||
assert.ok(
|
||||
files.length >= 25,
|
||||
`expected a meaningful tracked file set to scan (got ${files.length})`,
|
||||
);
|
||||
const violations = [];
|
||||
for (const file of files) violations.push(...violationsForFile(REPO_ROOT, file));
|
||||
assert.deepEqual(violations, [], `formatting/lint violations:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('the scan covers the workspace source, docs, manifests and workflow', () => {
|
||||
const files = trackedFiles();
|
||||
for (const expected of [
|
||||
'apps/server/src/index.ts',
|
||||
'packages/config/src/env.ts',
|
||||
'docs/development/non-container.md',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'compose.yaml',
|
||||
'.gitea/workflows/ci.yml',
|
||||
]) {
|
||||
assert.ok(files.includes(expected), `tracked file set must include ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation probes — every rule is non-vacuous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a trailing whitespace is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line with trailing space \n');
|
||||
assert.ok(violations.some((v) => /trailing whitespace/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a tab character is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\twith tab\n');
|
||||
assert.ok(violations.some((v) => /tab character/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a CRLF line ending is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\r\n');
|
||||
assert.ok(violations.some((v) => /carriage return/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a UTF-8 byte-order mark is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', '\uFEFFline\n');
|
||||
assert.ok(violations.some((v) => /byte-order mark/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a missing final newline is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'no final newline');
|
||||
assert.ok(violations.some((v) => /missing final newline/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a blank line at the end of the file is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.txt', 'line\n\n');
|
||||
assert.ok(violations.some((v) => /blank line at end of file/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('invalid JSON is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{"a": 1,}\n');
|
||||
assert.ok(violations.some((v) => /invalid JSON/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('a duplicate JSON key is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{"a": 1, "a": 2}\n');
|
||||
assert.ok(violations.some((v) => /duplicate JSON key/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('odd JSON indentation is reported (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{\n "a": 1,\n "b": 2\n}\n');
|
||||
assert.ok(violations.some((v) => /2 spaces per level/.test(v)), `got: ${violations.join('; ')}`);
|
||||
});
|
||||
|
||||
test('binary files are out of policy scope (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.bin', 'a\0b');
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('a conforming file yields no violations (mutation probe)', () => {
|
||||
const violations = probeViolations('probe.json', '{\n "a": 1\n}\n');
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
@@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) {
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/server\.listen\(config\.port/,
|
||||
'the server must bind the port from the validated configuration (config.port, E00-S04-T04)',
|
||||
/3000/,
|
||||
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,19 +189,6 @@ test('the endpoint reports a healthy application (committed health payload is {"
|
||||
);
|
||||
});
|
||||
|
||||
test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => {
|
||||
// The server binds `config.port` (asserted above); the port default (3000,
|
||||
// matching the Dockerfile EXPOSE / compose :3000) and the PORT override
|
||||
// live in the config package's environment adapter — the single owner of
|
||||
// process.env reads.
|
||||
const envSrc = read('packages/config/src/env.ts');
|
||||
assert.match(
|
||||
envSrc,
|
||||
/3000/,
|
||||
'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)',
|
||||
);
|
||||
});
|
||||
|
||||
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
|
||||
if (!tsExecMode()) {
|
||||
t.skip(
|
||||
|
||||
Reference in New Issue
Block a user