Compare commits

...
59 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
kpcto ecc945ce65 Merge pull request '[E00-S04-T01] TypeBox/Ajv schema' (#396) from feature/182 into main
CI / Frozen lockfile install (push) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 30s
CI / Migration ledger (E00-S03-T03) (push) Successful in 45s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 49s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 56s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 53s
CI / Compose config (E00-S03-T01) (push) Successful in 25s
2026-08-30 02:42:55 +00:00
implementer 5eff1580ac docs: document the config package in the non-container guide (E00-S04-T01)
CI / Frozen lockfile install (pull_request) Successful in 47s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 31s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 41s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 42s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 57s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 49s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
2026-08-30 02:29:52 +00:00
implementer ce0d3c0d44 test: lock in the config schema with static, mutation and deterministic probes (E00-S04-T01)
tests/config-schema.test.mjs covers both acceptance criteria: the schema
is defined with TypeBox/Ajv (static assertions on the committed package —
golden-tuple exact pins, Type.Object schema, Ajv compile, boundary
re-exports — each backed by a mutation probe proving non-vacuity) and the
schema covers the validated config fields (host, port, databaseUrl,
sessionSecret with their constraints). The deterministic probe executes
the issue's test plan — 'validate a full config against the TypeBox/Ajv
schema' — against the committed schema through Ajv via Node type
stripping (no build step), plus the negative cases (missing required field
naming sessionSecret, secret too short, unknown property, port bounds, and
empty databaseUrl); when the package is built (as in the CI job) it also
exercises the compiled validateConfig boundary exactly as the later
adapter will consume it.
2026-08-30 02:29:52 +00:00
implementer bcea489391 feat: add TypeBox/Ajv config schema package to the workspace (E00-S04-T01)
Adds packages/config (@personal-blog/config) — the EPPP configuration
service foundation. The package defines the configuration schema with
TypeBox (configSchema: host, port, databaseUrl, sessionSecret — the
validated config fields, golden-tuple pins @sinclair/typebox@0.34.52 and
ajv@8.20.0) and compiles it with Ajv (validateConfig). The environment
adapter (T04), field-specific startup errors (T02) and secret redaction
(T03) build on this boundary in later tasks; nothing reads process.env yet.

Wiring for the new workspace package: lockfile importer + resolved
typebox/ajv tree, apps/server/Dockerfile manifest copy (frozen in-image
install must match the lockfile importers), config-schema CI job, package
set fixtures (workspace-layout, workspace-config, strict-tsconfig,
typescript-pin), probe-file gitignore entry.
2026-08-30 02:29:52 +00:00
kpcto 6a65d4c789 Merge pull request '[E00-S03-T06] App does not report ready before migrations complete' (#395) from feature/181 into main
CI / Frozen lockfile install (push) Successful in 52s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 27s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 29s
CI / Migration ledger (E00-S03-T03) (push) Successful in 42s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 42s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 51s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 54s
CI / Compose config (E00-S03-T01) (push) Successful in 25s
2026-08-30 02:09:49 +00:00
implementer 5b0bded4b4 docs: document the readiness gate in the non-container guide (E00-S03-T06)
CI / Frozen lockfile install (pull_request) Successful in 49s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 23s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 40s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 56s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 41s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 55s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
2026-08-30 01:56:22 +00:00
implementer c20110be15 test: lock in the app readiness gate with static, deterministic and real-stack probes (E00-S03-T06) 2026-08-30 01:56:22 +00:00
implementer ebdee5daa5 feat: app reports ready only after the startup migration run (E00-S03-T06) 2026-08-30 01:56:19 +00:00
kpcto e8cafa090b Merge pull request '[E00-S03-T05] Migration failure produces structured diagnostic' (#394) from feature/180 into main
CI / Frozen lockfile install (push) Successful in 44s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 30s
CI / Migration ledger (E00-S03-T03) (push) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 46s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 43s
CI / Compose config (E00-S03-T01) (push) Successful in 29s
2026-08-30 01:31:48 +00:00
implementer bb3a68648a fix: inject the ledger into MigrationRunner (type-only import) so probes load the committed module under type stripping
CI / Frozen lockfile install (pull_request) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 29s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 28s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 42s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 41s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
Node's type stripping does not rewrite './ledger.js' to './ledger.ts', so the
runner's runtime import of the ledger could not resolve when the behavioral
probes execute the committed runner.ts directly (CI failure on Node 24).
The ledger is now imported type-only and the caller passes the instance
(new MigrationLedger(pool)) — the probes already do. runner.ts has no
runtime imports left, so type stripping erases them and the committed
module loads as-is.
2026-08-30 01:24:14 +00:00
implementer 52190d082c test: lock in the migration failure diagnostic (E00-S03-T05)
CI / Frozen lockfile install (pull_request) Successful in 44s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 51s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Failing after 51s
Static assertions + mutation probes on the committed runner source, a
deterministic stub-pool behavioral probe (intentionally failing migration
fixture -> structured diagnostic naming the failing migration, apply and
record phases), a docker-gated real-stack probe against a real database
(the issue's test plan), and CI enforcement via the additive
database-postgres-diagnostic job.
2026-08-30 01:17:23 +00:00
implementer f6d407394d feat: add migration runner with structured failure diagnostic (E00-S03-T05)
MigrationRunner applies pending migrations through the migration ledger
exactly once; when a migration fails it throws a MigrationFailedError
whose diagnostic is a structured object identifying the failing migration
(version), the failure phase (apply/record), the underlying cause, and the
applied/pending ledger state, serializable via toJSON. Re-exported from
the driver boundary so no other package needs the pg driver to run
migrations. Advisory lock (T04) and ready gate (T06) remain out of scope.
2026-08-30 01:17:23 +00:00
kpcto 4f169ace4e Merge pull request '[E00-S03-T04] Advisory lock prevents concurrent migration runners' (#393) from feature/179 into main
CI / Frozen lockfile install (push) Successful in 43s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 24s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 35s
CI / Migration ledger (E00-S03-T03) (push) Successful in 41s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 43s
CI / Compose config (E00-S03-T01) (push) Successful in 29s
2026-08-30 01:00:39 +00:00
implementer cb1b161ce9 fix: destroy the connection on unlock failure so a still-locked session is never reused (E00-S03-T04)
CI / Frozen lockfile install (pull_request) Successful in 47s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 25s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 41s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 44s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
release() previously returned the connection to the pool in a finally even
when the pg_advisory_unlock statement failed, so a pooled connection could be
reused while its session still held the migration advisory lock - the next
borrower would block every other runner (reviewer finding F4). On unlock
failure the connection is now destroyed (client.release(error) removes the
client from the pool, ending the session and its lock); the plain
client.release() is kept only on the success path. Locked in by a static
assertion, a mutation probe, and a deterministic stub-pool behavioral probe
of the committed release() control flow.
2026-08-30 00:49:56 +00:00
implementer dac3679e33 fix: memoize in-flight acquire so concurrent acquire() is re-entrant-safe (E00-S03-T04)
CI / Frozen lockfile install (pull_request) Successful in 49s
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 ledger (E00-S03-T03) (pull_request) Successful in 45s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 50s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
Security-review finding F2: two concurrent acquire() calls on the same
MigrationLock instance could each check out a connection; the second
pg_advisory_lock would overwrite this.client, leaking the first locked
connection until session end.

acquire() now memoizes the in-flight acquire in acquireInFlight and
returns it on re-entry, so exactly one connection is checked out and no
locked connection leaks. The memo is cleared once the acquire settles.
tryAcquire()/release() paths unchanged.

Locked in by:
- static criterion test: acquireInFlight field, re-entry guard returns
  the in-flight acquire, memo cleared on settle
- mutation probe: removing the re-entry guard fails the criterion
- real-stack probe: two concurrent acquire() calls on one instance leave
  pool.totalCount at 1 (exactly one connection), the lock granted once,
  nothing left after release; probe fails (hangs) on the pre-fix code
- CI job comment updated to reflect the re-entrancy criterion
2026-08-30 00:29:20 +00:00
implementer a2b98439e9 test: lock in the migration advisory lock with static + real-stack probes (E00-S03-T04)
CI / Frozen lockfile install (pull_request) Successful in 56s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 24s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 49s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 53s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
2026-08-30 00:13:46 +00:00
implementer 3f80063303 feat: add migration advisory lock to database-postgres (E00-S03-T04) 2026-08-30 00:08:57 +00:00
48 changed files with 8744 additions and 125 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
+203 -58
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,74 +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
with:
node-version: '24'
- name: Run secrets-not-embedded test suite
run: node --test tests/secrets-not-embedded.test.mjs
# E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs
# gate every PR — the scan proves pg/Kysely imports live only in
# packages/database-postgres and the mutation probes prove the scan catches
# a driver import injected into any other package.
database-postgres-imports:
name: Database-postgres import isolation (E00-S03-T02)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run database-postgres import isolation suite
run: node --test tests/database-postgres-imports.test.mjs
# E00-S03-T03: the static assertions of tests/database-postgres-ledger.test.mjs
# gate every PR — the suite locks in the migration ledger (schema_migrations
# table DDL, idempotent parameterized record, driver-boundary re-export)
# with mutation probes, and the docker-gated real-stack probe (migrate an
# empty database and confirm the ledger exists) runs where a Docker daemon
# is available and skips cleanly otherwise. The job installs the frozen
# workspace because the real-stack probe executes the committed ledger
# module from the host (it imports `pg` through the package's own links).
database-postgres-ledger:
name: Migration ledger (E00-S03-T03)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run migration ledger test suite
run: node --test tests/database-postgres-ledger.test.mjs
- name: Typecheck every workspace package
run: pnpm typecheck
# E00-S03-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)
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
# newline, and valid JSON with 2-space indentation and no duplicate keys.
formatting-lint:
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Run compose-config 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: 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
# Stage 5 — architecture tests (E00-S05-T01). Static structure and policy
# suites: dependency boundaries, workspace layout/configuration, strict
# TypeScript base, engine/TypeScript pins, root commands, frozen-install
# clean clone, container definition structure, and the CI baseline itself.
architecture:
name: Stage 5 — Architecture tests (E00-S05-T01)
needs: unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (strict-tsconfig typechecks apps/server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run the architecture-import suite
run: node --test tests/architecture-import.test.mjs
- name: Run the no-core-extension-imports suite
run: node --test tests/no-core-extension-imports.test.mjs
- name: Run the workspace-layout suite
run: node --test tests/workspace-layout.test.mjs
- name: Run the workspace-config suite
run: node --test tests/workspace-config.test.mjs
- name: Run the strict-tsconfig suite
run: node --test tests/strict-tsconfig.test.mjs
- name: Run the typescript-pin suite
run: node --test tests/typescript-pin.test.mjs
- name: Run the node-engine suite
run: node --test tests/node-engine.test.mjs
- name: Run the frozen-install suite
run: node --test tests/frozen-install.test.mjs
- name: Run the root-commands suite
run: node --test tests/root-commands.test.mjs
- name: Run the compose-config suite
run: node --test tests/compose-config.test.mjs
- name: Run the build-targets suite
run: node --test tests/build-targets.test.mjs
- name: Run the non-root-user suite
run: node --test tests/non-root-user.test.mjs
- name: Run the readonly-rootfs suite
run: node --test tests/readonly-rootfs.test.mjs
- name: Run the database-postgres-imports suite
run: node --test tests/database-postgres-imports.test.mjs
- name: Run the ci-stages baseline suite
run: node --test tests/ci-stages.test.mjs
# Stage 6 — PostgreSQL integration tests (E00-S05-T01). The PostgreSQL
# adapter suites (migration ledger, advisory lock, failure diagnostic) and
# the app readiness suite: their docker-gated real-stack probes (migrate an
# empty database, hold/release the advisory lock, readiness waits on the
# startup migration run) run where a Docker daemon is available and skip
# cleanly otherwise; the static and deterministic probes always gate. The
# app-readiness probes boot the committed server, so the config and
# database-postgres packages are built first.
postgres-integration:
name: Stage 6 — PostgreSQL integration tests (E00-S05-T01)
needs: architecture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Node.js 24
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '24'
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
run: corepack enable
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
- name: Run the database-postgres-ledger suite
run: node --test tests/database-postgres-ledger.test.mjs
- name: Run the database-postgres-lock suite
run: node --test tests/database-postgres-lock.test.mjs
- name: Run the database-postgres-diagnostic suite
run: node --test tests/database-postgres-diagnostic.test.mjs
- name: Run the app-readiness suite
run: node --test tests/app-readiness.test.mjs
# 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@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 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
+20 -3
View File
@@ -6,16 +6,33 @@ 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
# Transient host-side probe file written by tests/database-postgres-ledger.test.mjs
# into the database-postgres package (removed in its finally block)
# Transient host-side probe files written by the database-postgres test
# suites into the package (removed in their finally blocks)
.ledger-probe-*.mjs
.lock-probe-*.mjs
.diagnostic-probe-*.mjs
# Transient host-side probe file written by the config-schema test suite into
# 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
+34 -5
View File
@@ -5,8 +5,12 @@
# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the
# pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the
# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http`
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
# 3000, so the app container stays up and the health endpoint succeeds. The
# server answering `GET /health` on port 3000. Since T06 (E00-S03) the health
# endpoint is the readiness probe: the app runs the startup migrations (the
# `MigrationRunner` from `@personal-blog/database-postgres`) before reporting
# ready — `GET /health` answers 503 `{"status":"not ready"}` while the run is
# in flight and flips to 200 `{"status":"ok"}` only after it completes — so
# the app container does not report ready before migrations complete. The
# Fastify 5 application shell (and the real HTTP API) lands in a later story;
# DB volume persistence (T04), read-only root filesystem (T06) and multi-arch
# build targets (T07) are Compose-level concerns (see compose.yaml — the
@@ -17,6 +21,18 @@
# T05 the runtime stage drops root privileges (runs as the image's non-root
# `node` user).
#
# 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
# copies a fixed, non-secret path (manifests, source, compiled dist) — never
@@ -44,15 +60,23 @@ RUN corepack enable
# invalidate the dependency layer, then install against the committed lockfile
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
# package manifest is copied so the in-image workspace matches the lockfile
# importers exactly (apps/server, packages/core, extensions/example).
# importers exactly (apps/server, packages/core, packages/config,
# packages/database-postgres, extensions/example).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/server/package.json apps/server/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/database-postgres/package.json packages/database-postgres/package.json
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/).
# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The
# 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
# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------
@@ -61,10 +85,15 @@ 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 and manifest.
# 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
# T05: run as the image's non-root `node` user (uid/gid 1000, shipped with the
# official Node image) so the app container does not run with root privileges.
+7 -3
View File
@@ -3,12 +3,16 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story.",
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly and binds the validated HOST interface (E00-S04-T04); the Fastify 5 application shell lands in a later story.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "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": {
"@types/node": "24.13.3"
},
+170 -19
View File
@@ -2,36 +2,106 @@
* @personal-blog/server — EPPP public server application.
*
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
* `node:http` (no runtime dependencies yet) that answers the application
* health endpoint. `GET /health` reports a healthy application — HTTP 200 with
* `{"status":"ok"}` — so the Compose stack's `app` service stays up and the
* health endpoint succeeds once the stack is running.
* `node:http` that answers the application health endpoint. `GET /health`
* reports the application readiness — HTTP 200 with `{"status":"ok"}` — so
* the Compose stack's `app` service stays up and the health endpoint
* succeeds once the stack is running.
*
* [E00-S03-T06] readiness gate: the app does **not** report ready before
* migrations complete. When a `DATABASE_URL` is configured, the server runs
* the startup migrations (the `MigrationRunner` from
* `@personal-blog/database-postgres` over the migration ledger, E00-S03-T03)
* before it reports ready: `GET /health` answers HTTP 503 with
* `{"status":"not ready"}` while the migration run is in flight, and flips to
* HTTP 200 `{"status":"ok"}` only after the run finishes. When no
* `DATABASE_URL` is configured (e.g. the local non-container developer path,
* 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();
/** Health payload — reports a healthy application. */
// [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' });
/** Payload while the startup migration run is still in flight — the app is up but NOT ready. */
const NOT_READY_PAYLOAD = JSON.stringify({ status: 'not ready' });
/** Payload for any route that is not the health endpoint. */
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
/**
* 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.
* The migrations the server applies at startup, oldest first. Empty until the
* first schema migration lands (E00-S03 is the migration-runner foundation;
* real schema migrations arrive with the domain stories). The runner still
* ensures the migration ledger (E00-S03-T03) exists and reads the applied
* versions, so even an empty run is a real, observable migration step that
* readiness waits for.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
const MIGRATIONS: readonly Migration[] = [];
/**
* Readiness state — false until the startup migration run completes. The app
* is "up" (the HTTP server is listening) but reports not-ready (E00-S03-T06)
* while migrations are pending.
*/
let migrationsComplete = false;
/** Writes a JSON response with an explicit content-length. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
@@ -42,13 +112,60 @@ 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.
* stage; anything else is a 404 so misconfiguration is loud. The health route
* is the readiness probe (E00-S03-T06): it answers 200 only after the startup
* migration run completes, and 503 while the run is still pending.
*/
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
sendJson(res, 200, HEALTH_PAYLOAD);
if (migrationsComplete) {
sendJson(res, 200, HEALTH_PAYLOAD);
} else {
sendJson(res, 503, NOT_READY_PAYLOAD);
}
return;
}
sendJson(res, 404, NOT_FOUND_PAYLOAD);
@@ -56,8 +173,42 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const server = createServer(handleRequest);
server.listen(PORT, () => {
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
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;
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 });
const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));
runner
.run()
.then((result) => {
migrationsComplete = true;
logger.log(
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
);
})
.catch((error) => {
// Failure diagnostics are E00-S03-T05 (out of scope for T06): the
// 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. 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);
});
}
// 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.
+66 -19
View File
@@ -15,9 +15,10 @@ 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); 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/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) is implemented here; the migration runner (advisory lock, failure diagnostics) lands in later stories. |
| `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. |
A dependency-boundary rule (`dependency-boundaries.json`, enforced by
@@ -76,28 +77,61 @@ pnpm typecheck
`pnpm build` runs `tsc -p tsconfig.json` in each package in topological order
and emits `dist/` (JavaScript + type declarations + source maps) per package.
Expected result: `apps/server/dist/`, `packages/core/dist/`,
`packages/database-postgres/dist/` and `extensions/example/dist/` are
produced, all packages report `Done`, exit 0.
`packages/config/dist/`, `packages/database-postgres/dist/` and
`extensions/example/dist/` are produced, all packages report `Done`, exit 0.
`dist/` is git-ignored; rebuild whenever you change `src/`.
## Run
```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` with HTTP 200 and `{"status":"ok"}`, so the process stays up.
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.
`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
runs the startup migrations before reporting ready — `GET /health` answers
HTTP 503 with `{"status":"not ready"}` while the run is in flight and HTTP
200 with `{"status":"ok"}` only after it completes. With **no
`DATABASE_URL`** (this local non-container path) there is no migration run
to wait for, so the server reports ready immediately and `GET /health`
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:
@@ -109,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
@@ -121,10 +166,11 @@ This is also the suite that enforces the dependency-boundary rule.
git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog
corepack enable
pnpm install --frozen-lockfile # exit 0, lockfile untouched
pnpm build # 4/4 packages emit dist/, exit 0
pnpm typecheck # 4/4 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 build # 5/5 packages emit dist/, exit 0
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
pnpm lint # formatting/lint policy passes, exit 0
pnpm test # all node:test suites pass, exit 0
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
```
## Troubleshooting
@@ -135,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"
},
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@personal-blog/config",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@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": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
+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,
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @personal-blog/config — EPPP configuration service.
*
* [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`).
*
* [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;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* EPPP configuration schema — [E00-S04-T01] TypeBox/Ajv schema.
*
* The single source of truth for the **validated configuration fields** of
* the EPPP server (the `@personal-blog/config` package). The schema is
* defined with TypeBox (`@sinclair/typebox`, golden-tuple pin 0.34.52 —
* Technology-Stack §5.2/§7) and validated with Ajv (`ajv`, golden-tuple pin
* 8.20.0) via `validateConfig` (see `validate.ts`). E00-S04-T02
* (field-specific startup errors), T03 (secret redaction), T04 (the
* `process.env` adapter) and T05 (`.env.example` placeholders) build on this
* schema; this module only defines it.
*
* The validated config fields:
*
* - `host` — the interface the HTTP server binds. Default `0.0.0.0` (the
* committed server binds all interfaces today, E00-S02-T03). Environment
* source: `HOST`.
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
* port range (1–65535), default `3000` (the container default, matching
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
* `PORT` is read by the environment adapter, E00-S04-T04).
* - `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
* local non-container developer path, E00-S01-T06/E00-S03-T06). Must be
* non-empty when present. Environment source: `DATABASE_URL`.
* - `sessionSecret` — the admin-session secret (`EPPP_SESSION_SECRET`, the
* required secret per Security-and-Operations §32/§26). **Required** and at
* least 32 characters: it is the story's secret field (E00-S04-T03 redacts
* it from logs) and the schema's required field (E00-S04-T02 reports a
* missing required setting). No default — a secret must never be invented
* by the schema.
*
* The object is closed (`additionalProperties: false`) so a typo'd or
* unexpected setting is rejected loudly instead of silently ignored.
*/
import { Type, type Static } from '@sinclair/typebox';
/** The EPPP configuration schema — validates the parsed configuration object. */
export const configSchema = Type.Object(
{
host: Type.Optional(Type.String({ default: '0.0.0.0' })),
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535, default: 3000 })),
databaseUrl: Type.Optional(Type.String({ minLength: 1 })),
sessionSecret: Type.String({ minLength: 32 }),
},
{ additionalProperties: false },
);
/** The validated configuration type — `Static` of `configSchema`. */
export type Config = Static<typeof configSchema>;
+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);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* EPPP configuration validation — [E00-S04-T01] TypeBox/Ajv schema.
*
* Compiles the TypeBox `configSchema` with Ajv (the golden-tuple validator,
* Technology-Stack §5.2/§7) and exposes `validateConfig`, the generic
* schema-validation entry point: given an unknown value it reports whether
* the value is a valid configuration and the Ajv error messages otherwise.
*
* 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 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';
import { configSchema } 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 outcome of validating a value against the config schema. */
export interface ConfigValidationResult {
/** True when the value is a valid configuration. */
valid: boolean;
/** Ajv error messages, empty when `valid` is true. */
errors: string[];
}
/**
* Validates an unknown value against the config schema.
*
* @param value - the value to validate (typically the parsed config object)
* @returns `{ valid: true, errors: [] }` for a valid configuration, or
* `{ valid: false, errors }` with the Ajv messages naming each violation
* (e.g. `"must have required property 'sessionSecret'"`).
*/
export function validateConfig(value: unknown): ConfigValidationResult {
const valid = validateConfigValue(value);
if (valid) {
return { valid: true, errors: [] };
}
const errors = (validateConfigValue.errors ?? []).map((error) => error.message ?? 'invalid');
return { valid: false, errors };
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03) is implemented here; the migration runner (advisory lock, diagnostics) lands in later stories.",
"description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
+8 -4
View File
@@ -9,10 +9,11 @@
*
* This module is the driver boundary: it imports the PostgreSQL driver (`pg`)
* and Kysely and re-exports the pieces the adapter is built on — the driver
* surface (E00-S03-T02) and the migration ledger (E00-S03-T03). The advisory
* lock (E00-S03-T04) and failure diagnostic (E00-S03-T05) land in later
* stories; until then the re-exports keep the driver reachable only from here
* — the isolation is real, not a placeholder.
* surface (E00-S03-T02), the migration ledger (E00-S03-T03), the migration
* advisory lock (E00-S03-T04) and the migration runner with its failure
* diagnostic (E00-S03-T05). Keeping every re-export here means the driver
* stays reachable only from this package — the isolation is real, not a
* placeholder.
*/
import { Pool } from 'pg';
@@ -20,3 +21,6 @@ import { Kysely, PostgresDialect } from 'kysely';
export { Pool, Kysely, PostgresDialect };
export { MigrationLedger, MIGRATION_LEDGER_TABLE } from './ledger.js';
export { MigrationLock, MIGRATION_LOCK_KEY } from './lock.js';
export { MigrationRunner, MigrationFailedError } from './runner.js';
export type { Migration, MigrationDiagnostic, MigrationRunResult, MigrationFailurePhase } from './runner.js';
+2 -2
View File
@@ -2,8 +2,8 @@
* Migration ledger — [E00-S03-T03].
*
* The ledger is the PostgreSQL table that records applied migrations, so the
* migration runner (built on this boundary in later stories — advisory lock
* E00-S03-T04, failure diagnostic E00-S03-T05) can tell which migrations have
* migration runner (built on this boundary — the advisory lock E00-S03-T04
* and the failure diagnostic E00-S03-T05) can tell which migrations have
* already been applied and apply the rest exactly once.
*
* The ledger lives in `database-postgres` — the single workspace package
+166
View File
@@ -0,0 +1,166 @@
/**
* Migration advisory lock — [E00-S03-T04].
*
* The advisory lock is the PostgreSQL-side guarantee that concurrent migration
* runners cannot run at the same time: the migration runner (built on this
* boundary — the failure diagnostic E00-S03-T05; the ready gate E00-S03-T06)
* takes the lock before applying migrations, so a second runner
* either waits (`acquire()`) or fails fast (`tryAcquire()`) while the first
* holds it.
*
* The lock lives in `database-postgres` — the single workspace package
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
* database exclusively through the package-owned `pg` Pool, so no other
* package needs the driver to lock migration state.
*
* Lock model:
* - session-scoped advisory lock (`pg_advisory_lock`/`pg_try_advisory_lock`
* on the `bigint` key `hashtextextended($1, 0)`), so it lives exactly as
* long as the holding database session and no longer;
* - held on a dedicated connection checked out of the caller's pool
* (`pool.connect()`), so the lock never taints a pooled connection that
* other queries share;
* - released explicitly by `release()` (`pg_advisory_unlock` first, then
* the client goes back to the pool; if the unlock statement itself fails,
* the connection is destroyed — `client.release(error)` — so a pooled
* connection is never reused while its session still holds the lock),
* and released implicitly by the server when the holding session ends —
* the issue's rollback note: "the lock releases when the runner exits".
*
* The lock key is always passed as a bound parameter (`$1`) — the SQL never
* interpolates it, so the only interpolated value is the `$1` placeholder
* itself. `hashtextextended($1, 0)` maps the key string to a stable `bigint`
* advisory-lock key: deterministic for a given database, identical across all
* sessions, so every runner contends on the same lock.
*/
import type { Pool, PoolClient } from 'pg';
/** Namespace string for the migration advisory lock. */
export const MIGRATION_LOCK_KEY = 'personal-blog-migrations';
/**
* The migration advisory lock: serializes migration runners so two runners
* can never apply migrations at the same time. Instances are cheap and share
* the caller's pool; the lock performs no ledger writes (migration ledger is
* E00-S03-T03) and no diagnostics (E00-S03-T05).
*/
export class MigrationLock {
private readonly pool: Pool;
private client: PoolClient | null = null;
private held = false;
/** In-flight acquire, memoized so concurrent acquire() calls share one connection (re-entrant-safe). */
private acquireInFlight: Promise<void> | null = null;
/** @param pool The package-owned PostgreSQL pool (`pg.Pool`). */
constructor(pool: Pool) {
this.pool = pool;
}
/** True while this instance holds the advisory lock. */
get isHeld(): boolean {
return this.held;
}
/**
* Acquires the migration advisory lock, blocking until it is free — a
* second runner waits here while the first holds the lock. Idempotent:
* acquiring an already-held instance is a no-op. Re-entrant-safe:
* concurrent `acquire()` calls on the same instance share the single
* in-flight acquire (memoized in `acquireInFlight`), so exactly one
* connection is checked out and no locked connection leaks. The lock is
* held on a dedicated connection until `release()` (or until the session
* ends — e.g. the runner exits and the pool closes its connections).
*/
async acquire(): Promise<void> {
if (this.held) return;
// A second concurrent acquire() on this instance returns the in-flight
// acquire instead of checking out another connection: exactly one
// connection is checked out and no locked connection leaks.
if (this.acquireInFlight !== null) return this.acquireInFlight;
const inFlight = this.doAcquire();
this.acquireInFlight = inFlight;
try {
await inFlight;
} finally {
this.acquireInFlight = null;
}
}
/** The memoized acquire body: checks out one dedicated connection and takes the session-scoped lock on it. */
private async doAcquire(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(
'SELECT pg_advisory_lock(hashtextextended($1, 0))',
[MIGRATION_LOCK_KEY],
);
} catch (error) {
client.release();
throw error;
}
this.client = client;
this.held = true;
}
/**
* Non-blocking acquire: resolves `true` when this runner got the lock and
* `false` when another runner holds it — a second runner fails fast while
* the first holds the lock. Also resolves `true` when this instance
* already holds the lock.
*/
async tryAcquire(): Promise<boolean> {
if (this.held) return true;
const client = await this.pool.connect();
try {
const result = await client.query<{ acquired: boolean }>(
'SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired',
[MIGRATION_LOCK_KEY],
);
const acquired = result.rows[0]?.acquired === true;
if (acquired) {
this.client = client;
this.held = true;
return true;
}
client.release();
return false;
} catch (error) {
client.release();
throw error;
}
}
/**
* Releases the advisory lock: unlocks it on the holding session
* (`pg_advisory_unlock`) and returns the connection to the pool, so the
* lock is gone before any other query could reuse that pooled connection.
* If the unlock statement fails (e.g. statement timeout or cancellation)
* the connection is destroyed instead — `client.release(error)` tells the
* pool to drop the client, ending the session (and its lock), so a pooled
* connection is never reused while its session still holds the lock; the
* failure is re-thrown. Idempotent: releasing an instance that does not
* hold the lock is a no-op.
*/
async release(): Promise<void> {
if (!this.held || this.client === null) return;
const client = this.client;
this.client = null;
this.held = false;
try {
await client.query(
'SELECT pg_advisory_unlock(hashtextextended($1, 0))',
[MIGRATION_LOCK_KEY],
);
} catch (error) {
// The unlock statement failed while this session may still hold the
// advisory lock. The connection must not go back into the pool in that
// state — the next borrower would block every other runner. Destroy it
// (release(error) removes the client from the pool), so the session —
// and its lock — ends.
client.release(error as Error);
throw error;
}
client.release();
}
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Migration runner with failure diagnostic — [E00-S03-T05].
*
* The migration runner applies pending migrations to the database exactly
* once and, when a migration fails, produces a structured diagnostic that
* identifies the failing migration. It is built on the boundary that came
* before it: the migration ledger (E00-S03-T03) records applied migrations,
* so a rerun never double-applies. The advisory lock (E00-S03-T04) serializes
* concurrent runners, but wiring the lock into the runner is out of scope for
* this story — the runner performs no locking itself; a runner that wants to
* serialize takes the lock (E00-S03-T04) around `run()`.
*
* Failure model:
* - migrations run in the order given, oldest first; a migration whose
* version is already recorded in the ledger is skipped;
* - when a migration's `up` throws, the runner wraps the failure into a
* `MigrationFailedError` whose `diagnostic` is a structured object that
* identifies the failing migration (`migration` — its version), where the
* run failed (`phase`: 'apply' when the migration's `up` threw, 'record'
* when the ledger insert threw after a successful `up`), the underlying
* cause, and the ledger state at failure time (`applied`/`pending` —
* `applied` + `pending` cover the runner's migrations exactly);
* - the error is serializable: `toJSON()` returns a plain structured object
* (including a structured cause — for pg errors the `code`, e.g. `42P01`),
* so operators can log/parse the diagnostic without string-matching.
*
* The runner lives in `database-postgres` — the single workspace package
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
* database exclusively through the package-owned `pg` Pool and the
* `MigrationLedger`, so no other package needs the driver to run migrations.
* The app (apps/server) runs this runner at startup and gates its readiness
* on the run (E00-S03-T06): it does not report ready before the run
* completes.
*
* Rollback note from the issue: revert the diagnostic/error handling changes.
*/
import type { Pool } from 'pg';
import type { MigrationLedger } from './ledger.js';
/**
* A single migration step: an identifier (recorded in the ledger once the
* step has been applied) and the apply function. `up` receives the
* package-owned pool, so a migration can run any SQL (and multi-statement
* work) through the same driver boundary the runner itself uses.
*/
export interface Migration {
version: string;
up(pool: Pool): Promise<void> | void;
}
/**
* Where a migration run failed: 'apply' when the migration's `up` threw, or
* 'record' when the ledger insert threw after a successful `up`.
*/
export type MigrationFailurePhase = 'apply' | 'record';
/**
* The structured diagnostic produced when a migration fails. `migration`
* identifies the failing migration; `applied` and `pending` are disjoint and
* together cover the runner's migration list in run order.
*/
export interface MigrationDiagnostic {
/** Version of the migration that failed — identifies the failing migration. */
migration: string;
/** Where the run failed: 'apply' (the migration's `up` threw) or 'record' (the ledger insert threw). */
phase: MigrationFailurePhase;
/** The underlying failure (e.g. the pg error), preserved for inspection. */
cause: unknown;
/** Versions recorded in the ledger when the failure happened, in run order. */
applied: string[];
/** Versions not yet recorded when the failure happened, in run order — includes the failing migration. */
pending: string[];
}
/** Result of a successful migration run. */
export interface MigrationRunResult {
/** Versions applied by this run, in run order (oldest first). */
applied: string[];
/** Versions skipped because they were already recorded in the ledger. */
skipped: string[];
}
/**
* The error thrown when a migration fails. Carries the structured diagnostic
* (`.diagnostic`) and is serializable (`toJSON()`), so callers and operators
* can inspect and parse the failure without string-matching the message.
*/
export class MigrationFailedError extends Error {
readonly diagnostic: MigrationDiagnostic;
constructor(diagnostic: MigrationDiagnostic) {
super(`migration "${diagnostic.migration}" failed during ${diagnostic.phase}`);
this.name = 'MigrationFailedError';
this.diagnostic = diagnostic;
}
/** Serializable form of the error and its structured diagnostic. */
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
diagnostic: {
migration: this.diagnostic.migration,
phase: this.diagnostic.phase,
applied: [...this.diagnostic.applied],
pending: [...this.diagnostic.pending],
cause: structuredCause(this.diagnostic.cause),
},
};
}
}
/**
* Reduces the underlying cause to a structured, serializable shape: for an
* `Error` the name/message (plus the pg error `code` when present, e.g.
* `42P01`); for anything else a `{ value }` wrapper, so `toJSON()` never
* stringifies to an empty object.
*/
function structuredCause(cause: unknown): Record<string, unknown> {
if (cause instanceof Error) {
const structured: Record<string, unknown> = { name: cause.name, message: cause.message };
const code = (cause as Error & { code?: unknown }).code;
if (code !== undefined) structured.code = code;
return structured;
}
return { value: cause };
}
/**
* The migration runner: applies pending migrations in order, exactly once,
* through the migration ledger. Instances are cheap and share the caller's
* pool and ledger; the runner performs no locking (advisory lock is
* E00-S03-T04) and no ready gating (the app gates its readiness on the run,
* E00-S03-T06).
*/
export class MigrationRunner {
private readonly pool: Pool;
private readonly ledger: MigrationLedger;
private readonly migrations: readonly Migration[];
/**
* @param pool The package-owned PostgreSQL pool (`pg.Pool`), handed to each
* migration's `up`.
* @param migrations The migrations to run, in apply order (oldest first).
* @param ledger The migration ledger (E00-S03-T03) the runner reads applied
* versions from and records applied migrations into; constructed by the
* caller from the same pool (`new MigrationLedger(pool)`).
*/
constructor(pool: Pool, migrations: readonly Migration[], ledger: MigrationLedger) {
this.pool = pool;
this.migrations = migrations;
this.ledger = ledger;
}
/**
* Runs the pending migrations: creates the ledger if missing, then applies
* every migration whose version is not yet recorded, recording each one as
* it completes. Idempotent: a rerun skips everything already recorded, so a
* second run never double-applies. When a migration fails — its `up` throws
* ('apply') or the ledger insert throws ('record') — the runner throws a
* `MigrationFailedError` whose `diagnostic` identifies the failing migration
* and the ledger state at failure time.
*/
async run(): Promise<MigrationRunResult> {
await this.ledger.ensure();
const recorded = new Set(await this.ledger.applied());
const applied: string[] = [];
const skipped: string[] = [];
for (const migration of this.migrations) {
if (recorded.has(migration.version)) {
skipped.push(migration.version);
continue;
}
try {
await migration.up(this.pool);
} catch (error) {
throw this.failure(migration.version, 'apply', error, recorded);
}
try {
await this.ledger.record(migration.version);
} catch (error) {
throw this.failure(migration.version, 'record', error, recorded);
}
recorded.add(migration.version);
applied.push(migration.version);
}
return { applied, skipped };
}
/**
* Builds the structured diagnostic for a failing migration: `migration` is
* the failing version, `applied`/`pending` are the ledger state at failure
* time scoped to this runner's migrations (disjoint, in run order — the
* failing migration is still pending, since it was never recorded).
*/
private failure(
version: string,
phase: MigrationFailurePhase,
cause: unknown,
recorded: ReadonlySet<string>,
): MigrationFailedError {
const applied: string[] = [];
const pending: string[] = [];
for (const migration of this.migrations) {
if (recorded.has(migration.version)) applied.push(migration.version);
else pending.push(migration.version);
}
return new MigrationFailedError({ migration: version, phase, cause, applied, pending });
}
}
+56
View File
@@ -13,6 +13,13 @@ importers:
version: 6.0.3
apps/server:
dependencies:
'@personal-blog/config':
specifier: workspace:*
version: link:../../packages/config
'@personal-blog/database-postgres':
specifier: workspace:*
version: link:../../packages/database-postgres
devDependencies:
'@types/node':
specifier: 24.13.3
@@ -20,6 +27,19 @@ importers:
extensions/example: {}
packages/config:
dependencies:
'@sinclair/typebox':
specifier: 0.34.52
version: 0.34.52
ajv:
specifier: 8.20.0
version: 8.20.0
devDependencies:
'@types/node':
specifier: 24.13.3
version: 24.13.3
packages/core: {}
packages/database-postgres:
@@ -37,12 +57,27 @@ importers:
packages:
'@sinclair/typebox@0.34.52':
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/pg@8.21.0':
resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.6:
resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
kysely@0.29.4:
resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==}
engines: {node: '>=22.0.0'}
@@ -97,6 +132,10 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -115,6 +154,8 @@ packages:
snapshots:
'@sinclair/typebox@0.34.52': {}
'@types/node@24.13.3':
dependencies:
undici-types: 7.18.2
@@ -125,6 +166,19 @@ snapshots:
pg-protocol: 1.16.0
pg-types: 2.2.0
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.6
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
fast-deep-equal@3.1.3: {}
fast-uri@3.1.6: {}
json-schema-traverse@1.0.0: {}
kysely@0.29.4: {}
pg-cloudflare@1.4.0:
@@ -172,6 +226,8 @@ snapshots:
dependencies:
xtend: 4.0.2
require-from-string@2.0.2: {}
split2@4.2.0: {}
typescript@6.0.3: {}
+765
View File
@@ -0,0 +1,765 @@
/**
* App readiness test — locks in the [E00-S03-T06] guarantee that the app does
* not report ready before migrations complete.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "app does not report ready before migrations complete" → the committed
* `apps/server/src/index.ts` gates the health endpoint on a readiness
* flag (`migrationsComplete`) that starts `false` and flips to `true`
* only inside the startup migration run's success handler: while the run
* is in flight, `GET /health` answers HTTP 503 with `{"status":"not
* ready"}` (the app is up but not ready), never 200. Locked in
* statically (mutation probes prove non-vacuity: removing the 503
* branch, answering 200 in the not-ready state, replacing the gate with
* `if (true)`, or flipping the flag before the run all fail) and
* behaviorally by the deterministic probes 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).
* - "readiness is reported only after migrations finish" → the committed
* source flips the flag only after `runner.run()` resolves (the
* `migrationsComplete = true` assignment sits inside the `.then` of
* `run()`, at a source index after the `runner.run()` call), and the
* real-stack probe confirms `/health` answers HTTP 200
* `{"status":"ok"}` exactly after the blocked run completes; the app
* logs the completed run (`applied`/`skipped`) so the flip is
* attributable to a real migration run.
* - the readiness gate runs the startup migrations through the driver
* boundary (`@personal-blog/database-postgres` — `Pool`,
* `MigrationLedger`, `MigrationRunner`; `Migration` type), never
* importing `pg`/Kysely directly (isolation E00-S03-T02 stays intact).
* - the no-migration path: when no `DATABASE_URL` is configured (the local
* non-container developer path, E00-S01-T06) there is no startup
* migration run to wait for, so the app reports ready immediately —
* locked in statically and by a deterministic probe (boot without
* `DATABASE_URL` → 200 `{"status":"ok"}`), keeping the E00-S02-T03
* health endpoint and the local `pnpm --filter @personal-blog/server
* start` path working.
*
* Run: `node --test tests/app-readiness.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, existsSync } 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 server entrypoint under test (the readiness gate lives here). */
const SERVER_SRC = 'apps/server/src/index.ts';
/** The driver boundary the server must run migrations through (E00-S03-T02). */
const DRIVER_BOUNDARY = '@personal-blog/database-postgres';
/** 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 readiness criterion on every PR. */
const CI_JOB = 'app-readiness';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Static assertions on the committed server entrypoint
// ---------------------------------------------------------------------------
/**
* Asserts the server runs the startup migrations through the driver boundary:
* it imports `Pool`, `MigrationLedger` and `MigrationRunner` (and the
* `Migration` type) from `@personal-blog/database-postgres`, and never
* imports `pg`/Kysely directly (isolation E00-S03-T02 stays intact). Fails
* fast on a missing/placeholder entrypoint; the mutation probes below prove
* the assertions are non-vacuous.
*/
function assertDriverBoundaryImports(src) {
assert.match(
src,
/from '@personal-blog\/database-postgres'/,
`the server must run migrations through the driver boundary (import from '${DRIVER_BOUNDARY}')`,
);
assert.match(
src,
/import \{ Pool \} from '@personal-blog\/database-postgres'/,
`the server must import the pool from the driver boundary (import { Pool } from '${DRIVER_BOUNDARY}')`,
);
assert.match(
src,
/import \{ MigrationLedger, MigrationRunner \} from '@personal-blog\/database-postgres'/,
`the server must import the ledger and runner from the driver boundary (import { MigrationLedger, MigrationRunner } from '${DRIVER_BOUNDARY}')`,
);
assert.match(
src,
/import type \{ Migration \} from '@personal-blog\/database-postgres'/,
`the server must import the Migration type from the driver boundary (import type { Migration } from '${DRIVER_BOUNDARY}')`,
);
assert.doesNotMatch(
src,
/from\s+'pg'/,
'the server must not import the pg driver directly (pg/Kysely imports are isolated to database-postgres, E00-S03-T02)',
);
assert.doesNotMatch(
src,
/from\s+'kysely'/,
'the server must not import Kysely directly (pg/Kysely imports are isolated to database-postgres, E00-S03-T02)',
);
}
/**
* Asserts the readiness gate exists in the /health route: the server answers
* HTTP 200 with the healthy payload only when `migrationsComplete` is true,
* and HTTP 503 with the not-ready payload otherwise — the app does not report
* ready before migrations complete.
*/
function assertReadinessGate(src) {
const route = src.slice(src.indexOf('function handleRequest'));
assert.match(
route,
/if \(migrationsComplete\) \{/,
'the /health route must gate the healthy answer on migrationsComplete (readiness reported only after migrations finish)',
);
assert.match(
route,
/sendJson\(res, 200, HEALTH_PAYLOAD\)/,
'the /health route must answer HTTP 200 with the healthy payload only when ready',
);
assert.match(
route,
/sendJson\(res, 503, NOT_READY_PAYLOAD\)/,
'the /health route must answer HTTP 503 with the not-ready payload while migrations are pending',
);
assert.match(
src,
/const NOT_READY_PAYLOAD = JSON\.stringify\(\{ status: 'not ready' \}\)/,
"the not-ready payload must report a not-ready application ({\"status\":\"not ready\"})",
);
assert.match(
src,
/let migrationsComplete = false;/,
'the readiness flag must start false (the app is not ready before the migration run completes)',
);
}
/**
* Asserts readiness flips only after the startup migration run finishes: in
* the DATABASE_URL-configured path the committed source creates a
* `MigrationRunner` and calls `run()`, and the `migrationsComplete = true`
* assignment sits inside the run's `.then` handler — at a source index
* strictly after the `runner.run()` call — so readiness is reported only
* after migrations finish.
*/
function assertReadyAfterRun(src) {
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)');
const catchIndex = src.indexOf('.catch(', elseStart);
assert.ok(catchIndex !== -1, 'the startup migration run must have a failure handler (.catch)');
const runPath = src.slice(elseStart, catchIndex);
assert.match(
runPath,
/const runner = new MigrationRunner\(pool, MIGRATIONS, new MigrationLedger\(pool\)\)/,
'the server must build the startup migration runner over the migration ledger (new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool)))',
);
const runMatch = /runner\s*\n?\s*\.run\(\)/.exec(runPath);
assert.ok(runMatch, 'the server must run the startup migrations (runner.run())');
assert.match(
runPath,
/\.then\(\(result\) => \{/,
'the readiness flip must be attached to the run success path (runner.run().then(...))',
);
const flipMatch = /migrationsComplete = true;/.exec(runPath);
assert.ok(
flipMatch !== null && flipMatch.index > runMatch.index,
'migrationsComplete must flip to true only after the startup migration run (runner.run()) is invoked',
);
}
/**
* Asserts the no-DATABASE_URL path reports ready immediately: when no
* `DATABASE_URL` is configured there is no startup migration run to wait for,
* so the app marks migrations complete without a run (keeping the local
* non-container path and the E00-S02-T03 health endpoint working).
*/
function assertNoDatabaseUrlPath(src) {
const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl'));
assert.match(
startup,
/if \(databaseUrl === undefined\) \{/,
'the server must branch on a missing DATABASE_URL (no database configured)',
);
const noDbBranch = startup.slice(startup.indexOf('if (databaseUrl === undefined) {'), startup.indexOf('} else {'));
assert.match(
noDbBranch,
/migrationsComplete = true;/,
'with no DATABASE_URL configured the server must report ready immediately (no migration run to wait for)',
);
}
/** Runs every static criterion assertion against the committed server entrypoint. */
function assertReadySource(src) {
assertDriverBoundaryImports(src);
assertReadinessGate(src);
assertReadyAfterRun(src);
assertNoDatabaseUrlPath(src);
}
// ---------------------------------------------------------------------------
// Boot helpers for the behavioral probes (Node type stripping, no build step)
// ---------------------------------------------------------------------------
/**
* 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;
}
/** 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
* (merged over `process.env`; `DATABASE_URL` is stripped unless explicitly
* 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 = {}) {
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
// 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,
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()})`,
);
}
/** 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');
}
// ---------------------------------------------------------------------------
// Criterion tests — static assertions on the committed server entrypoint
// ---------------------------------------------------------------------------
test('the committed server entrypoint exists and gates readiness on the startup migration run', () => {
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
assertReadySource(read(SERVER_SRC));
});
test('the server runs migrations through the driver boundary, never importing pg/Kysely directly', () => {
assertDriverBoundaryImports(read(SERVER_SRC));
});
test('the /health route reports not-ready while migrations are pending and ready only after they finish', () => {
assertReadinessGate(read(SERVER_SRC));
});
test('the readiness flag flips to true only inside the startup migration run success handler', () => {
assertReadyAfterRun(read(SERVER_SRC));
});
test('with no DATABASE_URL configured the server reports ready immediately (no migration run to wait for)', () => {
assertNoDatabaseUrlPath(read(SERVER_SRC));
});
test('the app-readiness 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/app-readiness.test.mjs`),
`CI must run the app-readiness suite (job "${CI_JOB}") on every PR`,
);
});
// ---------------------------------------------------------------------------
// Deterministic behavioral probes (no database, no Docker) — the committed
// server is booted and probed over real HTTP
// ---------------------------------------------------------------------------
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
const TS_STRIPPING = tsExecMode() !== null;
test('without DATABASE_URL the app reports ready immediately (deterministic probe)', { skip: !TS_STRIPPING }, async (t) => {
// The no-migration path (local non-container dev, E00-S01-T06): no
// DATABASE_URL is configured, so there is no startup migration run to wait
// for and GET /health must answer 200 {"status":"ok"} right away — keeping
// the E00-S02-T03 health endpoint working.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port); // DATABASE_URL stripped
try {
const response = await waitForAnswer(port, child, stderr);
assert.equal(
response.status,
200,
`GET /health without DATABASE_URL must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.deepEqual(
await response.json(),
{ status: 'ok' },
'without DATABASE_URL the app must report a healthy application ({"status":"ok"})',
);
} finally {
await stopChild(child);
}
});
test('the app does not report ready while the migration run cannot complete (unreachable database)', { skip: !TS_STRIPPING }, async (t) => {
// "app does not report ready before migrations complete": with a
// DATABASE_URL that refuses connections, the startup migration run cannot
// complete, so the app must stay up but answer HTTP 503
// {"status":"not ready"} — never 200 — until the process is stopped.
const port = await reservePort();
const deadPort = await reservePort(); // reserved then released: nothing listens
const { child, stdout, stderr } = bootServer(port, {
DATABASE_URL: `postgres://eppp:eppp@127.0.0.1:${deadPort}/eppp`,
});
try {
// Every answer over a ~2s window must be 503 not-ready, never 200.
let answered = 0;
for (let attempt = 0; attempt < 6; attempt += 1) {
const response = await waitForAnswer(port, child, stderr);
answered += 1;
assert.equal(
response.status,
503,
`GET /health must answer 503 while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.deepEqual(
await response.json(),
{ status: 'not ready' },
'the app must report not-ready ({"status":"not ready"}) while migrations are incomplete',
);
await delay(300);
}
assert.ok(answered >= 2, `the app must keep answering during the probe (answered ${answered} times)`);
assert.equal(
child.exitCode,
null,
'the app must stay up (not crash) while the migration run cannot complete — not-ready is a stable reported state',
);
assert.match(
stdout() + stderr(),
/startup migration run failed; app stays not-ready/,
'the app must log that the startup migration run failed and it stays not-ready',
);
} finally {
await stopChild(child);
}
});
// ---------------------------------------------------------------------------
// Docker probe helpers (the real-stack probe skips cleanly without Docker)
// ---------------------------------------------------------------------------
function run(cmd, args, opts = {}) {
return spawnSync(cmd, args, {
encoding: 'utf8',
timeout: 600_000,
...opts,
});
}
/** True when the `docker` CLI with the Compose plugin is on PATH. */
function dockerComposeAvailable() {
try {
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** True when a reachable Docker daemon exists. */
function dockerDaemonAvailable() {
try {
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** Parses `docker compose ps --format json` (JSON array or one object per line). */
function parsePsJson(stdout) {
const text = String(stdout).trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
}
/** Tolerant field lookup across compose ps JSON shapes. */
function field(container, ...names) {
for (const name of names) {
if (container[name] !== undefined) return container[name];
}
return undefined;
}
/**
* Polls `docker compose ps` until the db container of the given project
* reports healthy (or the deadline passes), so the probe never races a
* still-booting database.
*/
function waitForDbHealthy(project, deadlineMs = 60_000) {
const deadline = Date.now() + deadlineMs;
let last = '';
while (Date.now() < deadline) {
const ps = run('docker', ['compose', '-p', project, 'ps', '--format', 'json'], {
cwd: REPO_ROOT,
timeout: 15_000,
});
if (ps.status === 0) {
last = ps.stdout;
const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db');
if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return;
}
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry
}
throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`);
}
// ---------------------------------------------------------------------------
// Real-stack probe — the issue's test plan: "start with pending migrations
// and confirm readiness waits", against a real database
// ---------------------------------------------------------------------------
const DOCKER_COMPOSE = dockerComposeAvailable();
const DOCKER_DAEMON = dockerDaemonAvailable();
// An isolated compose project + non-default host port so this probe never
// collides with the other suites' projects/ports (ledger 55432, lock 55433,
// diagnostic 55434, compose-config's default project/5432).
const COMPOSE_PROJECT = 'eppp-readiness-probe';
const POSTGRES_HOST_PORT = '55435';
const DATABASE_URL = `postgres://eppp:eppp@127.0.0.1:${POSTGRES_HOST_PORT}/eppp`;
test('the app reports not-ready while the migration run is pending and ready only after it finishes (real stack)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON || !TS_STRIPPING }, async (t) => {
// The issue's test plan: "start with pending migrations and confirm
// readiness waits". The probe starts the committed compose `db` service
// (its own project + host port), creates the migration ledger, then holds
// an ACCESS EXCLUSIVE lock on the ledger from a background psql session so
// the app's startup migration run (ledger.ensure/applied) is genuinely
// pending. It boots the committed server against that database and asserts
// GET /health answers 503 {"status":"not ready"} while the run is blocked,
// then — once the lock releases and the run completes — answers 200
// {"status":"ok"} and logs the completed run. Rollback: drop the ledger
// and `docker compose -p eppp-readiness-probe down -v` (issue rollback
// note: revert the readiness gating logic).
const composeEnv = { ...process.env, POSTGRES_PORT: POSTGRES_HOST_PORT };
const compose = (args, opts = {}) =>
run('docker', ['compose', '-p', COMPOSE_PROJECT, ...args], { cwd: REPO_ROOT, env: composeEnv, ...opts });
const execPsql = (args, opts = {}) =>
compose(['exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], opts);
let app;
let lockHolder;
try {
const up = compose(['up', '-d', 'db'], { timeout: 180_000 });
assert.equal(
up.status,
0,
`"docker compose up -d db" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
);
waitForDbHealthy(COMPOSE_PROJECT);
// Clean slate (also recovers from a previously interrupted run): drop any
// leftover ledger, then create it so the app's migration run has a ledger
// to read (the run's ledger.ensure() is then a no-op and its
// ledger.applied() SELECT is what blocks behind the lock below).
const clean = execPsql([
'-v', 'ON_ERROR_STOP=1',
'-c',
'DROP TABLE IF EXISTS schema_migrations; ' +
'CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());',
]);
assert.equal(
clean.status,
0,
`creating the migration ledger must succeed:\n${(clean.stdout || '')}\n${(clean.stderr || '')}`.trim(),
);
// Hold an ACCESS EXCLUSIVE lock on the ledger in a background psql
// session (BEGIN; LOCK; SELECT pg_sleep(30) keeps the transaction — and
// the lock — open). The app's migration run blocks on this lock, so the
// run is deterministically "pending" while we assert not-ready.
lockHolder = spawn(
'docker',
[
'compose', '-p', COMPOSE_PROJECT, 'exec', '-T', 'db',
'psql', '-U', 'eppp', '-d', 'eppp', '-v', 'ON_ERROR_STOP=1',
'-c', 'BEGIN; LOCK TABLE schema_migrations IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(30);',
],
{ cwd: REPO_ROOT, env: composeEnv, stdio: ['ignore', 'pipe', 'pipe'] },
);
let holderErr = '';
lockHolder.stderr.on('data', (chunk) => {
holderErr += String(chunk);
});
// Wait until the AccessExclusiveLock is actually held (deterministic, so
// the app boots into a genuinely blocked run).
const lockDeadline = Date.now() + 15_000;
let lockHeld = false;
while (Date.now() < lockDeadline) {
const held = execPsql([
'-tA',
'-c',
"SELECT count(*) FROM pg_locks WHERE locktype = 'relation' AND relation = 'schema_migrations'::regclass AND mode = 'AccessExclusiveLock';",
]);
if (held.status === 0 && held.stdout.trim() === '1') {
lockHeld = true;
break;
}
run(process.execPath, ['-e', 'setTimeout(() => {}, 500)']);
}
assert.ok(lockHeld, `the probe must hold the ACCESS EXCLUSIVE lock on the ledger (holder stderr: "${holderErr.trim()}")`);
// Boot the committed server against the probe database.
const port = await reservePort();
app = bootServer(port, { DATABASE_URL });
// "app does not report ready before migrations complete": while the run
// is blocked, every /health answer must be 503 {"status":"not ready"}.
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await waitForAnswer(port, app.child, app.stderr);
assert.equal(
response.status,
503,
`GET /health must answer 503 while the migration run is pending (got ${response.status}); server output: ${app.stdout().trim()} ${app.stderr().trim()}`,
);
assert.deepEqual(
await response.json(),
{ status: 'not ready' },
'the app must report not-ready ({"status":"not ready"}) while migrations are pending',
);
await delay(300);
}
assert.equal(
app.child.exitCode,
null,
'the app must stay up (not crash) while the migration run is pending',
);
// Release the lock: the psql session ends, its transaction rolls back and
// the ACCESS EXCLUSIVE lock is gone — the app's migration run completes.
lockHolder.kill('SIGTERM');
await Promise.race([once(lockHolder, 'exit'), delay(2_000)]);
if (lockHolder.exitCode === null && lockHolder.signalCode === null) lockHolder.kill('SIGKILL');
lockHolder = null;
// "readiness is reported only after migrations finish": once the run
// completes, /health must answer 200 {"status":"ok"}.
const readyDeadline = Date.now() + 15_000;
let ready = null;
while (Date.now() < readyDeadline) {
const response = await waitForAnswer(port, app.child, app.stderr);
if (response.status === 200) {
ready = await response.json();
break;
}
assert.equal(
response.status,
503,
`GET /health must stay 503 only while the run is pending (got ${response.status})`,
);
await delay(200);
}
assert.ok(ready, `the app must report ready after the migration run completes (server output: ${app.stdout().trim()} ${app.stderr().trim()})`);
assert.deepEqual(ready, { status: 'ok' }, 'the app must report a healthy application ({"status":"ok"}) after migrations finish');
// The readiness flip must be attributable to a completed migration run:
// the app logs the completed run, and the migration ledger still exists.
assert.match(
app.stdout() + app.stderr(),
/startup migration run complete \(applied 0, skipped 0\)/,
`the app must log the completed startup migration run (got: ${app.stdout().trim()} ${app.stderr().trim()})`,
);
const ledgerClass = execPsql(['-tA', '-c', "SELECT to_regclass('public.schema_migrations');"]);
assert.equal(
ledgerClass.status,
0,
`ledger existence query must succeed:\n${(ledgerClass.stdout || '')}\n${(ledgerClass.stderr || '')}`.trim(),
);
assert.match(
ledgerClass.stdout,
/schema_migrations/,
'the migration ledger must exist after the startup migration run',
);
} finally {
if (app) await stopChild(app.child);
if (lockHolder) {
lockHolder.kill('SIGKILL');
await Promise.race([once(lockHolder, 'exit'), delay(1_000)]);
}
// Rollback note from the issue: revert the readiness gating logic — here,
// reset migration state and tear down the isolated project.
try {
execPsql(['-c', 'DROP TABLE IF EXISTS schema_migrations;']);
} catch {
// container may already be gone — the compose down below still cleans up
}
compose(['down', '-v'], { timeout: 120_000 });
}
});
// ---------------------------------------------------------------------------
// Non-vacuous probes — the assertions above really do fail on violations
// ---------------------------------------------------------------------------
test('removing the 503 not-ready branch makes the readiness-gate criterion fail (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutNotReady = src.replace(/ } else \{\n sendJson\(res, 503, NOT_READY_PAYLOAD\);\n \}\n/, ' }\n');
assert.notEqual(withoutNotReady, src, 'the mutation must actually remove the 503 branch');
assert.throws(() => assertReadinessGate(withoutNotReady), /503/);
});
test('answering 200 in the not-ready state makes the gate criterion fail (mutation probe)', () => {
const src = read(SERVER_SRC);
const notReady200 = src.replace('sendJson(res, 503, NOT_READY_PAYLOAD)', 'sendJson(res, 200, NOT_READY_PAYLOAD)');
assert.notEqual(notReady200, src, 'the mutation must actually change the not-ready status code');
assert.throws(() => assertReadinessGate(notReady200), /503/);
});
test('replacing the gate with an unconditional healthy answer fails the gate criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const noGate = src.replace(/if \(migrationsComplete\) \{/, 'if (true) {');
assert.notEqual(noGate, src, 'the mutation must actually bypass the readiness flag');
assert.throws(() => assertReadinessGate(noGate), /migrationsComplete/);
});
test('removing the not-ready payload fails the not-ready-report criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const healthyNotReady = src.replace("status: 'not ready'", "status: 'ok'");
assert.notEqual(healthyNotReady, src, 'the mutation must actually change the not-ready payload');
assert.throws(() => assertReadinessGate(healthyNotReady), /not ready/);
});
test('flipping the readiness flag before the migration run fails the readiness-only-after criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const earlyFlip = src.replace(
/const runner = new MigrationRunner\(pool, MIGRATIONS, new MigrationLedger\(pool\)\);\n runner/,
'migrationsComplete = true;\n const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));\n runner',
);
assert.notEqual(earlyFlip, src, 'the mutation must actually flip the flag before the run');
assert.throws(() => assertReadyAfterRun(earlyFlip), /only after/);
});
test('dropping the migration run fails the readiness-after-run criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const noRun = src.replace(/ runner\n \.run\(\)\n/, '');
assert.notEqual(noRun, src, 'the mutation must actually remove the runner.run() call');
assert.throws(() => assertReadyAfterRun(noRun), /runner\.run\(\)/);
});
test('importing pg directly instead of the driver boundary fails the isolation criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const directPg = src.replace(
"import { Pool } from '@personal-blog/database-postgres';",
"import { Pool } from 'pg';",
);
assert.notEqual(directPg, src, 'the mutation must actually replace the boundary import');
assert.throws(() => assertDriverBoundaryImports(directPg), /must import the pool from the driver boundary/);
});
test('removing the no-DATABASE_URL ready path fails the no-migration criterion (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutNoDb = src.replace(/if \(databaseUrl === undefined\) \{\n[\s\S]*?\} else \{/, '} else {');
assert.notEqual(withoutNoDb, src, 'the mutation must actually remove the no-DATABASE_URL branch');
assert.throws(() => assertNoDatabaseUrlPath(withoutNoDb), /must branch on a missing DATABASE_URL/);
});
test('a placeholder entrypoint (no server at all) fails the readiness-source criterion (mutation probe)', () => {
assert.throws(() => assertReadySource('export {};\n'), /driver boundary/);
});
+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/);
});
+9 -4
View File
@@ -509,15 +509,20 @@ test('docker compose up -d starts the database and application containers', { sk
`the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`,
);
// T03: the app serves the health endpoint — HTTP smoke test against the
// endpoint inside the app container (no host-port dependency), polling
// until it answers or times out.
// T03 + E00-S03-T06: the app serves the health endpoint — HTTP smoke test
// against the endpoint inside the app container (no host-port dependency),
// polling until it answers or times out. Since T06 the endpoint is the
// readiness probe: it answers 503 {"status":"not ready"} while the startup
// migration run is in flight and 200 {"status":"ok"} only after it
// completes, so the probe treats a 503 (and a connection failure) as
// "still starting — retry" and fail-fasts only on a definitive non-2xx
// answer.
let healthOutput = '';
let healthOk = false;
for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) {
const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', `
fetch('http://127.0.0.1:3000/health')
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); })
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : res.status === 503 ? 2 : 1); })
.catch(() => process.exit(2));
`], { cwd: REPO_ROOT, timeout: 15_000 });
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
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);
}
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Config schema test — locks in the [E00-S04-T01] TypeBox/Ajv configuration
* schema for the workspace.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "configuration schema is defined with TypeBox/Ajv" → the new
* `packages/config` package (`@personal-blog/config`) pins the
* golden-tuple runtime deps exactly (`@sinclair/typebox@0.34.52`,
* `ajv@8.20.0` — Technology-Stack §5.2/§7), its `src/schema.ts` defines
* `configSchema` with TypeBox (`Type.Object`), and its `src/validate.ts`
* compiles that schema with Ajv (`new Ajv({ allErrors: true })` +
* `.compile(configSchema)`) and exports `validateConfig`; the package
* boundary re-exports both. Mutation probes prove the assertions are
* non-vacuous (dropping a field, relaxing a constraint, replacing
* TypeBox/Ajv with a hand-rolled object all fail).
* - "schema covers the validated config fields" → the schema defines the
* four validated config fields with their constraints: `host` (string,
* default `0.0.0.0`), `port` (integer 1–65535, default `3000`),
* `databaseUrl` (optional non-empty string — the local non-container path
* has no database), `sessionSecret` (required, ≥ 32 chars — the story's
* secret field, EPPP_SESSION_SECRET, Security-and-Operations §32/§26),
* and the object is closed (`additionalProperties: false`).
* - the issue's test plan — "validate a full config against the
* TypeBox/Ajv schema" → the deterministic probe imports the committed
* `src/schema.ts` (Node type stripping, no build step) and validates a
* full config through Ajv, plus the negative cases (missing required
* field naming `sessionSecret`, secret too short, unknown property, port
* out of range / not an integer, empty `databaseUrl`); when the package
* is built (CI builds it first) the probe additionally exercises the
* compiled `validateConfig` boundary exactly as the later configuration
* adapter will consume it.
*
* Run: `node --test tests/config-schema.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, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
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 under test. */
const CONFIG_DIR = 'packages/config';
const MANIFEST_PATH = `${CONFIG_DIR}/package.json`;
const SCHEMA_SRC = `${CONFIG_DIR}/src/schema.ts`;
const VALIDATE_SRC = `${CONFIG_DIR}/src/validate.ts`;
const INDEX_SRC = `${CONFIG_DIR}/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 config-schema criterion on every PR. */
const CI_JOB = 'config-schema';
/** Golden-tuple pins for the configuration schema (Technology-Stack §5.2/§7). */
const GOLDEN_TYPEBOX = '0.34.52';
const GOLDEN_AJV = '8.20.0';
// ---------------------------------------------------------------------------
// Static assertions on the committed config package
// ---------------------------------------------------------------------------
/**
* Asserts the schema is defined with TypeBox and covers the validated config
* fields with their documented constraints. Fails fast on any deviation; the
* mutation probes below prove the assertions are non-vacuous.
*/
function assertSchemaShape(src) {
// Defined with TypeBox: the schema object is built by Type.Object from the
// @sinclair/typebox `Type` factory (never a hand-rolled plain object).
assert.match(
src,
/import \{ Type, type Static \} from '@sinclair\/typebox'/,
'the schema module must import Type (and the Static type) from @sinclair/typebox',
);
assert.match(
src,
/export const configSchema = Type\.Object\(/,
'the schema must be defined with TypeBox (Type.Object) — not a hand-rolled object',
);
// The validated config fields, each with its documented constraint.
assert.match(
src,
/host: Type\.Optional\(Type\.String\(\{ default: '0\.0\.0\.0' \}\)\)/,
'the schema must cover the host field (optional string, default 0.0.0.0 — the bind address)',
);
assert.match(
src,
/port: Type\.Optional\(Type\.Integer\(\{ minimum: 1, maximum: 65535, default: 3000 \}\)\)/,
'the schema must cover the port field (optional integer 1-65535, default 3000)',
);
assert.match(
src,
/databaseUrl: Type\.Optional\(Type\.String\(\{ minLength: 1 \}\)\)/,
'the schema must cover the databaseUrl field (optional non-empty string)',
);
assert.match(
src,
/sessionSecret: Type\.String\(\{ minLength: 32 \}\)/,
'the schema must cover the sessionSecret field (required string, at least 32 chars — never Type.Optional)',
);
assert.match(
src,
/additionalProperties: false/,
'the schema must close the object (additionalProperties: false) so unexpected settings are rejected',
);
}
/** Asserts the validator compiles the schema with Ajv and exports validateConfig. */
function assertValidateShape(src) {
assert.match(
src,
/import \{ Ajv \} from 'ajv'/,
'the validator module must import Ajv (the golden-tuple validator)',
);
assert.match(
src,
/new Ajv\(\{ allErrors: true \}\)/,
'the validator must create the Ajv instance with allErrors (report every violation)',
);
assert.match(
src,
/\.compile\(configSchema\)/,
'the validator must compile the TypeBox configSchema with Ajv',
);
assert.match(
src,
/export function validateConfig/,
'the validator module must export the validateConfig entry point',
);
}
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the config package exists, pins the golden-tuple runtime deps exactly, and builds with tsc', () => {
const manifest = JSON.parse(read(MANIFEST_PATH));
assert.equal(manifest.name, '@personal-blog/config');
assert.equal(
manifest.dependencies?.['@sinclair/typebox'],
GOLDEN_TYPEBOX,
`@sinclair/typebox must be pinned exactly to the golden tuple (${GOLDEN_TYPEBOX})`,
);
assert.match(
manifest.dependencies?.['@sinclair/typebox'],
/^\d+\.\d+\.\d+$/,
'the @sinclair/typebox pin must be exact (no ^ / ~ / range)',
);
assert.equal(
manifest.dependencies?.ajv,
GOLDEN_AJV,
`ajv must be pinned exactly to the golden tuple (${GOLDEN_AJV})`,
);
assert.match(
manifest.dependencies?.ajv,
/^\d+\.\d+\.\d+$/,
'the ajv pin must be exact (no ^ / ~ / range)',
);
// The CI job and the local build path compile the package with tsc.
assert.equal(manifest.scripts?.build, 'tsc -p tsconfig.json');
assert.equal(manifest.scripts?.typecheck, 'tsc -p tsconfig.json --noEmit');
});
test('the configuration schema is defined with TypeBox and covers the validated config fields', () => {
assert.ok(existsSync(path.join(REPO_ROOT, SCHEMA_SRC)), `committed ${SCHEMA_SRC} must exist`);
assertSchemaShape(read(SCHEMA_SRC));
});
test('the schema is validated with Ajv: validate.ts compiles configSchema and exports validateConfig', () => {
assert.ok(existsSync(path.join(REPO_ROOT, VALIDATE_SRC)), `committed ${VALIDATE_SRC} must exist`);
assertValidateShape(read(VALIDATE_SRC));
});
test('the package boundary re-exports the schema and the validator', () => {
const src = read(INDEX_SRC);
assert.match(src, /export \{ configSchema \} from '\.\/schema\.js'/, 'the boundary must re-export configSchema');
assert.match(src, /export type \{ Config \} from '\.\/schema\.js'/, 'the boundary must re-export the Config type');
assert.match(src, /export \{ validateConfig \} from '\.\/validate\.js'/, 'the boundary must re-export validateConfig');
assert.match(src, /export type \{ ConfigValidationResult \} from '\.\/validate\.js'/, 'the boundary must re-export the ConfigValidationResult type');
});
test('the config-schema 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-schema.test.mjs`),
`CI must run the config-schema suite (job "${CI_JOB}") on every PR`,
);
assert.ok(
workflow.includes(`pnpm --filter @personal-blog/config build`),
'the CI job must build the config package (the probe exercises the compiled boundary)',
);
});
// ---------------------------------------------------------------------------
// Mutation probes — the static assertions are non-vacuous
// ---------------------------------------------------------------------------
test('removing a validated config field from the schema fails the coverage assertion (mutation probe)', () => {
const mutated = read(SCHEMA_SRC).replace(
/sessionSecret: Type\.String\(\{ minLength: 32 \}\),\n/,
'',
);
assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually remove the sessionSecret field');
assert.throws(() => assertSchemaShape(mutated), /sessionSecret/);
});
test('making the secret optional fails the coverage assertion (mutation probe)', () => {
const mutated = read(SCHEMA_SRC).replace(
'sessionSecret: Type.String({ minLength: 32 })',
'sessionSecret: Type.Optional(Type.String({ minLength: 32 }))',
);
assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually make sessionSecret optional');
assert.throws(() => assertSchemaShape(mutated), /never Type\.Optional/);
});
test('relaxing the secret length fails the coverage assertion (mutation probe)', () => {
const mutated = read(SCHEMA_SRC).replace('minLength: 32', 'minLength: 8');
assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually relax the minLength');
assert.throws(() => assertSchemaShape(mutated), /at least 32 chars/);
});
test('opening the object fails the coverage assertion (mutation probe)', () => {
// Replace every occurrence (the docstring mentions the keyword too) so the
// committed schema code itself is the mutation target.
const mutated = read(SCHEMA_SRC).replace(/additionalProperties: false/g, 'additionalProperties: true');
assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually open the object');
assert.throws(() => assertSchemaShape(mutated), /additionalProperties: false/);
});
test('replacing TypeBox with a hand-rolled object fails the TypeBox assertion (mutation probe)', () => {
const mutated = read(SCHEMA_SRC)
.replace("import { Type, type Static } from '@sinclair/typebox';", '// no TypeBox')
.replace('export const configSchema = Type.Object(', 'export const configSchema = {');
assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually drop TypeBox');
assert.throws(() => assertSchemaShape(mutated), /must import Type/);
});
test('dropping the Ajv compile fails the validator assertion (mutation probe)', () => {
const mutated = read(VALIDATE_SRC)
.replace("import { Ajv } from 'ajv';", '// no Ajv')
.replace('const validateConfigValue = ajv.compile(configSchema);', 'const validateConfigValue = () => true;');
assert.notEqual(mutated, read(VALIDATE_SRC), 'the mutation must actually drop the Ajv compile');
assert.throws(() => assertValidateShape(mutated), /Ajv|compile/);
});
// ---------------------------------------------------------------------------
// Deterministic behavioral probe — the issue's test plan: validate a full
// config against the TypeBox/Ajv schema
// ---------------------------------------------------------------------------
/**
* 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;
}
const TS_STRIPPING = tsExecMode() !== null;
/**
* The probe source: imports the committed `src/schema.ts` (type-stripped,
* no build step) and validates a full config plus the negative cases through
* Ajv — the issue's test plan. When the package is built (`dist/` present,
* as in the CI job), it additionally exercises the compiled
* `validateConfig` boundary exactly as the later configuration adapter will.
* Written to a temp file inside `packages/config/` so `@sinclair/typebox` and
* `ajv` resolve through the package's own dependency links, then removed.
*/
const PROBE_SOURCE = `
import { configSchema } from './src/schema.ts';
import { Ajv } from 'ajv';
import { existsSync } from 'node:fs';
const validate = new Ajv({ allErrors: true }).compile(configSchema);
const errorsOf = (value) => {
validate(value);
return (validate.errors ?? []).map((error) => error.message ?? 'invalid');
};
const FULL = {
host: '0.0.0.0',
port: 3000,
databaseUrl: 'postgres://eppp:eppp@db:5432/eppp',
sessionSecret: 's'.repeat(32),
};
const result = {
// The issue's test plan: "validate a full config against the TypeBox/Ajv
// schema" — a full config is valid.
fullValid: validate(FULL),
// host/port/databaseUrl are optional (port and host carry defaults; the
// local non-container path has no database), so a config with only the
// required secret is valid too.
defaultsValid: validate({ sessionSecret: 's'.repeat(32) }),
// The required field is enforced and the error names it (E00-S04-T02 will
// format these as field-specific startup errors).
missingSecretErrors: errorsOf({ host: '0.0.0.0', port: 3000 }),
shortSecretErrors: errorsOf({ ...FULL, sessionSecret: 'short' }),
// The object is closed: an unexpected setting is rejected loudly.
unknownPropertyErrors: errorsOf({ ...FULL, extra: true }),
// Port is an integer in 1-65535.
portTooHighErrors: errorsOf({ ...FULL, port: 65536 }),
portNotIntegerErrors: errorsOf({ ...FULL, port: '3000' }),
// databaseUrl must be non-empty when present.
emptyDatabaseUrlErrors: errorsOf({ ...FULL, databaseUrl: '' }),
};
// Compiled boundary (built by the CI job): the committed validateConfig.
if (existsSync('./dist/index.js')) {
const { validateConfig } = await import('./dist/index.js');
result.boundaryFullValid = validateConfig(FULL).valid;
result.boundaryMissingSecretErrors = validateConfig({ host: '0.0.0.0', port: 3000 }).errors;
result.boundaryUnknownPropertyErrors = validateConfig({ ...FULL, extra: true }).errors;
}
console.log('CONFIG_SCHEMA_PROBE_RESULT ' + JSON.stringify(result));
`;
test('a full config validates against the committed TypeBox/Ajv schema, and violations are rejected (deterministic probe)', { skip: !TS_STRIPPING }, () => {
// The issue's test plan: "validate a full config against the TypeBox/Ajv
// schema". The probe runs the committed schema.ts through Ajv (Node type
// stripping, no build step) from inside packages/config so the golden-tuple
// deps resolve through the package's own links.
const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-schema-probe-${process.pid}.mjs`);
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', path.basename(probeFile)]
: [path.basename(probeFile)];
try {
writeFileSync(probeFile, PROBE_SOURCE);
const run = spawnSync(process.execPath, args, {
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_SCHEMA_PROBE_RESULT (\{.*\})/);
assert.ok(match, `the probe must print CONFIG_SCHEMA_PROBE_RESULT:\n${run.stdout.trim()}`);
const result = JSON.parse(match[1]);
// The issue's test plan: a full config validates.
assert.equal(result.fullValid, true, 'a full config must validate against the TypeBox/Ajv schema');
assert.equal(result.defaultsValid, true, 'host/port/databaseUrl are optional (defaults; no-database path)');
// The required field is enforced and the error names it.
assert.ok(
result.missingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)),
`missing sessionSecret must be rejected naming the field (got: ${JSON.stringify(result.missingSecretErrors)})`,
);
assert.ok(
result.shortSecretErrors.some((msg) => /fewer than 32/.test(msg)),
`a short sessionSecret must be rejected (got: ${JSON.stringify(result.shortSecretErrors)})`,
);
// The object is closed and the numeric/string constraints hold.
assert.ok(
result.unknownPropertyErrors.some((msg) => /additional properties/.test(msg)),
`an unknown property must be rejected (got: ${JSON.stringify(result.unknownPropertyErrors)})`,
);
assert.ok(
result.portTooHighErrors.some((msg) => /<= 65535/.test(msg)),
`a port above 65535 must be rejected (got: ${JSON.stringify(result.portTooHighErrors)})`,
);
assert.ok(
result.portNotIntegerErrors.some((msg) => /integer/.test(msg)),
`a non-integer port must be rejected (got: ${JSON.stringify(result.portNotIntegerErrors)})`,
);
assert.ok(
result.emptyDatabaseUrlErrors.some((msg) => /fewer than 1/.test(msg)),
`an empty databaseUrl must be rejected (got: ${JSON.stringify(result.emptyDatabaseUrlErrors)})`,
);
// When the package is built (the CI job builds it), the compiled
// validateConfig boundary behaves identically.
if (existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'))) {
assert.equal(result.boundaryFullValid, true, 'validateConfig must accept a full config');
assert.ok(
result.boundaryMissingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)),
`validateConfig must reject a missing secret naming the field (got: ${JSON.stringify(result.boundaryMissingSecretErrors)})`,
);
assert.ok(
result.boundaryUnknownPropertyErrors.some((msg) => /additional properties/.test(msg)),
`validateConfig must reject an unknown property (got: ${JSON.stringify(result.boundaryUnknownPropertyErrors)})`,
);
}
} finally {
rmSync(probeFile, { force: true });
}
});
+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);
}
});
+797
View File
@@ -0,0 +1,797 @@
/**
* Migration failure diagnostic test — locks in the [E00-S03-T05] structured
* diagnostic produced when a migration fails.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "migration failure produces a structured diagnostic" → the committed
* `packages/database-postgres/src/runner.ts` defines a `MigrationRunner`
* that applies pending migrations through the migration ledger (E00-S03-T03)
* and, when a migration fails, throws a `MigrationFailedError` whose
* `.diagnostic` is a structured object (`migration`, `phase`, `cause`,
* `applied`, `pending`); the error is serializable (`toJSON()` returns a
* plain object, including a structured cause — for pg errors the `code`).
* The deterministic stub-pool behavioral probe drives the committed
* module with an intentionally failing migration fixture and confirms the
* structured diagnostic, and the docker-gated real-stack probe does the
* same against a real database (the issue's test plan: "run an
* intentionally failing migration fixture and confirm the diagnostic").
* - "the diagnostic identifies the failing migration" → the diagnostic's
* `migration` field is the failing migration's version (also named in the
* error `message` and in `toJSON()`), and `phase` distinguishes 'apply'
* (the migration's `up` threw) from 'record' (the ledger insert threw
* after a successful `up`); `applied`/`pending` carry the ledger state at
* failure time (disjoint, in run order — the failing migration is still
* pending). Locked in statically (mutation probes prove non-vacuity) and
* behaviorally (stub-pool probe: apply-failure and record-failure
* fixtures both produce a diagnostic naming the failing migration;
* real-stack probe: a fixture whose `up` runs invalid SQL fails with a
* diagnostic naming that migration and carrying the pg error code).
* - the runner is part of the driver boundary: `src/index.ts` re-exports
* `MigrationRunner` + `MigrationFailedError` (and the migration types), so
* no other package needs the `pg` driver to run migrations.
*
* Run: `node --test tests/database-postgres-diagnostic.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, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
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 runner module and the driver boundary that re-exports it. */
const RUNNER_SRC = 'packages/database-postgres/src/runner.ts';
const INDEX_SRC = 'packages/database-postgres/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 failure-diagnostic criterion on every PR. */
const CI_JOB = 'database-postgres-diagnostic';
/** Versions used by the real-stack probe's intentionally failing migration fixture. */
const OK_MIGRATION = '2026-08-30_001_fixture_ok';
const FAIL_MIGRATION = '2026-08-30_002_fixture_fail';
/** Table the real-stack probe's ok migration creates (and its missing sibling for the failing one). */
const FIXTURE_TABLE = 'diagnostic_fixture_ok';
// ---------------------------------------------------------------------------
// Static assertions on the committed runner source
// ---------------------------------------------------------------------------
/**
* Asserts the runner module exists with the documented shape: a
* `MigrationRunner` class, a `MigrationFailedError` class, and the
* `Migration`/`MigrationDiagnostic`/`MigrationRunResult` types. Fails fast on
* a missing module; the mutation probes below prove the assertions are
* non-vacuous.
*/
function assertRunnerModule(src) {
assert.match(src, /export class MigrationRunner/, 'the runner module must export the MigrationRunner class');
assert.match(src, /export class MigrationFailedError/, 'the runner module must export the MigrationFailedError class');
assert.match(src, /export interface Migration \{/, 'the runner module must export the Migration step interface');
assert.match(src, /up\(pool: Pool\)/, 'a migration step must apply through an up(pool) function');
assert.match(src, /export interface MigrationDiagnostic/, 'the runner module must export the MigrationDiagnostic interface');
assert.match(src, /export interface MigrationRunResult/, 'the runner module must export the MigrationRunResult interface');
}
/**
* Asserts the diagnostic is structured and identifies the failing migration:
* the `MigrationDiagnostic` interface carries `migration` (the failing
* migration's version), `phase` ('apply' | 'record'), `cause`, `applied` and
* `pending`. Fails fast on a missing field; the mutation probes prove the
* assertions are non-vacuous.
*/
function assertDiagnosticShape(src) {
const diagSrc = src.slice(
src.indexOf('export interface MigrationDiagnostic'),
src.indexOf('export interface MigrationRunResult'),
);
assert.match(
diagSrc,
/migration:\s*string/,
'the diagnostic must identify the failing migration (migration: string)',
);
assert.match(
diagSrc,
/phase:\s*MigrationFailurePhase/,
'the diagnostic must record where the run failed (phase: MigrationFailurePhase — apply | record)',
);
assert.match(diagSrc, /cause:\s*unknown/, 'the diagnostic must carry the underlying cause (cause: unknown)');
assert.match(
diagSrc,
/applied:\s*string\[\]/,
'the diagnostic must list the migrations applied before the failure (applied: string[])',
);
assert.match(
diagSrc,
/pending:\s*string\[\]/,
'the diagnostic must list the migrations still pending at failure time (pending: string[])',
);
}
/**
* Asserts `MigrationFailedError` carries the structured diagnostic and is
* serializable: a `readonly diagnostic: MigrationDiagnostic` field, a
* `toJSON()` method, and a message naming the failing migration.
*/
function assertFailedError(src) {
const errSrc = src.slice(
src.indexOf('export class MigrationFailedError'),
src.indexOf('export class MigrationRunner'),
);
assert.match(
errSrc,
/readonly diagnostic:\s*MigrationDiagnostic/,
'MigrationFailedError must carry the structured diagnostic (readonly diagnostic: MigrationDiagnostic)',
);
assert.match(errSrc, /toJSON\(\)/, 'the error must be serializable (toJSON())');
assert.match(
errSrc,
/migration "\$\{diagnostic\.migration\}" failed during/,
'the error message must name the failing migration',
);
}
/**
* Asserts the runner wraps a failing migration into the structured diagnostic
* instead of rethrowing the raw error: both failure paths (apply and record)
* build the diagnostic with the failing migration's version, and the
* diagnostic error is constructed (new MigrationFailedError).
*/
function assertWrapsFailure(src) {
const runnerBody = src.slice(src.indexOf('export class MigrationRunner'));
assert.match(
runnerBody,
/new MigrationFailedError\(\{/,
'the runner must construct the structured diagnostic error (new MigrationFailedError({ ... }))',
);
assert.match(
runnerBody,
/failure\(migration\.version, 'apply', error, recorded\)/,
"the apply-failure path must build the diagnostic with the failing migration's version",
);
assert.match(
runnerBody,
/failure\(migration\.version, 'record', error, recorded\)/,
"the record-failure path must build the diagnostic with the failing migration's version",
);
}
/** Asserts the runner applies pending migrations through the migration ledger (exactly once). */
function assertLedgerIntegration(src) {
const runnerBody = src.slice(src.indexOf('export class MigrationRunner'));
assert.match(
runnerBody,
/await this\.ledger\.ensure\(\);/,
'the runner must ensure the ledger exists before applying (self-sufficient on an empty database)',
);
assert.match(
runnerBody,
/await this\.ledger\.applied\(\)/,
'the runner must read the applied migrations from the ledger before applying (never double-applies)',
);
assert.match(
runnerBody,
/await this\.ledger\.record\(migration\.version\);/,
'the runner must record each applied migration in the ledger',
);
}
/** Asserts the driver boundary re-exports the runner + diagnostic from the package entrypoint. */
function assertBoundaryReexport(src) {
assert.match(
src,
/export \{ MigrationRunner, MigrationFailedError \} from '\.\/runner\.js'/,
'the driver boundary must re-export the runner (src/index.ts → ./runner.js)',
);
assert.match(
src,
/export type \{ Migration, MigrationDiagnostic, MigrationRunResult, MigrationFailurePhase \} from '\.\/runner\.js'/,
'the driver boundary must re-export the runner types (src/index.ts → ./runner.js)',
);
}
// ---------------------------------------------------------------------------
// Docker probe helpers (integration test skips cleanly without Docker)
// ---------------------------------------------------------------------------
function run(cmd, args, opts = {}) {
return spawnSync(cmd, args, {
encoding: 'utf8',
timeout: 600_000,
...opts,
});
}
/** True when the `docker` CLI with the Compose plugin is on PATH. */
function dockerComposeAvailable() {
try {
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** True when a reachable Docker daemon exists. */
function dockerDaemonAvailable() {
try {
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** Parses `docker compose ps --format json` (JSON array or one object per line). */
function parsePsJson(stdout) {
const text = String(stdout).trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
}
/** Tolerant field lookup across compose ps JSON shapes. */
function field(container, ...names) {
for (const name of names) {
if (container[name] !== undefined) return container[name];
}
return undefined;
}
/**
* Polls `docker compose ps` until the db container of the given project
* reports healthy (or the deadline passes), so the probe never races a
* still-booting database.
*/
function waitForDbHealthy(project, deadlineMs = 60_000) {
const deadline = Date.now() + deadlineMs;
let last = '';
while (Date.now() < deadline) {
const ps = run('docker', ['compose', '-p', project, 'ps', '--format', 'json'], {
cwd: REPO_ROOT,
timeout: 15_000,
});
if (ps.status === 0) {
last = ps.stdout;
const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db');
if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return;
}
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry
}
throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`);
}
// ---------------------------------------------------------------------------
// Deterministic behavioral probe (no database, no Docker) — the issue's test
// plan: "run an intentionally failing migration fixture and confirm the
// diagnostic", driven against a stub pool so it runs anywhere Node can strip
// types (Node >= 23.6, i.e. the CI Node 24).
// ---------------------------------------------------------------------------
/**
* The host-side probe body: drives the committed `MigrationRunner` with a
* stub pg pool (the same SQL shapes the committed ledger module uses, plus an
* insert that can be made to fail for versions starting with 'fail') through
* three scenarios — a successful run + idempotent rerun, an apply-failure
* fixture (the intentionally failing migration), and a record-failure fixture
* — and reports the structured diagnostics. Written to a temp file inside
* `packages/database-postgres/` so `pg` resolves through the package's own
* dependency links, then removed.
*/
const PROBE_SOURCE = `
import { MigrationRunner, MigrationFailedError } from './src/runner.ts';
import { MigrationLedger } from './src/ledger.ts';
// A stub pg pool: tracks the migration ledger in memory (the same SQL shapes
// the committed ledger module issues) and fails a ledger insert for versions
// starting with 'fail' — so the committed runner is driven deterministically
// with no database and no Docker.
const makeStubPool = () => {
const store = new Map();
const queries = [];
return {
store,
queries,
async query(text, values) {
queries.push({ text, values });
if (/^INSERT INTO schema_migrations/.test(text)) {
const version = values[0];
if (version.startsWith('fail')) {
const err = new Error('the ledger insert failed (read-only transaction)');
err.code = '25006';
throw err;
}
store.set(version, new Date().toISOString());
return { rows: [], rowCount: 1 };
}
if (/^SELECT version FROM schema_migrations/.test(text)) {
const versions = [...store.keys()].sort();
return { rows: versions.map((version) => ({ version })), rowCount: versions.length };
}
return { rows: [], rowCount: 0 };
},
};
};
const ok = (version) => ({ version, up: async (pool) => { await pool.query('SELECT 1'); } });
const results = {};
// Scenario 1 — success: all migrations apply in order; a rerun skips them
// (the story's "second migration run is idempotent").
{
const pool = makeStubPool();
const ledger = new MigrationLedger(pool);
const runner = new MigrationRunner(pool, [ok('ok-1'), ok('ok-2')], ledger);
const first = await runner.run();
const second = await runner.run();
results.success = { first, second, ledger: [...pool.store.keys()] };
}
// Scenario 2 — apply failure: the intentionally failing migration fixture.
{
const pool = makeStubPool();
const ledger = new MigrationLedger(pool);
const cause = new Error('relation "diagnostic_missing_table" does not exist');
cause.code = '42P01';
const failing = { version: 'fail-2', up: async () => { throw cause; } };
const runner = new MigrationRunner(pool, [ok('ok-1'), failing, ok('ok-3')], ledger);
let outcome = { rejected: false };
try {
await runner.run();
} catch (error) {
outcome = {
rejected: true,
isMigrationFailedError: error instanceof MigrationFailedError,
name: error.name,
message: error.message,
migration: error.diagnostic.migration,
phase: error.diagnostic.phase,
applied: error.diagnostic.applied,
pending: error.diagnostic.pending,
causeIsOriginal: error.diagnostic.cause === cause,
causeName: error.diagnostic.cause.name,
causeCode: error.diagnostic.cause.code,
toJson: JSON.parse(JSON.stringify(error)),
};
}
results.applyFailure = { ...outcome, ledger: [...pool.store.keys()] };
}
// Scenario 3 — record failure: the migration's up succeeds but the ledger
// insert fails — the diagnostic must still identify the failing migration,
// with phase 'record'.
{
const pool = makeStubPool();
const ledger = new MigrationLedger(pool);
const runner = new MigrationRunner(pool, [ok('ok-1'), ok('fail-record'), ok('ok-3')], ledger);
let outcome = { rejected: false };
try {
await runner.run();
} catch (error) {
outcome = {
rejected: true,
name: error.name,
migration: error.diagnostic.migration,
phase: error.diagnostic.phase,
applied: error.diagnostic.applied,
pending: error.diagnostic.pending,
causeCode: error.diagnostic.cause.code,
toJson: JSON.parse(JSON.stringify(error)),
};
}
results.recordFailure = { ...outcome, ledger: [...pool.store.keys()] };
}
console.log('DIAGNOSTIC_PROBE_RESULT ' + JSON.stringify(results));
`;
// ---------------------------------------------------------------------------
// Real-stack probe — the issue's test plan against a real database
// ---------------------------------------------------------------------------
/**
* The host-side probe body: runs the committed `MigrationRunner` against a
* real database with an intentionally failing migration fixture — the ok
* migration creates a table, the failing migration runs valid SQL against a
* table that does not exist (pg error 42P01) — and reports the structured
* diagnostic plus the ledger state and whether the ok migration took effect.
* Written to a temp file inside `packages/database-postgres/` so `pg`
* resolves through the package's own dependency links, then removed.
*/
const REAL_PROBE_SOURCE = `
import { Pool } from 'pg';
import { MigrationRunner, MigrationFailedError } from './src/runner.ts';
import { MigrationLedger } from './src/ledger.ts';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const result = {};
try {
const ledger = new MigrationLedger(pool);
const runner = new MigrationRunner(pool, [
{
version: '${OK_MIGRATION}',
up: async (p) => {
await p.query('CREATE TABLE ${FIXTURE_TABLE} (id integer)');
},
},
{
version: '${FAIL_MIGRATION}',
// The intentionally failing migration fixture: valid SQL against a
// table that does not exist -> pg error 42P01 (undefined_table).
up: async (p) => {
await p.query('SELECT * FROM ${FIXTURE_TABLE}_missing');
},
},
], ledger);
try {
await runner.run();
} catch (error) {
result.rejected = true;
result.isMigrationFailedError = error instanceof MigrationFailedError;
result.name = error.name;
result.message = error.message;
result.migration = error.diagnostic.migration;
result.phase = error.diagnostic.phase;
result.applied = error.diagnostic.applied;
result.pending = error.diagnostic.pending;
result.causeName = error.diagnostic.cause && error.diagnostic.cause.name;
result.causeCode = error.diagnostic.cause && error.diagnostic.cause.code;
result.toJson = JSON.parse(JSON.stringify(error));
}
result.ledgerApplied = await ledger.applied();
const tableClass = await pool.query('SELECT to_regclass($1) AS cls', ['public.' + '${FIXTURE_TABLE}']);
result.fixtureTable = tableClass.rows[0] ? tableClass.rows[0].cls : null;
console.log('DIAGNOSTIC_PROBE_RESULT ' + JSON.stringify(result));
} finally {
await pool.end();
}
`;
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the runner module exists in the driver-owner package and defines the runner and its diagnostic types', () => {
assert.ok(existsSync(path.join(REPO_ROOT, RUNNER_SRC)), `committed ${RUNNER_SRC} must exist`);
assertRunnerModule(read(RUNNER_SRC));
});
test('the failure diagnostic is structured and identifies the failing migration (migration/phase/cause/applied/pending)', () => {
assertDiagnosticShape(read(RUNNER_SRC));
});
test('MigrationFailedError carries the structured diagnostic and is serializable, naming the failing migration', () => {
assertFailedError(read(RUNNER_SRC));
});
test('the runner wraps a failing migration into the structured diagnostic instead of rethrowing the raw error', () => {
assertWrapsFailure(read(RUNNER_SRC));
});
test('the runner applies pending migrations through the migration ledger exactly once', () => {
assertLedgerIntegration(read(RUNNER_SRC));
});
test('the driver boundary re-exports the runner and the diagnostic from the package entrypoint', () => {
const src = read(INDEX_SRC);
assert.match(
src,
/from '\.\/runner\.js'/,
'the driver boundary must import the runner module (from \'./runner.js\')',
);
assertBoundaryReexport(src);
});
test('the failure-diagnostic 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/database-postgres-diagnostic.test.mjs`),
`CI must run the failure-diagnostic suite (job "${CI_JOB}") on every PR`,
);
});
// ---------------------------------------------------------------------------
// Behavioral probe — the issue's test plan: "run an intentionally failing
// migration fixture and confirm the diagnostic" (deterministic, no Docker)
// ---------------------------------------------------------------------------
/** True when this Node can execute the committed `.ts` runner module (>= 23.6, type stripping). */
const TS_STRIPPING = (() => {
const [major, minor] = process.versions.node.split('.').map(Number);
return major > 23 || (major === 23 && minor >= 6);
})();
test('an intentionally failing migration produces a structured diagnostic identifying it (behavioral probe)', { skip: !TS_STRIPPING }, () => {
// The issue's test plan, driven deterministically: the committed runner is
// exercised with a stub pool through three scenarios — a successful run and
// idempotent rerun, an apply-failure fixture (the intentionally failing
// migration), and a record-failure fixture — and the structured diagnostic
// is asserted to identify the failing migration in every case.
const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.diagnostic-probe-${process.pid}.mjs`);
try {
writeFileSync(probeFile, PROBE_SOURCE);
const probe = run(process.execPath, [probeFile], { cwd: REPO_ROOT, timeout: 30_000 });
assert.equal(
probe.status,
0,
`the diagnostic probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(),
);
const resultLine = (probe.stdout || '')
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('DIAGNOSTIC_PROBE_RESULT'));
assert.ok(resultLine, `the diagnostic probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`);
const result = JSON.parse(resultLine.slice('DIAGNOSTIC_PROBE_RESULT'.length).trim());
// Scenario 1 — success: all migrations apply in order, and a rerun skips
// them (the story's "second migration run is idempotent").
assert.deepEqual(
result.success.first,
{ applied: ['ok-1', 'ok-2'], skipped: [] },
'a successful run must apply every migration in order',
);
assert.deepEqual(
result.success.second,
{ applied: [], skipped: ['ok-1', 'ok-2'] },
'a rerun must skip everything already recorded in the ledger (never double-applies)',
);
assert.deepEqual(result.success.ledger, ['ok-1', 'ok-2'], 'the ledger must record the applied migrations');
// Scenario 2 — apply failure: the intentionally failing migration fixture
// (fail-2's up throws a pg-like error with code 42P01).
assert.equal(result.applyFailure.rejected, true, 'run() must reject when a migration fails');
assert.equal(result.applyFailure.isMigrationFailedError, true, 'the failure must be a MigrationFailedError');
assert.equal(result.applyFailure.name, 'MigrationFailedError', 'the error name must be MigrationFailedError');
assert.match(result.applyFailure.message, /fail-2/, 'the error message must name the failing migration');
assert.equal(result.applyFailure.migration, 'fail-2', 'the diagnostic must identify the failing migration');
assert.equal(result.applyFailure.phase, 'apply', 'the diagnostic must report the apply phase');
assert.deepEqual(result.applyFailure.applied, ['ok-1'], 'the diagnostic must list the migrations applied before the failure');
assert.deepEqual(result.applyFailure.pending, ['fail-2', 'ok-3'], 'the diagnostic must list the still-pending migrations, including the failing one');
assert.equal(result.applyFailure.causeIsOriginal, true, 'the diagnostic must preserve the original underlying cause');
assert.equal(result.applyFailure.causeName, 'Error', 'the diagnostic cause must carry the error name');
assert.equal(result.applyFailure.causeCode, '42P01', 'the diagnostic cause must carry the pg error code');
assert.equal(result.applyFailure.toJson.diagnostic.migration, 'fail-2', 'toJSON() must identify the failing migration');
assert.equal(result.applyFailure.toJson.diagnostic.phase, 'apply', 'toJSON() must carry the failure phase');
assert.equal(result.applyFailure.toJson.diagnostic.cause.code, '42P01', 'toJSON() must carry a structured cause');
assert.deepEqual(result.applyFailure.ledger, ['ok-1'], 'only the migrations before the failure may be recorded');
// Scenario 3 — record failure: the migration's up succeeds but the ledger
// insert fails — the diagnostic must still identify the failing migration,
// with phase 'record'.
assert.equal(result.recordFailure.rejected, true, 'run() must reject when recording a migration fails');
assert.equal(result.recordFailure.name, 'MigrationFailedError', 'a record failure must also be a MigrationFailedError');
assert.equal(result.recordFailure.migration, 'fail-record', 'a record failure must identify the failing migration');
assert.equal(result.recordFailure.phase, 'record', 'the diagnostic must report the record phase');
assert.deepEqual(result.recordFailure.applied, ['ok-1'], 'the diagnostic must list the migrations applied before the failure');
assert.deepEqual(result.recordFailure.pending, ['fail-record', 'ok-3'], 'the failing migration must still be pending (never recorded)');
assert.equal(result.recordFailure.causeCode, '25006', 'the diagnostic must carry the ledger-insert error code');
assert.equal(result.recordFailure.toJson.diagnostic.migration, 'fail-record', 'toJSON() must identify the failing migration on a record failure');
assert.deepEqual(result.recordFailure.ledger, ['ok-1'], 'the failed record must not be in the ledger');
} finally {
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Real-stack probe — the issue's test plan against a real database
// ---------------------------------------------------------------------------
const DOCKER_COMPOSE = dockerComposeAvailable();
const DOCKER_DAEMON = dockerDaemonAvailable();
// An isolated compose project + non-default host port so this probe never
// collides with the compose-config suite's default-project containers, the
// ledger probe's project/port, the lock probe's project/port, or the default
// 5432 binding when several suites run on the same host.
const COMPOSE_PROJECT = 'eppp-diagnostic-probe';
const POSTGRES_HOST_PORT = '55434';
const DATABASE_URL = `postgres://eppp:eppp@127.0.0.1:${POSTGRES_HOST_PORT}/eppp`;
test('an intentionally failing migration fixture produces a structured diagnostic identifying it (real stack)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON || !TS_STRIPPING }, () => {
// The issue's test plan: "run an intentionally failing migration fixture
// and confirm the diagnostic". The probe starts the committed compose `db`
// service (its own project + host port), runs the committed runner against
// the real database with a fixture whose second migration executes valid
// SQL against a missing table, and asserts the run rejects with a
// MigrationFailedError whose diagnostic identifies the failing migration,
// reports phase 'apply', carries the pg error code (42P01), lists the
// applied/pending state, and is serializable — while the ok migration's
// effect (its table) and ledger row persist and the failing migration is
// not recorded. Rollback: drop the fixture table and ledger (issue rollback
// note) and `docker compose -p eppp-diagnostic-probe down`.
const composeEnv = { ...process.env, POSTGRES_PORT: POSTGRES_HOST_PORT };
const compose = (args, opts = {}) =>
run('docker', ['compose', '-p', COMPOSE_PROJECT, ...args], { cwd: REPO_ROOT, env: composeEnv, ...opts });
const execPsql = (args, opts = {}) =>
compose(['exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], opts);
const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.diagnostic-probe-real-${process.pid}.mjs`);
try {
const up = compose(['up', '-d', 'db'], { timeout: 180_000 });
assert.equal(
up.status,
0,
`"docker compose up -d db" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
);
waitForDbHealthy(COMPOSE_PROJECT);
// Clean slate (also recovers from a previously interrupted run): drop the
// fixture table and the ledger so the database is empty of migration state.
const clean = execPsql([
'-v', 'ON_ERROR_STOP=1',
'-c', `DROP TABLE IF EXISTS ${FIXTURE_TABLE}; DROP TABLE IF EXISTS schema_migrations;`,
]);
assert.equal(
clean.status,
0,
`dropping leftover fixture/ledger tables must succeed:\n${(clean.stdout || '')}\n${(clean.stderr || '')}`.trim(),
);
// Run the committed runner module against the real database.
writeFileSync(probeFile, REAL_PROBE_SOURCE);
const probe = run(process.execPath, [probeFile], {
cwd: REPO_ROOT,
env: { ...process.env, DATABASE_URL },
timeout: 60_000,
});
assert.equal(
probe.status,
0,
`the diagnostic real-stack probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(),
);
const resultLine = (probe.stdout || '')
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('DIAGNOSTIC_PROBE_RESULT'));
assert.ok(resultLine, `the diagnostic real-stack probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`);
const result = JSON.parse(resultLine.slice('DIAGNOSTIC_PROBE_RESULT'.length).trim());
// "migration failure produces a structured diagnostic": the run rejects
// with a MigrationFailedError carrying a structured diagnostic.
assert.equal(result.rejected, true, 'run() must reject when the failing migration fixture fails');
assert.equal(result.isMigrationFailedError, true, 'the failure must be a MigrationFailedError');
assert.equal(result.name, 'MigrationFailedError', 'the error name must be MigrationFailedError');
assert.match(result.message, /002_fixture_fail/, 'the error message must name the failing migration');
// "the diagnostic identifies the failing migration": the failing fixture
// migration is named in the diagnostic and serialized form, phase is
// 'apply', and the pg cause (code 42P01) is preserved.
assert.equal(result.migration, FAIL_MIGRATION, 'the diagnostic must identify the failing migration');
assert.equal(result.phase, 'apply', 'the diagnostic must report the apply phase');
assert.deepEqual(result.applied, [OK_MIGRATION], 'the diagnostic must list the migration applied before the failure');
assert.deepEqual(result.pending, [FAIL_MIGRATION], 'the diagnostic must list the still-pending migrations');
assert.equal(result.causeName, 'error', 'the diagnostic cause must be the pg error');
assert.equal(result.causeCode, '42P01', 'the diagnostic must carry the pg error code (undefined_table)');
assert.equal(result.toJson.diagnostic.migration, FAIL_MIGRATION, 'toJSON() must identify the failing migration');
assert.equal(result.toJson.diagnostic.phase, 'apply', 'toJSON() must carry the failure phase');
assert.equal(result.toJson.diagnostic.cause.code, '42P01', 'toJSON() must carry a structured cause');
// The ok migration's effect persists: it is recorded in the ledger and its
// table exists; the failing migration is not recorded.
assert.deepEqual(result.ledgerApplied, [OK_MIGRATION], 'only the ok migration may be recorded in the ledger');
assert.ok(result.fixtureTable, 'the ok migration must have taken effect (fixture table exists)');
// Cross-check from inside the database container.
const ledgerVersions = execPsql(['-tA', '-c', 'SELECT version FROM schema_migrations ORDER BY version;']);
assert.equal(
ledgerVersions.status,
0,
`ledger rows query via psql must succeed:\n${(ledgerVersions.stdout || '')}\n${(ledgerVersions.stderr || '')}`.trim(),
);
assert.match(
ledgerVersions.stdout,
new RegExp(OK_MIGRATION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
'the ok migration must be recorded in the ledger',
);
assert.doesNotMatch(
ledgerVersions.stdout,
new RegExp(FAIL_MIGRATION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
'the failing migration must not be recorded in the ledger',
);
} finally {
// Rollback note from the issue: revert the diagnostic/error handling
// changes — here, drop the fixture table and reset migration state, then
// tear down the isolated project.
try {
execPsql(['-c', `DROP TABLE IF EXISTS ${FIXTURE_TABLE}; DROP TABLE IF EXISTS schema_migrations;`]);
} catch {
// container may already be gone — the compose down below still cleans up
}
compose(['down', '-v'], { timeout: 120_000 });
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Non-vacuous probes — the assertions above really do fail on violations
// ---------------------------------------------------------------------------
test('removing the migration field makes the identifies-the-failing-migration criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const withoutMigration = src.replace(/ migration: string;\n/, '');
assert.notEqual(withoutMigration, src, 'the mutation must actually remove the migration field');
assert.throws(() => assertDiagnosticShape(withoutMigration), /migration/);
});
test('removing the phase field makes the structured-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const withoutPhase = src.replace(/ phase: MigrationFailurePhase;\n/, '');
assert.notEqual(withoutPhase, src, 'the mutation must actually remove the phase field');
assert.throws(() => assertDiagnosticShape(withoutPhase), /MigrationFailurePhase/);
});
test('removing the pending field makes the structured-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const withoutPending = src.replace(/ pending: string\[\];\n/, '');
assert.notEqual(withoutPending, src, 'the mutation must actually remove the pending field');
assert.throws(() => assertDiagnosticShape(withoutPending), /pending/);
});
test('replacing the wrapped failure with a bare rethrow makes the structured-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const bareRethrow = src
.replace(/throw this\.failure\(migration\.version, 'apply', error, recorded\);\n/, 'throw error;\n')
.replace(/throw this\.failure\(migration\.version, 'record', error, recorded\);\n/, 'throw error;\n');
assert.notEqual(bareRethrow, src, 'the mutation must actually replace the wrapped failures');
assert.throws(() => assertWrapsFailure(bareRethrow), /apply-failure path/);
});
test('constructing a plain Error instead of MigrationFailedError makes the structured-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const plainError = src.replace(/new MigrationFailedError\(\{/, 'new Error({');
assert.notEqual(plainError, src, 'the mutation must actually replace the MigrationFailedError construction');
assert.throws(() => assertWrapsFailure(plainError), /new MigrationFailedError/);
});
test('removing the diagnostic field from MigrationFailedError makes the structured-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const withoutField = src.replace(/ readonly diagnostic: MigrationDiagnostic;\n/, '');
assert.notEqual(withoutField, src, 'the mutation must actually remove the diagnostic field');
assert.throws(() => assertFailedError(withoutField), /readonly diagnostic/);
});
test('removing toJSON() makes the serializable-diagnostic criterion fail (mutation probe)', () => {
const src = read(RUNNER_SRC);
const withoutToJson = src.replaceAll('toJSON()', 'toJson()');
assert.notEqual(withoutToJson, src, 'the mutation must actually rename toJSON()');
assert.throws(() => assertFailedError(withoutToJson), /toJSON\(\)/);
});
test('dropping the runner re-export from the boundary fails the boundary criterion (mutation probe)', () => {
const src = read(INDEX_SRC);
const withoutReexport = src.replace(/export \{ MigrationRunner, MigrationFailedError \} from '\.\/runner\.js';\n/, '');
assert.notEqual(withoutReexport, src, 'the mutation must actually remove the runner re-export');
assert.throws(() => assertBoundaryReexport(withoutReexport), /re-export/);
});
test('a placeholder runner module fails the runner-module criterion (mutation probe)', () => {
assert.throws(
() => assertRunnerModule('export class MigrationRunner {}\n'),
/MigrationFailedError/,
);
});
+837
View File
@@ -0,0 +1,837 @@
/**
* Migration advisory lock test — locks in the [E00-S03-T04] advisory lock
* that prevents concurrent migration runners.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "advisory lock prevents concurrent migration runners" → the committed
* `packages/database-postgres/src/lock.ts` defines a `MigrationLock`
* backed by PostgreSQL session-scoped advisory locks keyed by a stable
* constant (`MIGRATION_LOCK_KEY` → `hashtextextended($1, 0)`): `acquire()`
* blocks (`pg_advisory_lock`) and `tryAcquire()` fails fast
* (`pg_try_advisory_lock`), both on a dedicated pool connection; the
* docker-gated real-stack probe starts a real database and proves two
* runners cannot hold the lock at once (the issue's test plan: "run a
* concurrent migration lock test").
* - "a second runner waits or fails while the first holds the lock" → the
* real-stack probe holds the lock in the first runner, shows the second
* runner's `tryAcquire()` returns false (fails fast) and its blocking
* `acquire()` waits (the server reports `wait_event advisory` for the
* waiting session) until the first runner releases, then acquires; a
* session that takes the lock and then closes (the runner exits)
* releases it — the issue's rollback note ("the lock releases when the
* runner exits").
* - "concurrent acquire() calls on the same lock instance are
* re-entrant-safe" → `acquire()` memoizes the in-flight acquire
* (`acquireInFlight`): a second concurrent `acquire()` on the same
* instance returns the in-flight acquire instead of checking out another
* connection, so exactly one connection is checked out and no locked
* connection leaks; the real-stack probe proves it behaviorally (two
* concurrent acquire() calls → `pool.totalCount` stays 1, one granted
* advisory lock, none left after release).
* - the release() failure path (reviewer finding F4): if the
* `pg_advisory_unlock` statement fails, `release()` destroys the
* connection (`client.release(error)` — the pool drops the client, ending
* the session and its lock) instead of returning it to the pool, so a
* pooled connection is never reused while its session still holds the
* lock; locked in statically, by a mutation probe, and behaviorally by a
* deterministic stub-pool probe of the committed module's control flow.
* - the lock is part of the driver boundary: `src/index.ts` re-exports
* `MigrationLock` + `MIGRATION_LOCK_KEY`, so no other package needs the
* `pg` driver to lock migration state.
*
* Run: `node --test tests/database-postgres-lock.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, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
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 advisory-lock module and the driver boundary that re-exports it. */
const LOCK_SRC = 'packages/database-postgres/src/lock.ts';
const INDEX_SRC = 'packages/database-postgres/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 advisory-lock criterion on every PR. */
const CI_JOB = 'database-postgres-lock';
/** The committed advisory-lock key (stable namespace, bound as $1). */
const LOCK_KEY = 'personal-blog-migrations';
// ---------------------------------------------------------------------------
// Static assertions on the committed lock source
// ---------------------------------------------------------------------------
/**
* Asserts the lock module exists with the documented shape: a `MigrationLock`
* class and a stable `MIGRATION_LOCK_KEY` constant. Fails fast on a missing
* module; the mutation probes below prove the assertions are non-vacuous.
*/
function assertLockModule(src) {
assert.match(src, /export class MigrationLock/, 'the lock module must export the MigrationLock class');
assert.match(
src,
/export const MIGRATION_LOCK_KEY = 'personal-blog-migrations'/,
'the lock module must declare the MIGRATION_LOCK_KEY constant',
);
assert.match(
src,
/get isHeld\(\)/,
'the lock module must expose isHeld so callers can tell whether the instance holds the lock',
);
}
/**
* Asserts the blocking path: `acquire()` takes a session-scoped advisory lock
* (`SELECT pg_advisory_lock(hashtextextended($1, 0))`) on a dedicated
* connection (`pool.connect()`), keyed by the stable constant bound as a
* parameter — a second runner calling `acquire()` waits while the first holds
* the lock. The key is never interpolated into the SQL.
*/
function assertBlockingAcquire(src) {
assert.match(
src,
/SELECT pg_advisory_lock\(hashtextextended\(\$1, 0\)\)/,
'acquire() must take the blocking session-scoped advisory lock (SELECT pg_advisory_lock(hashtextextended($1, 0)))',
);
assert.match(src, /pool\.connect\(\)/, 'the lock must be held on a dedicated connection (pool.connect())');
assert.match(src, /\[MIGRATION_LOCK_KEY\]/, 'the lock key must be passed as the bound-parameter array ([MIGRATION_LOCK_KEY])');
assert.doesNotMatch(
src,
/\$\{MIGRATION_LOCK_KEY\}/,
'the lock key must never be interpolated into the SQL',
);
}
/**
* Asserts the non-blocking path: `tryAcquire()` uses
* `SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired` and
* resolves `false` when another runner holds the lock — a second runner fails
* fast while the first holds the lock.
*/
function assertNonBlockingAcquire(src) {
assert.match(
src,
/SELECT pg_try_advisory_lock\(hashtextextended\(\$1, 0\)\) AS acquired/,
'tryAcquire() must use the non-blocking advisory lock (SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired)',
);
assert.match(src, /result\.rows\[0\]\?\.acquired === true/, 'tryAcquire() must resolve true only when the lock was acquired');
assert.match(src, /return false;/, 'tryAcquire() must resolve false when another runner holds the lock');
}
/**
* Asserts the re-entrancy guard: `acquire()` memoizes the in-flight acquire
* (`acquireInFlight`) so a concurrent `acquire()` on the same instance
* returns the same in-flight promise instead of checking out another
* connection — exactly one connection is checked out and no locked
* connection leaks. The memo is cleared once the acquire settles, so later
* acquire() calls behave normally.
*/
function assertReentrantAcquire(src) {
assert.match(
src,
/private acquireInFlight: Promise<void> \| null = null;/,
'the lock module must memoize the in-flight acquire (acquireInFlight field)',
);
const acquireBody = src.slice(src.indexOf('async acquire()'), src.indexOf('async tryAcquire()'));
assert.match(
acquireBody,
/if \(this\.acquireInFlight !== null\) return this\.acquireInFlight;/,
'a concurrent acquire() on the same instance must return the in-flight acquire (memoized) instead of checking out another connection',
);
assert.match(
acquireBody,
/this\.acquireInFlight = null;/,
'the in-flight acquire memo must be cleared once the acquire settles',
);
}
/**
* Asserts the session-scoped release: `release()` unlocks the holding session
* (`SELECT pg_advisory_unlock(hashtextextended($1, 0))`) BEFORE returning the
* connection to the pool (`client.release()`), so a pooled connection is
* never reused while still holding the lock; releasing an unheld instance is
* a no-op.
*/
function assertSessionScopedRelease(src) {
const releaseBody = src.slice(src.indexOf('async release()'));
assert.match(
releaseBody,
/SELECT pg_advisory_unlock\(hashtextextended\(\$1, 0\)\)/,
'release() must unlock the holding session (SELECT pg_advisory_unlock(hashtextextended($1, 0)))',
);
assert.match(releaseBody, /client\.release\(\)/, 'release() must return the connection to the pool (client.release())');
assert.ok(
releaseBody.indexOf('pg_advisory_unlock') < releaseBody.indexOf('client.release()'),
'release() must unlock the session before returning the connection to the pool',
);
}
/**
* Asserts the unlock-failure path (F4): when the `pg_advisory_unlock`
* statement fails, `release()` must DESTROY the connection —
* `client.release(error)` tells the pool to drop the client, ending the
* session (and its advisory lock) — so a pooled connection is never reused
* while its session still holds the lock. The plain `client.release()`
* (returning the connection to the pool) must live only on the success path,
* after the unlock try/catch.
*/
function assertReleaseDestroysOnUnlockFailure(src) {
const releaseBody = src.slice(src.indexOf('async release()'));
assert.match(
releaseBody,
/SELECT pg_advisory_unlock\(hashtextextended\(\$1, 0\)\)/,
'release() must unlock the holding session (SELECT pg_advisory_unlock(hashtextextended($1, 0)))',
);
assert.match(
releaseBody,
/catch \(error\) \{[\s\S]*?client\.release\(error/,
'on unlock failure release() must destroy the connection (client.release(error) in the catch of the unlock query)',
);
const destroyIdx = releaseBody.indexOf('client.release(error');
const catchIdx = releaseBody.indexOf('catch (error) {');
const plainReleaseIdx = releaseBody.indexOf('client.release()');
assert.ok(
destroyIdx !== -1 &&
catchIdx !== -1 &&
plainReleaseIdx !== -1 &&
destroyIdx < plainReleaseIdx &&
catchIdx < plainReleaseIdx,
'the plain client.release() (return to pool) must come after the unlock failure handling (destroy path) — a still-locked connection is never returned to the pool',
);
}
/** Asserts the driver boundary re-exports the lock from the package entrypoint. */
function assertBoundaryReexport(src) {
assert.match(
src,
/export \{ MigrationLock, MIGRATION_LOCK_KEY \} from '\.\/lock\.js'/,
'the driver boundary must re-export the lock (src/index.ts → ./lock.js)',
);
}
// ---------------------------------------------------------------------------
// Docker probe helpers (integration test skips cleanly without Docker)
// ---------------------------------------------------------------------------
function run(cmd, args, opts = {}) {
return spawnSync(cmd, args, {
encoding: 'utf8',
timeout: 600_000,
...opts,
});
}
/** True when the `docker` CLI with the Compose plugin is on PATH. */
function dockerComposeAvailable() {
try {
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** True when a reachable Docker daemon exists. */
function dockerDaemonAvailable() {
try {
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
} catch {
return false;
}
}
/** Parses `docker compose ps --format json` (JSON array or one object per line). */
function parsePsJson(stdout) {
const text = String(stdout).trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
}
/** Tolerant field lookup across compose ps JSON shapes. */
function field(container, ...names) {
for (const name of names) {
if (container[name] !== undefined) return container[name];
}
return undefined;
}
/**
* Polls `docker compose ps` until the db container of the given project
* reports healthy (or the deadline passes), so the probe never races a
* still-booting database.
*/
function waitForDbHealthy(project, deadlineMs = 60_000) {
const deadline = Date.now() + deadlineMs;
let last = '';
while (Date.now() < deadline) {
const ps = run('docker', ['compose', '-p', project, 'ps', '--format', 'json'], {
cwd: REPO_ROOT,
timeout: 15_000,
});
if (ps.status === 0) {
last = ps.stdout;
const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db');
if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return;
}
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry
}
throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`);
}
/**
* The host-side probe body: exercises the committed lock module against a
* real database (DATABASE_URL) exactly as concurrent migration runners will —
* first runner holds the lock, second runner fails fast AND waits, acquires
* after release, and a session that closes (the runner exits) releases the
* lock. Written to a temp file inside `packages/database-postgres/` so `pg`
* resolves through the package's own dependency links, then removed.
*/
const PROBE_SOURCE = `
import { Pool, Client } from 'pg';
import { MigrationLock, MIGRATION_LOCK_KEY } from './src/lock.ts';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const advisoryCount = async () => {
const r = await pool.query(
"SELECT count(*)::int AS n FROM pg_locks WHERE locktype = 'advisory' AND granted",
);
return r.rows[0].n;
};
const advisoryWaitEvents = async () => {
const r = await pool.query(
\`SELECT wait_event_type, wait_event FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()\`,
);
return r.rows
.filter((row) => row.wait_event_type !== null && row.wait_event !== null)
.map((row) => row.wait_event_type + ':' + row.wait_event);
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
const baseline = await advisoryCount();
// First runner takes the lock: the server must hold one granted advisory
// lock more than before.
const lockA = new MigrationLock(pool);
await lockA.acquire();
const heldCount = await advisoryCount();
// A second runner fails fast while the first holds the lock.
const lockB = new MigrationLock(pool);
const secondFailsFast = await lockB.tryAcquire();
// A second runner waits while the first holds the lock: the blocking
// acquire() must not resolve, and the server must report the waiting
// session (wait_event 'advisory').
const waitPromise = lockB.acquire();
let observedWait = false;
const waitDeadline = Date.now() + 15_000;
while (Date.now() < waitDeadline) {
if ((await advisoryWaitEvents()).includes('Lock:advisory')) {
observedWait = true;
break;
}
await sleep(100);
}
// First runner finishes -> the waiting runner acquires, then releases.
await lockA.release();
await waitPromise;
const secondAcquiredAfterRelease = lockB.isHeld;
await lockB.release();
const countAfterRelease = await advisoryCount();
// Session exit releases the lock (rollback note: "the lock releases when
// the runner exits"): a dedicated connection takes the same advisory lock
// and then closes; the server must release the lock with the session.
const exitClient = new Client({ connectionString: process.env.DATABASE_URL });
await exitClient.connect();
await exitClient.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [MIGRATION_LOCK_KEY]);
const heldWhileSessionOpen = await advisoryCount();
await exitClient.end(); // the runner exits -> session closes -> lock releases
let thirdAcquiresAfterSessionEnd = false;
const retryDeadline = Date.now() + 5_000;
while (Date.now() < retryDeadline) {
const lockC = new MigrationLock(pool);
if (await lockC.tryAcquire()) {
thirdAcquiresAfterSessionEnd = true;
await lockC.release();
break;
}
await sleep(100);
}
// Re-entrant-safe acquire (acceptance criterion: concurrent acquire() on
// the same lock instance is re-entrant-safe — the in-flight acquire is
// memoized so exactly one connection is checked out and no locked
// connection leaks). A fresh pool so the connection counts are exact: two
// concurrent acquire() calls must share one in-flight acquire — one
// connection checked out (pool.totalCount stays 1), the lock granted once,
// and nothing left held after release.
let reentrantPool = null;
try {
reentrantPool = new Pool({ connectionString: process.env.DATABASE_URL });
const lockR = new MigrationLock(reentrantPool);
await Promise.all([lockR.acquire(), lockR.acquire()]);
const reentrantPoolTotal = reentrantPool.totalCount;
const reentrantPoolIdle = reentrantPool.idleCount;
const reentrantHeld = lockR.isHeld;
const reentrantHeldCount = await advisoryCount();
await lockR.release();
const reentrantCountAfterRelease = await advisoryCount();
await reentrantPool.end();
reentrantPool = null;
console.log('LOCK_PROBE_RESULT ' + JSON.stringify({
baseline,
heldCount,
secondFailsFast,
observedWait,
secondAcquiredAfterRelease,
countAfterRelease,
heldWhileSessionOpen,
thirdAcquiresAfterSessionEnd,
reentrantPoolTotal,
reentrantPoolIdle,
reentrantHeld,
reentrantHeldCount,
reentrantCountAfterRelease,
}));
} finally {
if (reentrantPool !== null) {
await reentrantPool.end().catch(() => {});
}
}
} finally {
await pool.end();
}
`;
/**
* Deterministic behavioral probe of the release() failure path (F4): models
* pg's PoolClient disposal contract — release() returns the client to the
* pool, release(error) destroys it (the pool removes the client, ending its
* session) — and drives the COMMITTED lock module's release() directly with a
* stub pool, so the check is about the module's behavior (which disposal call
* it makes), not its source shape. No database or Docker needed.
*/
const RELEASE_FAILURE_PROBE_SOURCE = `
import { MigrationLock } from './src/lock.ts';
// A stub pg pool whose client fails (or succeeds) the unlock query and
// records how release() disposes of it.
const makeStub = (unlockSucceeds) => {
const events = [];
const client = {
async query() {
if (unlockSucceeds) {
events.push('unlock:ok');
return { rows: [] };
}
const err = new Error('unlock statement failed (statement timeout)');
err.code = '57014';
events.push('unlock:fail');
throw err;
},
release(error) {
events.push(error === undefined ? 'release()' : 'release(error)');
if (error !== undefined) client.destroyed = true;
},
};
const pool = { connect: async () => client };
return { pool, client, events };
};
const results = {};
// Failure path: the unlock query rejects -> release() must DESTROY the
// connection (release(error)), not return it to the pool while its session
// still holds the advisory lock.
{
const { pool, client, events } = makeStub(false);
const lock = new MigrationLock(pool);
lock.held = true; // drive the committed release() directly (no DB needed)
lock.client = client;
let rejected = false;
try {
await lock.release();
} catch (error) {
rejected = String(error.message).includes('unlock statement failed');
}
results.failurePath = {
rejected,
events,
destroyed: client.destroyed === true,
stateCleared: lock.isHeld === false && lock.client === null,
};
}
// Success path: the unlock query resolves -> release() returns the connection
// to the pool with the plain release().
{
const { pool, client, events } = makeStub(true);
const lock = new MigrationLock(pool);
lock.held = true;
lock.client = client;
await lock.release();
results.successPath = {
events,
destroyed: client.destroyed === true,
stateCleared: lock.isHeld === false && lock.client === null,
};
}
console.log('LOCK_RELEASE_FAILURE_PROBE_RESULT ' + JSON.stringify(results));
`;
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the lock module exists in the driver-owner package and defines the migration lock key', () => {
assert.ok(existsSync(path.join(REPO_ROOT, LOCK_SRC)), `committed ${LOCK_SRC} must exist`);
const src = read(LOCK_SRC);
assertLockModule(src);
});
test('acquire() takes the blocking session-scoped advisory lock keyed by a bound parameter (a second runner waits)', () => {
assertBlockingAcquire(read(LOCK_SRC));
});
test('tryAcquire() fails fast while another runner holds the lock (a second runner fails)', () => {
assertNonBlockingAcquire(read(LOCK_SRC));
});
test('concurrent acquire() on the same instance is re-entrant-safe (in-flight acquire memoized, exactly one connection)', () => {
assertReentrantAcquire(read(LOCK_SRC));
});
test('release() unlocks the holding session and returns the connection to the pool', () => {
assertSessionScopedRelease(read(LOCK_SRC));
});
test('release() destroys the connection when the unlock statement fails (never reuses a still-locked connection)', () => {
assertReleaseDestroysOnUnlockFailure(read(LOCK_SRC));
});
test('the driver boundary re-exports the lock from the package entrypoint', () => {
const src = read(INDEX_SRC);
assert.match(
src,
/from '\.\/lock\.js'/,
'the driver boundary must import the lock module (from \'./lock.js\')',
);
assertBoundaryReexport(src);
});
test('the advisory-lock 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/database-postgres-lock.test.mjs`),
`CI must run the advisory-lock suite (job "${CI_JOB}") on every PR`,
);
});
// ---------------------------------------------------------------------------
// Real-stack probe — the issue's test plan: "run a concurrent migration
// lock test" against a real database
// ---------------------------------------------------------------------------
const DOCKER_COMPOSE = dockerComposeAvailable();
const DOCKER_DAEMON = dockerDaemonAvailable();
/** True when this Node can execute the committed `.ts` lock module (>= 23.6, type stripping). */
const TS_STRIPPING = (() => {
const [major, minor] = process.versions.node.split('.').map(Number);
return major > 23 || (major === 23 && minor >= 6);
})();
// An isolated compose project + non-default host port so this probe never
// collides with the compose-config suite's default-project containers, the
// ledger probe's project/port, or the default 5432 binding when several
// suites run on the same host.
const COMPOSE_PROJECT = 'eppp-lock-probe';
const POSTGRES_HOST_PORT = '55433';
const DATABASE_URL = `postgres://eppp:eppp@127.0.0.1:${POSTGRES_HOST_PORT}/eppp`;
test('concurrent migration runners are serialized by the advisory lock (real stack)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON || !TS_STRIPPING }, () => {
// The issue's test plan: "run a concurrent migration lock test". The probe
// starts the committed compose `db` service (its own project + host port),
// runs the committed lock module against it with two runner instances, and
// asserts the first runner holds the lock, the second fails fast AND waits
// until the first releases (server-reported wait_event 'advisory'), and a
// session that closes — the runner exits — releases the lock (rollback
// note). Rollback: `docker compose -p eppp-lock-probe down`.
const composeEnv = { ...process.env, POSTGRES_PORT: POSTGRES_HOST_PORT };
const compose = (args, opts = {}) =>
run('docker', ['compose', '-p', COMPOSE_PROJECT, ...args], { cwd: REPO_ROOT, env: composeEnv, ...opts });
const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.lock-probe-${process.pid}.mjs`);
try {
const up = compose(['up', '-d', 'db'], { timeout: 180_000 });
assert.equal(
up.status,
0,
`"docker compose up -d db" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
);
waitForDbHealthy(COMPOSE_PROJECT);
// Run the committed lock module against the real database.
writeFileSync(probeFile, PROBE_SOURCE);
const probe = run(process.execPath, [probeFile], {
cwd: REPO_ROOT,
env: { ...process.env, DATABASE_URL },
timeout: 60_000,
});
assert.equal(
probe.status,
0,
`the lock probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(),
);
const resultLine = (probe.stdout || '')
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('LOCK_PROBE_RESULT'));
assert.ok(resultLine, `the lock probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`);
const result = JSON.parse(resultLine.slice('LOCK_PROBE_RESULT'.length).trim());
// "advisory lock prevents concurrent migration runners": the first runner
// holds exactly one granted advisory lock more than the empty baseline.
assert.equal(
result.heldCount,
result.baseline + 1,
`acquire() must hold one granted advisory lock (baseline ${result.baseline}, got ${result.heldCount})`,
);
// "a second runner fails while the first holds the lock".
assert.equal(
result.secondFailsFast,
false,
'tryAcquire() must fail fast while the first runner holds the lock',
);
// "a second runner waits while the first holds the lock": the blocking
// acquire() does not resolve and the server reports the waiting session.
assert.equal(
result.observedWait,
true,
'the blocking acquire() must wait (server-reported wait_event advisory) while the first runner holds the lock',
);
// The waiting runner acquires once the first runner releases.
assert.equal(
result.secondAcquiredAfterRelease,
true,
'the waiting runner must acquire the lock once the first runner releases it',
);
// Nothing is left held after both runners release.
assert.equal(
result.countAfterRelease,
result.baseline,
`no advisory lock may remain granted after both runners release (got ${result.countAfterRelease}, expected ${result.baseline})`,
);
// Rollback note: "the lock releases when the runner exits" — a session
// that holds the lock and then closes releases it.
assert.equal(
result.heldWhileSessionOpen,
result.baseline + 1,
`the dedicated session must hold the advisory lock while open (baseline ${result.baseline}, got ${result.heldWhileSessionOpen})`,
);
assert.equal(
result.thirdAcquiresAfterSessionEnd,
true,
'the lock must be acquirable again after the holding session closes (the runner exits)',
);
// "concurrent acquire() on the same lock instance is re-entrant-safe":
// two concurrent acquire() calls share one in-flight acquire — exactly
// one connection is checked out (pool.totalCount stays 1), the lock is
// granted once, and nothing is left held after release (no locked
// connection leaks).
assert.equal(
result.reentrantPoolTotal,
1,
`two concurrent acquire() calls must check out exactly one connection (pool.totalCount ${result.reentrantPoolTotal})`,
);
assert.equal(
result.reentrantPoolIdle,
0,
`the re-entrant acquire must hold its connection checked out while the lock is held (pool.idleCount ${result.reentrantPoolIdle})`,
);
assert.equal(
result.reentrantHeld,
true,
'the re-entrant acquire must leave the instance holding the lock',
);
assert.equal(
result.reentrantHeldCount,
result.baseline + 1,
`two concurrent acquire() calls must grant the advisory lock once (baseline ${result.baseline}, got ${result.reentrantHeldCount})`,
);
assert.equal(
result.reentrantCountAfterRelease,
result.baseline,
`no advisory lock may remain granted after the re-entrant instance releases (got ${result.reentrantCountAfterRelease})`,
);
} finally {
compose(['down', '-v'], { timeout: 120_000 });
rmSync(probeFile, { force: true });
}
});
test('release() destroys the connection on unlock failure and returns it to the pool on success (behavioral probe)', { skip: !TS_STRIPPING }, () => {
// F4: pg's release(error) destroys the client (the pool removes it, ending
// the session and its lock); the plain release() returns it to the pool.
// The probe drives the committed release() with a stub pool: a failed unlock
// must take the destroy path (release(error)), a successful unlock the
// return path (plain release()) — a still-locked connection is never reused.
const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.lock-probe-release-${process.pid}.mjs`);
try {
writeFileSync(probeFile, RELEASE_FAILURE_PROBE_SOURCE);
const probe = run(process.execPath, [probeFile], { cwd: REPO_ROOT, timeout: 30_000 });
assert.equal(
probe.status,
0,
`the release-failure probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(),
);
const resultLine = (probe.stdout || '')
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('LOCK_RELEASE_FAILURE_PROBE_RESULT'));
assert.ok(resultLine, `the release-failure probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`);
const result = JSON.parse(resultLine.slice('LOCK_RELEASE_FAILURE_PROBE_RESULT'.length).trim());
// Failure path: the unlock statement failed -> the connection is destroyed
// (release(error)), never returned to the pool while still locked.
assert.equal(
result.failurePath.rejected,
true,
'release() must reject when the unlock statement fails',
);
assert.deepEqual(
result.failurePath.events,
['unlock:fail', 'release(error)'],
'on unlock failure release() must destroy the connection (client.release(error)), not return it to the pool',
);
assert.equal(
result.failurePath.destroyed,
true,
'the failed-unlock connection must be destroyed (removed from the pool)',
);
assert.equal(
result.failurePath.stateCleared,
true,
'release() must clear held/client even on the failure path',
);
// Success path: the unlock succeeded -> the connection is returned to the
// pool with the plain release().
assert.deepEqual(
result.successPath.events,
['unlock:ok', 'release()'],
'on a successful unlock release() must return the connection to the pool (plain client.release())',
);
assert.equal(
result.successPath.destroyed,
false,
'a successful unlock must not destroy the connection',
);
} finally {
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Non-vacuous probes — the assertions above really do fail on violations
// ---------------------------------------------------------------------------
test('removing pg_advisory_lock makes the blocking-acquire criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
const withoutBlocking = src.replace(/SELECT pg_advisory_lock\(/, 'SELECT pg_advisory_xact_lock(');
assert.notEqual(withoutBlocking, src, 'the mutation must actually replace the blocking lock call');
assert.throws(() => assertBlockingAcquire(withoutBlocking), /pg_advisory_lock/);
});
test('removing pg_try_advisory_lock makes the non-blocking criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
const withoutTry = src.replace(/SELECT pg_try_advisory_lock\(/, 'SELECT pg_advisory_lock(');
assert.notEqual(withoutTry, src, 'the mutation must actually replace the non-blocking lock call');
assert.throws(() => assertNonBlockingAcquire(withoutTry), /pg_try_advisory_lock/);
});
test('interpolating the lock key into the SQL makes the keyed-parameter criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
const interpolated = src.replace(/hashtextextended\(\$1, 0\)/g, "hashtextextended('${MIGRATION_LOCK_KEY}', 0)");
assert.notEqual(interpolated, src, 'the mutation must actually replace the $1 placeholder');
assert.throws(() => assertBlockingAcquire(interpolated), /\$1/);
});
test('removing pg_advisory_unlock makes the session-release criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
const withoutUnlock = src.replace(/SELECT pg_advisory_unlock\(/, 'SELECT pg_advisory_lock(');
assert.notEqual(withoutUnlock, src, 'the mutation must actually replace the unlock call');
assert.throws(() => assertSessionScopedRelease(withoutUnlock), /pg_advisory_unlock/);
});
test('returning the connection to the pool on unlock failure makes the failure-path criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
// The buggy shape F4 describes: a try/finally that always returns the client
// to the pool, so a failed unlock reuses a still-locked connection.
const withoutDestroy = src.replace('client.release(error as Error);', 'client.release();');
assert.notEqual(withoutDestroy, src, 'the mutation must actually replace the failure-path destroy with a plain release');
assert.throws(() => assertReleaseDestroysOnUnlockFailure(withoutDestroy), /client\.release\(error/);
});
test('removing the in-flight acquire memo makes the re-entrancy criterion fail (mutation probe)', () => {
const src = read(LOCK_SRC);
const withoutMemo = src.replace(/if \(this\.acquireInFlight !== null\) return this\.acquireInFlight;\n/, '');
assert.notEqual(withoutMemo, src, 'the mutation must actually remove the in-flight acquire guard');
assert.throws(() => assertReentrantAcquire(withoutMemo), /in-flight acquire/);
});
test('dropping the lock re-export from the boundary fails the boundary criterion (mutation probe)', () => {
const src = read(INDEX_SRC);
const withoutReexport = src.replace(/export \{ MigrationLock, MIGRATION_LOCK_KEY \} from '\.\/lock\.js';\n/, '');
assert.notEqual(withoutReexport, src, 'the mutation must actually remove the lock re-export');
assert.throws(() => assertBoundaryReexport(withoutReexport), /re-export/);
});
test('a placeholder lock module fails the advisory-lock criterion (mutation probe)', () => {
assert.throws(
() => assertLockModule('export class MigrationLock {}\n'),
/MIGRATION_LOCK_KEY/,
);
});
+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, []);
});
+32 -3
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)',
);
}
@@ -111,15 +111,31 @@ function reservePort() {
/**
* Boots the committed server source on `port`. Returns `{ child, stderr }`;
* the child writes its stderr into the `stderr()` closure for diagnostics.
*
* The child boots WITHOUT `DATABASE_URL` (it is stripped from the inherited
* 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. 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),
EPPP_SESSION_SECRET: 's'.repeat(32),
};
delete env.DATABASE_URL;
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
env: { ...process.env, PORT: String(port) },
env,
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
@@ -173,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(
+1 -1
View File
@@ -37,7 +37,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
/** The workspace packages that must compile under the strict base config. */
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
/** The strict-family flags the committed base config must set. */
const STRICT_FAMILY_FLAGS = [
+1 -1
View File
@@ -33,7 +33,7 @@ const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
const PINNED_TYPESCRIPT = '6.0.3';
/** Every workspace package that must resolve the pinned TypeScript version. */
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
// ---------------------------------------------------------------------------
// Tests
+1 -1
View File
@@ -28,7 +28,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*'];
const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
const PINNED_PNPM = '11.23.0';
// ---------------------------------------------------------------------------
+1
View File
@@ -38,6 +38,7 @@ const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions'];
const EXPECTED_PACKAGES = {
'@personal-blog/server': 'apps/server',
'@personal-blog/core': 'packages/core',
'@personal-blog/config': 'packages/config',
'@personal-blog/database-postgres': 'packages/database-postgres',
'@personal-blog/example-extension': 'extensions/example',
};