Compare commits

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