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