Compare commits

...
42 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
implementer ae900589c3 docs: document the redacted resolved-configuration log in the non-container guide (E00-S04-T03)
CI / Frozen lockfile install (pull_request) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 23s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 48s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 43s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m11s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m9s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m18s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 57s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
2026-08-30 03:54:36 +00:00
implementer 60bd097b70 test: lock in secret redaction from logs with static, mutation and deterministic probes (E00-S04-T03) 2026-08-30 03:52:55 +00:00
implementer 6458013306 feat: secrets redact from logs via config redaction layer and server redacting logger (E00-S04-T03) 2026-08-30 03:52:52 +00:00
kpcto ebb9d4f421 Merge pull request '[E00-S04-T02] Missing required setting gives field-specific startup error' (#397) from feature/183 into main
CI / Frozen lockfile install (push) Successful in 43s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 26s
CI / Migration ledger (E00-S03-T03) (push) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 50s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 48s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m6s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m18s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 50s
CI / Compose config (E00-S03-T01) (push) Successful in 26s
2026-08-30 03:32:23 +00:00
implementer 873264004a docs: document the required EPPP_SESSION_SECRET and the startup error in the non-container guide (E00-S04-T02)
CI / Frozen lockfile install (pull_request) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 43s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 42s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m1s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 46s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m6s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 48s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 27s
2026-08-30 03:00:47 +00:00
implementer 1a9fd592d9 test: lock in the field-specific startup error with static, mutation and deterministic probes (E00-S04-T02)
- tests/config-startup-error.test.mjs: static assertions on the committed
  startup-error module, the package boundary, the server wiring (validation
  before bind), the compose secret and the Dockerfile shipping, each backed
  by mutation probes; the deterministic probes execute the issue's test plan
  ("start with a missing required field and confirm the error names it") —
  the compiled boundary throws MissingRequiredSettingError naming
  sessionSecret, and booting the committed server without EPPP_SESSION_SECRET
  exits non-zero naming the field while a valid secret boots to /health 200
- health-endpoint/app-readiness boot probes: provide a valid
  EPPP_SESSION_SECRET (the required setting is validated at startup)
- ci.yml: new config-startup-error job (builds config + database-postgres,
  runs the suite); app-readiness job now builds the config package too
- .gitignore: transient .config-startup-probe-*.mjs files
2026-08-30 03:00:45 +00:00
implementer 0ce790fca3 feat: missing required setting fails startup with a field-specific error (E00-S04-T02)
- packages/config: add src/startup.ts exposing assertValidConfig (builds on
  the T01 TypeBox/Ajv schema) and the field-specific startup errors
  (MissingRequiredSettingError names the missing field; ConfigStartupError
  names each violating field); re-export from the package boundary
- apps/server: validate the startup configuration (including the required
  EPPP_SESSION_SECRET) before the server binds, so a missing required
  setting crashes the process at startup naming the field; depends on
  @personal-blog/config
- compose.yaml: provide EPPP_SESSION_SECRET for the app service (dev-only
  >= 32 char default; override via .env / shell)
- Dockerfile: ship the compiled packages/config in the image (build source +
  runtime dist), matching the server's new workspace dependency
- pnpm-lock.yaml: apps/server importer gains @personal-blog/config
2026-08-30 03:00:40 +00:00
35 changed files with 5217 additions and 220 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
+182 -154
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,191 +56,185 @@ jobs:
- name: Verify workspace groups
run: pnpm -r list --depth -1
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
# gate every PR (the docker-gated layer-scan probe inside the same file runs
# where a Docker daemon is available and skips cleanly otherwise).
secrets-not-embedded:
name: Secrets not embedded (E00-S02-T08)
# Stage 2 — typecheck (E00-S05-T01).
typecheck:
name: Stage 2 — Typecheck (E00-S05-T01)
needs: frozen-install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Run secrets-not-embedded test suite
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Typecheck every workspace package
run: pnpm typecheck
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
# newline, and valid JSON with 2-space indentation and no duplicate keys.
formatting-lint:
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run the formatting/lint policy
run: pnpm lint
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
# PR without external services: the app health endpoint, the secrets scan
# and the configuration service suites (schema, startup error, log
# redaction, env adapter, .env.example). The config suites boot the
# committed server, so the config and database-postgres packages are built
# first.
unit:
name: Stage 4 — Unit tests (E00-S05-T01)
needs: formatting-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run the health-endpoint unit suite
run: node --test tests/health-endpoint.test.mjs
- name: Run the secrets-not-embedded unit suite
run: node --test tests/secrets-not-embedded.test.mjs
- name: Run the config-schema unit suite
run: node --test tests/config-schema.test.mjs
- name: Run the config-startup-error unit suite
run: node --test tests/config-startup-error.test.mjs
- name: Run the config-log-redaction unit suite
run: node --test tests/config-log-redaction.test.mjs
- name: Run the config-env-adapter unit suite
run: node --test tests/config-env-adapter.test.mjs
- name: Run the env-example unit suite
run: node --test tests/env-example.test.mjs
# E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs
# gate every PR — the scan proves pg/Kysely imports live only in
# packages/database-postgres and the mutation probes prove the scan catches
# a driver import injected into any other package.
database-postgres-imports:
name: Database-postgres import isolation (E00-S03-T02)
# Stage 5 — architecture tests (E00-S05-T01). Static structure and policy
# suites: dependency boundaries, workspace layout/configuration, strict
# TypeScript base, engine/TypeScript pins, root commands, frozen-install
# clean clone, container definition structure, and the CI baseline itself.
architecture:
name: Stage 5 — Architecture tests (E00-S05-T01)
needs: unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Run database-postgres import isolation suite
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (strict-tsconfig typechecks apps/server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run the architecture-import suite
run: node --test tests/architecture-import.test.mjs
- name: Run the no-core-extension-imports suite
run: node --test tests/no-core-extension-imports.test.mjs
- name: Run the workspace-layout suite
run: node --test tests/workspace-layout.test.mjs
- name: Run the workspace-config suite
run: node --test tests/workspace-config.test.mjs
- name: Run the strict-tsconfig suite
run: node --test tests/strict-tsconfig.test.mjs
- name: Run the typescript-pin suite
run: node --test tests/typescript-pin.test.mjs
- name: Run the node-engine suite
run: node --test tests/node-engine.test.mjs
- name: Run the frozen-install suite
run: node --test tests/frozen-install.test.mjs
- name: Run the root-commands suite
run: node --test tests/root-commands.test.mjs
- name: Run the compose-config suite
run: node --test tests/compose-config.test.mjs
- name: Run the build-targets suite
run: node --test tests/build-targets.test.mjs
- name: Run the non-root-user suite
run: node --test tests/non-root-user.test.mjs
- name: Run the readonly-rootfs suite
run: node --test tests/readonly-rootfs.test.mjs
- name: Run the database-postgres-imports suite
run: node --test tests/database-postgres-imports.test.mjs
- name: Run the ci-stages baseline suite
run: node --test tests/ci-stages.test.mjs
# E00-S03-T03: the static assertions of tests/database-postgres-ledger.test.mjs
# gate every PR — the suite locks in the migration ledger (schema_migrations
# table DDL, idempotent parameterized record, driver-boundary re-export)
# with mutation probes, and the docker-gated real-stack probe (migrate an
# empty database and confirm the ledger exists) runs where a Docker daemon
# is available and skips cleanly otherwise. The job installs the frozen
# workspace because the real-stack probe executes the committed ledger
# module from the host (it imports `pg` through the package's own links).
database-postgres-ledger:
name: Migration ledger (E00-S03-T03)
# Stage 6 — PostgreSQL integration tests (E00-S05-T01). The PostgreSQL
# adapter suites (migration ledger, advisory lock, failure diagnostic) and
# the app readiness suite: their docker-gated real-stack probes (migrate an
# empty database, hold/release the advisory lock, readiness waits on the
# startup migration run) run where a Docker daemon is available and skip
# cleanly otherwise; the static and deterministic probes always gate. The
# app-readiness probes boot the committed server, so the config and
# database-postgres packages are built first.
postgres-integration:
name: Stage 6 — PostgreSQL integration tests (E00-S05-T01)
needs: architecture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration ledger test suite
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run the database-postgres-ledger suite
run: node --test tests/database-postgres-ledger.test.mjs
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
# gate every PR — the suite locks in the migration advisory lock (session-
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
# acquire() calls share one connection, driver-boundary re-export) with
# mutation probes, and the docker-gated real-stack concurrent probe (a
# second runner waits or fails while the first holds the lock; concurrent
# acquire() checks out exactly one connection; the lock releases when the
# holding session ends) runs where a Docker daemon is available and skips
# cleanly otherwise. The job installs the frozen workspace because the
# real-stack probe executes the committed lock module from the host (it
# imports `pg` through the package's own links).
database-postgres-lock:
name: Migration advisory lock (E00-S03-T04)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration advisory lock test suite
- name: Run the database-postgres-lock suite
run: node --test tests/database-postgres-lock.test.mjs
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
# gate every PR — the suite locks in the migration failure diagnostic (a
# structured MigrationFailedError whose diagnostic identifies the failing
# migration, the failure phase, the underlying cause, and the applied/pending
# ledger state, serializable via toJSON) with mutation probes, and a
# deterministic stub-pool behavioral probe (intentionally failing migration
# fixture -> structured diagnostic naming the failing migration) runs on
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
# intentionally failing migration fixture and confirm the diagnostic") runs
# where a Docker daemon is available and skips cleanly otherwise. The job
# installs the frozen workspace because the probes execute the committed
# runner module from the host (it imports `pg` through the package's own
# links).
database-postgres-diagnostic:
name: Migration failure diagnostic (E00-S03-T05)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration failure diagnostic test suite
- name: Run the database-postgres-diagnostic suite
run: node --test tests/database-postgres-diagnostic.test.mjs
# E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate
# every PR — the suite locks in the readiness gate (the app answers
# GET /health with 503 {"status":"not ready"} until the startup migration
# run completes, then 200 {"status":"ok"}) with mutation probes, the
# deterministic probes (boot the committed server: no DATABASE_URL ->
# ready immediately; unreachable DATABASE_URL -> stays not-ready) run on
# Node 24, and the docker-gated real-stack probe (the issue's test plan:
# "start with pending migrations and confirm readiness waits" — the app's
# migration run is blocked behind a held ACCESS EXCLUSIVE lock on the
# migration ledger, /health stays not-ready, then flips ready once the lock
# releases) runs where a Docker daemon is available and skips cleanly
# otherwise. The job installs the frozen workspace and builds the
# database-postgres package because the probes boot the committed server
# from the host (it imports @personal-blog/database-postgres through the
# package's own links).
app-readiness:
name: App readiness after migrations (E00-S03-T06)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the database-postgres package (the probes boot the committed server which imports it)
run: pnpm --filter @personal-blog/database-postgres build
- name: Run app readiness test suite
- name: Run the app-readiness suite
run: node --test tests/app-readiness.test.mjs
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
# every PR — the suite locks in the TypeBox/Ajv configuration schema
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
# ajv@8.20.0) with mutation probes, and the deterministic probe executes
# the issue's test plan ("validate a full config against the TypeBox/Ajv
# schema") against the committed schema through Ajv. The job installs the
# frozen workspace and builds the config package because the probe also
# exercises the compiled package boundary (@personal-blog/config) exactly
# as the later configuration adapter will consume it.
config-schema:
name: TypeBox/Ajv config schema (E00-S04-T01)
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
# — the pnpm apps-group glob picks it up automatically) and verifies the
# compiled server artifact.
build-apps:
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
needs: postgres-integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config package (the probe exercises the compiled package boundary)
run: pnpm --filter @personal-blog/config build
- name: Run config schema test suite
run: node --test tests/config-schema.test.mjs
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
# build platforms) gate every PR (the docker-gated real-stack probes inside
# the same file run where a Docker daemon is available and skip cleanly
# otherwise).
compose-config:
name: Compose config (E00-S03-T01)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run compose-config test suite
run: node --test tests/compose-config.test.mjs
- name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands)
run: pnpm --filter "./apps/**" run build
- name: Verify the compiled server application artifact
run: test -f apps/server/dist/index.js
+12 -1
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
@@ -23,5 +26,13 @@ coverage/
# the package (removed in its finally block)
.config-schema-probe-*.mjs
# Transient host-side probe file written by the config-startup-error test
# suite into the package (removed in its finally block)
.config-startup-probe-*.mjs
# Transient host-side probe file written by the config-env-adapter test
# suite into the package (removed in its finally block)
.config-env-adapter-probe-*.mjs
# OS / editor
.DS_Store
+19 -13
View File
@@ -21,14 +21,17 @@
# T05 the runtime stage drops root privileges (runs as the image's non-root
# `node` user).
#
# The app now depends on the `database-postgres` workspace package (the single
# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also
# installs/builds that package — the server's `build`/`typecheck` scripts
# build their workspace dependency first (`pnpm --filter
# @personal-blog/database-postgres build`), and the runtime stage ships the
# compiled `packages/database-postgres/dist` next to the copied workspace
# node_modules links so the server's `@personal-blog/database-postgres` import
# resolves at run time.
# The app now depends on the `config` and `database-postgres` workspace
# packages (the configuration service — E00-S04-T02 validates the required
# settings at startup — and the single owner of the pg/Kysely driver,
# E00-S03-T02). The build stage therefore also installs/builds those
# packages — the server's `build`/`typecheck` scripts build their workspace
# dependencies first (`pnpm --filter @personal-blog/config build` and
# `pnpm --filter @personal-blog/database-postgres build`), and the runtime
# stage ships the compiled `packages/config/dist` and
# `packages/database-postgres/dist` next to the copied workspace node_modules
# links so the server's `@personal-blog/config` and
# `@personal-blog/database-postgres` imports resolve at run time.
#
# T08: the image embeds no secrets. The Dockerfile declares no secret-bearing
# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY
@@ -68,10 +71,11 @@ COPY extensions/example/package.json extensions/example/package.json
RUN pnpm install --frozen-lockfile
# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The
# server's build script builds its workspace dependency first (the
# `database-postgres` package, whose compiled dist the server imports), so a
# single command produces both dists in the right order.
# server's build script builds its workspace dependencies first (the `config`
# and `database-postgres` packages, whose compiled dists the server imports),
# so a single command produces all dists in the right order.
COPY apps/server apps/server
COPY packages/config packages/config
COPY packages/database-postgres packages/database-postgres
RUN pnpm --filter @personal-blog/server build
@@ -81,11 +85,13 @@ WORKDIR /app
ENV NODE_ENV=production
# The workspace install (devDependencies included — image-size pruning is a
# later E00-S02 concern) plus the compiled server output, the compiled
# database-postgres output the server imports, and the package manifests.
# later E00-S02 concern) plus the compiled server output, the compiled config
# and database-postgres outputs the server imports, and the package manifests.
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/server/dist ./apps/server/dist
COPY --from=build /app/apps/server/package.json ./apps/server/package.json
COPY --from=build /app/packages/config/dist ./packages/config/dist
COPY --from=build /app/packages/config/package.json ./packages/config/package.json
COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist
COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json
+4 -3
View File
@@ -3,13 +3,14 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story.",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly and binds the validated HOST interface (E00-S04-T04); the Fastify 5 application shell lands in a later story.",
"scripts": {
"build": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
"typecheck": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@personal-blog/config": "workspace:*",
"@personal-blog/database-postgres": "workspace:*"
},
"devDependencies": {
+102 -20
View File
@@ -18,17 +18,64 @@
* E00-S01-T06) there are no migrations to run, so the app reports ready
* immediately.
*
* [E00-S04-T02] field-specific startup error: the required settings are
* validated before the server binds, so a deployment missing a required
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
* required field, Security-and-Operations §32/§26) fails fast at startup
* with an error naming the missing field instead of booting with an invalid
* configuration.
*
* [E00-S04-T04] environment adapter: ALL settings flow through the config
* package's environment adapter (`loadConfigFromEnv` from
* `@personal-blog/config`) — the workspace's single owner of `process.env`
* reads — so this module (and every other module outside the config package)
* never reads `process.env` directly. The adapter maps the environment
* (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated
* config shape and validates it with `assertValidConfig` (E00-S04-T02) before
* the server binds, so a missing required setting is still a startup error
* naming the missing field. `HOST` is validated at the adapter boundary as a
* hostname or IP address and the server passes `config.host` to
* `server.listen`, so a configured `HOST` binds exactly that interface and
* the startup log reflects the actual bind — it never claims a bind the
* process does not enforce, and never echoes unvalidated env content.
*
* [E00-S04-T03] secret redaction: ALL log output goes through the redacting
* logger (`createLogger`, defined below — every line is scrubbed of the
* config's secret values before it reaches stdout/stderr), so secret values —
* the admin-session secret and the password in a `DATABASE_URL` connection
* string — automatically redact from logs. The server logs its resolved
* configuration at startup through `redactConfig` (the issue's test plan:
* "log configuration and confirm secret values are redacted"), so operators
* see the effective settings with every secret value replaced by
* `[REDACTED]` and no secret value reaches the log output.
*
* The Fastify 5 application shell (and the real HTTP API) lands in a later
* story; this bootstrap keeps the application health-checkable until then.
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { loadConfigFromEnv } from '@personal-blog/config';
import { redactConfig, redactText, type Config } from '@personal-blog/config';
import { Pool } from '@personal-blog/database-postgres';
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */
const PORT = resolvePort(process.env.PORT);
// [E00-S04-T04] environment adapter: ALL settings flow through the config
// package's adapter — the workspace's single owner of process.env reads — so
// the server never reads process.env directly. The adapter maps the
// environment onto the validated config shape and validates it with
// assertValidConfig (E00-S04-T02) before the server binds, so a missing
// required setting (e.g. EPPP_SESSION_SECRET) still crashes the process at
// startup with an error naming the missing field — never boots with an
// invalid configuration.
const config = loadConfigFromEnv();
// [E00-S04-T03] secret redaction: every log line goes through the redacting
// logger, seeded with the validated config's secrets — and the resolved
// configuration is logged redacted, so operators see the effective settings
// while secret values stay out of the log output.
const logger = createLogger(config);
logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));
/** Health payload — reported once the startup migration run completes. */
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
@@ -56,17 +103,6 @@ const MIGRATIONS: readonly Migration[] = [];
*/
let migrationsComplete = false;
/**
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
* out-of-range override falls back to the default so a bad `PORT` value cannot
* crash the process at startup.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
/** Writes a JSON response with an explicit content-length. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.writeHead(statusCode, {
@@ -76,6 +112,47 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.end(body);
}
/** The server's logger: `log` writes to stdout, `error` writes to stderr — both redacted. */
interface ServerLogger {
log(...args: unknown[]): void;
error(...args: unknown[]): void;
}
/**
* Creates the redacting logger for the validated configuration (E00-S04-T03):
* each argument is serialized (strings verbatim, errors by message, other
* values as JSON) and the joined line is scrubbed of the config's secret
* values — the admin-session secret and the password embedded in a
* `DATABASE_URL` connection string — before it is written, so no secret value
* can reach the log output. The server uses this logger for ALL of its
* output; a bare `console.log`/`console.error` would bypass the redaction and
* is rejected by the test suite.
*/
function createLogger(config: Config): ServerLogger {
const write = (stream: NodeJS.WriteStream, args: unknown[]): void => {
stream.write(`${redactText(args.map(serialize).join(' '), config)}\n`);
};
return {
log: (...args) => write(process.stdout, args),
error: (...args) => write(process.stderr, args),
};
}
/** Serializes one log argument: strings verbatim, errors by message, objects as JSON. */
function serialize(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Error) return String(value);
if (typeof value === 'undefined') return 'undefined';
if (typeof value === 'object' && value !== null) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
/**
* Routes one request. The application only serves the health endpoint at this
* stage; anything else is a 404 so misconfiguration is loud. The health route
@@ -96,13 +173,13 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const server = createServer(handleRequest);
const databaseUrl = process.env.DATABASE_URL;
const databaseUrl = config.databaseUrl;
if (databaseUrl === undefined) {
// No DATABASE_URL configured (e.g. local non-container dev): there are no
// migrations to run, so the app reports ready from the start.
migrationsComplete = true;
console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
logger.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
} else {
// E00-S03-T06: run the startup migrations; readiness follows completion.
const pool = new Pool({ connectionString: databaseUrl });
@@ -111,7 +188,7 @@ if (databaseUrl === undefined) {
.run()
.then((result) => {
migrationsComplete = true;
console.log(
logger.log(
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
);
})
@@ -120,13 +197,18 @@ if (databaseUrl === undefined) {
// runner already throws a serializable MigrationFailedError. The app
// logs the failure and stays not-ready, so a deployment with failed
// migrations is surfaced by the readiness probe instead of
// crash-looping.
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
// crash-looping. The redacting logger scrubs any secret value (e.g.
// the database password) the error text may embed.
logger.error('[migrate] startup migration run failed; app stays not-ready:', error);
});
}
server.listen(PORT, () => {
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
// The server binds the validated bind interface: `config.host` (default
// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to
// `server.listen`, so a configured `HOST` binds exactly that interface and
// the startup log reflects the actual bind.
server.listen(config.port, config.host, () => {
logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`);
});
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
+11 -1
View File
@@ -49,7 +49,13 @@
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
#
# All values have defaults so `docker compose up -d` works from a clean clone
# without a .env file (a committed .env.example template lands in E00-S04).
# without a .env file (the committed .env.example template, E00-S04-T05,
# lists the overridable variables with placeholder values).
# Since E00-S04-T02 the app validates its required settings at startup: the
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
# Security-and-Operations §32/§26) is provided here with a dev-only default —
# override it via a `.env` file / shell environment for anything beyond local
# development.
services:
db:
@@ -93,6 +99,10 @@ services:
- linux/arm64
environment:
DATABASE_URL: postgres://eppp:eppp@db:5432/eppp
# E00-S04-T02: the app's required admin-session secret (the schema's
# required field, EPPP_SESSION_SECRET per Security-and-Operations
# §32/§26) — dev-only default (>= 32 chars), override via .env / shell.
EPPP_SESSION_SECRET: ${EPPP_SESSION_SECRET:-eppp-local-session-secret-change-me-0123456789}
ports:
- "${APP_PORT:-3000}:3000"
# T02: start only once the database reports healthy (service_healthy), so
+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.
+51 -11
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); the Fastify 5 application shell lands in a later story. |
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. |
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret redaction (E00-S04-T03) land in later tasks. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the committed `.env.example` template (E00-S04-T05) documents every variable with placeholder values only. |
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
@@ -85,15 +85,23 @@ Expected result: `apps/server/dist/`, `packages/core/dist/`,
```sh
# Run the compiled public server entrypoint
pnpm --filter @personal-blog/server start
EPPP_SESSION_SECRET='change-me-0123456789abcdefghijklmnopqrstuvwxyz' pnpm --filter @personal-blog/server start
```
This runs the `start` script of `apps/server` (`node dist/index.js`), i.e. the
compiled application entrypoint. Two things to know:
compiled application entrypoint. Three things to know:
1. The command **must follow `pnpm build`** — the `start` script executes the
compiled artifact in `dist/`, it does not compile first.
2. Since [E00-S02-T03], `apps/server` serves the **application health
2. Since [E00-S04-T02] the server validates its **required settings at
startup**: the admin-session secret `EPPP_SESSION_SECRET` (the config
schema's required field, ≥ 32 characters, Security-and-Operations §32/§26)
must be set in the environment — if it is missing, the process fails fast
with a field-specific startup error (`missing required setting:
sessionSecret`) that names the missing field instead of booting. Provide it
in your shell or a local `.env` file copied from the committed
`.env.example` template (E00-S04-T05).
3. Since [E00-S02-T03], `apps/server` serves the **application health
endpoint**: starting it opens an HTTP server on port 3000 answering
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
is the **readiness probe**: when a `DATABASE_URL` is configured, the app
@@ -105,6 +113,25 @@ compiled application entrypoint. Two things to know:
answers 200 `{"status":"ok"}` from the start. The real Fastify 5
application shell — which turns this into the full serving API — lands in
a later story; the `start` command shape stays the same once it does.
4. Since [E00-S04-T03], the server **logs its resolved configuration at
startup with secret values redacted**: the first log line is
`[config] resolved configuration: {"host":"0.0.0.0","port":3000, ...,
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
redacting logger — the admin-session secret and the password embedded in a
`DATABASE_URL` connection string never appear in the log output.
5. Since [E00-S04-T04], **all settings flow through the config package's
environment adapter** (`loadConfigFromEnv` in `@personal-blog/config` —
the workspace's single owner of `process.env` reads): `HOST`, `PORT`,
`DATABASE_URL` and `EPPP_SESSION_SECRET` are mapped onto the validated
config shape (defaults: `host` `0.0.0.0`, `port` 3000, no `databaseUrl`)
and validated at startup — no module outside the config package reads
`process.env` directly. `HOST` is validated at the adapter boundary as a
hostname or IP address (an invalid value fails startup with a
field-specific error naming `host` instead of being logged) and the server
passes `config.host` to `server.listen`, so a configured `HOST` binds
exactly that interface — e.g. `HOST=127.0.0.1` binds loopback only — and
the startup log line (`@personal-blog/server listening on
http://<host>:<port>`) reflects the actual bind.
To run the compiled output of any other workspace package directly:
@@ -116,11 +143,22 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
```sh
pnpm test
pnpm lint
```
`pnpm test` runs the `node:test` suites under `tests/` (currently
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
This is also the suite that enforces the dependency-boundary rule.
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
dependencies (this is also the suite that enforces the dependency-boundary
rule). `pnpm lint` runs the formatting/lint policy suite
(`tests/formatting-policy.test.mjs`): every tracked text file must use LF
line endings, no BOM, no trailing whitespace, no tab indentation and exactly
one final newline; JSON files must additionally parse, carry no duplicate
keys and use 2-space indentation.
Pull requests run these checks as CI stages, in order — frozen lockfile
install → typecheck → formatting/lint → unit → architecture → PostgreSQL
integration → build of the applications (E00-S05-T01); the stage order,
the `needs` chain and the `tests/` coverage are locked in by
`tests/ci-stages.test.mjs`.
## Smoke check from a clean clone
@@ -130,8 +168,9 @@ corepack enable
pnpm install --frozen-lockfile # exit 0, lockfile untouched
pnpm build # 5/5 packages emit dist/, exit 0
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
pnpm test # 10/10 pass, exit 0
pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up
pnpm lint # formatting/lint policy passes, exit 0
pnpm test # all node:test suites pass, exit 0
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
```
## Troubleshooting
@@ -142,7 +181,8 @@ pnpm --filter @personal-blog/server start # serves GET /health on port 3000, s
| `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. |
| `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
## Out of scope
+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"
},
+4 -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 environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
@@ -12,6 +12,9 @@
"@sinclair/typebox": "0.34.52",
"ajv": "8.20.0"
},
"devDependencies": {
"@types/node": "24.13.3"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
+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,
});
}
+28 -3
View File
@@ -3,12 +3,37 @@
*
* [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the
* configuration schema (`configSchema`, defined with TypeBox) and the Ajv
* schema-validation entry point (`validateConfig`). The environment adapter
* (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret
* redaction (E00-S04-T03) build on this boundary in later tasks.
* schema-validation entry point (`validateConfig`).
*
* [E00-S04-T02] Field-specific startup error: the boundary also exposes the
* startup validation entry point (`assertValidConfig`) and its field-specific
* errors (`MissingRequiredSettingError` — names the missing required setting —
* and `ConfigStartupError` — names each violating field), so the application
* fails fast at startup when a required setting is missing.
*
* [E00-S04-T03] Secret redaction: the boundary also exposes the redaction
* layer (`redactConfig` — a config value with every secret replaced by
* `[REDACTED]`, for logging the resolved configuration — and `redactText` —
* scrubbing free-form log text of the config's secret values), which the
* server's redacting logger applies to every log line, so secrets
* automatically redact from logs.
*
* [E00-S04-T04] Environment adapter: the boundary also exposes
* `loadConfigFromEnv` — the workspace's single owner of `process.env` reads.
* It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`)
* onto the validated config shape and validates it with `assertValidConfig`
* at startup, so every setting flows through the adapter and no other module
* reads `process.env` directly. `HOST` is validated at the adapter boundary
* as a hostname or IP address before it is used for binding or logged, so
* arbitrary env content is never echoed verbatim into the startup log. The
* committed `.env.example` template (E00-S04-T05) documents the same
* variables with placeholder values only.
*/
export { configSchema } from './schema.js';
export type { Config } from './schema.js';
export { validateConfig } from './validate.js';
export type { ConfigValidationResult } from './validate.js';
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
export { loadConfigFromEnv } from './env.js';
+135
View File
@@ -0,0 +1,135 @@
/**
* EPPP secret redaction — [E00-S04-T03] secrets automatically redact from
* logs.
*
* The config package owns which configuration fields are secrets, so the
* redaction layer lives here (the environment adapter, E00-S04-T04, feeds
* the validated config into it via the app's logger):
*
* - `redactConfig(config)` — a copy of a config value with every secret
* replaced by `[REDACTED]`: the secret fields by name (see
* `SECRET_FIELD_NAMES`) and the password embedded in a `databaseUrl`
* connection string, masked in place. The app logs its resolved
* configuration through this (the issue's test plan: "log configuration
* and confirm secret values are redacted").
* - `redactText(text, config)` — scrubs every occurrence of the config's
* secret values from arbitrary text, so a free-form log line that embeds
* a secret value (e.g. an error message carrying a connection string) is
* redacted even when the value was not redacted by field.
*
* Both feed the server's redacting logger (the `createLogger` in
* `apps/server/src/index.ts`), so secret values never reach stdout/stderr —
* the acceptance criteria: "secrets automatically redact from logs", "log
* output contains no secret values".
*
* Rollback note from the issue: revert the redaction changes.
*/
import { URL } from 'node:url';
import type { Config } from './schema.js';
/** The placeholder every redacted secret value is replaced with. */
export const REDACTED = '[REDACTED]';
/**
* The config fields whose values are secrets, derived from the E00-S04-T01
* schema: `sessionSecret` is the story's secret field — the admin-session
* secret (Security-and-Operations §32/§26), required and at least 32
* characters. The password embedded in a `databaseUrl` connection string is a
* credential too, but it is not a config field of its own, so it is redacted
* separately (see `redactDatabaseUrl` / `secretValuesOf`).
*/
export const SECRET_FIELD_NAMES: readonly string[] = ['sessionSecret'];
/**
* A copy of a config value with every secret replaced by `[REDACTED]` — for
* logging the resolved configuration. Secret fields are replaced by name; the
* `databaseUrl` password is masked in place (`scheme://user:[REDACTED]@host`).
* A `databaseUrl` that cannot be parsed as a URL is replaced wholesale (its
* password cannot be isolated, so the whole value must not be logged).
*/
export function redactConfig(config: Config): Config {
const redacted: Record<string, unknown> = {};
for (const key of Object.keys(config)) {
const value = (config as Record<string, unknown>)[key];
if (typeof value === 'string' && SECRET_FIELD_NAMES.includes(key)) {
redacted[key] = REDACTED;
} else if (key === 'databaseUrl' && typeof value === 'string') {
redacted[key] = redactDatabaseUrl(value);
} else {
redacted[key] = value;
}
}
return redacted as Config;
}
/**
* Scrubs every occurrence of the config's secret values from `text`,
* replacing each with `[REDACTED]` — for free-form log lines (e.g. an error
* message that embeds a connection string). Non-secret text passes through
* unchanged.
*/
export function redactText(text: string, config: Config): string {
let redacted = text;
for (const value of secretValuesOf(config)) {
if (value.length === 0) {
continue;
}
redacted = redacted.split(value).join(REDACTED);
}
return redacted;
}
/**
* The raw secret values of a config value — what must never appear in log
* output: the values of the secret fields plus the password embedded in
* `databaseUrl`. A `databaseUrl` that cannot be parsed as a URL (so its
* password cannot be isolated) is included whole, keeping the credential
* inside it redactable from free text. Empty values are never collected
* (scrubbing an empty string would redact nothing).
*/
function secretValuesOf(config: Config): readonly string[] {
const values: string[] = [];
for (const field of SECRET_FIELD_NAMES) {
const value = (config as Record<string, unknown>)[field];
if (typeof value === 'string' && value.length > 0) {
values.push(value);
}
}
if (config.databaseUrl !== undefined) {
const password = databaseUrlPassword(config.databaseUrl);
if (password === null) {
values.push(config.databaseUrl);
} else if (password.length > 0) {
values.push(password);
}
}
return values;
}
/**
* The `databaseUrl` with its password masked in place; the whole value is
* replaced when it cannot be parsed as a URL (its password cannot be
* isolated, so the raw value must never be logged).
*/
function redactDatabaseUrl(databaseUrl: string): string {
try {
const url = new URL(databaseUrl);
if (url.password === '') {
return databaseUrl;
}
return `${url.protocol}//${encodeURIComponent(url.username)}:${REDACTED}@${url.host}${url.pathname}${url.search}${url.hash}`;
} catch {
return REDACTED;
}
}
/** The password embedded in a connection string, or `null` when it is not a parseable URL. */
function databaseUrlPassword(databaseUrl: string): string | null {
try {
return new URL(databaseUrl).password;
} catch {
return null;
}
}
+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
+129
View File
@@ -0,0 +1,129 @@
/**
* EPPP configuration startup validation — [E00-S04-T02] missing required
* setting gives a field-specific startup error.
*
* Builds on the E00-S04-T01 boundary (`configSchema` from `schema.ts`,
* validated with Ajv): `assertValidConfig` is the startup entry point the
* application calls with its parsed configuration before it binds — when a
* required setting is missing it throws `MissingRequiredSettingError`, whose
* message and `missingField` name the missing field (the issue's acceptance:
* "missing required setting gives a field-specific startup error", "the error
* names the missing field"); any other schema violation throws a
* `ConfigStartupError` whose message names each violating field too.
*
* This is deliberately NOT the T04 environment adapter: nothing here reads
* `process.env`. The adapter (E00-S04-T04, `env.ts`) maps the environment
* onto the validated config shape and passes it to `assertValidConfig` at
* startup; the secret redaction layer (E00-S04-T03) builds on the same
* boundary.
*
* Rollback note from the issue: revert the validation error handling.
*/
import { Ajv, type ErrorObject } from 'ajv';
import { configSchema, type Config } from './schema.js';
/** Ajv instance for the config schema — `allErrors` reports every violation. */
const ajv = new Ajv({ allErrors: true });
/** The compiled validator — TypeBox schemas are JSON Schema, so Ajv compiles them directly. */
const validateConfigValue = ajv.compile(configSchema);
/**
* The field-specific startup error thrown when a configuration value is
* invalid at startup (any schema violation). `violations` holds one
* field-prefixed message per violation (e.g. `"sessionSecret: must NOT have
* fewer than 32 characters"`), so the error names the offending field(s).
*/
export class ConfigStartupError extends Error {
/** Field-prefixed messages naming each violation (never empty). */
readonly violations: ReadonlyArray<string>;
constructor(message: string, violations: readonly string[]) {
super(message);
this.name = 'ConfigStartupError';
this.violations = violations;
}
}
/**
* The error thrown when a required setting is missing — the E00-S04-T02
* field-specific startup error. `missingField` and the message name the
* missing field (e.g. `"missing required setting: sessionSecret"`), so an
* operator starting the app with an incomplete configuration sees exactly
* which setting to provide.
*/
export class MissingRequiredSettingError extends ConfigStartupError {
/** The name of the required setting that is missing. */
readonly missingField: string;
constructor(missingField: string) {
super(`missing required setting: ${missingField}`, [`missing required setting: ${missingField}`]);
this.name = 'MissingRequiredSettingError';
this.missingField = missingField;
}
}
/**
* Formats one Ajv violation as a field-specific message: the field named by
* the error's `instancePath` (e.g. `/sessionSecret`) prefixes the Ajv
* message, so every startup error names the offending setting — never just a
* bare schema message. An `additionalProperties` violation points at the
* object (empty `instancePath`), so its offending key (Ajv
* `params.additionalProperty`) is used as the field instead.
*/
function formatViolation(error: ErrorObject): string {
const field = error.instancePath.replace(/^\//, '');
const message = error.message ?? 'invalid';
if (field !== '') {
return `${field}: ${message}`;
}
const extra = (error.params as { additionalProperty?: unknown } | undefined)?.additionalProperty;
return typeof extra === 'string' && extra.length > 0 ? `${extra}: ${message}` : message;
}
/**
* Validates a configuration value at startup and returns it as the typed
* `Config` — or throws a field-specific startup error:
*
* - a missing required setting throws `MissingRequiredSettingError` naming
* the missing field (the issue's acceptance criteria);
* - any other schema violation throws `ConfigStartupError` whose message
* names the violating field(s).
*
* The application calls this before it starts serving, so an invalid
* configuration fails fast at startup with a clear, field-specific error
* instead of booting with a silently-wrong setting.
*
* @param value - the parsed configuration value (the T04 adapter will hand
* this the mapped environment)
* @returns the validated configuration
* @throws {MissingRequiredSettingError} when a required setting is missing
* @throws {ConfigStartupError} when the configuration violates the schema
*/
export function assertValidConfig(value: unknown): Config {
const valid = validateConfigValue(value);
if (valid) {
return value as Config;
}
const errors = validateConfigValue.errors ?? [];
// Missing required settings get the dedicated field-specific error — the
// Ajv `required` keyword error carries the missing property name, which is
// exactly the field the acceptance criteria require the error to name.
const missingFields = errors
.filter((error) => error.keyword === 'required')
.map((error) => {
const missing = (error.params as { missingProperty?: unknown } | undefined)?.missingProperty;
return typeof missing === 'string' ? missing : '';
})
.filter((field) => field.length > 0);
if (missingFields.length > 0) {
throw new MissingRequiredSettingError(missingFields.join(', '));
}
const violations = errors.map(formatViolation);
throw new ConfigStartupError(`invalid configuration: ${violations.join('; ')}`, violations);
}
+3 -3
View File
@@ -8,9 +8,9 @@
*
* This is deliberately NOT the E00-S04-T02 field-specific startup error:
* `validateConfig` returns the raw schema-validation outcome (valid or not,
* with the Ajv messages) and performs no startup wiring — the adapter
* (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it
* in later tasks.
* with the Ajv messages) and performs no startup wiring — the startup error
* formatting (E00-S04-T02, `startup.ts`) and the environment adapter
* (E00-S04-T04) build on this raw outcome in their own modules.
*/
import { Ajv } from 'ajv';
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
}
+7
View File
@@ -14,6 +14,9 @@ importers:
apps/server:
dependencies:
'@personal-blog/config':
specifier: workspace:*
version: link:../../packages/config
'@personal-blog/database-postgres':
specifier: workspace:*
version: link:../../packages/database-postgres
@@ -32,6 +35,10 @@ importers:
ajv:
specifier: 8.20.0
version: 8.20.0
devDependencies:
'@types/node':
specifier: 24.13.3
version: 24.13.3
packages/core: {}
+13 -4
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\) \{/,
@@ -246,7 +246,11 @@ function reservePort() {
/**
* Boots the committed server source on `port` with the given env overrides
* (merged over `process.env`; `DATABASE_URL` is stripped unless explicitly
* provided). Returns `{ child, stderr, stdout }`; the child writes its
* provided, and a valid `EPPP_SESSION_SECRET` is provided unless explicitly
* overridden — since E00-S04-T02 the required admin-session secret is
* validated at startup, and a missing secret is the field-specific
* startup-error path locked in by tests/config-startup-error.test.mjs).
* Returns `{ child, stderr, stdout }`; the child writes its
* stdout/stderr into closures for diagnostics.
*/
function bootServer(port, envOverrides = {}) {
@@ -254,7 +258,12 @@ function bootServer(port, envOverrides = {}) {
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
const env = { ...process.env, PORT: String(port), ...envOverrides };
// Strip an inherited DATABASE_URL unless the caller explicitly provides one
// (the no-database path must be deterministic), and provide the required
// admin-session secret (E00-S04-T02) unless the caller overrides it.
const env = { ...process.env, PORT: String(port) };
delete env.DATABASE_URL;
Object.assign(env, { EPPP_SESSION_SECRET: 's'.repeat(32) }, envOverrides);
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
env,
+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
+653
View File
@@ -0,0 +1,653 @@
/**
* Config log redaction test — locks in the [E00-S04-T03] guarantee that
* secrets automatically redact from logs: the app's log output contains no
* secret values.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "secrets automatically redact from logs" → `packages/config` exposes the
* redaction layer (`redactConfig` — a config value with every secret
* replaced by `[REDACTED]`, for logging the resolved configuration — and
* `redactText` — scrubbing free-form log text of the config's secret
* values), and the committed server writes ALL of its log output through
* the redacting logger (`createLogger` in the server entrypoint, seeded
* with the validated config). Locked in statically (mutation probes prove
* non-vacuity: renaming the exports, dropping the split/join scrub,
* unmasking the databaseUrl password, or reintroducing a bare
* `console.log`/`console.error` all fail) and behaviorally by the
* deterministic probes.
* - "log output contains no secret values" → the deterministic probes
* execute the issue's test plan ("log configuration and confirm secret
* values are redacted") against the committed code: the compiled
* `@personal-blog/config` boundary redacts the admin-session secret and
* the password embedded in a `DATABASE_URL` connection string, and
* booting the committed server logs its resolved configuration with
* every secret value replaced by `[REDACTED]` — the booted server's
* stdout/stderr contain no secret value.
*
* Run: `node --test tests/config-log-redaction.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.
* The deterministic probes boot the committed server, which imports
* `@personal-blog/config` and `@personal-blog/database-postgres` — build those
* packages first, exactly as the CI job does.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { createServer as createNetServer } from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
/** The committed redaction layer, package boundary and server entrypoint under test. */
const REDACT_SRC = 'packages/config/src/redact.ts';
const INDEX_SRC = 'packages/config/src/index.ts';
const SERVER_SRC = 'apps/server/src/index.ts';
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
/** The CI job that gates the log-redaction criterion on every PR. */
const CI_JOB = 'config-log-redaction';
/** A distinctive >= 32-char admin-session secret the probes must never leak. */
const SECRET = 'redact-me-0123456789abcdefghijklmnopqrstuv';
/** A connection string whose password the probes must never leak. */
const DATABASE_URL = 'postgres://redact-user:redact-password@db:5432/redact-db';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Static assertions on the committed sources
// ---------------------------------------------------------------------------
/**
* Asserts the config package exposes the redaction layer: `redactConfig`
* (a config value with every secret replaced by `[REDACTED]` — the secret
* fields by name and the `databaseUrl` password masked in place) and
* `redactText` (free-form log text scrubbed of the config's secret values).
* Fails fast on a deviation; the mutation probes below prove the assertions
* are non-vacuous.
*/
function assertRedactionSource(src) {
// The placeholder and the schema-derived secret field names.
assert.match(
src,
/export const REDACTED = '\[REDACTED\]'/,
'the redaction module must export the [REDACTED] placeholder',
);
assert.match(
src,
/export const SECRET_FIELD_NAMES/,
'the redaction module must export the secret field names (SECRET_FIELD_NAMES)',
);
assert.match(
src,
/SECRET_FIELD_NAMES: readonly string\[\] = \['sessionSecret'\]/,
"the secret field names must name the schema's secret field (sessionSecret, E00-S04-T01)",
);
// redactConfig — a config copy with every secret replaced by the placeholder.
assert.match(
src,
/export function redactConfig\(config: Config\): Config/,
'the redaction module must export redactConfig (a redacted copy of a config value, for logging the resolved configuration)',
);
assert.match(
src,
/SECRET_FIELD_NAMES\.includes\(key\)/,
'redactConfig must replace the secret fields by name (SECRET_FIELD_NAMES)',
);
assert.match(
src,
/redacted\[key\] = REDACTED/,
'redactConfig must replace secret field values with the [REDACTED] placeholder',
);
assert.match(
src,
/redactDatabaseUrl\(value\)/,
'redactConfig must mask the databaseUrl password (redactDatabaseUrl)',
);
assert.match(
src,
/:\$\{REDACTED\}@/,
'redactDatabaseUrl must mask the password in place (scheme://user:[REDACTED]@host)',
);
// redactText — free-form log text scrubbed of the config's secret values.
assert.match(
src,
/export function redactText\(text: string, config: Config\): string/,
'the redaction module must export redactText (scrub free-form log text of the config\'s secret values)',
);
assert.match(
src,
/\.split\(value\)\.join\(REDACTED\)/,
'redactText must replace every occurrence of a secret value with the [REDACTED] placeholder',
);
}
/**
* Asserts the package boundary re-exports the redaction layer.
*/
function assertBoundary(src) {
assert.match(
src,
/export \{ REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText \} from '\.\/redact\.js'/,
'the package boundary must re-export the redaction layer (REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText)',
);
}
/**
* Asserts the committed server logs through a redacting logger only: it
* imports the redaction entry points from `@personal-blog/config`, defines
* `createLogger(config)` which scrubs every joined log line with the
* validated config's secret values before writing it to stdout/stderr, and
* logs its resolved configuration at startup via `redactConfig` (the issue's
* test plan: "log configuration and confirm secret values are redacted"). A
* bare `console.log`/`console.error` would bypass the redaction and is
* rejected.
*/
function assertServerSource(src) {
// The redacting logger — every log line passes through the config
// package's scrubber before it reaches stdout/stderr.
assert.match(
src,
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
);
assert.match(
src,
/import \{ redactConfig, redactText, type Config \} from '@personal-blog\/config'/,
'the server must import the redaction entry points (redactConfig, redactText) and the Config type from the config package',
);
assert.match(
src,
/function createLogger\(config: Config\): ServerLogger/,
'the server must define the redacting logger (createLogger, seeded with the validated configuration)',
);
assert.match(
src,
/redactText\(args\.map\(serialize\)\.join\(' '\), config\)/,
'every log line must pass through redactText (the config package\'s scrubber) before it is written',
);
assert.match(
src,
/write\(process\.stdout, args\)/,
'log lines must be written to stdout',
);
assert.match(
src,
/write\(process\.stderr, args\)/,
'error lines must be written to stderr',
);
// The wiring — the server keeps its validated config (loaded through the
// environment adapter, E00-S04-T04), creates the logger with it and logs
// the resolved configuration redacted.
assert.match(
src,
/const config = loadConfigFromEnv\(\);/,
'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)',
);
assert.match(
src,
/const logger = createLogger\(config\)/,
'the server must create the redacting logger seeded with its validated configuration',
);
assert.match(
src,
/resolved configuration/,
'the server must log its resolved configuration at startup (the issue\'s test plan: "log configuration and confirm secret values are redacted")',
);
assert.match(
src,
/redactConfig\(config\)/,
'the configuration log must be redacted (redactConfig) so secret values never reach the log output',
);
assert.doesNotMatch(
src,
/console\.(log|error)\(/,
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
);
assert.doesNotMatch(
src,
/process\.env\.[A-Z_]+/,
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
);
// The startup configuration loads through the adapter (which validates it)
// before the logger is created, so a missing required setting still fails
// fast (E00-S04-T02) before any log output.
const validationIndex = src.indexOf('loadConfigFromEnv(');
const loggerIndex = src.indexOf('createLogger(config)');
assert.ok(
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)',
);
}
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the config package exposes the secret redaction layer (redactConfig + redactText)', () => {
assert.ok(existsSync(path.join(REPO_ROOT, REDACT_SRC)), `committed ${REDACT_SRC} must exist`);
assertRedactionSource(read(REDACT_SRC));
});
test('the package boundary re-exports the redaction layer', () => {
assertBoundary(read(INDEX_SRC));
});
test('the server logs through the redacting logger and logs its resolved configuration redacted', () => {
assertServerSource(read(SERVER_SRC));
});
test('the config-log-redaction criterion is enforced in CI', () => {
// Picked up by the root test command (root `scripts.test` glob).
const scripts = JSON.parse(read('package.json')).scripts ?? {};
assert.equal(
scripts.test,
`node --test "${ROOT_TEST_GLOB}"`,
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
);
// And a dedicated CI job gates it on every PR.
const workflow = read('.gitea/workflows/ci.yml');
assert.ok(
workflow.includes(`node --test tests/config-log-redaction.test.mjs`),
`CI must run the config-log-redaction suite (job "${CI_JOB}") on every PR`,
);
assert.ok(
workflow.includes(
'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build',
),
'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)',
);
});
// ---------------------------------------------------------------------------
// Mutation probes — the static assertions are non-vacuous
// ---------------------------------------------------------------------------
test('renaming the redactText export fails the redaction assertion (mutation probe)', () => {
const src = read(REDACT_SRC);
const renamed = src.replace('export function redactText(', 'export function redactTextX(');
assert.notEqual(renamed, src, 'the mutation must actually rename the redactText export');
assert.throws(() => assertRedactionSource(renamed), /must export redactText/);
});
test('changing the [REDACTED] placeholder fails the redaction assertion (mutation probe)', () => {
const src = read(REDACT_SRC);
const noPlaceholder = src.replace("export const REDACTED = '[REDACTED]';", "export const REDACTED = '***';");
assert.notEqual(noPlaceholder, src, 'the mutation must actually change the placeholder');
assert.throws(() => assertRedactionSource(noPlaceholder), /\[REDACTED\] placeholder/);
});
test('replacing every occurrence instead of scrubbing the whole value fails the redaction assertion (mutation probe)', () => {
const src = read(REDACT_SRC);
const partial = src.replace('.split(value).join(REDACTED)', '.replace(value, REDACTED)');
assert.notEqual(partial, src, 'the mutation must actually change the scrubbing');
assert.throws(() => assertRedactionSource(partial), /every occurrence/);
});
test('unmasking the databaseUrl password fails the redaction assertion (mutation probe)', () => {
const src = read(REDACT_SRC);
const unmasked = src.replace('redactDatabaseUrl(value)', 'value');
assert.notEqual(unmasked, src, 'the mutation must actually drop the databaseUrl password masking');
assert.throws(() => assertRedactionSource(unmasked), /must mask the databaseUrl password/);
});
test('dropping a redaction export from the package boundary fails the boundary assertion (mutation probe)', () => {
const src = read(INDEX_SRC);
const dropped = src.replace('REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText', 'REDACTED, SECRET_FIELD_NAMES, redactConfig');
assert.notEqual(dropped, src, 'the mutation must actually drop the redactText export');
assert.throws(() => assertBoundary(dropped), /must re-export the redaction layer/);
});
test('renaming createLogger fails the logger assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const renamed = src.replace('function createLogger(', 'function createLoggerX(');
assert.notEqual(renamed, src, 'the mutation must actually rename the createLogger function');
assert.throws(() => assertServerSource(renamed), /must define the redacting logger/);
});
test('writing a log line without redacting it fails the logger assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const unredacted = src.replace("redactText(args.map(serialize).join(' '), config)", "args.map(serialize).join(' ')");
assert.notEqual(unredacted, src, 'the mutation must actually drop the redactText pass-through');
assert.throws(() => assertServerSource(unredacted), /must pass through redactText/);
});
test('reintroducing a bare console.log/console.error in the server fails the wiring assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const bareConsole = src.replaceAll('logger.log(', 'console.log(').replaceAll('logger.error(', 'console.error(');
assert.notEqual(bareConsole, src, 'the mutation must actually replace the logger calls with bare console calls');
assert.throws(() => assertServerSource(bareConsole), /console\.(log|error)/);
});
test('dropping the resolved-configuration log fails the wiring assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const noConfigLog = src.replace(
"logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));",
'',
);
assert.notEqual(noConfigLog, src, 'the mutation must actually drop the resolved-configuration log');
assert.throws(() => assertServerSource(noConfigLog), /resolved configuration/);
});
// ---------------------------------------------------------------------------
// Deterministic behavioral probe — the compiled @personal-blog/config boundary
// ---------------------------------------------------------------------------
/**
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
* stripping on by default), `strip-types-flag` (>= 22.6 via
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
* workspace pins engines.node to 24.x, where type stripping is stable.
*/
function tsExecMode() {
const [major, minor] = process.versions.node.split('.').map(Number);
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
if (major === 22 && minor >= 6) return 'strip-types-flag';
return null;
}
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
const TS_STRIPPING = tsExecMode() !== null;
/** The compiled config package boundary the probes import (built by the CI job first). */
const CONFIG_DIST = existsSync(path.join(REPO_ROOT, 'packages/config', 'dist', 'index.js'));
/** The compiled database-postgres package the booted server also imports. */
const DATABASE_POSTGRES_DIST = existsSync(
path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'),
);
/** Why the boot probes may be skipped on a clean clone without a build step. */
const BUILD_HINT =
'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)';
/**
* The boundary probe source: exercises the compiled `@personal-blog/config`
* redaction boundary (`redactConfig` + `redactText`) exactly as the server's
* logger consumes it — the admin-session secret is replaced by `[REDACTED]`,
* the `databaseUrl` password is masked in place (and an unparseable
* `databaseUrl` is replaced wholesale), free-form text is scrubbed of the
* secret values, and non-secret text passes through unchanged. Written to a
* temp file inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve
* through the package's own dependency links, then removed.
*/
const PROBE_SOURCE = `
import { REDACTED, redactConfig, redactText } from './dist/index.js';
const SECRET = '${SECRET}';
const URL = '${DATABASE_URL}';
const result = {
placeholder: REDACTED,
redactedSecret: redactConfig({ sessionSecret: SECRET }).sessionSecret,
maskedUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: URL }).databaseUrl,
unparseableUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: 'not a url' }).databaseUrl,
nonSecretKept: redactConfig({ sessionSecret: SECRET, host: '0.0.0.0', port: 3000 }),
scrubText: redactText('connecting with ' + SECRET + ' now', { sessionSecret: SECRET }),
scrubUrl: redactText('failed at ' + URL, { sessionSecret: SECRET, databaseUrl: URL }),
untouched: redactText('no secrets here', { sessionSecret: SECRET }),
};
console.log('CONFIG_REDACTION_PROBE_RESULT ' + JSON.stringify(result));
`;
test('the compiled boundary redacts secret values from config and text (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
const probeFile = path.join(REPO_ROOT, 'packages/config', `.config-redaction-probe-${process.pid}.mjs`);
try {
writeFileSync(probeFile, PROBE_SOURCE);
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
cwd: path.join(REPO_ROOT, 'packages/config'),
encoding: 'utf8',
timeout: 60_000,
});
assert.equal(
run.status,
0,
`the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`,
);
const match = run.stdout.match(/CONFIG_REDACTION_PROBE_RESULT (\{.*\})/);
assert.ok(match, `the probe must print CONFIG_REDACTION_PROBE_RESULT:\n${run.stdout.trim()}`);
const result = JSON.parse(match[1]);
// The placeholder and the redacted admin-session secret.
assert.equal(result.placeholder, '[REDACTED]', 'REDACTED must be the [REDACTED] placeholder');
assert.equal(
result.redactedSecret,
'[REDACTED]',
'redactConfig must replace the admin-session secret with [REDACTED]',
);
// The databaseUrl password is masked in place; an unparseable databaseUrl
// is replaced wholesale (its password cannot be isolated).
assert.equal(
result.maskedUrl,
'postgres://redact-user:[REDACTED]@db:5432/redact-db',
`redactConfig must mask the databaseUrl password in place (got: ${JSON.stringify(result.maskedUrl)})`,
);
assert.equal(
result.unparseableUrl,
'[REDACTED]',
'redactConfig must replace an unparseable databaseUrl wholesale (its password cannot be isolated)',
);
// Non-secret fields pass through unchanged.
assert.equal(result.nonSecretKept.host, '0.0.0.0', 'non-secret fields must pass through unchanged');
assert.equal(result.nonSecretKept.port, 3000, 'non-secret fields must pass through unchanged');
assert.equal(result.nonSecretKept.sessionSecret, '[REDACTED]', 'the secret field must still be redacted');
// Free-form text is scrubbed of the config's secret values.
assert.equal(
result.scrubText,
'connecting with [REDACTED] now',
`redactText must scrub the admin-session secret from free text (got: ${JSON.stringify(result.scrubText)})`,
);
assert.ok(
!result.scrubText.includes(SECRET),
'redactText output must not contain the admin-session secret value',
);
assert.equal(
result.scrubUrl,
'failed at postgres://redact-user:[REDACTED]@db:5432/redact-db',
`redactText must scrub the database password from free text (got: ${JSON.stringify(result.scrubUrl)})`,
);
assert.ok(
!result.scrubUrl.includes('redact-password'),
'redactText output must not contain the database password',
);
assert.equal(result.untouched, 'no secrets here', 'redactText must leave non-secret text unchanged');
} finally {
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Server-boot probes — the issue's test plan executed against the real
// committed server: "log configuration and confirm secret values are
// redacted"
// ---------------------------------------------------------------------------
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
function reservePort() {
return new Promise((resolve, reject) => {
const probe = createNetServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
probe.close(() => resolve(port));
});
});
}
/**
* Boots the committed server source on `port` with the given env overrides.
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
* and `EPPP_SESSION_SECRET` (the secret must be deterministic) are stripped
* unless explicitly provided. Returns `{ child, stdout, stderr }` with
* closures for the captured output.
*/
function bootServer(port, envOverrides = {}) {
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
const env = { ...process.env, PORT: String(port) };
delete env.DATABASE_URL;
delete env.EPPP_SESSION_SECRET;
Object.assign(env, envOverrides);
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
return { child, stdout: () => stdout, stderr: () => stderr };
}
/**
* Polls `GET /health` until the server answers with ANY status, the child
* exits, or the deadline passes. Returns the fetch Response.
*/
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
const deadline = Date.now() + deadlineMs;
let lastError = '';
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
);
}
try {
return await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(1_000),
});
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
await delay(100);
}
}
throw new Error(
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
);
}
/** Waits until the captured log output matches `pattern` (or the deadline passes). */
async function waitForLog(readOutput, pattern, deadlineMs = 5_000) {
const deadline = Date.now() + deadlineMs;
while (Date.now() < deadline) {
if (pattern.test(readOutput())) return true;
await delay(100);
}
return false;
}
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
await Promise.race([once(child, 'exit'), delay(2_000)]);
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}
test('booting the server logs the resolved configuration with secret values redacted (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// The issue's test plan: "log configuration and confirm secret values are
// redacted". The committed server logs its resolved configuration at
// startup through the redacting logger — the admin-session secret must
// appear as [REDACTED], and the secret value must not appear in the log
// output at all.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET }); // DATABASE_URL stripped
try {
const response = await waitForAnswer(port, child, stderr);
assert.equal(
response.status,
200,
`the server must boot to GET /health 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
// The resolved configuration is logged, with the secret redacted.
assert.match(
stdout(),
/\[config\] resolved configuration:/,
`the server must log its resolved configuration at startup (got: ${stdout().trim()})`,
);
assert.match(
stdout(),
/"sessionSecret":"\[REDACTED\]"/,
`the resolved-configuration log must show the admin-session secret redacted (got: ${stdout().trim()})`,
);
// No secret value in the log output (the acceptance criterion).
assert.ok(
!(stdout() + stderr()).includes(SECRET),
`the log output must not contain the admin-session secret value (got: ${stdout().trim()} ${stderr().trim()})`,
);
} finally {
await stopChild(child);
}
});
test('the log output contains no database password when a DATABASE_URL is configured (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// With a DATABASE_URL whose password must never leak, the startup
// migration run cannot complete (nothing listens on the dead port), so the
// app stays not-ready — and the log output (the resolved-configuration log
// AND the migration-failure log) must contain the masked URL, never the
// password and never the raw connection string.
const port = await reservePort();
const deadPort = await reservePort(); // reserved then released: nothing listens
const databaseUrl = `postgres://redact-user:redact-password@127.0.0.1:${deadPort}/redact-db`;
const { child, stdout, stderr } = bootServer(port, {
EPPP_SESSION_SECRET: SECRET,
DATABASE_URL: databaseUrl,
});
try {
const response = await waitForAnswer(port, child, stderr);
assert.equal(
response.status,
503,
`the app must stay not-ready while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
// The resolved-configuration log masks the databaseUrl password in place.
assert.match(
stdout(),
new RegExp(`postgres://redact-user:${'\\[REDACTED\\]'}@127\\.0\\.0\\.1:`),
`the resolved-configuration log must show the databaseUrl password masked in place (got: ${stdout().trim()})`,
);
// Wait for the migration-failure log (also written through the redacting logger).
assert.ok(
await waitForLog(() => stdout() + stderr(), /startup migration run failed; app stays not-ready/),
`the app must log the failed startup migration run (got: ${stdout().trim()} ${stderr().trim()})`,
);
const output = stdout() + stderr();
assert.ok(
!output.includes('redact-password'),
`the log output must not contain the database password (got: ${output.trim()})`,
);
assert.ok(
!output.includes('postgres://redact-user:redact-password@'),
`the log output must not contain the raw connection string with its password (got: ${output.trim()})`,
);
assert.ok(
!output.includes(SECRET),
`the log output must not contain the admin-session secret value (got: ${output.trim()})`,
);
} finally {
await stopChild(child);
}
});
+649
View File
@@ -0,0 +1,649 @@
/**
* Config startup error test — locks in the [E00-S04-T02] guarantee that a
* missing required setting gives a field-specific startup error.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "missing required setting gives a field-specific startup error" → the
* `packages/config` package exposes the startup validation entry point
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
* and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the
* config package's single owner of `process.env` reads) validates the
* mapped environment through it; the committed `apps/server/src/index.ts`
* loads its startup configuration through the adapter before the server
* binds, so a deployment missing a required setting (the admin-session
* secret `EPPP_SESSION_SECRET` — the schema's required field,
* Security-and-Operations §32/§26) fails fast at startup instead of
* booting with an invalid configuration. Locked in statically (mutation
* probes prove non-vacuity: dropping the adapter call, moving it after
* the bind, or dropping the compose/Dockerfile support all fail) and
* behaviorally by the deterministic probes (the issue's test plan: "start
* with a missing required field and confirm the error names it" — booting
* the committed server without `EPPP_SESSION_SECRET` exits non-zero with
* the error naming the missing field).
* - "the error names the missing field" → a missing required setting throws
* `MissingRequiredSettingError` whose message and `missingField` name the
* missing field (e.g. `"missing required setting: sessionSecret"`); other
* schema violations throw `ConfigStartupError` whose message names the
* violating field too. Locked in statically and by the deterministic
* probes (boundary + booted server).
* - the compose stack and the server image stay runnable with the required
* secret: `compose.yaml` provides `EPPP_SESSION_SECRET` for the `app`
* service (dev-only default, ≥ 32 chars — override via .env / shell) and
* the `apps/server/Dockerfile` ships the compiled `packages/config` next
* to the other workspace deps, so the app container boots (the
* deterministic probe boots with a valid secret and `GET /health` answers
* 200 — a valid startup still works).
*
* Run: `node --test tests/config-startup-error.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.
* The deterministic probes boot the committed server, which imports
* `@personal-blog/config` and `@personal-blog/database-postgres` — build those
* packages first, exactly as the CI job does.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { createServer as createNetServer } from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
/** The committed config package and server entrypoint under test. */
const CONFIG_DIR = 'packages/config';
const STARTUP_SRC = `${CONFIG_DIR}/src/startup.ts`;
const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`;
const SERVER_SRC = 'apps/server/src/index.ts';
const COMPOSE_PATH = 'compose.yaml';
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
/** The CI job that gates the startup-error criterion on every PR. */
const CI_JOB = 'config-startup-error';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Static assertions on the committed sources
// ---------------------------------------------------------------------------
/**
* Asserts the config package exposes the field-specific startup error: the
* startup module compiles the TypeBox `configSchema` with Ajv and exports
* `assertValidConfig` plus the field-specific errors — `ConfigStartupError`
* for any schema violation, and `MissingRequiredSettingError` (whose message
* and `missingField` name the missing field, e.g.
* `"missing required setting: sessionSecret"`) for a missing required
* setting. Fails fast on a deviation; the mutation probes below prove the
* assertions are non-vacuous.
*/
function assertStartupErrorSource(src) {
// Built on the E00-S04-T01 schema boundary: the module compiles the
// TypeBox configSchema with Ajv (same golden-tuple validator as
// validate.ts) and derives the missing-field names from the Ajv `required`
// keyword errors.
assert.match(
src,
/import \{ Ajv, type ErrorObject \} from 'ajv'/,
'the startup module must import Ajv (the golden-tuple validator) and the ErrorObject type',
);
assert.match(
src,
/import \{ configSchema, type Config \} from '\.\/schema\.js'/,
'the startup module must build on the committed configSchema (import from ./schema.js)',
);
assert.match(
src,
/new Ajv\(\{ allErrors: true \}\)/,
'the startup module must create the Ajv instance with allErrors (report every violation)',
);
assert.match(
src,
/\.compile\(configSchema\)/,
'the startup module must compile the TypeBox configSchema with Ajv',
);
// The field-specific errors.
assert.match(
src,
/export class ConfigStartupError extends Error/,
'the startup module must export ConfigStartupError (any invalid configuration is a field-specific startup error)',
);
assert.match(
src,
/export class MissingRequiredSettingError extends ConfigStartupError/,
'the startup module must export MissingRequiredSettingError (a missing required setting is the field-specific startup error)',
);
assert.match(
src,
/missing required setting: \$\{missingField\}/,
'the MissingRequiredSettingError message must name the missing field ("missing required setting: <field>")',
);
assert.match(
src,
/readonly missingField: string/,
'MissingRequiredSettingError must carry the missing field name (missingField)',
);
// The startup entry point: validates and throws the field-specific error
// for a missing required setting (Ajv `required` keyword -> missingProperty).
assert.match(
src,
/export function assertValidConfig\(value: unknown\): Config/,
'the startup module must export assertValidConfig (the startup validation entry point)',
);
assert.match(
src,
/error\.keyword === 'required'/,
'assertValidConfig must detect missing required settings from the Ajv "required" keyword errors',
);
assert.match(
src,
/throw new MissingRequiredSettingError\(missingFields\.join\(', '\)\)/,
'assertValidConfig must throw MissingRequiredSettingError naming the missing field(s)',
);
}
/**
* Asserts the committed server loads its startup configuration through the
* environment adapter (E00-S04-T04) before it binds: it imports
* `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter
* validates the mapped environment with `assertValidConfig`, including the
* required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing
* required setting is a startup error, never a silently-booted invalid
* configuration, and the server itself never reads `process.env` directly.
*/
function assertServerStartupValidation(src) {
assert.match(
src,
/import \{ loadConfigFromEnv \} from '@personal-blog\/config'/,
'the server must import the environment adapter (loadConfigFromEnv) from the config package',
);
assert.match(
src,
/const config = loadConfigFromEnv\(\);/,
'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())',
);
assert.doesNotMatch(
src,
/process\.env\.[A-Z_]+/,
'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)',
);
const callIndex = src.indexOf('loadConfigFromEnv(');
const listenIndex = src.indexOf('server.listen(');
assert.ok(
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error',
);
}
/**
* Asserts the compose app service provides the required admin-session secret
* (`EPPP_SESSION_SECRET`) with a dev-only default of at least 32 characters
* (the schema's required field, Security-and-Operations §32/§26), so
* `docker compose up -d` keeps working from a clean clone while the app's
* startup validation has the secret it requires.
*/
function assertComposeSecret(composeText) {
// The app service block runs from the top-level " app:" key to the
// top-level "volumes:" map (the db service has its own nested "volumes:"
// key earlier, so slice to the root-level one).
const appBlock = composeText.slice(composeText.indexOf(' app:'), composeText.indexOf('\nvolumes:'));
assert.match(
appBlock,
/EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]{32,}\}/,
'the app service must provide EPPP_SESSION_SECRET with a >= 32 char dev-only default (${EPPP_SESSION_SECRET:-...}), so the app boots with the required secret from a clean clone',
);
}
/**
* Asserts the server image ships the config package: the build stage copies
* `packages/config` source (the server's build compiles it) and the runtime
* stage copies the compiled `packages/config/dist` + manifest next to the
* other workspace deps the server imports.
*/
function assertDockerfileConfig(dockerfile) {
assert.match(
dockerfile,
/COPY packages\/config packages\/config/,
'the build stage must copy the config package source (the server build compiles its workspace dependency)',
);
assert.match(
dockerfile,
/COPY --from=build \/app\/packages\/config\/dist \.\/packages\/config\/dist/,
'the runtime stage must ship the compiled config package (packages/config/dist) so the server import resolves in the image',
);
}
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the config package exposes the field-specific startup error (MissingRequiredSettingError + assertValidConfig)', () => {
assert.ok(existsSync(path.join(REPO_ROOT, STARTUP_SRC)), `committed ${STARTUP_SRC} must exist`);
assertStartupErrorSource(read(STARTUP_SRC));
});
test('the package boundary re-exports the startup validation entry point and its errors', () => {
const src = read(INDEX_SRC);
assert.match(
src,
/export \{ assertValidConfig, ConfigStartupError, MissingRequiredSettingError \} from '\.\/startup\.js'/,
'the boundary must re-export assertValidConfig and the field-specific startup errors',
);
});
test('the server validates the required settings at startup, before it binds', () => {
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
assertServerStartupValidation(read(SERVER_SRC));
});
test('the compose app service provides the required admin-session secret (EPPP_SESSION_SECRET)', () => {
assertComposeSecret(read(COMPOSE_PATH));
});
test('the server image ships the config package (build source + runtime dist)', () => {
assertDockerfileConfig(read(DOCKERFILE_PATH));
});
test('the config-startup-error criterion is enforced in CI', () => {
// Picked up by the root test command (root `scripts.test` glob).
const scripts = JSON.parse(read('package.json')).scripts ?? {};
assert.equal(
scripts.test,
`node --test "${ROOT_TEST_GLOB}"`,
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
);
// And a dedicated CI job gates it on every PR.
const workflow = read('.gitea/workflows/ci.yml');
assert.ok(
workflow.includes(`node --test tests/config-startup-error.test.mjs`),
`CI must run the config-startup-error suite (job "${CI_JOB}") on every PR`,
);
assert.ok(
workflow.includes(
'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build',
),
'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)',
);
});
// ---------------------------------------------------------------------------
// Mutation probes — the static assertions are non-vacuous
// ---------------------------------------------------------------------------
test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();');
assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring');
assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/);
});
test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const moved = src
.replace('const config = loadConfigFromEnv();\n', '')
.replace(
'server.listen(config.port, config.host, () => {',
'server.listen(config.port, config.host, () => {\n const config = loadConfigFromEnv();',
);
assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind');
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
});
test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const directRead = src.replace(
'const config = loadConfigFromEnv();',
'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;',
);
assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read');
assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/);
});
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
assert.notEqual(noField, src, 'the mutation must actually drop the field name from the message');
assert.throws(() => assertStartupErrorSource(noField), /must name the missing field/);
});
test('dropping MissingRequiredSettingError fails the field-specific error assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noError = src.replace('export class MissingRequiredSettingError extends ConfigStartupError', 'export class MissingRequiredSettingErrorX extends ConfigStartupError');
assert.notEqual(noError, src, 'the mutation must actually rename the error class');
assert.throws(() => assertStartupErrorSource(noError), /must export MissingRequiredSettingError/);
});
test('dropping the missing-field detection fails the required-setting assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noDetection = src.replace("error.keyword === 'required'", "error.keyword === 'minLength'");
assert.notEqual(noDetection, src, 'the mutation must actually change the missing-field detection');
assert.throws(() => assertStartupErrorSource(noDetection), /"required" keyword/);
});
test('removing EPPP_SESSION_SECRET from the compose app service fails the compose assertion (mutation probe)', () => {
const composeText = read(COMPOSE_PATH);
const withoutSecret = composeText.replace(
/ EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]*\}\n/,
'',
);
assert.notEqual(withoutSecret, composeText, 'the mutation must actually remove the secret env entry');
assert.throws(() => assertComposeSecret(withoutSecret), /EPPP_SESSION_SECRET/);
});
test('dropping the config package from the image fails the Dockerfile assertion (mutation probe)', () => {
const dockerfile = read(DOCKERFILE_PATH);
const withoutDist = dockerfile.replace(
'COPY --from=build /app/packages/config/dist ./packages/config/dist\n',
'',
);
assert.notEqual(withoutDist, dockerfile, 'the mutation must actually drop the runtime dist copy');
assert.throws(() => assertDockerfileConfig(withoutDist), /runtime stage must ship the compiled config package/);
});
// ---------------------------------------------------------------------------
// Deterministic behavioral probe — the issue's test plan: "start with a
// missing required field and confirm the error names it"
// ---------------------------------------------------------------------------
/**
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
* stripping on by default), `strip-types-flag` (>= 22.6 via
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
* workspace pins engines.node to 24.x, where type stripping is stable.
*/
function tsExecMode() {
const [major, minor] = process.versions.node.split('.').map(Number);
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
if (major === 22 && minor >= 6) return 'strip-types-flag';
return null;
}
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
const TS_STRIPPING = tsExecMode() !== null;
/** The compiled config package boundary the probes import (built by the CI job first). */
const CONFIG_DIST = existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'));
/** The compiled database-postgres package the booted server also imports. */
const DATABASE_POSTGRES_DIST = existsSync(
path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'),
);
/** Why the boot probes may be skipped on a clean clone without a build step. */
const BUILD_HINT =
'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)';
/**
* The boundary probe source: exercises the compiled `@personal-blog/config`
* startup boundary (`assertValidConfig` + the field-specific errors) exactly
* as the server consumes it — a valid configuration passes (including the
* no-database local path), a missing required setting throws
* `MissingRequiredSettingError` naming the field, and other violations throw
* `ConfigStartupError` naming the violating field. Written to a temp file
* inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve through the
* package's own dependency links, then removed.
*/
const PROBE_SOURCE = `
import { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './dist/index.js';
const capture = (fn) => {
try {
return { threw: false, value: fn() };
} catch (error) {
return {
threw: true,
name: error && typeof error === 'object' ? error.name : String(error),
message: error instanceof Error ? error.message : String(error),
missingField: error && typeof error === 'object' ? error.missingField : undefined,
isMissingRequired: error instanceof MissingRequiredSettingError,
isConfigStartup: error instanceof ConfigStartupError,
};
}
};
const result = {
// A valid configuration passes and is returned — including the local
// non-container path with no databaseUrl (host/port/databaseUrl optional).
validReturnsConfig: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32) })).threw === false,
fullValid: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000, databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', sessionSecret: 's'.repeat(32) })).threw === false,
// The issue's test plan: start with a missing required field — the error
// must be MissingRequiredSettingError and name the missing field.
missingSecret: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000 })),
// Other violations are field-specific too (the message names the field).
shortSecret: capture(() => assertValidConfig({ sessionSecret: 'short' })),
unknownProperty: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32), extra: true })),
};
console.log('CONFIG_STARTUP_PROBE_RESULT ' + JSON.stringify(result));
`;
test('the compiled startup boundary throws a field-specific error naming the missing required setting (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-startup-probe-${process.pid}.mjs`);
try {
writeFileSync(probeFile, PROBE_SOURCE);
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
cwd: path.join(REPO_ROOT, CONFIG_DIR),
encoding: 'utf8',
timeout: 60_000,
});
assert.equal(
run.status,
0,
`the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`,
);
const match = run.stdout.match(/CONFIG_STARTUP_PROBE_RESULT (\{.*\})/);
assert.ok(match, `the probe must print CONFIG_STARTUP_PROBE_RESULT:\n${run.stdout.trim()}`);
const result = JSON.parse(match[1]);
// A valid configuration passes (including the no-database local path).
assert.equal(result.validReturnsConfig, true, 'a valid config (only the required secret) must pass assertValidConfig');
assert.equal(result.fullValid, true, 'a full valid config must pass assertValidConfig');
// Missing required setting: field-specific startup error naming the field.
assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw');
assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError');
assert.equal(result.missingSecret.isConfigStartup, true, 'MissingRequiredSettingError must be a ConfigStartupError');
assert.equal(
result.missingSecret.missingField,
'sessionSecret',
`MissingRequiredSettingError must carry the missing field name (got: ${JSON.stringify(result.missingSecret)})`,
);
assert.match(
result.missingSecret.message,
/missing required setting: sessionSecret/,
`the startup error must name the missing field (got: ${JSON.stringify(result.missingSecret)})`,
);
// Other violations are field-specific too.
assert.equal(result.shortSecret.threw, true, 'a too-short secret must throw');
assert.equal(result.shortSecret.isMissingRequired, false, 'a too-short secret is not a missing required setting');
assert.equal(result.shortSecret.isConfigStartup, true, 'a too-short secret must throw ConfigStartupError');
assert.match(
result.shortSecret.message,
/sessionSecret/,
`the startup error must name the violating field (got: ${JSON.stringify(result.shortSecret)})`,
);
assert.equal(result.unknownProperty.threw, true, 'an unknown property must throw');
assert.match(
result.unknownProperty.message,
/extra/,
`the startup error must name the unexpected field (got: ${JSON.stringify(result.unknownProperty)})`,
);
} finally {
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Server-boot probes — the issue's test plan executed against the real
// committed startup: "start with a missing required field and confirm the
// error names it"
// ---------------------------------------------------------------------------
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
function reservePort() {
return new Promise((resolve, reject) => {
const probe = createNetServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
probe.close(() => resolve(port));
});
});
}
/**
* Boots the committed server source on `port` with the given env overrides.
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
* and `EPPP_SESSION_SECRET` (the missing-secret case must be deterministic)
* are stripped unless explicitly provided. Returns `{ child, stdout, stderr }`
* with closures for diagnostics.
*/
function bootServer(port, envOverrides = {}) {
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
const env = { ...process.env, PORT: String(port) };
delete env.DATABASE_URL;
delete env.EPPP_SESSION_SECRET;
Object.assign(env, envOverrides);
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
return { child, stdout: () => stdout, stderr: () => stderr };
}
/** Waits for the child to exit (it fails fast on a startup error). */
function waitForExit(child, deadlineMs = 10_000) {
return new Promise((resolve, reject) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve({ code: child.exitCode, signal: child.signalCode });
return;
}
const timer = setTimeout(
() => reject(new Error('the server did not exit within the deadline (expected a startup error)')),
deadlineMs,
);
child.once('exit', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
}
/**
* Polls `GET /health` until the server answers with ANY status, the child
* exits, or the deadline passes. Returns the fetch Response.
*/
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
const deadline = Date.now() + deadlineMs;
let lastError = '';
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
);
}
try {
return await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(1_000),
});
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
await delay(100);
}
}
throw new Error(
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
);
}
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
await Promise.race([once(child, 'exit'), delay(2_000)]);
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}
test('starting without the required setting exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// The issue's test plan: "start with a missing required field and confirm
// the error names it". The committed server validates its required settings
// at startup, so booting it without EPPP_SESSION_SECRET must fail fast —
// the process exits non-zero and the error names the missing field.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port); // EPPP_SESSION_SECRET stripped
const { code, signal } = await waitForExit(child);
assert.notEqual(
code,
0,
`the server must exit non-zero when a required setting is missing (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.match(
stderr() + stdout(),
/missing required setting: sessionSecret/,
`the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
);
});
test('starting with an invalid required secret exits non-zero naming the field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// A required setting that violates the schema (a too-short secret, < 32
// chars) is also a field-specific startup error naming the field.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 'short' });
const { code, signal } = await waitForExit(child);
assert.notEqual(
code,
0,
`the server must exit non-zero when the required secret is invalid (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.match(
stderr() + stdout(),
/sessionSecret/,
`the startup error must name the invalid field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
);
});
test('starting with the required secret present boots to GET /health 200 (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// The startup validation must not reject a valid configuration: with the
// required admin-session secret (and no DATABASE_URL — the local
// non-container path) the server reports ready immediately and GET /health
// answers 200 {"status":"ok"}.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 's'.repeat(32) });
try {
const response = await waitForAnswer(port, child, stderr);
assert.equal(
response.status,
200,
`GET /health with a valid required secret must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.deepEqual(
await response.json(),
{ status: 'ok' },
'the app must report a healthy application ({"status":"ok"}) when the required settings are valid',
);
} finally {
await stopChild(child);
}
});
+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, []);
});
+25 -4
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)',
);
}
@@ -116,14 +116,22 @@ function reservePort() {
* env): since E00-S03-T06 the health endpoint is the readiness probe, and the
* no-DATABASE_URL path is the one with no startup migration run to wait for —
* the server reports ready immediately, so this smoke test stays deterministic
* and exercises exactly the committed no-migration readiness path.
* and exercises exactly the committed no-migration readiness path. Since
* E00-S04-T02 the required admin-session secret (EPPP_SESSION_SECRET, the
* schema's required field) is validated at startup, so the boot provides a
* valid one — a missing secret is the field-specific startup-error path
* locked in by tests/config-startup-error.test.mjs.
*/
function bootServer(port) {
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
const env = { ...process.env, PORT: String(port) };
const env = {
...process.env,
PORT: String(port),
EPPP_SESSION_SECRET: 's'.repeat(32),
};
delete env.DATABASE_URL;
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
@@ -181,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {"
);
});
test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => {
// The server binds `config.port` (asserted above); the port default (3000,
// matching the Dockerfile EXPOSE / compose :3000) and the PORT override
// live in the config package's environment adapter — the single owner of
// process.env reads.
const envSrc = read('packages/config/src/env.ts');
assert.match(
envSrc,
/3000/,
'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)',
);
});
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
if (!tsExecMode()) {
t.skip(