Files
Ekklesia/apps/api/src/platform-permissions.ts
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
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
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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]>
2026-10-07 08:38:14 -05:00

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;
}