feat: integrate module authorization #11

Merged
Zayin_Admin merged 4 commits from phase-5/module-authorization-integration into main 2026-07-24 23:05:43 +00:00
20 changed files with 765 additions and 52 deletions
Showing only changes of commit d9dd409aa3 - Show all commits
+15 -15
View File
@@ -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.",
+7 -1
View File
@@ -47,7 +47,13 @@ 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 extends (...arguments_: never[]) => unknown
? T ? T
: T extends readonly (infer Item)[] : T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[] ? readonly DeepReadonly<Item>[]
+1
View File
@@ -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";
+73
View File
@@ -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);
});
});
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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: [] },
+20 -4
View File
@@ -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",
+223
View File
@@ -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);
}
});
});
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+67 -8
View File
@@ -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`
+24 -2
View File
@@ -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
+39
View File
@@ -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
+15 -6
View File
@@ -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",
+8 -2
View File
@@ -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": [
{ {
+7
View File
@@ -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");