import { type PermissionDeclaration, type PermissionId, parsePermissionId, safeParsePermissionId, } from "@ekklesia/contracts"; /** * Core's registry consumes the one shared `PermissionDeclaration` shape * (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module * manifest's `permissions` array parses into the exact same type, so a declaration a module ships * and a declaration Core registers are provably interchangeable. Re-exported here so existing * `@ekklesia/core/authorization` consumers keep importing it from this package. */ export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts"; export interface PermissionRegistry { readonly declarations: readonly PermissionDeclaration[]; /** Returns the declaration for a permission id, or `undefined` for anything malformed or not * registered — the two are deliberately indistinguishable to callers, so "unknown permission" * and "invalid permission" fail closed the same way. */ get(permissionId: unknown): PermissionDeclaration | undefined; has(permissionId: unknown): boolean; } export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION"; /** Thrown only at composition time (registry construction), never per-request. Carries the * offending permission id deliberately — this is a startup-time programmer/configuration error * message, not a runtime response, and a namespaced permission id is not sensitive. */ export class PermissionRegistryError extends Error { readonly code: PermissionRegistryErrorCode; readonly permissionId: string; constructor(permissionId: string) { super(`Permission "${permissionId}" is already registered.`); this.name = "PermissionRegistryError"; this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION"; this.permissionId = permissionId; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** * Builds an immutable permission registry from a fixed list of declarations, rejecting any * duplicate permission id outright at composition time. Deliberately the smallest mechanism that * distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no * network or filesystem I/O. The module integration layer's composer * (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own * declarations with every discovered module's into one combined list and calls this same * constructor, inheriting duplicate detection for free rather than reimplementing it. */ export function createPermissionRegistry( declarations: readonly PermissionDeclaration[], ): PermissionRegistry { const byId = new Map(); for (const declaration of declarations) { if (byId.has(declaration.id)) { throw new PermissionRegistryError(declaration.id); } byId.set(declaration.id, Object.freeze({ ...declaration })); } const frozenDeclarations = Object.freeze([...byId.values()]); return Object.freeze({ declarations: frozenDeclarations, get(permissionId: unknown): PermissionDeclaration | undefined { const parsed = safeParsePermissionId(permissionId); return parsed.success ? byId.get(parsed.data) : undefined; }, has(permissionId: unknown): boolean { const parsed = safeParsePermissionId(permissionId); return parsed.success && byId.has(parsed.data); }, }); } /** Restricted: delegated grant administration is itself a privileged action, never public or * merely authenticated. A platform owner always passes the evaluator's existing owner bypass and * can delegate this permission to another account deliberately via the explicit-grant API. */ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( "core.authorization.grants.manage", ); /** * Authenticated: any signed-in user of this installation may learn which module UIs the platform is * currently hosting, because that is exactly what a browser shell has to render before it can offer * anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is * careful never to become an enumeration oracle, and an anonymous inventory would hand over the very * listing that route withholds. It is deliberately not `restricted` either: a shell that rendered * nothing until an operator granted every user this permission would make the platform unusable for * its ordinary users, and the module UIs themselves already gate their own content on their own * permissions. * * Listing a module UI is not permission to use that module. Every module route remains enforced * against its own declared permission, unchanged. */ export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read"); /** * Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in * user may do. It is deliberately a **platform** permission and never a module's own — grouping is * Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027). * * Holding it confers no module authority whatsoever. Moving a module into a folder changes a * presentation row; it grants nobody access to that module's data, and every module route stays * enforced against its own declared permission exactly as before. */ export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId( "core.dashboard.navigation.manage", ); /** * Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is * an administrative operation, and the read is not a milder version of it: the lifecycle view is the * only place a *disabled* module, a *blocked* module, and the required-dependency edges between them * are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in * user; this exposes the parts of the installation that are deliberately not running, which is not * the same audience. A second, weaker read permission over the same resource would be ceremony * (ADR 0027's reasoning, applied in the other direction). * * It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor * observe it: whether a module runs is a decision its host makes about it, not a decision it takes * part in (ADR 0028). Holding it confers no authority inside any module — every module route stays * enforced against that module's own declared permission. */ export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId( "core.modules.lifecycle.manage", ); /** * Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize * genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides * *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted * to take modules offline, and the reverse is equally true, so folding them into one permission * would grant authority nobody asked for in whichever direction it was used (ADR 0029). * * It gates reading as well as writing, because the read returns the module's declared schema * together with every stored value — endpoints, bucket names, and whatever else an operator has * configured. That is deployment detail, not something every signed-in user needs. * * **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never * persisted, and is never returned by any route; the API reports only whether one is configured. * Holding this permission therefore confers no ability to read a credential — there is no code path * that would serve one. * * Like every other Core permission it is a **platform** permission a module can neither declare, * gate, nor observe, and holding it confers no authority inside any module. */ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId( "core.modules.configuration.manage", ); /** * Restricted, and deliberately a **platform** permission rather than a module's. * * A device is an identity the platform issues, so deciding that a screen in the foyer may exist — * approving its pairing, seeing what has been paired, and taking it away again — is authority over * Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare * or gate it would mean whoever administers a slideshow could mint credentials that outlive it. * * It gates approval, listing, and revocation together. Splitting "see the paired devices" from * "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act * on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again). * * Holding it confers **no** authority inside any module and no ability to read a device credential * — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036). */ export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage"); /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from * exactly these plus every discovered module's declarations, in a fixed, deterministic order, * without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the * registry Core-only code and existing tests use directly. */ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([ Object.freeze({ baseline: "restricted" as const, description: "Create, list, and revoke another user's explicit authorization grants.", id: CORE_AUTHORIZATION_GRANTS_MANAGE, }), Object.freeze({ baseline: "authenticated" as const, description: "List the installed module UIs a browser host may mount.", id: CORE_MODULES_UI_READ, }), Object.freeze({ baseline: "restricted" as const, description: "Organize the Dashboard: create, rename, order, and delete navigation groups.", id: CORE_DASHBOARD_NAVIGATION_MANAGE, }), Object.freeze({ baseline: "restricted" as const, description: "Read and change which installed modules are enabled.", id: CORE_MODULES_LIFECYCLE_MANAGE, }), Object.freeze({ baseline: "restricted" as const, description: "Read and change an installed module's own configuration values.", id: CORE_MODULES_CONFIGURATION_MANAGE, }), Object.freeze({ baseline: "restricted" as const, description: "Approve display device pairing, list paired devices, and revoke a device.", id: CORE_DEVICES_MANAGE, }), ]); /** * Core's own permission registry, containing exactly the Core-owned permissions declared above. * Production `apps/api` does not consult this constant directly: it enforces requests against the * composed platform registry (`composePlatformPermissionRegistry`, * `core/src/modules/permissions.ts`), which merges these same declarations with every installed * module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's * permissions in isolation, without composing in any module. */ export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry( CORE_PERMISSION_DECLARATIONS, );