fix: give Doctor the host configuration the runtime receives
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s

`pnpm doctor` and `startApi` each read their own subset of the same
environment variables, and the subsets had drifted. Doctor never read
`S3_*` or module secrets at all, so a correctly configured installation
was told its object storage was "not configured" and every module
requiring a bucket or a secret was reported blocked -- while the runtime,
from the identical environment, bound those buckets and ran the modules.

Both executables now map the environment through one
`loadHostConfiguration(env)` in Core, which takes the environment as an
argument and never reads `process.env` itself. Validation stays with the
existing per-domain validators, so each caller keeps its own failure
behaviour: startup refuses a rejected value, Doctor reports it as a
sanitized code.

Doctor's storage option becomes raw input like its mail option, so a
half-configured provider is reported as a blocking configuration failure
instead of throwing before a report exists. The outbound-email check --
computed, counted toward the result, but absent from every rendered
section -- is now printed, and the rendered set is asserted against a
real report so a future check cannot go missing the same way.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-24 19:15:49 -05:00
co-authored by Claude Opus 5
parent a835215eb8
commit 2b4f0afb72
23 changed files with 1411 additions and 209 deletions
+16 -6
View File
@@ -118,15 +118,25 @@ describe("outbound email stays server-only", () => {
}
});
it("reads SMTP environment only in the executable that owns configuration", async () => {
// Reusable Core code never touches `process.env`; the composition root turns environment into
// validated configuration and injects it.
it("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
// The mail provider takes validated configuration and nothing else — it never reads the
// environment, under any name.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("process.env");
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
}
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8");
expect(server).toContain("EKKLESIA_SMTP_HOST");
expect(server).toContain("EKKLESIA_SMTP_PASSWORD");
// The variable names live in the shared host-configuration loader, which both `startApi` and
// `pnpm doctor` call with their own environment. Two executables mapping these names
// independently is what let Doctor and the runtime disagree about object storage.
const loader = await readFile(
path.join(repositoryRoot, "core/src/host/configuration.ts"),
"utf8",
);
expect(loader).toContain("EKKLESIA_SMTP_HOST");
expect(loader).toContain("EKKLESIA_SMTP_PASSWORD");
// That the loader itself reads no environment is asserted in
// `host-configuration-boundaries.test.ts`, which strips comments first — this file's plain
// substring match would trip over the loader's own prose about the rule.
});
it("keeps no SMTP password out of the committed environment example", async () => {