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.
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.
- 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
- 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
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).
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.
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.
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.
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.
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).
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
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).
- 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
- 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
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.
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.
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.
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.
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.