feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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]>
This commit is contained in:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+23 -3
View File
@@ -8,6 +8,7 @@ import {
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
/** Where the platform is installed: Core's migrations live beneath it, and so does the
* development default modules directory. Installed modules may live anywhere else. */
readonly repositoryRoot: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
return value;
}
/**
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
*/
function doctorModulesDirectory(
configured: string | undefined,
repositoryRoot: string,
): ModulesDirectorySelection {
try {
return resolveModulesDirectory({ configured, repositoryRoot });
} catch {
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
}
}
/**
* Turns one deployment environment into Doctor's inputs.
*
@@ -69,6 +88,7 @@ export function createDoctorOptions(
const host = loadHostConfiguration(environment);
return {
clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
@@ -76,7 +96,7 @@ export function createDoctorOptions(
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
modulesDirectory: metadata.modulesDirectory,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version),
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
+10 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { HostEnvironment } from "../host/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
readonly version?: unknown;
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version;
}
/**
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
* from the other.
*/
export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(),
environment: HostEnvironment = process.env,
): Promise<ModuleMigrationAggregate> {
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
},
});
+21 -3
View File
@@ -1,5 +1,9 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
import type {
MigrationApplyResult,
MigrationPlanResult,
RetainedMigrationHistory,
} from "../migrations/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
return Object.freeze(lines);
}
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
return retained.map(
(module) =>
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
);
}
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
const lines = [
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
];
for (const entry of plan.entries) {
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
if (plan.entries.length === 0) {
lines.push("- No installed module migrations.");
}
lines.push(...retainedLines(plan.retained));
lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
);
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) {
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
return Object.freeze([
"Migration apply completed: database is already current (no changes).",
...retainedLines(result.retained),
]);
}
const lines = [
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
}
lines.push(...retainedLines(result.retained));
return Object.freeze(lines);
}
@@ -0,0 +1,17 @@
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
/**
* The modules directory an operator command discovers, read through the same host configuration
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
*/
export function resolveCommandModulesDirectory(
environment: HostEnvironment,
repositoryRoot: string,
): ModulesDirectorySelection {
return resolveModulesDirectory({
configured: loadHostConfiguration(environment).modulesDirectory,
repositoryRoot,
});
}
+2 -1
View File
@@ -12,6 +12,7 @@ import {
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
/**
* Enables or disables one installed module, out of band from the running API.
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
// configurable here, which is the case this command exists for.
const discovered = await discoverModules({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
if (!discovered.some((module) => module.manifest.id === moduleId)) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
ModulePermissionCompositionError,
ModuleSchemaDiscoveryError,
} from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
version?: unknown;
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
]);
const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
const modules = schemaAggregate.discoveredModules;
+35 -17
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
@@ -43,6 +42,7 @@ import {
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
type DiscoverAllMigrationsOptions,
discoverAllMigrations,
type MigrationDatabase,
type MigrationPlanResult,
@@ -64,6 +64,7 @@ import {
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
/** Core's own migrations, where the platform is installed. Never derived from the modules
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
readonly coreMigrationsDirectory: string;
/** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase;
readonly discoverMigrations: (
options: ModuleDiscoveryOptions,
options: DiscoverAllMigrationsOptions,
) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: (
options: ModuleDiscoveryOptions,
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
async function discoverAllMigrationsForRuntime(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
return discoverAllMigrations({
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
moduleDiscovery: options,
});
}
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime(
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime,
discoverMigrations: discoverAllMigrations,
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
@@ -633,11 +628,23 @@ function storageBindingsCheck(
details,
);
}
function moduleDiscoveryFailureDetail(error: unknown): string {
if (!(error instanceof ModuleDiscoveryError)) {
return "Static module discovery could not be completed.";
}
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
// configured value: a path is machine detail.
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
return "The configured modules directory is not usable.";
}
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
return "The configured modules directory could not be read.";
}
return "The installed module inventory is invalid.";
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details =
error instanceof Error && error.name === "ModuleDiscoveryError"
? ["The installed module inventory is invalid."]
: ["Static module discovery could not be completed."];
const details = [moduleDiscoveryFailureDetail(error)];
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
}
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
}
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
}),
// An absent module's applied history (ADR 0048) is information, never attention: removing a
// module's code keeps its data, and nothing here is pending.
...plan.retained.map((module) => {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
throw new TypeError("Unsafe migration plan diagnostic value.");
}
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
}),
];
if (pendingCount + blockedCount === 0) {
@@ -1132,7 +1147,10 @@ export async function diagnoseEkklesia(
}
try {
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
migrations = await dependencies.discoverMigrations({
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum),
);
+5
View File
@@ -59,6 +59,10 @@ export interface HostConfiguration {
/** 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;
}
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
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).
+71 -9
View File
@@ -2,6 +2,7 @@ import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../modules/migration-discovery.js";
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
readonly tag: string;
}
/**
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
* its migrations were applied. It is reported, never matched against an inventory, and never a
* reason to refuse planning or applying anything else.
*/
export interface RetainedMigrationHistory {
readonly appliedCount: number;
readonly moduleId: string;
}
export interface MigrationPlanResult {
readonly appliedCount: number;
readonly approvalRequiredCount: number;
readonly entries: readonly MigrationPlanEntry[];
readonly pendingCount: number;
/** Absent modules' applied history, ordered by module id; empty when every applied migration
* belongs to Core or an installed module. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface AppliedMigrationResult {
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
readonly applied: readonly AppliedMigrationResult[];
readonly appliedCount: number;
readonly noOp: boolean;
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface PlanModuleMigrationsOptions {
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
}
}
interface ValidatedAppliedHistory {
/** Identities of applied migrations that belong to Core or an installed module. */
readonly applied: ReadonlySet<string>;
readonly retained: readonly RetainedMigrationHistory[];
}
/**
* Every module id this inventory treats as installed: each discovered module, whether or not it
* owns migrations, every module that contributed migrations, and always Core. A module counts as
* absent only when it is none of these, so a module that is installed but no longer ships its old
* migrations stays strict, and Core's history can never be classified as an absent module's.
*/
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
return new Set<string>([
CORE_MIGRATION_SOURCE_ID,
...aggregate.discoveredModules.map((module) => module.manifest.id),
...aggregate.modules.map((module) => module.moduleId),
]);
}
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
return Object.freeze(
[...counts.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
);
}
function validateAppliedHistoryUnchecked(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
const inventory = new Map(
aggregate.migrations.map((migration) => [migration.identity, migration]),
);
const installed = installedMigrationSources(aggregate);
const applied = new Set<string>();
const retained = new Map<string, number>();
const seenOrders = new Set<number>();
const seenIdentities = new Set<string>();
const seenModuleIndexes = new Set<string>();
@@ -240,7 +287,19 @@ function validateAppliedHistoryUnchecked(
const migration = knownMigration(inventory, record.identity);
if (!migration) {
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
if (installed.has(record.moduleId)) {
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
// established. A migration it no longer ships is a downgrade or a rewritten package.
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
}
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
// matched against anything. It must still name its own module and tag, so a corrupt row is
// never mistaken for one.
if (record.identity !== `${record.moduleId}:${record.tag}`) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
continue;
}
if (
record.moduleId !== migration.moduleId ||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
if (record.checksum !== migration.checksum) {
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
}
applied.add(record.identity);
}
for (const module of aggregate.modules) {
const applied = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of applied.entries()) {
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of moduleHistory.entries()) {
if (record.identity !== module.migrations[position]?.identity) {
const migration = knownMigration(inventory, record.identity);
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
}
}
return seenIdentities;
return Object.freeze({ applied, retained: freezeRetained(retained) });
}
function validateAppliedHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
try {
return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) {
@@ -285,7 +345,7 @@ function planFromHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): MigrationPlanResult {
const applied = validateAppliedHistory(aggregate, history);
const { applied, retained } = validateAppliedHistory(aggregate, history);
const entries = Object.freeze(
aggregate.migrations.map((migration): MigrationPlanEntry => {
const isApplied = applied.has(migration.identity);
@@ -314,6 +374,7 @@ function planFromHistory(
approvalRequiredCount,
entries,
pendingCount: entries.length - appliedCount,
retained,
});
}
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
} catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
}
const applied = validateAppliedHistory(options.aggregate, history);
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
const pending = Object.freeze(
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
);
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
if (pending.length === 0) {
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
}
let executingIdentity: string | undefined;
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
applied: resultMigrations,
appliedCount: resultMigrations.length,
noOp: false,
retained,
});
} catch (error) {
primaryError = error;
+35 -1
View File
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_PLATFORM_INCOMPATIBLE"
| "MODULE_SDK_INCOMPATIBLE"
| "MODULE_VERSION_MISMATCH"
| "MODULES_DIRECTORY_INVALID"
| "MODULES_DIRECTORY_UNREADABLE";
export interface ModuleDiscoveryIssue {
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
export interface ModuleDiscoveryOptions {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
/**
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
* A configured directory that is missing, not a directory, or not an absolute path then fails
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
* the platform with nothing installed. False or omitted keeps the development default lenient,
* where a checkout with no `modules/` simply has no modules.
*/
readonly modulesDirectoryRequired?: boolean;
readonly platformVersion: string;
}
/** The one sanitized message for an unusable configured modules directory. It never carries the
* value: a path is machine detail, and a misplaced value could be anything at all. */
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
/**
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
*/
export function isUsableModulesDirectoryPath(value: string): boolean {
return (
value.length > 0 &&
value.trim() === value &&
!value.includes("\u0000") &&
path.isAbsolute(value)
);
}
interface ModulePackageData {
name: string;
version: string;
@@ -695,6 +722,13 @@ export async function discoverModules(
});
}
}
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
issues.push({
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
});
}
if (issues.length > 0) {
throw new ModuleDiscoveryError(issues);
}
@@ -703,7 +737,7 @@ export async function discoverModules(
try {
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
} catch (error) {
if (errorCode(error) === "ENOENT") {
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
return Object.freeze([]);
}
throw new ModuleDiscoveryError([
+1
View File
@@ -7,6 +7,7 @@ export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js";
+66
View File
@@ -0,0 +1,66 @@
import path from "node:path";
import {
isUsableModulesDirectoryPath,
MODULES_DIRECTORY_INVALID_MESSAGE,
ModuleDiscoveryError,
} from "./discovery.js";
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
export interface ResolveModulesDirectoryOptions {
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
* variable is unset. */
readonly configured: string | undefined;
/** Where the platform itself is installed. Only consulted for the development default. */
readonly repositoryRoot: string;
}
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
* selection, including whether a missing directory is an error. */
export interface ModulesDirectorySelection {
readonly modulesDirectory: string;
readonly modulesDirectoryRequired: boolean;
}
/**
* The one place an installation's modules directory is chosen (ADR 0048).
*
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
* one has no modules, exactly as before.
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
* the working directory — and required to exist when discovery reads it. The shipping image sets
* it to `/var/lib/ekklesia/modules`.
*
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
* message, so every executable already reports it through the discovery error path it has, and the
* supplied value never reaches an error, a log, or a report.
*
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
*/
export function resolveModulesDirectory(
options: ResolveModulesDirectoryOptions,
): ModulesDirectorySelection {
if (options.configured === undefined) {
return Object.freeze({
modulesDirectory: path.join(options.repositoryRoot, "modules"),
modulesDirectoryRequired: false,
});
}
if (!isUsableModulesDirectoryPath(options.configured)) {
throw new ModuleDiscoveryError([
{
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
},
]);
}
const normalized = path.normalize(options.configured);
return Object.freeze({
modulesDirectory:
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
modulesDirectoryRequired: true,
});
}