[E00-S04-T02] Missing required setting gives field-specific startup error #397
No Reviewers
Labels
Clear labels
agent/analyst-drafted
agent/analyst-drafted
needs/human-decision
needs/human-decision
needs/security-review
needs/security-review
tier/t0
tier/t1
tier/t2
tier/t3
kind
bug
kind
bug
kind
epic
kind
epic
kind
initiative
EPPP programme initiative
kind
story
kind
story
kind
task
EPPP engineering card/task decomposed from a story
kind
toil
kind
toil
loop
1
loop
1
loop
2
loop
2
loop
3
loop
3
risk
agent-full
risk
agent-full
risk
human-gated
risk
human-gated
risk
human-only
risk
human-only
size
l
size
l
size
m
size
m
size
s
size
s
status
blocked
status
blocked
status
done
Workflow: Done
status
in-progress
status
in-progress
status
proposed
status
proposed
status
ready
status
ready
status
review
status
review
stream
checkout
stream
checkout
stream
onboarding
stream
onboarding
stream
platform
stream
platform
trivial — implementer only, auto-merge
standard — implementer + reviewer + tester
complex — security if triggered, human merge
critical — full chain + security, human merge
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#397
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed
Implements [E00-S04-T02] Missing required setting gives field-specific startup error (#183): a missing required setting now fails startup with an error that names the missing field, built on the E00-S04-T01 TypeBox/Ajv schema.
packages/config— field-specific startup error (@personal-blog/config): newsrc/startup.tscompiles the committedconfigSchemawith Ajv (same golden-tuple validator asvalidate.ts) and exportsassertValidConfig(value): Config— the startup entry point the application calls before it binds — plus the field-specific errors:MissingRequiredSettingError(messagemissing required setting: <field>and.missingFieldname the missing required setting — the schema's required fieldsessionSecret,EPPP_SESSION_SECRETper Security-and-Operations §32/§26) andConfigStartupError(any other schema violation, message names the violating field, e.g.sessionSecret: must NOT have fewer than 32 characters,extra: must NOT have additional properties). The package boundary re-exports both plus the entry point. Nothing readsprocess.envhere — the environment adapter (E00-S04-T04) remains out of scope and later maps the environment onto this validated shape.apps/server— startup wiring:src/index.tsimportsassertValidConfigfrom@personal-blog/config(new workspace dependency) and validates the startup configuration (host/port/databaseUrlkeep their committed defaults;sessionSecretfromEPPP_SESSION_SECRET) before the server binds, so a deployment missing the required secret crashes at startup withmissing required setting: sessionSecretinstead of booting with an invalid configuration. The readiness gate, the migration run and the health endpoint are unchanged.compose.yaml: theappservice now providesEPPP_SESSION_SECRETwith a dev-only ≥ 32-char default (${EPPP_SESSION_SECRET:-…}, override via.env/shell — same pattern asPOSTGRES_PASSWORD:-eppp), sodocker compose up -dkeeps working from a clean clone while the app's startup validation has its required secret.apps/server/Dockerfile: the build stage copiespackages/configsource and the runtime stage ships the compiledpackages/config/dist+ manifest (the server now imports@personal-blog/config); build/typecheck scripts build the config package first.tests/config-startup-error.test.mjs— new suite locking in both acceptance criteria (details in the criterion → test table): static assertions on the committed startup-error module, the package boundary, the server wiring (validation beforeserver.listen), the compose secret and the Dockerfile shipping — each backed by mutation probes proving non-vacuity — plus two deterministic probes: the compiled@personal-blog/configboundary throwsMissingRequiredSettingErrornamingsessionSecretfor a missing secret (and names the field for a short secret / unknown property), and the issue's test plan is executed against the real committed startup — booting the server withoutEPPP_SESSION_SECRETexits non-zero with the error naming the missing field, while a valid secret boots toGET /health200.health-endpoint/app-readinessboot probes provide a validEPPP_SESSION_SECRET(the required setting is now validated at startup);.gitea/workflows/ci.ymlgains aconfig-startup-errorjob (builds config + database-postgres, runs the suite) and theapp-readinessjob builds the config package too.docs/development/non-container.md: the local run path now documents the requiredEPPP_SESSION_SECRETand themissing required setting: sessionSecretstartup error.pnpm-lock.yaml:apps/serverimporter gains@personal-blog/config(workspace link);pnpm install --frozen-lockfilepasses.Explicitly out of scope per the brief, not touched: TypeBox/Ajv schema (E00-S04-T01, already merged), secret redaction (E00-S04-T03), the
process.envaccess rule / environment adapter (E00-S04-T04 — the server still reads the env vars it needs directly, as it did before this task; the adapter that centralizes these reads lands with T04).Criterion → test table
tests/config-startup-error.test.mjs— "the config package exposes the field-specific startup error (MissingRequiredSettingError + assertValidConfig)" (startup.ts compilesconfigSchemawith Ajv and exportsassertValidConfig+ConfigStartupError+MissingRequiredSettingError); "the package boundary re-exports the startup validation entry point and its errors"; "the server validates the required settings at startup, before it binds" (import { assertValidConfig } from '@personal-blog/config'+ call withsessionSecret: process.env.EPPP_SESSION_SECRETat a source index beforeserver.listen(); mutation probes "dropping the startup validation call …", "moving the startup validation after the server binds …", "dropping the missing-field detection …"; deterministic probes — the compiled boundary throws for a missing required setting and the server boot probe (the issue's test plan: "start with a missing required field and confirm the error names it") boots the committed server withoutEPPP_SESSION_SECRET→ exits non-zero; a too-short secret also exits non-zero; a valid secret boots toGET /health200tests/config-startup-error.test.mjs— "the config package exposes …" (/missing required setting: \$\{missingField\}/,/readonly missingField: string/,error.keyword === 'required'→MissingRequiredSettingError); mutation probe "an error message that does not name the missing field fails the naming assertion"; deterministic probes — the compiled boundary reportsmissingField === 'sessionSecret'and message/missing required setting: sessionSecret/for a missing secret, and names the violating field for a short secret (/sessionSecret/) and an unknown property (/extra/); the server boot probe asserts stderr matches/missing required setting: sessionSecret/tests/config-startup-error.test.mjs— "the compose app service provides the required admin-session secret (EPPP_SESSION_SECRET)" (≥ 32-char dev default${EPPP_SESSION_SECRET:-…}) and "the server image ships the config package (build source + runtime dist)"; mutation probes "removing EPPP_SESSION_SECRET from the compose app service …" and "dropping the config package from the image …"; the deterministic server boot probe with a valid secret confirmsGET /health200 (a valid startup still works)config-startup-errorjobtests/config-startup-error.test.mjs— "the config-startup-error criterion is enforced in CI" (root test glob covers the suite;.gitea/workflows/ci.ymlrunsnode --test tests/config-startup-error.test.mjsand builds@personal-blog/config+@personal-blog/database-postgresfirst);.gitea/workflows/ci.yml—config-startup-errorjob (additive, matching the security-reviewed #396 precedent)Test plan executed
node --test tests/config-startup-error.test.mjs→ 17 tests, 17 pass / 0 fail / 0 skip. The deterministic probes ran for real: the compiled boundary throwsMissingRequiredSettingErrornamingsessionSecretfor a missing secret and names the field for a short secret / unknown property; booting the committed server withoutEPPP_SESSION_SECRETexits non-zero withmissing required setting: sessionSecreton stderr; with a too-short secret it exits non-zero namingsessionSecret; with a valid secret it boots toGET /health200{"status":"ok"}.config-schema(12),health-endpoint(7),app-readiness(18 pass + 1 docker-gated skip) — all green;workspace-layout(8),workspace-config(6),strict-tsconfig(5),typescript-pin(3),architecture-import(10),no-core-extension-imports(2),compose-config,secrets-not-embedded,build-targets,non-root-user,readonly-rootfs— green (docker-gated probes skip without a daemon).node --test "tests/**/*.test.mjs"): 227 tests — 203 pass / 9 fail / 15 skip; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline documented in #396 (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24):tests/frozen-install.test.mjs×5 andtests/root-commands.test.mjs×3 fail onERR_PNPM_UNSUPPORTED_ENGINE,tests/node-engine.test.mjs×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass.pnpm install --frozen-lockfilepasses against the regeneratedpnpm-lock.yaml(apps/server importer +@personal-blog/configworkspace link).packages/config,packages/database-postgres,apps/serverand every other workspace package compile withtsc --noEmitexit 0 (strict base config, NodeNext, verbatimModuleSyntax, TypeScript 6.0.3).Risks / notes
EPPP_SESSION_SECRETat startup — this is the intended E00-S04-T02 behavior (the schema's only required field), and the compose dev default keeps the clean-clonedocker compose up -dpath working (same pattern as thePOSTGRES_PASSWORD:-epppdev default; runtime injection via Compose env, never baked into the image — T08's image-layer scan is unaffected, verified bysecrets-not-embedded). T05 (.env.example) will document overriding it.validateConfig(T01) is untouched: the startup module compiles the schema itself to read Ajv's structuredparams(e.g.missingProperty,additionalProperty) for field names;validateConfigkeeps returning the raw message-only outcome, so the T01 boundary and its tests are unchanged.DATABASE_URL); the secrets-not-embedded suite still passes.packages/config/src/startup.ts(+ boundary re-exports), the server'sassertValidConfigcall and@personal-blog/configdependency, the composeEPPP_SESSION_SECRETentry, the Dockerfile config copies, theconfig-startup-errorCI job and the fixture/probe updates (no data migration involved).app-readinessjob's build step was extended, no existing job removed/modified otherwise) and matches the security-reviewed #396 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested.Refs #183
- 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