feat: declare module permissions

Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
This commit is contained in:
ekklesia
2026-07-24 12:25:00 -05:00
parent 79aaab93b6
commit d9dd409aa3
20 changed files with 765 additions and 52 deletions
+15 -15
View File
@@ -1,25 +1,18 @@
import {
type PermissionBaseline,
type PermissionDeclaration,
type PermissionId,
parsePermissionId,
safeParsePermissionId,
} from "@ekklesia/contracts";
/**
* A registered permission declaration: the trusted pairing of a permission identifier with its
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
* baseline is never client-supplied or guessed.
* 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 interface PermissionDeclaration {
readonly id: PermissionId;
readonly baseline: PermissionBaseline;
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
* by enforcement. */
readonly description: string;
}
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
export interface PermissionRegistry {
readonly declarations: readonly PermissionDeclaration[];
@@ -89,7 +82,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
"core.authorization.grants.manage",
);
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
/**
* 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.",
+12 -6
View File
@@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints {
readonly jobs?: string;
}
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
// which structurally satisfy `T extends object` — without this check first, the mapped-type
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
: T extends (...arguments_: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
+1
View File
@@ -1,4 +1,5 @@
export * from "./discovery.js";
export * from "./migration-discovery.js";
export * from "./permissions.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
+73
View File
@@ -0,0 +1,73 @@
import {
CORE_PERMISSION_DECLARATIONS,
createPermissionRegistry,
type PermissionDeclaration,
type PermissionRegistry,
} from "../authorization/index.js";
import type { DiscoveredModule } from "./discovery.js";
const CORE_PERMISSION_NAMESPACE_PREFIX = "core.";
export type ModulePermissionCompositionErrorCode = "MODULE_PERMISSION_RESERVED_NAMESPACE";
/** Thrown only at composition time, never per-request. Carries the offending module and
* permission id deliberately — like `PermissionRegistryError`, this is a startup-time
* configuration error, not a runtime response, and neither value is sensitive. Never carries a
* module directory, manifest path, caught error, or stack detail. */
export class ModulePermissionCompositionError extends Error {
readonly code: ModulePermissionCompositionErrorCode;
readonly moduleId: string;
readonly permissionId: string;
constructor(moduleId: string, permissionId: string) {
super(
`Module "${moduleId}" cannot declare permission "${permissionId}": the "core." namespace is reserved for Core's own permissions.`,
);
this.name = "ModulePermissionCompositionError";
this.code = "MODULE_PERMISSION_RESERVED_NAMESPACE";
this.moduleId = moduleId;
this.permissionId = permissionId;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Core's module-integration-layer composition helper: builds the one platform permission
* registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every already
* discovered module's manifest-declared permissions. Deliberately lives in `core/src/modules`,
* not `core/src/authorization` — the dependency runs one way only, this layer depends on
* authorization's `createPermissionRegistry`, and authorization never imports module discovery
* or runtime. This is pure, synchronous, in-memory composition over already-discovered,
* already-validated data: no filesystem, environment, database, network, or module-code
* execution happens here, and the caller (not this function) is responsible for having already
* produced a complete, successfully discovered module list (for example via `discoverModules`).
*
* Declaration order is deterministic and meaningful for duplicate detection: Core's own
* declarations first, then each module in the exact order it appears in `modules`, and within a
* module its declarations in manifest-array order. Duplicate exact permission ids — including a
* module colliding with a Core-owned id or with another module's — fail via the same
* `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; this
* function does not reimplement or duplicate that detection.
*
* `core.*` is reserved for Core's own permissions. A discovered module contributing any
* `core.`-prefixed permission id — regardless of that module's own id, and regardless of whether
* it happens to exactly collide with an existing declaration — fails composition with
* `ModulePermissionCompositionError` rather than silently registering or silently colliding.
*/
export function composePlatformPermissionRegistry(
modules: readonly DiscoveredModule[],
): PermissionRegistry {
const declarations: PermissionDeclaration[] = [...CORE_PERMISSION_DECLARATIONS];
for (const module of modules) {
for (const declaration of module.manifest.permissions) {
if (declaration.id.startsWith(CORE_PERMISSION_NAMESPACE_PREFIX)) {
throw new ModulePermissionCompositionError(module.manifest.id, declaration.id);
}
declarations.push(declaration);
}
}
return createPermissionRegistry(declarations);
}