fix: give Doctor the host configuration the runtime receives #37

Merged
Zayin_Admin merged 1 commits from phase-7c/doctor-config-parity into main 2026-08-25 00:20:51 +00:00
23 changed files with 1411 additions and 209 deletions
+4
View File
@@ -1,4 +1,8 @@
# Local development only. Replace every credential outside a developer workstation. # Local development only. Replace every credential outside a developer workstation.
#
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
# deployment without the API process running.
# Required for a local workstation, and only ever correct there. Anything else — including an # Required for a local workstation, and only ever correct there. Anything else — including an
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS # unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
+19 -4
View File
@@ -791,8 +791,18 @@ pnpm verify
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed `pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration (TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset outbound-email integration state.
Doctor reads its host infrastructure configuration through the same
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
identical defaults from one deployment environment: configured object storage is reported as
configured, a module secret the deployment has set is reported as satisfied, and a value neither
process could accept is reported as a configuration failure rather than silently interpreted one way
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
development infrastructure provides is only usable with it set. development infrastructure provides is only usable with it set.
@@ -817,7 +827,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
5 permissions registered 6 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -827,13 +837,14 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
6 migrations · checksums valid 7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4 core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
@@ -856,6 +867,10 @@ Object storage
✓ No module requires object storage ✓ No module requires object storage
0 modules declare a logical storage bucket. 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 Result: READY WITH WARNINGS
``` ```
+1 -1
View File
@@ -90,7 +90,7 @@ export interface ApiRuntimeConfig {
export interface ApiRuntimeConfigInput { export interface ApiRuntimeConfigInput {
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy /** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
* trust unless explicitly configured. See `./client-address.js`. */ * trust unless explicitly configured. See `./client-address.js`. */
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput | undefined;
readonly databaseUrl?: unknown; readonly databaseUrl?: unknown;
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development; /** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */ * production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
+24 -83
View File
@@ -12,10 +12,10 @@ import {
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import { import {
createEnvironmentModuleSecretSource,
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
@@ -35,7 +35,6 @@ import { type ServerType, serve } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { import {
type ClientAddressConfigInput,
type ClientAddressResolver, type ClientAddressResolver,
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
@@ -719,98 +718,40 @@ export function installShutdownSignalHandlers(
process.on("SIGTERM", handleSignal); process.on("SIGTERM", handleSignal);
} }
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined { /**
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; * Turns one deployment environment into this process's startup inputs.
if (mode === undefined) { *
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/ * Every host infrastructure value comes from `loadHostConfiguration` — the same function
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally * `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
// half-configuring proxy mode by setting only one of the three variables. * it can never disagree about what this deployment is configured to use. Only the two values that
return undefined; * describe *this listener* rather than the host's infrastructure are read here.
} */
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
function readConfigFromEnvironment(): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0]; readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production"; readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
} { } {
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; const host = loadHostConfiguration(processEnvironment);
const trustedOrigins = trustedOriginsValue
?.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
const clientAddress = readClientAddressInputFromEnvironment();
return { return {
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: host.environment,
input: { input: {
...(clientAddress === undefined ? {} : { clientAddress }), clientAddress: host.clientAddress,
databaseTransport: { databaseTransport: host.databaseTransport,
...(process.env.EKKLESIA_DATABASE_CA === undefined databaseUrl: host.databaseUrl,
? {} host: processEnvironment.EKKLESIA_API_HOST,
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), identity: host.identity,
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined mail: host.mail,
? {} port: processEnvironment.EKKLESIA_API_PORT,
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), storage: host.storage,
},
databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST,
identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins,
},
port: process.env.EKKLESIA_API_PORT,
// The one place this process turns its environment into storage configuration. The names are
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
// validator never reads `process.env` itself.
// The one place this process turns its environment into mail configuration. Names follow the
// platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a provider's, and Core's
// validator never reads `process.env` itself. Absent host means this installation sends no
// outbound email, which is a complete deployment (ADR 0037).
mail: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
// Optional as a pair: a relay authorized by source IP has neither.
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
storage: {
accessKey: process.env.S3_ACCESS_KEY,
bucketPrefix: process.env.S3_BUCKET_PREFIX,
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
// storage by is not a name a browser could resolve.
deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT,
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
region: process.env.S3_REGION,
secretKey: process.env.S3_SECRET_KEY,
},
}, },
moduleSecrets: host.moduleSecrets,
}; };
} }
export async function main(): Promise<void> { export async function main(): Promise<void> {
const { environment, input } = readConfigFromEnvironment(); const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const config = createApiRuntimeConfig(input, { environment }); const config = createApiRuntimeConfig(input, { environment });
// The one place this process turns its environment into module secrets. Core's factory takes the const running = await startApi(config, { moduleSecrets });
// environment as an argument and never touches `process.env` itself, preserving the rule that
// only executables read the environment.
const running = await startApi(config, {
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
});
const address = running.server.address(); const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port; const boundPort = address && typeof address === "object" ? address.port : config.port;
@@ -0,0 +1,169 @@
import { loadHostConfiguration } from "@ekklesia/core/host";
import { createMailProviderConfig } from "@ekklesia/core/mail";
import { createStorageProviderConfig } from "@ekklesia/core/storage";
import { describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { readConfigFromEnvironment } from "../src/server.js";
/**
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
* previously could not: each executable read its own subset of the variables, and Doctor's subset
* omitted `S3_*` and the module secrets entirely.
*
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
* validates come from one `loadHostConfiguration` call and produce identical validated
* configuration, including every default.
*/
const secretKey = "sk-example-secret-material";
const smtpPassword = "sup3r-secret-relay-password";
function environment(
overrides: Readonly<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
return {
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
NODE_ENV: "production",
...overrides,
};
}
const configuredInfrastructure = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_USERNAME: "[email protected]",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_ENDPOINT: "https://storage.example.org",
S3_SECRET_KEY: secretKey,
});
/** Exactly what Doctor validates, from exactly the same load the API performs. */
function doctorView(env: Readonly<Record<string, string | undefined>>) {
const host = loadHostConfiguration(env);
return {
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
};
}
function apiView(env: Readonly<Record<string, string | undefined>>) {
const { environment: resolved, input } = readConfigFromEnvironment(env);
return createApiRuntimeConfig(input, { environment: resolved });
}
describe("host configuration parity between startApi and Doctor", () => {
it("derives identical object-storage configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).storage).toEqual(doctorView(env).storage);
// Named explicitly rather than only compared, so a change to either default fails here loudly.
expect(apiView(env).storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey,
});
});
it("derives identical outbound-email configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).mail).toEqual(doctorView(env).mail);
expect(apiView(env).mail).toMatchObject({
host: "smtp.example.org",
port: 587,
security: "starttls",
});
});
it("agrees that an installation with neither service is complete", () => {
const env = environment();
expect(apiView(env).storage).toBeUndefined();
expect(doctorView(env).storage).toBeUndefined();
expect(apiView(env).mail).toBeUndefined();
expect(doctorView(env).mail).toBeUndefined();
});
it("carries every storage field the direct-delivery pass established", () => {
const env = environment({
...configuredInfrastructure,
S3_BUCKET_PREFIX: "rogc",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_FORCE_PATH_STYLE: "false",
S3_REGION: "eu-west-1",
});
expect(apiView(env).storage).toEqual(doctorView(env).storage);
expect(apiView(env).storage).toMatchObject({
bucketPrefix: "rogc",
deliveryEndpoint: "https://files.example.org",
forcePathStyle: false,
region: "eu-west-1",
});
});
it("rejects the same malformed environment with the same sanitized code", () => {
for (const [overrides, code] of [
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
[
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
"MAIL_USERNAME_WITHOUT_PASSWORD",
],
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
] as const) {
const env = environment(overrides);
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
}
});
it("resolves module secrets from the same source the runtime is given", () => {
const env = environment({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
});
expect(
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
).toBe("module-secret-material");
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
});
it("reads only this listener's own address and port outside the shared loader", () => {
const config = apiView(
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
// Defaults, when unset, stay where they always were.
const defaults = apiView(environment());
expect(defaults.host).toBe("127.0.0.1");
expect(defaults.port).toBe(3000);
});
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
expect(() => apiView(env)).toThrowError(
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
);
});
});
+4
View File
@@ -50,6 +50,10 @@
"./mail": { "./mail": {
"types": "./dist/mail/index.d.ts", "types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js" "import": "./dist/mail/index.js"
},
"./host": {
"types": "./dist/host/index.d.ts",
"import": "./dist/host/index.js"
} }
}, },
"scripts": { "scripts": {
+14 -21
View File
@@ -4,35 +4,28 @@ import {
type DatabaseTransportPolicy, type DatabaseTransportPolicy,
createDatabaseTransportPolicy, createDatabaseTransportPolicy,
} from "../database/transport.js"; } from "../database/transport.js";
import { loadHostConfiguration } from "../host/index.js";
/** /**
* The one place every Core-owned executable reads its database transport policy from the process * The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and * `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`. * the process environment.
* *
* Environment reading lives here, in the executable layer, deliberately: the reusable Core * The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`. * call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string * point. Environment *reading* still happens here, in the executable layer: the reusable Core
* `"development"` relaxes anything, so an unset or misspelled value validates as production. * database and migration constructors keep taking explicit typed inputs and never touch
* `process.env`.
*/ */
export function readDatabaseTransportFromEnvironment(): { export function readDatabaseTransportFromEnvironment(): {
readonly environment: DatabaseTransportEnvironment; readonly environment: DatabaseTransportEnvironment;
readonly transport: DatabaseTransportPolicy; readonly transport: DatabaseTransportPolicy;
} { } {
const environment: DatabaseTransportEnvironment = const host = loadHostConfiguration(process.env);
process.env.NODE_ENV === "development" ? "development" : "production"; const transport = createDatabaseTransportPolicy(host.databaseTransport, {
const transport = createDatabaseTransportPolicy( environment: host.environment,
{ });
...(process.env.EKKLESIA_DATABASE_CA === undefined return { environment: host.environment, transport };
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
{ environment },
);
return { environment, transport };
} }
/** /**
+52 -64
View File
@@ -7,6 +7,7 @@ import {
doctorExitCode, doctorExitCode,
formatDoctorReport, formatDoctorReport,
} from "../diagnostics/index.js"; } from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
interface ModuleSdkPackageDocument { interface ModuleSdkPackageDocument {
readonly version?: unknown; readonly version?: unknown;
@@ -18,6 +19,18 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
}; };
} }
/**
* Everything Doctor needs that comes from the repository itself rather than the deployment
* environment, resolved by the caller so the environment mapping below stays pure and testable.
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
async function readJson(pathname: string): Promise<unknown> { async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown; return JSON.parse(await readFile(pathname, "utf8")) as unknown;
} }
@@ -36,40 +49,40 @@ function requiredString(value: unknown): string {
return value; return value;
} }
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { /**
const secret = process.env.EKKLESIA_IDENTITY_SECRET; * Turns one deployment environment into Doctor's inputs.
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL; *
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED; * Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; * with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
const trustedOrigins = trustedOriginsValue * deployment is configured to use. Doctor previously read its own subset here and silently omitted
? trustedOriginsValue * object storage and module secrets entirely, which made it report a correctly configured
.split(",") * installation as having neither.
.map((origin) => origin.trim()) *
.filter((origin) => origin.length > 0) * Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
: undefined; * malformed value appears in the report — which is the whole reason an operator runs this command —
* instead of throwing before a report exists.
*/
export function createDoctorOptions(
environment: HostEnvironment,
metadata: DoctorCommandMetadata,
): DoctorOptions {
const host = loadHostConfiguration(environment);
return { return {
...(baseUrl ? { baseUrl } : {}), clientAddress: host.clientAddress,
environment: process.env.NODE_ENV === "development" ? "development" : "production", databaseEnvironment: host.environment,
...(publicSignupEnabled ? { publicSignupEnabled } : {}), databaseTransport: host.databaseTransport,
...(secret ? { secret } : {}), databaseUrl: host.databaseUrl,
...(trustedOrigins ? { trustedOrigins } : {}), identityConfiguration: { ...host.identity, environment: host.environment },
}; mailConfiguration: host.mail,
} moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] { modulesDirectory: metadata.modulesDirectory,
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; platformVersion: metadata.platformVersion,
if (mode === undefined) { },
return undefined; moduleSecrets: host.moduleSecrets,
} runtimeRange: metadata.runtimeRange,
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; runtimeVersion: metadata.runtimeVersion,
return { storageConfiguration: host.storage,
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
}; };
} }
@@ -81,40 +94,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const rootPackage = rootPackageDocument(rootValue); const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const databaseEnvironment = const report = await diagnoseEkklesia(
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const); createDoctorOptions(process.env, {
const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseEnvironment,
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
// Raw, exactly like the identity settings above: Doctor validates them and reports a sanitized
// code, so a malformed SMTP value appears in the report instead of crashing the command.
mailConfiguration: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
moduleDiscovery: {
moduleSdkVersion: requiredString(moduleSdkPackage.version), moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"), modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version), platformVersion: requiredString(rootPackage.version),
}, runtimeRange: requiredString(rootPackage.engines?.node),
runtimeRange: requiredString(rootPackage.engines?.node), runtimeVersion: process.versions.node,
runtimeVersion: process.versions.node, }),
}); );
console.log(formatDoctorReport(report).join("\n")); console.log(formatDoctorReport(report).join("\n"));
return doctorExitCode(report); return doctorExitCode(report);
+14
View File
@@ -22,8 +22,22 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]), section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration", "object-storage.bindings"]), section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
section("Outbound email", ["outbound-email.configuration"]),
]); ]);
/**
* Every check id, exactly once.
*
* A check absent from `sections` is still computed and still counted toward the overall result, but
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
* cannot be added without being rendered.
*/
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
sections.flatMap((entry) => [...entry.checkIds]),
);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({ const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
failure: "✗", failure: "✗",
pass: "✓", pass: "✓",
+58 -17
View File
@@ -73,9 +73,12 @@ import {
} from "../modules/index.js"; } from "../modules/index.js";
import { import {
createS3StorageProvider, createS3StorageProvider,
createStorageProviderConfig,
describeStorageProvider, describeStorageProvider,
physicalBucketName, physicalBucketName,
StorageConfigError,
type StorageProviderConfig, type StorageProviderConfig,
type StorageProviderConfigInput,
} from "../storage/index.js"; } from "../storage/index.js";
export type DoctorCheckId = export type DoctorCheckId =
@@ -129,19 +132,27 @@ export interface DoctorOptions {
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions; readonly moduleDiscovery: ModuleDiscoveryOptions;
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret /** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required * matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
* secret as missing, which is the honest answer for a deployment that has not set it. */ * required secret as missing, which is only the honest answer for a deployment that has actually
* set none, so an executable must pass the same source it would give the runtime. */
readonly moduleSecrets?: ModuleSecretSource; readonly moduleSecrets?: ModuleSecretSource;
/** The validated host storage provider, or `undefined` when this installation has none. Doctor /**
* never receives raw environment values and never prints a credential. */ * The host's object-storage settings as the executable read them, validated here.
readonly storage?: StorageProviderConfig | undefined; *
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
* before a report existed, and — because the executable then had to validate it itself — meant
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
*/
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
/** /**
* The host's outbound mail settings as the executable read them, validated here. * The host's outbound mail settings as the executable read them, validated here.
* *
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`: * Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has * malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
* to be the one that validates it and reports the sanitized code. A pre-validated option would * be the one that validates it and reports the sanitized code. A pre-validated option would mean
* mean a bad value crashed the command instead of appearing in its report. * a bad value crashed the command instead of appearing in its report.
*/ */
readonly mailConfiguration?: MailProviderConfigInput | undefined; readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string; readonly runtimeRange: string;
@@ -851,6 +862,23 @@ export async function diagnoseEkklesia(
options: DoctorOptions, options: DoctorOptions,
dependencies: DoctorDependencies = productionDependencies, dependencies: DoctorDependencies = productionDependencies,
): Promise<DoctorReport> { ): Promise<DoctorReport> {
// Resolved first, before module eligibility is decided: whether a provider is configured is an
// input to whether a module that declares a bucket can run, so validating it later would mean
// deciding eligibility against a question that had not been answered yet.
const hostEnvironment =
options.databaseEnvironment === "development" ? "development" : "production";
let storageConfig: StorageProviderConfig | undefined;
let storageConfigurationError: string | undefined;
try {
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the endpoint, the access key, or the secret key.
storageConfigurationError =
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
}
let runtimeCheck: DoctorCheck; let runtimeCheck: DoctorCheck;
try { try {
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange) runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
@@ -960,7 +988,7 @@ export async function diagnoseEkklesia(
discoveredModules, discoveredModules,
configuration, configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings }, { secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: options.storage !== undefined }, { providerConfigured: storageConfig !== undefined },
); );
eligibilityEntries = resolution.entries; eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries); lifecycleCheck = moduleLifecycleCheck(resolution.entries);
@@ -1075,7 +1103,20 @@ export async function diagnoseEkklesia(
// whether modules run. Conflating them would let a momentary network blip read as a module // whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030). // problem (ADR 0030).
let storageCheck: DoctorCheck; let storageCheck: DoctorCheck;
if (!options.storage) { if (storageConfigurationError !== undefined) {
// A failure, unlike an unreachable provider: this is not a network condition an operator can
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
storageCheck = freezeCheck(
"object-storage.integration",
"failure",
"Object storage configuration invalid",
[
`The object-storage settings were rejected: ${storageConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
],
);
} else if (!storageConfig) {
storageCheck = freezeCheck( storageCheck = freezeCheck(
"object-storage.integration", "object-storage.integration",
"skipped", "skipped",
@@ -1084,14 +1125,14 @@ export async function diagnoseEkklesia(
); );
} else { } else {
try { try {
await dependencies.probeStorage(options.storage); await dependencies.probeStorage(storageConfig);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [ storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(options.storage), describeStorageProvider(storageConfig),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a // Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this // separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude // process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested. // direct delivery works when only half of it was tested.
...(options.storage.deliveryEndpoint === options.storage.endpoint ...(storageConfig.deliveryEndpoint === storageConfig.endpoint
? [] ? []
: [ : [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.", "The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
@@ -1106,7 +1147,7 @@ export async function diagnoseEkklesia(
"warning", "warning",
"Object storage unreachable", "Object storage unreachable",
[ [
describeStorageProvider(options.storage), describeStorageProvider(storageConfig),
"The configured storage service did not respond. Modules already running are unaffected.", "The configured storage service did not respond. Modules already running are unaffected.",
], ],
); );
@@ -1114,7 +1155,7 @@ export async function diagnoseEkklesia(
} }
const storageBindings = discoveredModules const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage) ? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [ : freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.", "Complete module discovery is required.",
]); ]);
@@ -1128,7 +1169,7 @@ export async function diagnoseEkklesia(
let mailConfigurationError: string | undefined; let mailConfigurationError: string | undefined;
try { try {
mailConfig = createMailProviderConfig(options.mailConfiguration, { mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: options.databaseEnvironment === "development" ? "development" : "production", environment: hostEnvironment,
}); });
} catch (error) { } catch (error) {
// The sanitized code only — never the host, the username, or the password. // The sanitized code only — never the host, the username, or the password.
+154
View File
@@ -0,0 +1,154 @@
import type { ModuleSecretSource } from "@ekklesia/contracts";
import type { DatabaseTransportPolicyInput } from "../database/index.js";
import type { ClientAddressConfigInput } from "../identity/index.js";
import type { MailProviderConfigInput } from "../mail/index.js";
import { createEnvironmentModuleSecretSource } from "../modules/index.js";
import type { StorageProviderConfigInput } from "../storage/index.js";
/**
* A deployment's environment, passed **as an argument**.
*
* `process.env` satisfies this, but nothing here reads it: the repository rule that only
* executables touch the process environment is preserved exactly as
* `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this
* module understands testable without mutating global state.
*/
export type HostEnvironment = Readonly<Record<string, string | undefined>>;
export type HostEnvironmentName = "development" | "production";
/**
* The identity settings as they appear in the environment — strings, unvalidated.
*
* Typed as `string | undefined` rather than `unknown` so this one object can be handed to both
* `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates
* it and reports the sanitized code) without either side re-deriving it.
*/
export interface HostIdentityEnvironmentInput {
readonly baseUrl: string | undefined;
readonly publicSignupEnabled: string | undefined;
readonly secret: string | undefined;
readonly trustedOrigins: readonly string[] | undefined;
}
/**
* Every host infrastructure input an Ekklesia executable resolves from its environment.
*
* Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but
* they do so at different moments and want different failure behaviour. `startApi` wants a
* malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this
* type were pre-validated, Doctor could never report a configuration error — the load would have
* thrown before a report existed.
*
* The API listener's own `host`/`port` are deliberately absent: they describe the API process, not
* the host infrastructure a diagnostic evaluates, and Doctor has no use for them.
*/
export interface HostConfiguration {
/** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value
* validates as production and never silently relaxes a security rule. */
readonly environment: HostEnvironmentName;
/** `undefined` when no mode is set at all, which selects direct mode. */
readonly clientAddress: ClientAddressConfigInput | undefined;
readonly databaseTransport: DatabaseTransportPolicyInput;
readonly databaseUrl: string | undefined;
readonly identity: HostIdentityEnvironmentInput;
readonly mail: MailProviderConfigInput;
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
* declared keys — there is no arbitrary variable lookup. */
readonly moduleSecrets: ModuleSecretSource;
readonly storage: StorageProviderConfigInput;
}
function splitList(value: string | undefined): readonly string[] | undefined {
if (value === undefined) {
return undefined;
}
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
/** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is
* actually present keeps an operator from half-configuring proxy mode by setting one of three
* variables. */
function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined {
const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
return undefined;
}
return {
header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES),
};
}
/**
* The one place any Ekklesia executable turns a deployment environment into host infrastructure
* configuration.
*
* It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read
* their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all,
* so a correctly configured installation was told its object storage was "not configured" and every
* module requiring a bucket was reported blocked — while `startApi`, from the identical environment,
* bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it
* diagnoses is worse than no diagnostic.
*
* So there is now exactly one mapping from variable names to typed inputs, and both executables call
* it. Validation stays where it already lives — `createIdentityConfig`,
* `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and
* is deliberately *not* performed here, so each caller keeps its own failure behaviour.
*
* Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned
* object carries the identity secret, both storage credentials, and the SMTP password.
*/
export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration {
return Object.freeze({
clientAddress: readClientAddress(environment),
databaseTransport: {
...(environment.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: environment.EKKLESIA_DATABASE_CA }),
...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: environment.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: environment.DATABASE_URL,
environment: environment.NODE_ENV === "development" ? "development" : "production",
identity: {
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: environment.EKKLESIA_IDENTITY_SECRET,
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
},
// Names follow the platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a
// provider's. Absent host means this installation sends no outbound email, which is a complete
// deployment (ADR 0037); username and password are optional as a pair, because a relay
// authorized by source IP has neither.
mail: {
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
host: environment.EKKLESIA_SMTP_HOST,
password: environment.EKKLESIA_SMTP_PASSWORD,
port: environment.EKKLESIA_SMTP_PORT,
security: environment.EKKLESIA_SMTP_SECURITY,
username: environment.EKKLESIA_SMTP_USERNAME,
},
moduleSecrets: createEnvironmentModuleSecretSource(environment),
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
// endpoint means this installation has no object storage, which is a complete deployment
// (ADR 0030).
storage: {
accessKey: environment.S3_ACCESS_KEY,
bucketPrefix: environment.S3_BUCKET_PREFIX,
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
// storage by is not a name a browser could resolve.
deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT,
endpoint: environment.S3_ENDPOINT,
forcePathStyle: environment.S3_FORCE_PATH_STYLE,
region: environment.S3_REGION,
secretKey: environment.S3_SECRET_KEY,
},
});
}
+7
View File
@@ -0,0 +1,7 @@
export {
type HostConfiguration,
type HostEnvironment,
type HostEnvironmentName,
type HostIdentityEnvironmentInput,
loadHostConfiguration,
} from "./configuration.js";
@@ -0,0 +1,425 @@
import type { ModuleManifest } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { createPermissionRegistry } from "../src/authorization/index.js";
import { createDoctorOptions } from "../src/cli/doctor.js";
import {
type DoctorCheck,
type DoctorDependencies,
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type {
DiscoveredModule,
ModuleMigrationAggregate,
ModuleSchemaAggregate,
} from "../src/modules/index.js";
/**
* The bug this suite exists for was operational, not theoretical: `pnpm doctor` built its own
* `DoctorOptions` and simply never included object storage or module secrets, so a correctly
* configured installation was told its storage was "not configured" and every module requiring a
* bucket was reported blocked — while `startApi`, from the identical environment, ran them.
*
* These tests therefore go through `createDoctorOptions`, the exact function the CLI calls, with an
* environment fixture rather than a hand-built options object. A helper-only test would have passed
* happily on the broken code.
*/
const databaseUrl = "postgres://[email protected]:5432/ekklesia";
const accessKey = "AKIAEXAMPLEACCESSID";
const secretKey = "sk-example-secret-material";
const smtpPassword = "sup3r-secret-relay-password";
const identitySecret = "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW";
const moduleSecret = "module-secret-material";
const metadata = Object.freeze({
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
function environment(
overrides: Readonly<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
return {
DATABASE_URL: databaseUrl,
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: identitySecret,
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
NODE_ENV: "production",
...overrides,
};
}
const storageEnvironment = Object.freeze({
S3_ACCESS_KEY: accessKey,
S3_ENDPOINT: "https://storage.example.org",
S3_SECRET_KEY: secretKey,
});
const mailEnvironment = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
});
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
return {
directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [],
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 6,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
version: "0.1.0",
...overrides,
} as unknown as ModuleManifest,
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
const storingModule = discoveredModule("announcements", {
storage: { buckets: [{ id: "announcement-assets" }] },
});
const secretRequiringModule = discoveredModule("announcements", {
configuration: [{ key: "storageCredential", required: true, type: "secret" }],
});
function schemaAggregate(): ModuleSchemaAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
drizzleSchema: Object.freeze({}),
modules: Object.freeze([]),
tables: Object.freeze([]),
});
}
function migrationAggregate(): ModuleMigrationAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze([]),
modules: Object.freeze([]),
});
}
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
return {
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
createPermissionRegistry(
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
),
),
createMigrationDatabase: vi.fn(() => {
throw new Error("not used");
}),
discoverMigrations: vi.fn(async () => migrationAggregate()),
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => {
throw new Error("not used");
}),
probeMail: vi.fn(async () => undefined),
probeStorage: vi.fn(async () => undefined),
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
runtimeSatisfies: vi.fn(() => true),
...overrides,
};
}
async function diagnose(
env: Readonly<Record<string, string | undefined>>,
services: DoctorDependencies = dependencies(),
): Promise<{ readonly report: Awaited<ReturnType<typeof diagnoseEkklesia>> }> {
return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) };
}
function check(
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
id: DoctorCheck["id"],
): DoctorCheck {
const found = report.checks.find((candidate) => candidate.id === id);
if (!found) {
throw new Error(`Missing test check ${id}.`);
}
return found;
}
describe("Doctor host-configuration parity — object storage", () => {
it("recognizes configured object storage from the deployment environment", async () => {
const services = dependencies();
const { report } = await diagnose(environment(storageEnvironment), services);
const integration = check(report, "object-storage.integration");
// The regression itself: before this pass the CLI passed no storage at all and this read
// "skipped · Object storage not configured" from exactly this environment.
expect(integration.status).toBe("pass");
expect(integration.summary).toBe("Object storage reachable");
expect(services.probeStorage).toHaveBeenCalledWith({
accessKey,
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey,
});
});
it("still reports genuinely absent storage as absent", async () => {
const services = dependencies();
const { report } = await diagnose(environment(), services);
const integration = check(report, "object-storage.integration");
expect(integration.status).toBe("skipped");
expect(integration.summary).toBe("Object storage not configured");
expect(services.probeStorage).not.toHaveBeenCalled();
});
it("reports a half-configured provider as a configuration failure, naming no value", async () => {
const services = dependencies();
const { report } = await diagnose(
environment({ S3_ENDPOINT: "https://storage.example.org" }),
services,
);
const integration = check(report, "object-storage.integration");
// `startApi` refuses to start on this input, so the deployment is genuinely not ready — this is
// a failure rather than the warning an unreachable-but-valid provider gets.
expect(integration.status).toBe("failure");
expect(integration.details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ACCESS_KEY_MISSING.",
);
expect(report.result).toBe("not-ready");
// Nothing was connected to: a malformed configuration must not produce network traffic.
expect(services.probeStorage).not.toHaveBeenCalled();
});
it("reports a malformed endpoint safely rather than crashing the command", async () => {
const { report } = await diagnose(
environment({ ...storageEnvironment, S3_ENDPOINT: "not-a-url" }),
);
const integration = check(report, "object-storage.integration");
expect(integration.status).toBe("failure");
expect(integration.details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ENDPOINT_INVALID.",
);
expect(JSON.stringify(integration)).not.toContain("not-a-url");
});
it("refuses a plaintext production endpoint exactly as startup would", async () => {
const { report } = await diagnose(
environment({ ...storageEnvironment, S3_ENDPOINT: "http://storage.example.org" }),
);
expect(check(report, "object-storage.integration").details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION.",
);
});
it("applies the same region and bucket-prefix defaults the runtime applies", async () => {
const services = dependencies();
await diagnose(environment(storageEnvironment), services);
expect(services.probeStorage).toHaveBeenCalledWith(
expect.objectContaining({
bucketPrefix: "ekklesia",
forcePathStyle: true,
region: "us-east-1",
}),
);
});
it("defaults the browser delivery origin to the endpoint, and flags a separate one", async () => {
const { report: shared } = await diagnose(environment(storageEnvironment));
expect(check(shared, "object-storage.integration").details).toHaveLength(1);
const { report: separate } = await diagnose(
environment({ ...storageEnvironment, S3_DELIVERY_ENDPOINT: "https://files.example.org" }),
);
const integration = check(separate, "object-storage.integration");
expect(integration.details[0]).toContain("browser delivery https://files.example.org");
expect(integration.details[1]).toContain("was not probed");
});
it("binds a module's declared bucket instead of reporting it blocked", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(environment(storageEnvironment), services);
// The operator-visible consequence of the bug: this module was reported blocked by a Doctor run
// against the very environment that ran it.
expect(check(report, "object-storage.bindings").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
});
it("still reports a bucket as unbound when no provider is configured", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(environment(), services);
expect(check(report, "object-storage.bindings").status).toBe("failure");
expect(check(report, "modules.lifecycle").details).toContain(
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
);
});
it("treats a malformed provider as unusable when deciding module eligibility", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(
environment({ S3_ENDPOINT: "https://storage.example.org" }),
services,
);
// A provider that cannot be constructed cannot bind a bucket, and saying otherwise would report
// a module as runnable that startup would refuse to start at all.
expect(check(report, "object-storage.bindings").status).toBe("failure");
});
});
describe("Doctor host-configuration parity — module secrets", () => {
it("sees a secret the deployment actually set", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
});
const { report } = await diagnose(
environment({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
}),
services,
);
const configuration = check(report, "modules.configuration");
// Same class of bug as storage: the CLI never passed a secret source, so a configured secret
// was always reported missing.
expect(configuration.status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
expect(JSON.stringify(report)).not.toContain(moduleSecret);
});
it("still reports a genuinely unset secret as missing", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
});
const { report } = await diagnose(environment(), services);
expect(check(report, "modules.configuration").status).toBe("warning");
expect(check(report, "modules.lifecycle").details).toContain(
"announcements 0.1.0 · blocked · secret_missing · storageCredential",
);
});
});
describe("Doctor host-configuration parity — outbound email regression", () => {
it("leaves an installation with no SMTP host reported as complete", async () => {
const services = dependencies();
const { report } = await diagnose(environment(), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("skipped");
expect(mailCheck.summary).toBe("Outbound email not configured");
expect(services.probeMail).not.toHaveBeenCalled();
});
it("leaves a configured relay reported as reachable, without its password", async () => {
const services = dependencies();
const { report } = await diagnose(environment(mailEnvironment), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("pass");
expect(services.probeMail).toHaveBeenCalledTimes(1);
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
});
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("failure");
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_PORT_INVALID.");
expect(JSON.stringify(mailCheck)).not.toContain("70000");
});
it("still refuses a plaintext production relay", async () => {
const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_SECURITY: "plaintext" }),
);
expect(check(report, "outbound-email.configuration").details[0]).toBe(
"The SMTP settings were rejected: MAIL_SECURITY_INSECURE_IN_PRODUCTION.",
);
});
});
describe("Doctor host-configuration parity — sanitization", () => {
it("prints no secret material anywhere in a fully configured report", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(
environment({
...mailEnvironment,
...storageEnvironment,
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
}),
services,
);
const rendered = formatDoctorReport(report).join("\n");
for (const secret of [accessKey, secretKey, smtpPassword, identitySecret, moduleSecret]) {
expect(rendered).not.toContain(secret);
expect(JSON.stringify(report)).not.toContain(secret);
}
// The infrastructure host names are deliberately still shown: they carry no credential and are
// what an operator needs in order to act on the report.
expect(rendered).toContain("https://storage.example.org");
expect(rendered).toContain("smtp.example.org");
});
it("never serializes the environment object wholesale", async () => {
const services = dependencies();
const { report } = await diagnose(
environment({ ...storageEnvironment, TOTALLY_UNRELATED_SECRET: "unrelated-value" }),
services,
);
expect(JSON.stringify(report)).not.toContain("unrelated-value");
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
});
});
+29
View File
@@ -3,6 +3,7 @@ import {
type DoctorDependencies, type DoctorDependencies,
diagnoseEkklesia, diagnoseEkklesia,
formatDoctorReport, formatDoctorReport,
RENDERED_DOCTOR_CHECK_IDS,
} from "../src/diagnostics/index.js"; } from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js"; import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js"; import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
@@ -123,9 +124,37 @@ Object storage
✓ No module requires object storage ✓ No module requires object storage
0 modules declare a logical storage bucket. 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`); Result: READY WITH WARNINGS`);
}); });
it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
// `outbound-email.configuration` was computed, counted toward the overall result, and never
// printed — so a rejected SMTP setting turned an operator's report NOT READY with nothing on
// screen saying why. Asserting the two sets against each other is what keeps a future check
// from repeating it.
expect([...RENDERED_DOCTOR_CHECK_IDS].sort()).toEqual(
report.checks.map((entry) => entry.id).sort(),
);
expect(new Set(RENDERED_DOCTOR_CHECK_IDS).size).toBe(RENDERED_DOCTOR_CHECK_IDS.length);
});
it("does not depend on NO_COLOR or terminal interactivity", async () => { it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
+4 -4
View File
@@ -1378,7 +1378,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const bindings = check(report, "object-storage.bindings"); const bindings = check(report, "object-storage.bindings");
@@ -1398,7 +1398,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const integration = check(report, "object-storage.integration"); const integration = check(report, "object-storage.integration");
@@ -1420,7 +1420,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const integration = check(report, "object-storage.integration"); const integration = check(report, "object-storage.integration");
@@ -1448,7 +1448,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
@@ -0,0 +1,153 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about where deployment configuration is read, which no runtime assertion can
* make.
*
* The defect this pass fixed was structural: two executables each read their own subset of the same
* environment variables, and the subsets drifted until `pnpm doctor` reported a correctly configured
* installation as having no object storage. Restoring parity once is not enough — the arrangement
* that allowed the drift has to stay gone.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readTypeScriptFiles(
directory: string,
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
/**
* Strips block comments and whole-line `//` comments.
*
* These assertions are about what the *code* does, and prose that explains a rule inevitably quotes
* the very token the rule forbids. Trailing comments are deliberately left alone so a URL inside a
* string literal is never truncated.
*/
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
/**
* Every variable the shared loader *interprets* — parses, defaults, splits, or classifies.
*
* `DATABASE_URL` is deliberately absent: it is passed through verbatim, and the small migration and
* owner-bootstrap commands legitimately read it directly. Interpretation is what drifts.
*/
const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_CLIENT_ADDRESS_HEADER",
"EKKLESIA_CLIENT_ADDRESS_MODE",
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
"EKKLESIA_DATABASE_CA",
"EKKLESIA_DATABASE_TLS_MODE",
"EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME",
"EKKLESIA_SMTP_HOST",
"EKKLESIA_SMTP_PASSWORD",
"EKKLESIA_SMTP_PORT",
"EKKLESIA_SMTP_SECURITY",
"EKKLESIA_SMTP_USERNAME",
"NODE_ENV",
"S3_ACCESS_KEY",
"S3_BUCKET_PREFIX",
"S3_DELIVERY_ENDPOINT",
"S3_ENDPOINT",
"S3_FORCE_PATH_STYLE",
"S3_REGION",
"S3_SECRET_KEY",
]);
describe("host configuration is read in exactly one place", () => {
it("never reads the process environment from the shared loader itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "host"))) {
// The loader is reusable Core code: it takes the environment as an argument, exactly as
// `createEnvironmentModuleSecretSource` does, so both executables can hand it theirs.
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("interprets each host infrastructure variable in exactly one file", async () => {
const sources = [
...(await readTypeScriptFiles(path.join("core", "src"))),
...(await readTypeScriptFiles(path.join("apps", "api", "src"))),
];
for (const file of sources) {
const code = withoutComments(file.text);
for (const variable of INTERPRETED_VARIABLES) {
// Reading one of these anywhere else means a second, independently drifting interpretation
// of the same deployment — which is exactly the defect this pass fixed. Operator-facing
// message text that *names* a variable is untouched: only an actual read is forbidden.
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env.${variable}`);
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env["${variable}"]`);
}
}
});
it("leaves the two executables reading nothing but their own listener address", async () => {
const doctorCli = withoutComments(
await readFile(path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"), "utf8"),
);
const server = withoutComments(
await readFile(path.join(repositoryRoot, "apps", "api", "src", "server.ts"), "utf8"),
);
for (const source of [doctorCli, server]) {
expect(source).toContain("loadHostConfiguration");
expect(source).not.toContain("DATABASE_URL");
}
// `EKKLESIA_API_HOST`/`EKKLESIA_API_PORT` describe this listener rather than the host's
// infrastructure, so Doctor has no use for them and they stay with the process that binds.
expect(server).toContain("EKKLESIA_API_HOST");
expect(doctorCli).not.toContain("EKKLESIA_API_");
});
it("reads the environment only from executables, never from reusable Core", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src"))) {
if (file.path.includes(`${path.sep}cli${path.sep}`)) {
continue;
}
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("gives Doctor the same object-storage and secret inputs the runtime receives", async () => {
const doctorCli = await readFile(
path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"),
"utf8",
);
// The two options whose absence was the bug. A future edit that drops either fails here rather
// than in a support conversation about storage that Doctor says is missing.
expect(doctorCli).toContain("storageConfiguration: host.storage");
expect(doctorCli).toContain("moduleSecrets: host.moduleSecrets");
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { createDatabaseTransportPolicy } from "../src/database/index.js";
import { loadHostConfiguration } from "../src/host/index.js";
import { createIdentityConfig } from "../src/identity/index.js";
import { createMailProviderConfig } from "../src/mail/index.js";
import { createStorageProviderConfig } from "../src/storage/index.js";
/**
* A complete, valid production deployment. Every optional service is present, because the bug this
* suite exists for was a *configured* service being reported absent — an empty environment would
* never have shown it.
*/
const productionEnvironment = Object.freeze({
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: "10.40.0.1, 10.40.0.2 ,",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org, https://display.example.org",
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
EKKLESIA_SMTP_PORT: "587",
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
NODE_ENV: "production",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_BUCKET_PREFIX: "ekklesia",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_ENDPOINT: "https://storage.example.org",
S3_FORCE_PATH_STYLE: "true",
S3_REGION: "us-east-1",
S3_SECRET_KEY: "sk-example-secret-material",
});
describe("host configuration loading", () => {
it("never reads the process environment, only the object it is given", () => {
// Proved by construction: an empty environment produces an unconfigured deployment even though
// this test process's own `process.env` may carry DATABASE_URL, S3_*, or EKKLESIA_SMTP_*.
const host = loadHostConfiguration({});
expect(host.databaseUrl).toBeUndefined();
expect(host.storage.endpoint).toBeUndefined();
expect(host.mail.host).toBeUndefined();
expect(host.identity.secret).toBeUndefined();
});
it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: "true",
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
});
it("carries every SMTP field, leaving credentials optional as a pair", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.mail).toEqual({
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
password: "sup3r-secret-relay-password",
port: "587",
security: "starttls",
username: "[email protected]",
});
const relayByAddress = loadHostConfiguration({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp-relay.example.org",
});
expect(relayByAddress.mail.username).toBeUndefined();
expect(relayByAddress.mail.password).toBeUndefined();
// A source-IP-authorized relay has no credentials at all, and that must still validate.
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
});
it("resolves module secrets only under names derived from a module's own declared key", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
expect(host.moduleSecrets.get("announcements", "unknownKey")).toBeUndefined();
// There is no accessor that takes a raw variable name, so a module can never reach these.
expect(host.moduleSecrets.get("announcements", "DATABASE_URL")).toBeUndefined();
});
it("treats only the exact string development as development", () => {
expect(loadHostConfiguration({ NODE_ENV: "development" }).environment).toBe("development");
for (const value of ["Development", "dev", "test", "", "production", undefined]) {
expect(loadHostConfiguration({ NODE_ENV: value }).environment).toBe("production");
}
});
it("omits client-address settings entirely unless a mode is set", () => {
expect(loadHostConfiguration({}).clientAddress).toBeUndefined();
// Half-configuring proxy mode by setting only the header must not silently enable it.
expect(
loadHostConfiguration({ EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for" }).clientAddress,
).toBeUndefined();
expect(loadHostConfiguration(productionEnvironment).clientAddress).toEqual({
header: undefined,
mode: "trusted-proxy",
trustedProxies: ["10.40.0.1", "10.40.0.2"],
});
});
it("omits database transport keys that are unset rather than passing undefined values", () => {
expect(loadHostConfiguration({}).databaseTransport).toEqual({});
expect(
loadHostConfiguration({
EKKLESIA_DATABASE_CA: "-----BEGIN CERTIFICATE-----",
EKKLESIA_DATABASE_TLS_MODE: "verify-full",
}).databaseTransport,
).toEqual({
certificateAuthority: "-----BEGIN CERTIFICATE-----",
mode: "verify-full",
});
});
it("splits comma-separated lists identically wherever they appear", () => {
const host = loadHostConfiguration({
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: " 10.0.0.1 ,, 10.0.0.2 ",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: " https://a.example.org ,, https://b.example.org ",
});
expect(host.identity.trustedOrigins).toEqual([
"https://a.example.org",
"https://b.example.org",
]);
expect(host.clientAddress?.trustedProxies).toEqual(["10.0.0.1", "10.0.0.2"]);
// A variable that is set but empty yields an empty list, not `undefined` — the two mean
// different things to the identity validator (`TRUSTED_ORIGINS_EMPTY` vs
// `TRUSTED_ORIGINS_MISSING`), and both executables must see the same one.
expect(
loadHostConfiguration({ EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "" }).identity.trustedOrigins,
).toEqual([]);
expect(loadHostConfiguration({}).identity.trustedOrigins).toBeUndefined();
});
it("passes a malformed value through unchanged rather than repairing or discarding it", () => {
// The loader deliberately does not validate: `startApi` wants a malformed value to abort
// startup and Doctor wants it reported, and neither can happen if the load already threw or
// silently dropped it.
const host = loadHostConfiguration({
EKKLESIA_PUBLIC_SIGNUP_ENABLED: "",
S3_ENDPOINT: "not-a-url",
});
expect(host.identity.publicSignupEnabled).toBe("");
expect(host.storage.endpoint).toBe("not-a-url");
expect(() => createStorageProviderConfig(host.storage)).toThrowError(/absolute http/u);
expect(() => createIdentityConfig(host.identity)).toThrowError();
});
it("is accepted unchanged by every Core validator that consumes it", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(createIdentityConfig(host.identity, { environment: host.environment })).toMatchObject({
baseUrl: "https://ekklesia.example.org",
publicSignupEnabled: false,
});
expect(
createDatabaseTransportPolicy(host.databaseTransport, { environment: host.environment }),
).toBeDefined();
expect(createStorageProviderConfig(host.storage, { environment: host.environment })).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
expect(createMailProviderConfig(host.mail, { environment: host.environment })).toMatchObject({
host: "smtp.example.org",
port: 587,
security: "starttls",
});
});
});
+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 () => { it("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
// Reusable Core code never touches `process.env`; the composition root turns environment into // The mail provider takes validated configuration and nothing else — it never reads the
// validated configuration and injects it. // environment, under any name.
for (const file of await readTextFiles("core/src/mail")) { 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("process.env");
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
} }
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8"); // The variable names live in the shared host-configuration loader, which both `startApi` and
expect(server).toContain("EKKLESIA_SMTP_HOST"); // `pnpm doctor` call with their own environment. Two executables mapping these names
expect(server).toContain("EKKLESIA_SMTP_PASSWORD"); // 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 () => { it("keeps no SMTP password out of the committed environment example", async () => {
@@ -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 complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
Pass 2. 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 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`, the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only `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`, 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 migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
`pnpm doctor`'s own connection — through one shared environment reader `pnpm doctor`'s own connection — because they all map the environment through the one shared host
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable configuration loader (`core/src/host/configuration.ts`, reached by the small migration commands via
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth `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 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 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, 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 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. 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 ## Logging
Transactional email carries verification links, reset links, and personal data. The transport logs 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 migrations, creates the Core history table, acquires the migration apply lock, starts module server
entrypoints, provisions storage, or invokes Docker. 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: Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range. - 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 - 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. origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
This check never constructs Better Auth or opens a database connection. 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 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 checks continue after a failure; a failed prerequisite causes only its dependent checks to be
@@ -102,18 +124,28 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ 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 Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
3 migrations · checksums valid 7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 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 PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped ○ Connection check skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Migration plan skipped ○ Migration plan skipped
@@ -123,10 +155,18 @@ Identity
○ Identity configuration skipped ○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). 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. 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 Object storage
○ Check unavailable ○ Object storage not configured
Core storage integration has not been implemented yet. 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 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 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 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 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 `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** — 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. 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, `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 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 schema and migration counts, and shows safe migration identities with truncated checksums. Schema