Compare commits

..
35 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
kpcto 1e0f628651 Merge pull request '[E00-S04-T04] No module reads process.env except configuration adapter' (#403) from feature/185 into main
CI / Frozen lockfile install (push) Successful in 44s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m4s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m12s
CI / Env adapter owns process.env (E00-S04-T04) (push) Successful in 1m9s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 29s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 25s
CI / Migration ledger (E00-S03-T03) (push) Successful in 46s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 57s
CI / Secret redaction from logs (E00-S04-T03) (push) Successful in 1m4s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 53s
CI / Compose config (E00-S03-T01) (push) Successful in 35s
2026-08-30 05:02:48 +00:00
implementer ffda249617 docs: document HOST validation and bind control (E00-S04-T04)
CI / Frozen lockfile install (pull_request) Successful in 54s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 34s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 30s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 1m16s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 54s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m6s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m2s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m1s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 50s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m5s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
The non-container guide now notes that HOST is validated at the adapter
boundary as a hostname/IP (invalid values fail startup naming the field) and
that the server passes config.host to server.listen, so a configured HOST
binds exactly that interface and the startup log reflects the actual bind.
The config-env-adapter CI job comment is refreshed to describe the extended
suite (HOST validation + loopback-only boot probe).
2026-08-30 04:42:06 +00:00
implementer 408f33e4e9 test: lock in HOST validation and bind-interface control (E00-S04-T04)
Extend the env-adapter suite to the issue's reworked acceptance criteria:
- static assertions: the adapter resolves HOST via resolveHost (hostname/IP at
  the adapter boundary) and the server passes config.host to server.listen
- deterministic boundary probe: valid HOST forms (IPv4/IPv6/hostname) pass,
  invalid HOST forms throw ConfigStartupError naming host
- boot probes: HOST=127.0.0.1 binds loopback only (no answer on a
  non-loopback interface) with the startup log reflecting the actual bind;
  an invalid HOST exits non-zero naming the field without echoing the raw
  value
- mutation probes: bypassing resolveHost or dropping config.host from
  server.listen both fail
- config-startup-error: update the order-asserion mutation probe for the new
  server.listen(config.port, config.host, ...) signature
2026-08-30 04:42:03 +00:00
implementer 345ceccfad feat: HOST validated at the adapter boundary and controls the actual bind interface (E00-S04-T04)
The environment adapter now resolves HOST through resolveHost, validating it
at the adapter boundary as a hostname (RFC 1123) or IP address (IPv4/IPv6,
node:net isIP); an invalid HOST throws a field-specific ConfigStartupError
naming host, so arbitrary env content is never used for binding or echoed
verbatim into the startup log (issue acceptance criterion, resolving security
review finding SEC-3).

The server passes config.host to server.listen(config.port, config.host, ...),
so a configured HOST binds exactly that interface and the startup log never
claims a bind the process does not enforce (resolving SEC-2).
2026-08-30 04:41:53 +00:00
implementer 73a1ae88cd chore: drop unused mkdirSync import in the env-adapter suite (E00-S04-T04)
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 55s
CI / Frozen lockfile install (pull_request) Successful in 46s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 28s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 1m5s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m32s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m41s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m10s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m12s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 1m6s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 35s
2026-08-30 04:18:11 +00:00
implementer b9345e205c docs: document the environment adapter in the non-container guide (E00-S04-T04)
CI / Frozen lockfile install (pull_request) Successful in 46s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 24s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 32s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 50s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 48s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 45s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m20s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m20s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m11s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m48s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 1m21s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 31s
2026-08-30 04:17:22 +00:00
implementer 3214807c9d test: update suites locked to the old direct process.env wiring for the adapter (E00-S04-T04) 2026-08-30 04:17:22 +00:00
implementer 20173a8241 test: lock in the env adapter as the single owner of process.env with static scan, mutation and deterministic probes (E00-S04-T04) 2026-08-30 04:17:22 +00:00
implementer 1214a33adb feat: server loads all settings through the config environment adapter (E00-S04-T04) 2026-08-30 04:17:19 +00:00
implementer 7aeeeaaa97 feat: environment adapter is the single owner of process.env reads (E00-S04-T04) 2026-08-30 04:17:19 +00:00
kpcto 072f5c5785 Merge pull request '[E00-S04-T03] Secrets automatically redact from logs' (#402) from feature/184 into main
CI / Frozen lockfile install (push) Successful in 49s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 24s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 48s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 35s
CI / Migration ledger (E00-S03-T03) (push) Successful in 58s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 1m8s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m23s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m36s
CI / Secret redaction from logs (E00-S04-T03) (push) Successful in 1m17s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 59s
CI / Compose config (E00-S03-T01) (push) Successful in 32s
2026-08-30 04:00:42 +00:00
31 changed files with 3538 additions and 304 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 -208
View File
@@ -5,14 +5,48 @@ on:
push:
branches: [main]
# Minimal workflow token: the pipeline only reads repository contents
# (checkout, frozen install, typecheck, lint, tests, build) — nothing writes
# back, so the token is scoped to contents: read (E00-S05-T01).
permissions:
contents: read
# E00-S05-T01 — CI quality baseline (required PR stages).
#
# Every pull request runs the required quality stages in order, each gated on
# the previous stage through `needs`:
#
# 1. frozen-install — the committed lockfile installs cleanly
# 2. typecheck — every workspace package passes `tsc --noEmit`
# 3. formatting-lint — the dependency-free formatting/lint policy (`pnpm lint`)
# 4. unit — deterministic unit suites (health, config, env example…)
# 5. architecture — static workspace/container structure and policy suites
# 6. postgres-integration — PostgreSQL adapter suites (docker-gated real-stack
# probes run where a Docker daemon is available and
# skip cleanly otherwise)
# 7. build-apps — builds the workspace applications (apps/*: server
# today, admin when E06-S01 lands) and verifies the
# compiled artifact
#
# The stage order, the `needs` chain and the tests/ coverage are locked in by
# tests/ci-stages.test.mjs (architecture stage). Third-party actions
# (actions/checkout, actions/setup-node) are pinned to full commit SHAs — no
# floating tags — and the workflow token is scoped to `contents: read`
# (E00-S05-T01 hardening). Container/Compose smoke on main/release branches
# and the Docker Compose baseline stack (E00-S02) stay out of scope for this
# stage list.
jobs:
# Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the
# committed lockfile must install cleanly and be up to date with the
# manifests before any stage proceeds.
frozen-install:
name: Frozen lockfile install
name: Stage 1 — Frozen lockfile install (E00-S05-T01)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
@@ -22,141 +56,59 @@ jobs:
- name: Verify workspace groups
run: pnpm -r list --depth -1
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
# gate every PR (the docker-gated layer-scan probe inside the same file runs
# where a Docker daemon is available and skips cleanly otherwise).
secrets-not-embedded:
name: Secrets not embedded (E00-S02-T08)
# Stage 2 — typecheck (E00-S05-T01).
typecheck:
name: Stage 2 — Typecheck (E00-S05-T01)
needs: frozen-install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
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
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration ledger test suite
run: node --test tests/database-postgres-ledger.test.mjs
- name: Typecheck every workspace package
run: pnpm typecheck
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
# gate every PR — the suite locks in the migration advisory lock (session-
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
# acquire() calls share one connection, driver-boundary re-export) with
# mutation probes, and the docker-gated real-stack concurrent probe (a
# second runner waits or fails while the first holds the lock; concurrent
# acquire() checks out exactly one connection; the lock releases when the
# holding session ends) runs where a Docker daemon is available and skips
# cleanly otherwise. The job installs the frozen workspace because the
# real-stack probe executes the committed lock module from the host (it
# imports `pg` through the package's own links).
database-postgres-lock:
name: Migration advisory lock (E00-S03-T04)
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
# newline, and valid JSON with 2-space indentation and no duplicate keys.
formatting-lint:
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration advisory lock test suite
run: node --test tests/database-postgres-lock.test.mjs
- name: Run the formatting/lint policy
run: pnpm lint
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
# gate every PR — the suite locks in the migration failure diagnostic (a
# structured MigrationFailedError whose diagnostic identifies the failing
# migration, the failure phase, the underlying cause, and the applied/pending
# ledger state, serializable via toJSON) with mutation probes, and a
# deterministic stub-pool behavioral probe (intentionally failing migration
# fixture -> structured diagnostic naming the failing migration) runs on
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
# intentionally failing migration fixture and confirm the diagnostic") runs
# where a Docker daemon is available and skips cleanly otherwise. The job
# installs the frozen workspace because the probes execute the committed
# runner module from the host (it imports `pg` through the package's own
# links).
database-postgres-diagnostic:
name: Migration failure diagnostic (E00-S03-T05)
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
# PR without external services: the app health endpoint, the secrets scan
# and the configuration service suites (schema, startup error, log
# redaction, env adapter, .env.example). The config suites boot the
# committed server, so the config and database-postgres packages are built
# first.
unit:
name: Stage 4 — Unit tests (E00-S05-T01)
needs: formatting-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration failure diagnostic test suite
run: node --test tests/database-postgres-diagnostic.test.mjs
# E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate
# every PR — the suite locks in the readiness gate (the app answers
# GET /health with 503 {"status":"not ready"} until the startup migration
# run completes, then 200 {"status":"ok"}) with mutation probes, the
# deterministic probes (boot the committed server: no DATABASE_URL ->
# ready immediately; unreachable DATABASE_URL -> stays not-ready) run on
# Node 24, and the docker-gated real-stack probe (the issue's test plan:
# "start with pending migrations and confirm readiness waits" — the app's
# migration run is blocked behind a held ACCESS EXCLUSIVE lock on the
# migration ledger, /health stays not-ready, then flips ready once the lock
# releases) runs where a Docker daemon is available and skips cleanly
# otherwise. The job installs the frozen workspace and builds the config
# and database-postgres packages because the probes boot the committed
# server from the host (it imports @personal-blog/config and
# @personal-blog/database-postgres through the packages' own links; the
# required EPPP_SESSION_SECRET is provided by the probe's boot env).
app-readiness:
name: App readiness after migrations (E00-S03-T06)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
@@ -165,107 +117,124 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run 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
# E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs
# gate every PR — the suite locks in the field-specific startup error (a
# missing required setting fails startup with an error naming the missing
# field: packages/config's MissingRequiredSettingError/assertValidConfig,
# wired into the committed server before it binds) with mutation probes, and
# the deterministic probes execute the issue's test plan ("start with a
# missing required field and confirm the error names it"): booting the
# committed server without EPPP_SESSION_SECRET exits non-zero naming
# sessionSecret, while a valid secret boots to GET /health 200. The job
# installs the frozen workspace and builds the config and database-postgres
# packages because the probes boot the committed server which imports them.
config-startup-error:
name: Field-specific startup errors (E00-S04-T02)
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
# — the pnpm apps-group glob picks it up automatically) and verifies the
# compiled server artifact.
build-apps:
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
needs: postgres-integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run config startup error test suite
run: node --test tests/config-startup-error.test.mjs
# E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs
# gate every PR — the suite locks in automatic secret redaction from logs
# (packages/config's redactConfig/redactText + the server's redacting
# logger: every log line is scrubbed of the config's secret values) with
# mutation probes, and the deterministic probes execute the issue's test
# plan ("log configuration and confirm secret values are redacted"):
# booting the committed server logs its resolved configuration with the
# secret values replaced by [REDACTED], and no secret value appears in the
# log output. The job installs the frozen workspace and builds the config
# and database-postgres packages because the probes boot the committed
# server which imports them.
config-log-redaction:
name: Secret redaction from logs (E00-S04-T03)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run config log redaction test suite
run: node --test tests/config-log-redaction.test.mjs
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
# every PR — the suite locks in the TypeBox/Ajv configuration schema
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
# ajv@8.20.0) with mutation probes, and the deterministic probe executes
# the issue's test plan ("validate a full config against the TypeBox/Ajv
# schema") against the committed schema through Ajv. The job installs the
# frozen workspace and builds the config package because the probe also
# exercises the compiled package boundary (@personal-blog/config) exactly
# as the later configuration adapter will consume it.
config-schema:
name: TypeBox/Ajv config schema (E00-S04-T01)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config package (the probe exercises the compiled package boundary)
run: pnpm --filter @personal-blog/config build
- name: Run config schema test suite
run: node --test tests/config-schema.test.mjs
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
# build platforms) gate every PR (the docker-gated real-stack probes inside
# the same file run where a Docker daemon is available and skip cleanly
# otherwise).
compose-config:
name: Compose config (E00-S03-T01)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run compose-config test suite
run: node --test tests/compose-config.test.mjs
- name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands)
run: pnpm --filter "./apps/**" run build
- name: Verify the compiled server application artifact
run: test -f apps/server/dist/index.js
+8 -1
View File
@@ -6,9 +6,12 @@ node_modules/
dist/
coverage/
# Local environment files (a committed .env.example lands in E00-S04)
# Local environment files: real .env files are ignored, while the committed
# .env.example template (E00-S04-T05) is explicitly un-ignored so it stays
# tracked.
.env
.env.*
!.env.example
# Logs
*.log
@@ -27,5 +30,9 @@ coverage/
# suite into the package (removed in its finally block)
.config-startup-probe-*.mjs
# Transient host-side probe file written by the config-env-adapter test
# suite into the package (removed in its finally block)
.config-env-adapter-probe-*.mjs
# OS / editor
.DS_Store
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02) and automatic secret redaction from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story.",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly and binds the validated HOST interface (E00-S04-T04); the Fastify 5 application shell lands in a later story.",
"scripts": {
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
+32 -32
View File
@@ -23,10 +23,21 @@
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
* required field, Security-and-Operations §32/§26) fails fast at startup
* with an error naming the missing field instead of booting with an invalid
* configuration. The parsed config keeps the committed defaults for
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
* `MissingRequiredSettingError` naming the missing field.
* configuration.
*
* [E00-S04-T04] environment adapter: ALL settings flow through the config
* package's environment adapter (`loadConfigFromEnv` from
* `@personal-blog/config`) — the workspace's single owner of `process.env`
* reads — so this module (and every other module outside the config package)
* never reads `process.env` directly. The adapter maps the environment
* (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated
* config shape and validates it with `assertValidConfig` (E00-S04-T02) before
* the server binds, so a missing required setting is still a startup error
* naming the missing field. `HOST` is validated at the adapter boundary as a
* hostname or IP address and the server passes `config.host` to
* `server.listen`, so a configured `HOST` binds exactly that interface and
* the startup log reflects the actual bind — it never claims a bind the
* process does not enforce, and never echoes unvalidated env content.
*
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
* logger (`createLogger`, defined below — every line is scrubbed of the
@@ -43,25 +54,21 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { assertValidConfig } from '@personal-blog/config';
import { loadConfigFromEnv } from '@personal-blog/config';
import { redactConfig, redactText, type Config } from '@personal-blog/config';
import { Pool } from '@personal-blog/database-postgres';
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */
const PORT = resolvePort(process.env.PORT);
// [E00-S04-T02] field-specific startup error: validate the startup
// configuration before anything else, so a missing required setting (e.g.
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
// the missing field — never boots with an invalid configuration.
const config = assertValidConfig({
host: '0.0.0.0',
port: PORT,
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.EPPP_SESSION_SECRET,
});
// [E00-S04-T04] environment adapter: ALL settings flow through the config
// package's adapter — the workspace's single owner of process.env reads — so
// the server never reads process.env directly. The adapter maps the
// environment onto the validated config shape and validates it with
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
// startup with an error naming the missing field — never boots with an
// invalid configuration.
const config = loadConfigFromEnv();
// [E00-S04-T03] secret redaction: every log line goes through the redacting
// logger, seeded with the validated config's secrets — and the resolved
@@ -96,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
*/
let migrationsComplete = false;
/**
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
* out-of-range override falls back to the default so a bad `PORT` value cannot
* crash the process at startup.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
/** Writes a JSON response with an explicit content-length. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.writeHead(statusCode, {
@@ -177,7 +173,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const server = createServer(handleRequest);
const databaseUrl = process.env.DATABASE_URL;
const databaseUrl = config.databaseUrl;
if (databaseUrl === undefined) {
// No DATABASE_URL configured (e.g. local non-container dev): there are no
@@ -207,8 +203,12 @@ if (databaseUrl === undefined) {
});
}
server.listen(PORT, () => {
logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
// The server binds the validated bind interface: `config.host` (default
// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to
// `server.listen`, so a configured `HOST` binds exactly that interface and
// the startup log reflects the actual bind.
server.listen(config.port, config.host, () => {
logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`);
});
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
+2 -1
View File
@@ -49,7 +49,8 @@
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
#
# All values have defaults so `docker compose up -d` works from a clean clone
# without a .env file (a committed .env.example template lands in E00-S04).
# without a .env file (the committed .env.example template, E00-S04-T05,
# lists the overridable variables with placeholder values).
# Since E00-S04-T02 the app validates its required settings at startup: the
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
# Security-and-Operations §32/§26) is provided here with a dev-only default —
+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.
+34 -9
View File
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
| Group | Path | Purpose |
| --- | --- | --- |
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), and redacts secret values from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story. |
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. |
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the `.env.example` template (E00-S04-T05) land in later tasks. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the committed `.env.example` template (E00-S04-T05) documents every variable with placeholder values only. |
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
@@ -99,8 +99,8 @@ compiled application entrypoint. Three things to know:
must be set in the environment — if it is missing, the process fails fast
with a field-specific startup error (`missing required setting:
sessionSecret`) that names the missing field instead of booting. Provide it
in your shell or a local `.env` file (the `.env.example` template lands in
E00-S04).
in your shell or a local `.env` file copied from the committed
`.env.example` template (E00-S04-T05).
3. Since [E00-S02-T03], `apps/server` serves the **application health
endpoint**: starting it opens an HTTP server on port 3000 answering
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
@@ -119,6 +119,19 @@ compiled application entrypoint. Three things to know:
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
redacting logger — the admin-session secret and the password embedded in a
`DATABASE_URL` connection string never appear in the log output.
5. Since [E00-S04-T04], **all settings flow through the config package's
environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` —
the workspace's single owner of `process.env` reads): `HOST`, `PORT`,
`DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated
config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`)
and validated at startup — no module outside the config package reads
`process.env` directly. `HOST` is validated at the adapter boundary as a
hostname or IP address (an invalid value fails startup with a
field-specific error naming `host` instead of being logged) and the server
passes `config.host` to `server.listen`, so a configured `HOST` binds
exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and
the startup log line (`@personal-blog/server listening on
http://<host>:<port>`) reflects the actual bind.
To run the compiled output of any other workspace package directly:
@@ -130,11 +143,22 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
```sh
pnpm test
pnpm lint
```
`pnpm test` runs the `node:test` suites under `tests/` (currently
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
This is also the suite that enforces the dependency-boundary rule.
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
dependencies (this is also the suite that enforces the dependency-boundary
rule). `pnpm lint` runs the formatting/lint policy suite
(`tests/formatting-policy.test.mjs`): every tracked text file must use LF
line endings, no BOM, no trailing whitespace, no tab indentation and exactly
one final newline; JSON files must additionally parse, carry no duplicate
keys and use 2-space indentation.
Pull requests run these checks as CI stages, in order — frozen lockfile
install → typecheck → formatting/lint → unit → architecture → PostgreSQL
integration → build of the applications (E00-S05-T01); the stage order,
the `needs` chain and the `tests/` coverage are locked in by
`tests/ci-stages.test.mjs`.
## Smoke check from a clean clone
@@ -144,7 +168,8 @@ corepack enable
pnpm install --frozen-lockfile # exit 0, lockfile untouched
pnpm build # 5/5 packages emit dist/, exit 0
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
pnpm test # 10/10 pass, exit 0
pnpm 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
```
@@ -157,7 +182,7 @@ pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see
| `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
## Out of scope
+1
View File
@@ -9,6 +9,7 @@
},
"scripts": {
"build": "pnpm -r run build",
"lint": "node --test tests/formatting-policy.test.mjs",
"test": "node --test \"tests/**/*.test.mjs\"",
"typecheck": "pnpm -r run typecheck"
},
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the .env.example template (E00-S04-T05) land in later tasks.",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
+123
View File
@@ -0,0 +1,123 @@
/**
* EPPP configuration environment adapter — [E00-S04-T04] no module reads
* `process.env` except the configuration adapter.
*
* This module is the workspace's single owner of `process.env` reads.
* `loadConfigFromEnv` maps the environment onto the validated config shape
* (the E00-S04-T01 TypeBox/Ajv schema) and validates it with
* `assertValidConfig` (the E00-S04-T02 startup validation) before returning
* it, so every setting the application uses — `host`, `port`, `databaseUrl`,
* `sessionSecret` — flows through the adapter and no other module reads
* `process.env` directly (the issue's acceptance criteria: "no module reads
* `process.env` directly except the configuration adapter", "all settings
* flow through the adapter").
*
* The environment mapping (per the schema's documented environment sources,
* `schema.ts`):
*
* - `HOST` → `host` — the interface the HTTP server binds, default `0.0.0.0`
* (the container default). The value is validated at this adapter boundary
* as a hostname or IP address (IPv4/IPv6) before it is used for binding or
* logged: an invalid `HOST` throws a field-specific `ConfigStartupError`
* naming `host`, so arbitrary env content is never echoed verbatim into the
* startup log (the issue's acceptance criterion: "HOST is validated at the
* adapter boundary as a hostname or IP address before it is used for
* binding or logged").
* - `PORT` → `port` — integer in the valid TCP port range (1–65535), default
* `3000` (the Dockerfile `EXPOSE 3000` / compose `:3000` container port). A
* non-numeric or out-of-range override falls back to the default so a bad
* `PORT` cannot crash the process at startup (the behavior the server's
* entrypoint had before this adapter existed).
* - `DATABASE_URL` → `databaseUrl` — optional; when absent the app has no
* startup migration run to wait for and reports ready immediately (the
* local non-container developer path, E00-S01-T06/E00-S03-T06).
* - `EPPP_SESSION_SECRET` → `sessionSecret` — the required admin-session
* secret (Security-and-Operations §32/§26, ≥ 32 characters); a missing or
* too-short value fails startup with the field-specific error from
* `assertValidConfig`.
*
* `assertValidConfig` is what makes a missing required setting a startup
* error: the adapter hands the mapped value to it, and it throws
* `MissingRequiredSettingError` naming the missing field — the application
* never boots with an invalid configuration.
*
* Rollback note from the issue: revert any module changes that read
* `process.env`.
*/
import { isIP } from 'node:net';
import { assertValidConfig, ConfigStartupError } from './startup.js';
import type { Config } from './schema.js';
/**
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
* out-of-range override falls back to the default so a bad `PORT` value cannot
* crash the process at startup.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
/**
* One RFC 1123 hostname label: 1–63 alphanumerics/hyphens, not starting or
* ending with a hyphen.
*/
const HOSTNAME_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
/**
* A hostname: dot-separated RFC 1123 labels (e.g. `localhost`, `db`,
* `api.internal.example`), at most 253 characters total.
*/
const HOSTNAME_PATTERN = new RegExp(`^(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}$`);
/**
* Resolves the bind interface from `HOST` (default `0.0.0.0` — the container
* default). The value is validated at this adapter boundary as a hostname or
* IP address (IPv4/IPv6, via `node:net` `isIP` or the RFC 1123 hostname
* pattern) BEFORE it can be used for binding or logged: an invalid value
* throws a field-specific `ConfigStartupError` naming `host`, so arbitrary
* `HOST` content is never echoed verbatim into the startup log (the issue's
* acceptance criterion — the server passes the validated value to
* `server.listen`, and the startup log reflects the actual bind interface).
*
* Unlike `resolvePort` (which falls back to the default on a bad value), an
* invalid `HOST` fails startup: an operator who sets `HOST=127.0.0.1` to
* restrict network exposure must never silently get a different interface.
*/
function resolveHost(raw: string | undefined): string {
if (raw === undefined) return '0.0.0.0';
if (isIP(raw) !== 0 || (raw.length <= 253 && HOSTNAME_PATTERN.test(raw))) {
return raw;
}
throw new ConfigStartupError(
'invalid configuration: host: must be a valid hostname or IP address',
['host: must be a valid hostname or IP address'],
);
}
/**
* Maps the environment onto the validated configuration — the E00-S04-T04
* environment adapter.
*
* Reads every setting from the given environment (defaulting to `process.env`
* — this module is the workspace's single owner of `process.env` reads) and
* returns the validated `Config`; an invalid environment fails fast with the
* field-specific startup error, so a missing or malformed setting is a
* startup error, never a silently-booted invalid configuration.
*
* @param env - the environment to read (defaults to `process.env`)
* @returns the validated configuration
* @throws {MissingRequiredSettingError} when a required setting is missing
* @throws {ConfigStartupError} when the configuration violates the schema
*/
export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config {
return assertValidConfig({
host: resolveHost(env.HOST),
port: resolvePort(env.PORT),
databaseUrl: env.DATABASE_URL,
sessionSecret: env.EPPP_SESSION_SECRET,
});
}
+11 -2
View File
@@ -18,8 +18,16 @@
* server's redacting logger applies to every log line, so secrets
* automatically redact from logs.
*
* The environment adapter (E00-S04-T04) builds on this boundary in a later
* task.
* [E00-S04-T04] Environment adapter: the boundary also exposes
* `loadConfigFromEnv` — the workspace's single owner of `process.env` reads.
* It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`)
* onto the validated config shape and validates it with `assertValidConfig`
* at startup, so every setting flows through the adapter and no other module
* reads `process.env` directly. `HOST` is validated at the adapter boundary
* as a hostname or IP address before it is used for binding or logged, so
* arbitrary env content is never echoed verbatim into the startup log. The
* committed `.env.example` template (E00-S04-T05) documents the same
* variables with placeholder values only.
*/
export { configSchema } from './schema.js';
@@ -28,3 +36,4 @@ export { validateConfig } from './validate.js';
export type { ConfigValidationResult } from './validate.js';
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
export { loadConfigFromEnv } from './env.js';
+1 -1
View File
@@ -3,7 +3,7 @@
* logs.
*
* The config package owns which configuration fields are secrets, so the
* redaction layer lives here (the environment adapter, E00-S04-T04, will feed
* redaction layer lives here (the environment adapter, E00-S04-T04, feeds
* the validated config into it via the app's logger):
*
* - `redactConfig(config)` — a copy of a config value with every secret
+1 -1
View File
@@ -18,7 +18,7 @@
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
* port range (1–65535), default `3000` (the container default, matching
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
* `PORT` is read today, E00-S02-T03).
* `PORT` is read by the environment adapter, E00-S04-T04).
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
* startup migration run to wait for and reports ready immediately (the
+4 -3
View File
@@ -12,9 +12,10 @@
* `ConfigStartupError` whose message names each violating field too.
*
* This is deliberately NOT the T04 environment adapter: nothing here reads
* `process.env`. The adapter (E00-S04-T04) maps the environment onto the
* validated config shape and passes it to `assertValidConfig` at startup;
* secret redaction (E00-S04-T03) builds on the same boundary in a later task.
* `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment
* onto the validated config shape and passes it to `assertValidConfig` at
* startup; the secret redaction layer (E00-S04-T03) builds on the same
* boundary.
*
* Rollback note from the issue: revert the validation error handling.
*/
+2 -2
View File
@@ -157,7 +157,7 @@ function assertReadinessGate(src) {
* after migrations finish.
*/
function assertReadyAfterRun(src) {
const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL');
const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl');
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
const elseStart = src.indexOf('} else {', dbUrlIndex);
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
@@ -191,7 +191,7 @@ function assertReadyAfterRun(src) {
* non-container path and the E00-S02-T03 health endpoint working).
*/
function assertNoDatabaseUrlPath(src) {
const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL'));
const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl'));
assert.match(
startup,
/if \(databaseUrl === undefined\) \{/,
+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/);
});
File diff suppressed because it is too large Load Diff
+17 -10
View File
@@ -159,8 +159,8 @@ function assertServerSource(src) {
// package's scrubber before it reaches stdout/stderr.
assert.match(
src,
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
'the server must import the startup validation entry point from the config package',
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
);
assert.match(
src,
@@ -188,12 +188,13 @@ function assertServerSource(src) {
'error lines must be written to stderr',
);
// The wiring — the server keeps its validated config, creates the logger
// with it and logs the resolved configuration redacted.
// The wiring — the server keeps its validated config (loaded through the
// environment adapter, E00-S04-T04), creates the logger with it and logs
// the resolved configuration redacted.
assert.match(
src,
/const config = assertValidConfig\(\{/,
'the server must keep its validated configuration (const config = assertValidConfig(...))',
/const config = loadConfigFromEnv\(\);/,
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
);
assert.match(
src,
@@ -215,13 +216,19 @@ function assertServerSource(src) {
/console\.(log|error)\(/,
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
);
// The startup validation runs before the logger is created, so a missing
// required setting still fails fast (E00-S04-T02) before any log output.
const validationIndex = src.indexOf('assertValidConfig({');
assert.doesNotMatch(
src,
/process\.env\.[A-Z_]+/,
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
);
// The startup configuration loads through the adapter (which validates it)
// before the logger is created, so a missing required setting still fails
// fast (E00-S04-T02) before any log output.
const validationIndex = src.indexOf('loadConfigFromEnv(');
const loggerIndex = src.indexOf('createLogger(config)');
assert.ok(
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
'the startup validation must run before the logger is created (a missing required setting is still a startup error)',
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
);
}
+45 -30
View File
@@ -6,17 +6,20 @@
* - "missing required setting gives a field-specific startup error" → the
* `packages/config` package exposes the startup validation entry point
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
* and the committed `apps/server/src/index.ts` calls it before the server
* and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the
* config package's single owner of `process.env` reads) validates the
* mapped environment through it; the committed `apps/server/src/index.ts`
* loads its startup configuration through the adapter before the server
* binds, so a deployment missing a required setting (the admin-session
* secret `EPPP_SESSION_SECRET` — the schema's required field,
* Security-and-Operations §32/§26) fails fast at startup instead of
* booting with an invalid configuration. Locked in statically (mutation
* probes prove non-vacuity: dropping the startup validation call,
* moving it after the bind, or dropping the compose/Dockerfile support
* all fail) and behaviorally by the deterministic probes (the issue's
* test plan: "start with a missing required field and confirm the error
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
* exits non-zero with the error naming the missing field).
* probes prove non-vacuity: dropping the adapter call, moving it after
* the bind, or dropping the compose/Dockerfile support all fail) and
* behaviorally by the deterministic probes (the issue's test plan: "start
* with a missing required field and confirm the error names it" — booting
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
* the error naming the missing field).
* - "the error names the missing field" → a missing required setting throws
* `MissingRequiredSettingError` whose message and `missingField` name the
* missing field (e.g. `"missing required setting: sessionSecret"`); other
@@ -149,33 +152,35 @@ function assertStartupErrorSource(src) {
}
/**
* Asserts the committed server validates the required settings at startup:
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
* with the parsed startup configuration (including the required
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
* setting is a startup error, never a silently-booted invalid configuration.
* Asserts the committed server loads its startup configuration through the
* environment adapter (E00-S04-T04) before it binds: it imports
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
* validates the mapped environment with `assertValidConfig`, including the
* required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing
* required setting is a startup error, never a silently-booted invalid
* configuration, and the server itself never reads `process.env` directly.
*/
function assertServerStartupValidation(src) {
assert.match(
src,
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
'the server must import the startup validation entry point from the config package',
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
);
assert.match(
src,
/assertValidConfig\(\{/,
'the server must call assertValidConfig with its startup configuration',
/const config = loadConfigFromEnv\(\);/,
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
);
assert.match(
assert.doesNotMatch(
src,
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
/process\.env\.[A-Z_]+/,
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
);
const callIndex = src.indexOf('assertValidConfig({');
const callIndex = src.indexOf('loadConfigFromEnv(');
const listenIndex = src.indexOf('server.listen(');
assert.ok(
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error',
);
}
@@ -274,25 +279,35 @@ test('the config-startup-error criterion is enforced in CI', () => {
// Mutation probes — the static assertions are non-vacuous
// ---------------------------------------------------------------------------
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
});
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const moved = src
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
.replace('const config = loadConfigFromEnv();\n', '')
.replace(
'server.listen(PORT, () => {',
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
'server.listen(config.port, config.host, () => {',
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
);
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
});
test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const directRead = src.replace(
'const config = loadConfigFromEnv();',
'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;',
);
assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read');
assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/);
});
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
+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, []);
});
+15 -2
View File
@@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) {
);
assert.match(
src,
/3000/,
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
/server\.listen\(config\.port/,
'the server must bind the port from the validated configuration (config.port, E00-S04-T04)',
);
}
@@ -189,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {"
);
});
test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => {
// The server binds `config.port` (asserted above); the port default (3000,
// matching the Dockerfile EXPOSE / compose :3000) and the PORT override
// live in the config package's environment adapter — the single owner of
// process.env reads.
const envSrc = read('packages/config/src/env.ts');
assert.match(
envSrc,
/3000/,
'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)',
);
});
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
if (!tsExecMode()) {
t.skip(