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
@@ -10,10 +10,13 @@ import {
isUserActor,
parseAuthorizationScope,
parseGrantId,
parsePermissionDeclaration,
parsePermissionId,
type PermissionDeclaration,
type PermissionId,
safeParseAuthorizationScope,
safeParseGrantId,
safeParsePermissionDeclaration,
safeParsePermissionId,
SYSTEM_ACTOR,
} from "../src/index.js";
@@ -50,6 +53,87 @@ describe("PermissionId", () => {
});
});
describe("PermissionDeclaration", () => {
function validDeclaration(): Record<string, unknown> {
return {
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
};
}
it.each([
"public",
"authenticated",
"restricted",
] as const)("parses a well-formed declaration with baseline %s", (baseline) => {
const declaration = parsePermissionDeclaration({ ...validDeclaration(), baseline });
expect(declaration).toEqual({
id: "calendar.events.read",
baseline,
description: "Read calendar events.",
});
});
it("is exactly three fields — this is the one shape both a module manifest and Core's registry parse into", () => {
const declaration: PermissionDeclaration = parsePermissionDeclaration(validDeclaration());
expect(Object.keys(declaration).sort()).toEqual(["baseline", "description", "id"]);
});
it("rejects an unknown declaration property", () => {
const result = safeParsePermissionDeclaration({
...validDeclaration(),
scope: "calendar-event:123",
});
expect(result.success).toBe(false);
});
it("rejects a missing baseline", () => {
const { baseline: _baseline, ...withoutBaseline } = validDeclaration();
expect(safeParsePermissionDeclaration(withoutBaseline).success).toBe(false);
});
it("rejects an invalid baseline", () => {
expect(
safeParsePermissionDeclaration({ ...validDeclaration(), baseline: "superuser" }).success,
).toBe(false);
});
it("rejects a missing, empty, or whitespace-only description", () => {
const { description: _description, ...withoutDescription } = validDeclaration();
expect(safeParsePermissionDeclaration(withoutDescription).success).toBe(false);
expect(safeParsePermissionDeclaration({ ...validDeclaration(), description: "" }).success).toBe(
false,
);
expect(
safeParsePermissionDeclaration({ ...validDeclaration(), description: " " }).success,
).toBe(false);
});
it("rejects a description beyond the fixed maximum length", () => {
expect(
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(240) })
.success,
).toBe(true);
expect(
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(241) })
.success,
).toBe(false);
});
it("rejects a malformed permission id", () => {
expect(
safeParsePermissionDeclaration({ ...validDeclaration(), id: "not-namespaced" }).success,
).toBe(false);
});
it("never registers or evaluates anything — parsing only proves the value is well-formed", () => {
// Nothing here consults a registry: `parsePermissionDeclaration` succeeding says nothing
// about whether `calendar.events.read` is actually registered anywhere.
expect(() => parsePermissionDeclaration(validDeclaration())).not.toThrow();
});
});
describe("GrantId", () => {
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");