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");
@@ -4,12 +4,13 @@ import {
MODULE_LIFECYCLE_STAGES,
packageRelativePathSchema,
parseModuleManifest,
parsePermissionId,
safeParseModuleManifest,
} from "../src/index.js";
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 2,
manifestVersion: 3,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -18,7 +19,13 @@ function validManifest(): Record<string, unknown> {
moduleSdk: "^0.1.0",
},
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
permissions: ["calendar.events.read"],
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
],
storage: {
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
},
@@ -45,16 +52,23 @@ describe("module manifest contract", () => {
it("creates isolated default collections for every parsed manifest", () => {
const input = validManifest();
delete input.storage;
delete input.permissions;
const first = parseModuleManifest(input);
const second = parseModuleManifest(input);
first.dependencies.modules.required.people = "^0.1.0";
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
first.storage.buckets.push({ id: "exports" });
first.permissions.push({
id: parsePermissionId("calendar.events.write"),
baseline: "restricted",
description: "Write calendar events.",
});
expect(second.dependencies.modules.required).toEqual({});
expect(second.dependencies.capabilities.required).toEqual({});
expect(second.storage.buckets).toEqual([]);
expect(second.permissions).toEqual([]);
});
it("rejects unknown and presentation-oriented manifest properties", () => {
@@ -68,14 +82,16 @@ describe("module manifest contract", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "media.items", version: "1.0.0" }],
permissions: ["media.items.read"],
permissions: [
{ id: "media.items.read", baseline: "restricted", description: "Read media items." },
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["capabilities.0.id", "permissions.0"]),
expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]),
);
}
});
@@ -107,6 +123,32 @@ describe("module manifest contract", () => {
}
});
it("rejects a duplicate permission id at its exact issue path", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
{
id: "calendar.events.read",
baseline: "authenticated",
description: "Read calendar events, again.",
},
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"permissions.1.id",
);
}
});
it("rejects module and capability self-dependencies", () => {
const manifest = {
...validManifest(),
@@ -151,6 +193,113 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
});
it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => {
// manifestVersion 2's own permissions shape (a bare string array) is also what version 3
// must reject — this manifest is invalid twice over, deliberately, since a real version 2
// manifest is exactly this shape.
const version2Manifest = {
...validManifest(),
manifestVersion: 2,
permissions: ["calendar.events.read"],
};
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
});
it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("defaults to an empty permission list when omitted", () => {
const input = validManifest();
delete input.permissions;
const manifest = parseModuleManifest(input);
expect(manifest.permissions).toEqual([]);
});
it.each([
"public",
"authenticated",
"restricted",
] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => {
const manifest = parseModuleManifest({
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }],
});
expect(manifest.permissions).toEqual([
{ id: "calendar.events.read", baseline, description: "Read events." },
]);
});
it("rejects a permission declaration with a missing baseline", () => {
const manifest = {
...validManifest(),
permissions: [{ id: "calendar.events.read", description: "Read events." }],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an invalid baseline", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "superuser", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with a missing or blank description", () => {
const missingDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted" }],
};
const blankDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }],
};
expect(safeParseModuleManifest(missingDescription).success).toBe(false);
expect(safeParseModuleManifest(blankDescription).success).toBe(false);
});
it("rejects a permission declaration with an oversized description", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an unknown property", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read events.",
scope: "calendar-event:123",
},
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a malformed permission id", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
"../outside.ts",
"/absolute.ts",