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, } 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 { 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 { /** Test-only escape hatch: overrides the directory module discovery scans for installed * modules. Never set by the executable entry point — production always discovers the real * repository's `modules/` directory. 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 { if (options.discoveredModulesOverride) { const modules = options.discoveredModulesOverride; return Object.freeze({ modules, permissionRegistry: composePlatformPermissionRegistry(modules), }); } const repositoryRoot = resolveRepositoryRoot(); 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: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"), 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 { const inventory = await prepareStartupModuleInventory(options); return inventory.permissionRegistry; }