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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user