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]>
158 lines
7.7 KiB
TypeScript
158 lines
7.7 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { PermissionRegistry } from "@ekklesia/core/authorization";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
discoverModules,
|
|
type DiscoveredModule,
|
|
resolveModulesDirectory,
|
|
} from "@ekklesia/core/modules";
|
|
|
|
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
|
|
|
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
|
|
* content, or any other machine detail — a broken repository layout is a startup-time
|
|
* configuration problem, not something to describe in detail to whatever eventually logs it. */
|
|
export class PlatformPermissionConfigurationError extends Error {
|
|
readonly code: PlatformPermissionConfigurationErrorCode;
|
|
|
|
constructor() {
|
|
super("Module discovery configuration could not be resolved.");
|
|
this.name = "PlatformPermissionConfigurationError";
|
|
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the repository root from this compiled/source file's own location, never
|
|
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
|
|
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
|
|
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
|
|
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
|
|
* `..` segments resolves correctly in both.
|
|
*/
|
|
function resolveRepositoryRoot(): string {
|
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
}
|
|
|
|
interface PackageVersionFile {
|
|
readonly version?: unknown;
|
|
}
|
|
|
|
async function readPackageVersion(packagePath: string): Promise<string> {
|
|
let packageData: PackageVersionFile;
|
|
try {
|
|
packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile;
|
|
} catch {
|
|
throw new PlatformPermissionConfigurationError();
|
|
}
|
|
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
|
throw new PlatformPermissionConfigurationError();
|
|
}
|
|
return packageData.version;
|
|
}
|
|
|
|
export interface PrepareStartupModuleInventoryOptions {
|
|
/**
|
|
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
|
|
* A production input, not a test seam: the executable entry point always passes it. Unset means
|
|
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
|
|
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
|
|
* `ModuleDiscoveryError` before any pool exists.
|
|
*/
|
|
readonly configuredModulesDirectory?: string | undefined;
|
|
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
|
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
|
|
* Never set by the executable entry point. Platform and Module SDK versions are always read from
|
|
* the real repository's own `package.json` files regardless of this override. */
|
|
readonly modulesDirectoryOverride?: string;
|
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
|
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
|
* on it — and, if startup progresses that far, driving module runtime startup with this exact
|
|
* list too. Exists because a genuine exact permission-id collision between two modules is
|
|
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
|
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
|
* really-discovered modules can ever contribute the same permission id. Proving
|
|
* `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
|
|
* has created no pool before it runs, requires supplying deliberately conflicting declarations
|
|
* directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
|
|
* module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this
|
|
* case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the
|
|
* executable entry point.
|
|
*/
|
|
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
|
}
|
|
|
|
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
|
|
|
|
export interface StartupModuleInventory {
|
|
/** The exact discovered-module list — in discovery order — permission composition ran against.
|
|
* Production startup (`apps/api/src/server.ts`) passes this same list to
|
|
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
|
|
* second time. */
|
|
readonly modules: readonly DiscoveredModule[];
|
|
readonly permissionRegistry: PermissionRegistry;
|
|
}
|
|
|
|
/**
|
|
* Discovers every installed module's manifest exactly once and composes the one immutable platform
|
|
* permission registry (Core's own declarations plus every module's) from that exact discovered
|
|
* list — the production source of truth `apps/api` enforces every request against, and the exact
|
|
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
|
|
* module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
|
|
* platform and Module SDK versions only when this function actually executes, never at import
|
|
* time.
|
|
*/
|
|
export async function prepareStartupModuleInventory(
|
|
options: PrepareStartupModuleInventoryOptions = {},
|
|
): Promise<StartupModuleInventory> {
|
|
if (options.discoveredModulesOverride) {
|
|
const modules = options.discoveredModulesOverride;
|
|
return Object.freeze({
|
|
modules,
|
|
permissionRegistry: composePlatformPermissionRegistry(modules),
|
|
});
|
|
}
|
|
|
|
const repositoryRoot = resolveRepositoryRoot();
|
|
// Chosen before anything is read, so an unusable configured directory fails first and alone.
|
|
const modulesDirectory =
|
|
options.modulesDirectoryOverride === undefined
|
|
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
|
|
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
|
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
|
]);
|
|
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion,
|
|
...modulesDirectory,
|
|
platformVersion,
|
|
});
|
|
|
|
return Object.freeze({
|
|
modules,
|
|
permissionRegistry: composePlatformPermissionRegistry(modules),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compatible wrapper for callers — currently only existing tests — that need this package's
|
|
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
|
|
* Core-owned and never call into `apps/api` at all; they call Core's own
|
|
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
|
|
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
|
|
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
|
|
* than discarding it here and rediscovering modules a second time for runtime startup.
|
|
*/
|
|
export async function composeStartupPermissionRegistry(
|
|
options: ComposeStartupPermissionRegistryOptions = {},
|
|
): Promise<PermissionRegistry> {
|
|
const inventory = await prepareStartupModuleInventory(options);
|
|
return inventory.permissionRegistry;
|
|
}
|