CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
168 lines
8.3 KiB
TypeScript
168 lines
8.3 KiB
TypeScript
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;
|
|
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
|
|
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
|
|
readonly dashboardOrigin: 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;
|
|
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
|
|
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
|
|
* so a relative or blank value is refused there rather than repaired here. */
|
|
readonly modulesDirectory: string | undefined;
|
|
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,
|
|
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
|
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,
|
|
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
|
|
// matters only where the machine's own hostname is not a fully qualified name the relay will
|
|
// accept.
|
|
heloName: environment.EKKLESIA_SMTP_HELO_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),
|
|
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
|
|
// 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,
|
|
},
|
|
});
|
|
}
|