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
@@ -508,6 +508,17 @@ something real needs one, and nothing does. Email is optional: an installation w
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
Pass 2.
Phase 7C pass 1B makes host infrastructure configuration a single shared mapping. Every executable —
`apps/api`, `pnpm doctor`, and the migration and owner-bootstrap commands — turns its environment
into typed inputs through one `loadHostConfiguration(env)` in Core, which takes the environment **as
an argument** and never reads `process.env` itself, exactly as the module secret source does.
Validation stays with the existing per-domain validators, so each caller keeps its own failure
behaviour: `startApi` refuses to start on a rejected value, while Doctor reports it as a sanitized
code. This replaced two executables that each read their own subset of the same variables and had
drifted — Doctor never read `S3_*` or module secrets at all, so it reported a correctly configured
installation as having neither, and reported modules blocked that the runtime was running. A
diagnostic that disagrees with the runtime it diagnoses is worse than no diagnostic.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only
+5 -3
View File
@@ -1361,9 +1361,11 @@ rotation and client-certificate authentication.
The same policy is resolved identically by every executable that opens a connection — `apps/api`,
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
`pnpm doctor`'s own connection — through one shared environment reader
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
`pnpm doctor`'s own connection — because they all map the environment through the one shared host
configuration loader (`core/src/host/configuration.ts`, reached by the small migration commands via
`core/src/cli/database-transport-environment.ts`). The loader takes the environment **as an
argument**, so `process.env` stays out of Core's reusable constructors and only executables read
it. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,
+4
View File
@@ -102,6 +102,10 @@ carries no value.
A successful probe proves the relay accepts this deployment. It does **not** prove a message would
reach an inbox — that depends on the relay's authorization policy and the domain's DNS records.
Doctor reads the `EKKLESIA_SMTP_*` values through the same `loadHostConfiguration` the API
executable uses, so the relay it reports on is the relay the runtime would use. The check is
rendered in Doctor's own **Outbound email** section.
## Logging
Transactional email carries verification links, reset links, and personal data. The transport logs
+45 -5
View File
@@ -67,6 +67,18 @@ the diagnostics that follow are operationally read-only. Doctor never edits conf
migrations, creates the Core history table, acquires the migration apply lock, starts module server
entrypoints, provisions storage, or invokes Docker.
Doctor evaluates the **same host infrastructure configuration the runtime uses**. Both `pnpm doctor`
and `apps/api` map one deployment environment through the same `loadHostConfiguration(env)` in Core,
so a configured object-storage provider, a set module secret, and a configured SMTP relay are all
recognized by Doctor exactly as `startApi` would recognize them — and neither process can silently
interpret a value differently from the other. Doctor needs no running API process to do this; export
or set the environment the platform would start with and run the command.
Each optional service is reported in one of three distinct states: **not configured** (absent, and a
complete deployment), **configured incorrectly** (a blocking failure, reported as a sanitized code
with no value echoed), or **configured but unreachable** (a warning, because a transient outage is
not a broken deployment).
Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range.
@@ -80,7 +92,17 @@ Doctor reports a fixed, deterministic set of checks:
- Identity runtime configuration: skipped (not a failure) when no secret, base URL, or trusted
origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
This check never constructs Better Auth or opens a database connection.
- Object storage as skipped while Core has no production storage client or configuration contract.
- Module lifecycle and module configuration, resolved exactly as startup resolves them, so Doctor
can never report a module as runnable that startup would block, or the reverse.
- Object storage: skipped when no `S3_ENDPOINT` is set, a blocking failure when the `S3_*` settings
are structurally invalid, and otherwise probed for reachability. Bindings for every module-declared
logical bucket are reported separately, because binding is not health.
- Outbound email: skipped when no `EKKLESIA_SMTP_HOST` is set, a blocking failure when the
`EKKLESIA_SMTP_*` settings are structurally invalid, and otherwise a connection that authenticates
where credentials are set and **sends nothing**.
No check prints a credential: not the storage access or secret key, not the SMTP password, not the
identity secret, not a module secret value.
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
@@ -102,18 +124,28 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
1 permission registered
6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
3 migrations · checksums valid
7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped
DATABASE_URL is not configured.
○ Migration plan skipped
@@ -123,10 +155,18 @@ Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
○ Object storage not configured
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
✓ No module requires object storage
0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
Result: READY WITH WARNINGS
```
+7 -1
View File
@@ -1133,12 +1133,18 @@ by the process such a module takes down.
and reachable, and `object-storage.bindings` lists which modules require storage and where each
logical bucket is bound. Neither prints a credential. A module requiring storage on an installation
with none configured makes the bindings check fail with a remediation line; a provider that is merely
unreachable is a warning, because modules already running are unaffected.
unreachable is a warning, because modules already running are unaffected. A provider whose settings
are structurally invalid is a blocking failure, reported as a sanitized code — the runtime refuses to
start on the same input.
`pnpm doctor`'s `modules.configuration` check reports whether each installed module's configuration
would let it run, naming the offending key when it would not. It prints **no configuration values** —
secret or otherwise — only counts, keys, and reasons.
Both checks evaluate the same host configuration the runtime receives: Doctor and the API executable
map one deployment environment through the same loader, so a bucket the platform would bind is never
reported unbound, and a module secret the deployment has set is never reported missing.
`pnpm doctor` builds first and presents the same production discovery results as a concise,
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
schema and migration counts, and shows safe migration identities with truncated checksums. Schema