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
+67 -8
View File
@@ -31,10 +31,19 @@ permission registry, the bootstrap operation, and HTTP enforcement.
## Permission registry
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
matter:
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
interprets it.
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
declaration from a merely well-formed `PermissionId` string. Two properties matter:
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
@@ -44,14 +53,52 @@ matter:
and return `undefined`/`false` for anything malformed or simply not registered — there is no
"trust this permission id because it looks right" path.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
Permission matching is always exact string equality, both in the registry and in the evaluator's
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
same declarations exported separately in deeply immutable form, for the module composition helper
below to build on without reconstructing Core's own list:
| Permission | Baseline | Description |
| --- | --- | --- |
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
Module-owned permission declarations and a discovery/composition path for them are out of scope
for this slice (deferred to the Module SDK work).
## Module permission composition
A module manifest's `permissions` array (`manifestVersion: 3`,
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
composer: given an already-discovered module list (for example `discoverModules`'s result), it
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
not a second registry implementation. Declaration order is deterministic: Core first, then each
module in the order it appears in the supplied list, then each module's own declarations in
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
permission id must start with its own module's id, this is only reachable across two entries that
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
composition still re-derives the check rather than trusting that guarantee alone.
`core.*` is reserved for Core's own permissions. A discovered module contributing any
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
whether the id happens to exactly match an existing declaration.
Composition itself performs no filesystem, environment, database, network, or module-code
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
the other direction: authorization never imports module discovery or runtime, only the module
integration layer depends on authorization). This pass stops at producing the combined registry;
wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP
enforcement built on it, is later work.
### Two layers, not one: shape versus registration
@@ -287,7 +334,19 @@ there are or whether the failure happened during startup.
## Tests
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY`/`CORE_PERMISSION_DECLARATIONS` contents
and immutability.
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
rejection, malformed permission id rejection.
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
path, and default-empty-list isolation across separate parses.
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
module is absent from the supplied list, and input/output immutability with no I/O.
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
per-domain database tests).
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`