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.
213 lines
8.0 KiB
TypeScript
213 lines
8.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
AUTHORIZATION_ALLOWED,
|
|
AUTHORIZATION_DENIED,
|
|
type AuthorizationActor,
|
|
type AuthorizationDecision,
|
|
createUserActor,
|
|
type GrantId,
|
|
isAuthorizationAllowed,
|
|
isUserActor,
|
|
parseAuthorizationScope,
|
|
parseGrantId,
|
|
parsePermissionDeclaration,
|
|
parsePermissionId,
|
|
type PermissionDeclaration,
|
|
type PermissionId,
|
|
safeParseAuthorizationScope,
|
|
safeParseGrantId,
|
|
safeParsePermissionDeclaration,
|
|
safeParsePermissionId,
|
|
SYSTEM_ACTOR,
|
|
} from "../src/index.js";
|
|
|
|
describe("PermissionId", () => {
|
|
it("parses a valid lowercase dot-namespaced permission id", () => {
|
|
expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read");
|
|
});
|
|
|
|
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
|
|
// @ts-expect-error a bare string must not satisfy the branded PermissionId type.
|
|
const notAllowed: PermissionId = "calendar.events.read";
|
|
expect(notAllowed).toBe("calendar.events.read");
|
|
});
|
|
|
|
it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => {
|
|
expect(() => parsePermissionId("read")).toThrow();
|
|
expect(() => parsePermissionId("")).toThrow();
|
|
expect(() => parsePermissionId("Calendar.Events.Read")).toThrow();
|
|
expect(() => parsePermissionId("x".repeat(161))).toThrow();
|
|
expect(() => parsePermissionId(42)).toThrow();
|
|
expect(() => parsePermissionId(null)).toThrow();
|
|
});
|
|
|
|
it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => {
|
|
expect(() => parsePermissionId("media.*")).toThrow();
|
|
expect(() => parsePermissionId("media.*.read")).toThrow();
|
|
expect(() => parsePermissionId("*")).toThrow();
|
|
});
|
|
|
|
it("exposes a safe-parse variant that never throws", () => {
|
|
expect(safeParsePermissionId("calendar.events.read").success).toBe(true);
|
|
expect(safeParsePermissionId("*").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
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");
|
|
expect(id).toBe("11111111-1111-1111-1111-111111111111");
|
|
expect(() => parseGrantId("")).toThrow();
|
|
expect(() => parseGrantId("x".repeat(129))).toThrow();
|
|
});
|
|
|
|
it("exposes a safe-parse variant that never throws", () => {
|
|
expect(safeParseGrantId("abc").success).toBe(true);
|
|
expect(safeParseGrantId("").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("AuthorizationScope", () => {
|
|
it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => {
|
|
expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123");
|
|
expect(safeParseAuthorizationScope("").success).toBe(false);
|
|
expect(safeParseAuthorizationScope(" ").success).toBe(false);
|
|
expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("AuthorizationActor", () => {
|
|
it("creates a frozen user actor carrying only the opaque user id", () => {
|
|
const actor = createUserActor("user_123");
|
|
expect(actor).toEqual({ type: "user", userId: "user_123" });
|
|
expect(Object.isFrozen(actor)).toBe(true);
|
|
expect(isUserActor(actor)).toBe(true);
|
|
});
|
|
|
|
it("rejects an invalid user id when creating a user actor", () => {
|
|
expect(() => createUserActor("")).toThrow();
|
|
expect(() => createUserActor(42)).toThrow();
|
|
});
|
|
|
|
it("exposes a frozen system actor singleton distinguishable from a user actor", () => {
|
|
expect(SYSTEM_ACTOR).toEqual({ type: "system" });
|
|
expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true);
|
|
expect(isUserActor(SYSTEM_ACTOR)).toBe(false);
|
|
});
|
|
|
|
it("discriminates actors on type without leaking extra fields", () => {
|
|
const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")];
|
|
for (const actor of actors) {
|
|
expect(["user", "system"]).toContain(actor.type);
|
|
}
|
|
expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]);
|
|
});
|
|
});
|
|
|
|
describe("AuthorizationDecision", () => {
|
|
it("exposes exactly two frozen, minimal decision singletons", () => {
|
|
expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true });
|
|
expect(AUTHORIZATION_DENIED).toEqual({ allowed: false });
|
|
expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true);
|
|
expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true);
|
|
expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]);
|
|
expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]);
|
|
});
|
|
|
|
it("never carries a reason, matched grant, permission echo, or any other detail", () => {
|
|
const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED];
|
|
for (const decision of decisions) {
|
|
expect(Object.keys(decision)).not.toContain("reason");
|
|
expect(Object.keys(decision)).not.toContain("grantId");
|
|
expect(Object.keys(decision)).not.toContain("permission");
|
|
expect(Object.keys(decision)).not.toContain("code");
|
|
}
|
|
});
|
|
|
|
it("exposes a boolean helper matching the allowed field", () => {
|
|
expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true);
|
|
expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false);
|
|
});
|
|
});
|