feat: integrate module authorization #11
@@ -1,25 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
type PermissionBaseline,
|
type PermissionDeclaration,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A registered permission declaration: the trusted pairing of a permission identifier with its
|
* Core's registry consumes the one shared `PermissionDeclaration` shape
|
||||||
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
|
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
|
||||||
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
|
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
|
||||||
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
|
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
|
||||||
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
|
* `@ekklesia/core/authorization` consumers keep importing it from this package.
|
||||||
* baseline is never client-supplied or guessed.
|
|
||||||
*/
|
*/
|
||||||
export interface PermissionDeclaration {
|
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
|
||||||
readonly id: PermissionId;
|
|
||||||
readonly baseline: PermissionBaseline;
|
|
||||||
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
|
|
||||||
* by enforcement. */
|
|
||||||
readonly description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionRegistry {
|
export interface PermissionRegistry {
|
||||||
readonly declarations: readonly PermissionDeclaration[];
|
readonly declarations: readonly PermissionDeclaration[];
|
||||||
@@ -89,7 +82,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
|||||||
"core.authorization.grants.manage",
|
"core.authorization.grants.manage",
|
||||||
);
|
);
|
||||||
|
|
||||||
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
/**
|
||||||
|
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||||
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||||
|
* exactly these plus every discovered module's declarations, in a fixed, deterministic order,
|
||||||
|
* without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the
|
||||||
|
* registry Core-only code and existing tests use directly.
|
||||||
|
*/
|
||||||
|
export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
baseline: "restricted" as const,
|
baseline: "restricted" as const,
|
||||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||||
|
|||||||
@@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints {
|
|||||||
readonly jobs?: string;
|
readonly jobs?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown
|
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||||
|
// which structurally satisfy `T extends object` — without this check first, the mapped-type
|
||||||
|
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
|
||||||
|
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
|
||||||
|
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
|
||||||
? T
|
? T
|
||||||
: T extends readonly (infer Item)[]
|
: T extends (...arguments_: never[]) => unknown
|
||||||
? readonly DeepReadonly<Item>[]
|
? T
|
||||||
: T extends object
|
: T extends readonly (infer Item)[]
|
||||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
? readonly DeepReadonly<Item>[]
|
||||||
: T;
|
: T extends object
|
||||||
|
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||||
|
: T;
|
||||||
|
|
||||||
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
|
export * from "./permissions.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
export * from "./schema-discovery.js";
|
export * from "./schema-discovery.js";
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
|
createPermissionRegistry,
|
||||||
|
type PermissionDeclaration,
|
||||||
|
type PermissionRegistry,
|
||||||
|
} from "../authorization/index.js";
|
||||||
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
|
const CORE_PERMISSION_NAMESPACE_PREFIX = "core.";
|
||||||
|
|
||||||
|
export type ModulePermissionCompositionErrorCode = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||||
|
|
||||||
|
/** Thrown only at composition time, never per-request. Carries the offending module and
|
||||||
|
* permission id deliberately — like `PermissionRegistryError`, this is a startup-time
|
||||||
|
* configuration error, not a runtime response, and neither value is sensitive. Never carries a
|
||||||
|
* module directory, manifest path, caught error, or stack detail. */
|
||||||
|
export class ModulePermissionCompositionError extends Error {
|
||||||
|
readonly code: ModulePermissionCompositionErrorCode;
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly permissionId: string;
|
||||||
|
|
||||||
|
constructor(moduleId: string, permissionId: string) {
|
||||||
|
super(
|
||||||
|
`Module "${moduleId}" cannot declare permission "${permissionId}": the "core." namespace is reserved for Core's own permissions.`,
|
||||||
|
);
|
||||||
|
this.name = "ModulePermissionCompositionError";
|
||||||
|
this.code = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||||
|
this.moduleId = moduleId;
|
||||||
|
this.permissionId = permissionId;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's module-integration-layer composition helper: builds the one platform permission
|
||||||
|
* registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every already
|
||||||
|
* discovered module's manifest-declared permissions. Deliberately lives in `core/src/modules`,
|
||||||
|
* not `core/src/authorization` — the dependency runs one way only, this layer depends on
|
||||||
|
* authorization's `createPermissionRegistry`, and authorization never imports module discovery
|
||||||
|
* or runtime. This is pure, synchronous, in-memory composition over already-discovered,
|
||||||
|
* already-validated data: no filesystem, environment, database, network, or module-code
|
||||||
|
* execution happens here, and the caller (not this function) is responsible for having already
|
||||||
|
* produced a complete, successfully discovered module list (for example via `discoverModules`).
|
||||||
|
*
|
||||||
|
* Declaration order is deterministic and meaningful for duplicate detection: Core's own
|
||||||
|
* declarations first, then each module in the exact order it appears in `modules`, and within a
|
||||||
|
* module its declarations in manifest-array order. Duplicate exact permission ids — including a
|
||||||
|
* module colliding with a Core-owned id or with another module's — fail via the same
|
||||||
|
* `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; this
|
||||||
|
* function does not reimplement or duplicate that detection.
|
||||||
|
*
|
||||||
|
* `core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||||
|
* `core.`-prefixed permission id — regardless of that module's own id, and regardless of whether
|
||||||
|
* it happens to exactly collide with an existing declaration — fails composition with
|
||||||
|
* `ModulePermissionCompositionError` rather than silently registering or silently colliding.
|
||||||
|
*/
|
||||||
|
export function composePlatformPermissionRegistry(
|
||||||
|
modules: readonly DiscoveredModule[],
|
||||||
|
): PermissionRegistry {
|
||||||
|
const declarations: PermissionDeclaration[] = [...CORE_PERMISSION_DECLARATIONS];
|
||||||
|
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const declaration of module.manifest.permissions) {
|
||||||
|
if (declaration.id.startsWith(CORE_PERMISSION_NAMESPACE_PREFIX)) {
|
||||||
|
throw new ModulePermissionCompositionError(module.manifest.id, declaration.id);
|
||||||
|
}
|
||||||
|
declarations.push(declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPermissionRegistry(declarations);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
createPermissionRegistry,
|
createPermissionRegistry,
|
||||||
PermissionRegistryError,
|
PermissionRegistryError,
|
||||||
@@ -92,3 +93,21 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||||
|
it("is Core's own declaration list, exported separately from the registry in deeply immutable form", () => {
|
||||||
|
expect(CORE_PERMISSION_DECLARATIONS).toEqual([
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||||
|
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||||
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
|
||||||
|
expect(CORE_PERMISSION_DECLARATIONS).toEqual(CORE_PERMISSION_REGISTRY.declarations);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -40,7 +40,9 @@ function manifestFor(id: string): Record<string, unknown> {
|
|||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
||||||
permissions: [`${id}.records.read`],
|
permissions: [
|
||||||
|
{ id: `${id}.records.read`, baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
@@ -151,6 +153,16 @@ describe("module discovery", () => {
|
|||||||
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
||||||
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
||||||
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
||||||
|
|
||||||
|
expect(modules[1]?.manifest.permissions).toEqual([
|
||||||
|
{ id: "calendar.records.read", baseline: "restricted", description: "Read records." },
|
||||||
|
]);
|
||||||
|
expect(Object.isFrozen(modules[1]?.manifest.permissions)).toBe(true);
|
||||||
|
expect(Object.isFrozen(modules[1]?.manifest.permissions[0])).toBe(true);
|
||||||
|
const mutablePermissions = modules[1]?.manifest.permissions as unknown as Array<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
expect(() => mutablePermissions.push({ id: "tampered" })).toThrow(TypeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
||||||
@@ -215,7 +227,9 @@ describe("module discovery", () => {
|
|||||||
...manifestFor("one"),
|
...manifestFor("one"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: [
|
||||||
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
name: "@test/duplicate",
|
name: "@test/duplicate",
|
||||||
@@ -229,7 +243,9 @@ describe("module discovery", () => {
|
|||||||
...manifestFor("two"),
|
...manifestFor("two"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: [
|
||||||
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
name: "@test/duplicate",
|
name: "@test/duplicate",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
|
PermissionRegistryError,
|
||||||
|
} from "../src/authorization/index.js";
|
||||||
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
ModulePermissionCompositionError,
|
||||||
|
} from "../src/modules/permissions.js";
|
||||||
|
|
||||||
|
interface PermissionInput {
|
||||||
|
readonly id: string;
|
||||||
|
readonly baseline: PermissionBaseline;
|
||||||
|
readonly description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, in-memory `DiscoveredModule` double — no filesystem, no real discovery. The manifest
|
||||||
|
* is still built through `parseModuleManifest`, so a fixture that would not actually pass manifest
|
||||||
|
* validation cannot silently misrepresent what real discovery would ever produce. Only `manifest`
|
||||||
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
manifestVersion: 3,
|
||||||
|
id,
|
||||||
|
name: `${id} module`,
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||||
|
permissions,
|
||||||
|
entrypoints: {
|
||||||
|
// Deliberately a path nothing will ever resolve, proving composition never imports or
|
||||||
|
// otherwise touches a module's server entrypoint.
|
||||||
|
server: "./dist/does-not-exist/index.js",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
directory: `/modules/${id}`,
|
||||||
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
|
manifest,
|
||||||
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||||
|
packageName: `@test/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("composePlatformPermissionRegistry", () => {
|
||||||
|
it("composes a Core-only registry when no modules are supplied", () => {
|
||||||
|
const registry = composePlatformPermissionRegistry([]);
|
||||||
|
|
||||||
|
expect(registry.declarations).toEqual(CORE_PERMISSION_DECLARATIONS);
|
||||||
|
expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes Core and multiple discovered modules' declarations into one registry", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
const people = fakeModule("people", [
|
||||||
|
{ id: "people.directory.read", baseline: "authenticated", description: "Read directory." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registry = composePlatformPermissionRegistry([calendar, people]);
|
||||||
|
|
||||||
|
expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true);
|
||||||
|
expect(registry.has("calendar.events.read")).toBe(true);
|
||||||
|
expect(registry.has("people.directory.read")).toBe(true);
|
||||||
|
expect(registry.declarations).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
{ id: "calendar.events.write", baseline: "restricted", description: "Write events." },
|
||||||
|
]);
|
||||||
|
const people = fakeModule("people", [
|
||||||
|
{ id: "people.directory.read", baseline: "authenticated", description: "Read directory." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registry = composePlatformPermissionRegistry([calendar, people]);
|
||||||
|
|
||||||
|
expect(registry.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
"calendar.events.read",
|
||||||
|
"calendar.events.write",
|
||||||
|
"people.directory.read",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"public",
|
||||||
|
"authenticated",
|
||||||
|
"restricted",
|
||||||
|
] as const)("preserves the exact %s baseline and description of a module declaration", (baseline) => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline, description: "Read calendar events." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registry = composePlatformPermissionRegistry([calendar]);
|
||||||
|
|
||||||
|
expect(registry.get("calendar.events.read")).toEqual({
|
||||||
|
id: "calendar.events.read",
|
||||||
|
baseline,
|
||||||
|
description: "Read calendar events.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fails closed for a permission no module or Core ever declared", () => {
|
||||||
|
const registry = composePlatformPermissionRegistry([]);
|
||||||
|
|
||||||
|
expect(registry.has("calendar.events.read")).toBe(false);
|
||||||
|
expect(registry.get("calendar.events.read")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails composition on an exact duplicate permission id supplied by two module entries", () => {
|
||||||
|
// A permission id must start with its own module's id, so a genuine cross-module collision is
|
||||||
|
// only reachable if the supplied list itself contains the same module id twice — real
|
||||||
|
// discovery already guarantees module-id uniqueness (`MODULE_ID_DUPLICATE`), but composition
|
||||||
|
// re-derives duplicate detection from the flat declaration list regardless, rather than
|
||||||
|
// trusting that upstream guarantee alone.
|
||||||
|
const first = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
const second = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "public", description: "Also read events." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() => composePlatformPermissionRegistry([first, second])).toThrow(
|
||||||
|
PermissionRegistryError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a module contributing a permission in the reserved core.* namespace, without leaking a module path", () => {
|
||||||
|
// Only a module whose own id is literally "core" can produce a "core."-prefixed permission
|
||||||
|
// that still passes the manifest's own module-namespace check — this proves composition
|
||||||
|
// catches that case rather than trusting the manifest's self-consistency alone.
|
||||||
|
const impostor = fakeModule("core", [
|
||||||
|
{ id: "core.rogue.permission", baseline: "restricted", description: "Not really Core's." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
composePlatformPermissionRegistry([impostor]);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(ModulePermissionCompositionError);
|
||||||
|
expect(captured).toMatchObject({
|
||||||
|
code: "MODULE_PERMISSION_RESERVED_NAMESPACE",
|
||||||
|
moduleId: "core",
|
||||||
|
permissionId: "core.rogue.permission",
|
||||||
|
});
|
||||||
|
const publicText = String(captured);
|
||||||
|
expect(publicText).not.toContain("/modules/core");
|
||||||
|
expect(publicText).not.toContain("ekklesia.module.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reserved-namespace collision even when it would not otherwise duplicate an existing id", () => {
|
||||||
|
const impostor = fakeModule("core", [
|
||||||
|
{
|
||||||
|
id: "core.authorization.grants.manage",
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "Attempting to shadow Core's own permission.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() => composePlatformPermissionRegistry([impostor])).toThrow(
|
||||||
|
ModulePermissionCompositionError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a module's permissions from the resulting registry once that module is absent from the supplied list", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const withCalendar = composePlatformPermissionRegistry([calendar]);
|
||||||
|
expect(withCalendar.has("calendar.events.read")).toBe(true);
|
||||||
|
|
||||||
|
const withoutCalendar = composePlatformPermissionRegistry([]);
|
||||||
|
expect(withoutCalendar.has("calendar.events.read")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs no I/O: composition is synchronous and never imports a module's entrypoint", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A synchronous return (not a Promise) from a fixture whose entrypoint deliberately resolves
|
||||||
|
// to nothing on disk is direct evidence no filesystem or module-code operation occurred.
|
||||||
|
const result = composePlatformPermissionRegistry([calendar]);
|
||||||
|
expect(result).not.toBeInstanceOf(Promise);
|
||||||
|
expect(result.has("calendar.events.read")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the supplied module list or Core's own declaration list", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
const modules = Object.freeze([calendar]);
|
||||||
|
const coreDeclarationsBefore = [...CORE_PERMISSION_DECLARATIONS];
|
||||||
|
|
||||||
|
composePlatformPermissionRegistry(modules);
|
||||||
|
|
||||||
|
expect(modules).toEqual([calendar]);
|
||||||
|
expect(CORE_PERMISSION_DECLARATIONS).toEqual(coreDeclarationsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a registry and declarations that remain immutable, including module-contributed ones", () => {
|
||||||
|
const calendar = fakeModule("calendar", [
|
||||||
|
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registry = composePlatformPermissionRegistry([calendar]);
|
||||||
|
|
||||||
|
expect(Object.isFrozen(registry)).toBe(true);
|
||||||
|
expect(Object.isFrozen(registry.declarations)).toBe(true);
|
||||||
|
for (const declaration of registry.declarations) {
|
||||||
|
expect(Object.isFrozen(declaration)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -31,10 +31,19 @@ permission registry, the bootstrap operation, and HTTP enforcement.
|
|||||||
|
|
||||||
## Permission registry
|
## Permission registry
|
||||||
|
|
||||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
||||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
||||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
||||||
matter:
|
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
|
||||||
|
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
|
||||||
|
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
|
||||||
|
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
|
||||||
|
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
|
||||||
|
interprets it.
|
||||||
|
|
||||||
|
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
|
||||||
|
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
|
||||||
|
declaration from a merely well-formed `PermissionId` string. Two properties matter:
|
||||||
|
|
||||||
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
||||||
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
||||||
@@ -44,14 +53,52 @@ matter:
|
|||||||
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
||||||
"trust this permission id because it looks right" path.
|
"trust this permission id because it looks right" path.
|
||||||
|
|
||||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
|
Permission matching is always exact string equality, both in the registry and in the evaluator's
|
||||||
|
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
|
||||||
|
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||||||
|
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
||||||
|
|
||||||
|
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
||||||
|
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
||||||
|
same declarations exported separately in deeply immutable form, for the module composition helper
|
||||||
|
below to build on without reconstructing Core's own list:
|
||||||
|
|
||||||
| Permission | Baseline | Description |
|
| Permission | Baseline | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||||
|
|
||||||
Module-owned permission declarations and a discovery/composition path for them are out of scope
|
## Module permission composition
|
||||||
for this slice (deferred to the Module SDK work).
|
|
||||||
|
A module manifest's `permissions` array (`manifestVersion: 3`,
|
||||||
|
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||||
|
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||||
|
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||||
|
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
|
||||||
|
composer: given an already-discovered module list (for example `discoverModules`'s result), it
|
||||||
|
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
|
||||||
|
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
|
||||||
|
not a second registry implementation. Declaration order is deterministic: Core first, then each
|
||||||
|
module in the order it appears in the supplied list, then each module's own declarations in
|
||||||
|
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
|
||||||
|
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
|
||||||
|
permission id must start with its own module's id, this is only reachable across two entries that
|
||||||
|
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
|
||||||
|
composition still re-derives the check rather than trusting that guarantee alone.
|
||||||
|
|
||||||
|
`core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||||
|
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
|
||||||
|
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
|
||||||
|
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
|
||||||
|
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
|
||||||
|
whether the id happens to exactly match an existing declaration.
|
||||||
|
|
||||||
|
Composition itself performs no filesystem, environment, database, network, or module-code
|
||||||
|
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
||||||
|
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
|
||||||
|
the other direction: authorization never imports module discovery or runtime, only the module
|
||||||
|
integration layer depends on authorization). This pass stops at producing the combined registry;
|
||||||
|
wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP
|
||||||
|
enforcement built on it, is later work.
|
||||||
|
|
||||||
### Two layers, not one: shape versus registration
|
### Two layers, not one: shape versus registration
|
||||||
|
|
||||||
@@ -287,7 +334,19 @@ there are or whether the failure happened during startup.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
||||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
|
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY`/`CORE_PERMISSION_DECLARATIONS` contents
|
||||||
|
and immutability.
|
||||||
|
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||||
|
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||||
|
rejection, malformed permission id rejection.
|
||||||
|
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
||||||
|
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||||
|
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||||
|
path, and default-empty-list isolation across separate parses.
|
||||||
|
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
|
||||||
|
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
|
||||||
|
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
|
||||||
|
module is absent from the supplied list, and input/output immutability with no I/O.
|
||||||
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
||||||
per-domain database tests).
|
per-domain database tests).
|
||||||
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ without executing module code:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 3,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -77,7 +77,13 @@ without executing module code:
|
|||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["calendar.events.read"],
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "calendar.events.read",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Read calendar events."
|
||||||
|
}
|
||||||
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
{
|
{
|
||||||
@@ -99,6 +105,22 @@ has its own exact semantic version because its public contract can evolve indepe
|
|||||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||||
may be absent; if it is installed, its capability version must be compatible.
|
may be absent; if it is installed, its capability version must be compatible.
|
||||||
|
|
||||||
|
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||||
|
version 2 manifest, and any other version, fails validation outright rather than being silently
|
||||||
|
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||||
|
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||||
|
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||||
|
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||||
|
each baseline means to the evaluator), and a required, non-blank `description` up to 240 characters
|
||||||
|
for operators and audit review only — Core's authorization layer never reads or interprets it.
|
||||||
|
Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest
|
||||||
|
only proves the declaration is well-formed and namespaced correctly, and does not itself register
|
||||||
|
anything with Core's authorization registry (see
|
||||||
|
[Authorization](../development/authorization.md#module-permission-composition) for how Core composes
|
||||||
|
every discovered module's declarations, alongside its own, into the one platform permission
|
||||||
|
registry). A duplicate permission id within one manifest, or a permission id outside the module's
|
||||||
|
own namespace, fails validation the same way a duplicate or misnamed capability id does.
|
||||||
|
|
||||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
||||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
||||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { permissionIdSchema } from "./identifiers.js";
|
||||||
import type { EkklesiaUserId } from "./identity.js";
|
import type { EkklesiaUserId } from "./identity.js";
|
||||||
import { parseEkklesiaUserId } from "./identity.js";
|
import { parseEkklesiaUserId } from "./identity.js";
|
||||||
|
|
||||||
@@ -51,6 +52,44 @@ export const permissionBaselineSchema = z.enum(["public", "authenticated", "rest
|
|||||||
|
|
||||||
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one authoritative shape of a *registered* permission declaration — what both Core's
|
||||||
|
* authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's
|
||||||
|
* `permissions` array parse into, so a permission a module declares and a permission Core's
|
||||||
|
* registry enforces are provably the same structure, never two independently maintained
|
||||||
|
* interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three —
|
||||||
|
* there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a
|
||||||
|
* declaration Core treats as trusted.
|
||||||
|
*
|
||||||
|
* This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration`
|
||||||
|
* only proves it is shaped correctly, not that any registry has accepted it. Registration remains
|
||||||
|
* exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) —
|
||||||
|
* built by composing exactly the declarations a trusted caller supplies, Core's own plus every
|
||||||
|
* discovered module's.
|
||||||
|
*/
|
||||||
|
export const permissionDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
id: permissionIdSchema,
|
||||||
|
baseline: permissionBaselineSchema,
|
||||||
|
/** Operator/audit-facing text only. Authorization never reads, parses, or otherwise
|
||||||
|
* interprets this value — changing it can never change what a permission allows. */
|
||||||
|
description: z.string().trim().min(1).max(240),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type PermissionDeclarationInput = z.input<typeof permissionDeclarationSchema>;
|
||||||
|
export type PermissionDeclaration = z.output<typeof permissionDeclarationSchema>;
|
||||||
|
|
||||||
|
export function parsePermissionDeclaration(input: unknown): PermissionDeclaration {
|
||||||
|
return permissionDeclarationSchema.parse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParsePermissionDeclaration(
|
||||||
|
input: unknown,
|
||||||
|
): z.ZodSafeParseResult<PermissionDeclaration> {
|
||||||
|
return permissionDeclarationSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
||||||
* created as a side effect of its own business operation, attributed to some source the module
|
* created as a side effect of its own business operation, attributed to some source the module
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
logicalBucketIdSchema,
|
logicalBucketIdSchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
permissionIdSchema,
|
|
||||||
} from "./identifiers.js";
|
} from "./identifiers.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ function reportDuplicates(
|
|||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
manifestVersion: z.literal(2),
|
manifestVersion: z.literal(3),
|
||||||
id: moduleIdSchema,
|
id: moduleIdSchema,
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
@@ -102,7 +102,7 @@ export const moduleManifestSchema = z
|
|||||||
.strict(),
|
.strict(),
|
||||||
dependencies: dependenciesSchema,
|
dependencies: dependenciesSchema,
|
||||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||||
permissions: z.array(permissionIdSchema).default(() => []),
|
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
storage: moduleStorageSchema,
|
||||||
entrypoints: entrypointsSchema,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
@@ -114,7 +114,12 @@ export const moduleManifestSchema = z
|
|||||||
context,
|
context,
|
||||||
"id",
|
"id",
|
||||||
);
|
);
|
||||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
reportDuplicates(
|
||||||
|
manifest.permissions.map((permission) => permission.id),
|
||||||
|
["permissions"],
|
||||||
|
context,
|
||||||
|
"id",
|
||||||
|
);
|
||||||
reportDuplicates(
|
reportDuplicates(
|
||||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
["storage", "buckets"],
|
["storage", "buckets"],
|
||||||
@@ -133,11 +138,11 @@ export const moduleManifestSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
manifest.permissions.forEach((permission, index) => {
|
manifest.permissions.forEach((permission, index) => {
|
||||||
if (!permission.startsWith(`${manifest.id}.`)) {
|
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||||
path: ["permissions", index],
|
path: ["permissions", index, "id"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -197,3 +202,7 @@ export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
|||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||||
|
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
||||||
|
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
||||||
|
* discoverability alongside the manifest's other per-field declaration types. */
|
||||||
|
export type ModulePermissionDeclaration = PermissionDeclaration;
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
isUserActor,
|
isUserActor,
|
||||||
parseAuthorizationScope,
|
parseAuthorizationScope,
|
||||||
parseGrantId,
|
parseGrantId,
|
||||||
|
parsePermissionDeclaration,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
|
type PermissionDeclaration,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
safeParseAuthorizationScope,
|
safeParseAuthorizationScope,
|
||||||
safeParseGrantId,
|
safeParseGrantId,
|
||||||
|
safeParsePermissionDeclaration,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
SYSTEM_ACTOR,
|
SYSTEM_ACTOR,
|
||||||
} from "../src/index.js";
|
} 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", () => {
|
describe("GrantId", () => {
|
||||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
MODULE_LIFECYCLE_STAGES,
|
MODULE_LIFECYCLE_STAGES,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
|
parsePermissionId,
|
||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -18,7 +19,13 @@ function validManifest(): Record<string, unknown> {
|
|||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: [{ id: "calendar.events", version: "2.3.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: {
|
storage: {
|
||||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
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", () => {
|
it("creates isolated default collections for every parsed manifest", () => {
|
||||||
const input = validManifest();
|
const input = validManifest();
|
||||||
delete input.storage;
|
delete input.storage;
|
||||||
|
delete input.permissions;
|
||||||
const first = parseModuleManifest(input);
|
const first = parseModuleManifest(input);
|
||||||
const second = parseModuleManifest(input);
|
const second = parseModuleManifest(input);
|
||||||
|
|
||||||
first.dependencies.modules.required.people = "^0.1.0";
|
first.dependencies.modules.required.people = "^0.1.0";
|
||||||
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
||||||
first.storage.buckets.push({ id: "exports" });
|
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.modules.required).toEqual({});
|
||||||
expect(second.dependencies.capabilities.required).toEqual({});
|
expect(second.dependencies.capabilities.required).toEqual({});
|
||||||
expect(second.storage.buckets).toEqual([]);
|
expect(second.storage.buckets).toEqual([]);
|
||||||
|
expect(second.permissions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||||
@@ -68,14 +82,16 @@ describe("module manifest contract", () => {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
capabilities: [{ id: "media.items", version: "1.0.0" }],
|
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);
|
const result = safeParseModuleManifest(manifest);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
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", () => {
|
it("rejects module and capability self-dependencies", () => {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
@@ -151,6 +193,113 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
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([
|
it.each([
|
||||||
"../outside.ts",
|
"../outside.ts",
|
||||||
"/absolute.ts",
|
"/absolute.ts",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 3,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal backend-only Ekklesia module template.",
|
"description": "A minimal backend-only Ekklesia module template.",
|
||||||
@@ -14,7 +14,13 @@
|
|||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["example.records.read"],
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "example.records.read",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Read example records."
|
||||||
|
}
|
||||||
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ describe("module template", () => {
|
|||||||
|
|
||||||
expect(manifest.id).toBe("example");
|
expect(manifest.id).toBe("example");
|
||||||
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
||||||
|
expect(manifest.permissions).toEqual([
|
||||||
|
{
|
||||||
|
id: "example.records.read",
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "Read example records.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
||||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||||
|
|||||||
Reference in New Issue
Block a user