From d9dd409aa3c4ccd7293d88c043f610dcd99fa3b7 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 24 Jul 2026 12:25:00 -0500 Subject: [PATCH] feat: declare module permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2. --- core/src/authorization/permissions.ts | 30 +-- core/src/modules/discovery.ts | 18 +- core/src/modules/index.ts | 1 + core/src/modules/permissions.ts | 73 ++++++ core/tests/authorization-permissions.test.ts | 19 ++ core/tests/core-migration-discovery.test.ts | 2 +- core/tests/doctor.test.ts | 2 +- core/tests/module-discovery.test.ts | 24 +- core/tests/module-migration-discovery.test.ts | 2 +- core/tests/module-permissions.test.ts | 223 ++++++++++++++++++ core/tests/module-runtime.test.ts | 2 +- core/tests/module-schema-discovery.test.ts | 2 +- docs/development/authorization.md | 75 +++++- docs/module-development/README.md | 26 +- packages/contracts/src/authorization.ts | 39 +++ packages/contracts/src/module-manifest.ts | 21 +- .../contracts/tests/authorization.test.ts | 84 +++++++ .../contracts/tests/module-manifest.test.ts | 157 +++++++++++- templates/module/ekklesia.module.json | 10 +- templates/module/tests/manifest.test.ts | 7 + 20 files changed, 765 insertions(+), 52 deletions(-) create mode 100644 core/src/modules/permissions.ts create mode 100644 core/tests/module-permissions.test.ts diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index 66e52ce..f5242c1 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -1,25 +1,18 @@ import { - type PermissionBaseline, + type PermissionDeclaration, type PermissionId, parsePermissionId, safeParsePermissionId, } from "@ekklesia/contracts"; /** - * A registered permission declaration: the trusted pairing of a permission identifier with its - * one baseline. This is what distinguishes a *registered* permission from a merely well-formed - * `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an - * explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller - * (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that - * baseline is never client-supplied or guessed. + * Core's registry consumes the one shared `PermissionDeclaration` shape + * (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module + * manifest's `permissions` array parses into the exact same type, so a declaration a module ships + * and a declaration Core registers are provably interchangeable. Re-exported here so existing + * `@ekklesia/core/authorization` consumers keep importing it from this package. */ -export interface PermissionDeclaration { - 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 type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts"; export interface PermissionRegistry { readonly declarations: readonly PermissionDeclaration[]; @@ -89,7 +82,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( "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({ baseline: "restricted" as const, description: "Create, list, and revoke another user's explicit authorization grants.", diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index ca497ed..7c133bf 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints { readonly jobs?: string; } -type DeepReadonly = 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 extends string | number | boolean | bigint | symbol | null | undefined ? T - : T extends readonly (infer Item)[] - ? readonly DeepReadonly[] - : T extends object - ? { readonly [Key in keyof T]: DeepReadonly } - : T; + : T extends (...arguments_: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; export type ReadonlyModuleManifest = DeepReadonly; diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 085b2e4..b324e7e 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,4 +1,5 @@ export * from "./discovery.js"; export * from "./migration-discovery.js"; +export * from "./permissions.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; diff --git a/core/src/modules/permissions.ts b/core/src/modules/permissions.ts new file mode 100644 index 0000000..b1caaf1 --- /dev/null +++ b/core/src/modules/permissions.ts @@ -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); +} diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index fb4574a..d6574aa 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, createPermissionRegistry, PermissionRegistryError, @@ -92,3 +93,21 @@ describe("CORE_PERMISSION_REGISTRY", () => { 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); + }); +}); diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts index 6633404..76b8a52 100644 --- a/core/tests/core-migration-discovery.test.ts +++ b/core/tests/core-migration-discovery.test.ts @@ -91,7 +91,7 @@ async function createModule( writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 9854d2c..2c8f505 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -39,7 +39,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 2, + manifestVersion: 3, name: `${id} module`, permissions: [], storage: { buckets: [] }, diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 2db18e9..772750e 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -31,7 +31,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", @@ -40,7 +40,9 @@ function manifestFor(id: string): Record { moduleSdk: "^0.1.0", }, capabilities: [{ id: `${id}.records`, version: "1.0.0" }], - permissions: [`${id}.records.read`], + permissions: [ + { id: `${id}.records.read`, baseline: "restricted", description: "Read records." }, + ], entrypoints: { server: "./dist/server/index.js", }, @@ -151,6 +153,16 @@ describe("module discovery", () => { expect(Object.isFrozen(modules[1]?.manifest)).toBe(true); const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>; 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 () => { @@ -215,7 +227,9 @@ describe("module discovery", () => { ...manifestFor("one"), id: "shared", capabilities: [{ id: "shared.records", version: "1.0.0" }], - permissions: ["shared.records.read"], + permissions: [ + { id: "shared.records.read", baseline: "restricted", description: "Read records." }, + ], }, packageJson: { name: "@test/duplicate", @@ -229,7 +243,9 @@ describe("module discovery", () => { ...manifestFor("two"), id: "shared", capabilities: [{ id: "shared.records", version: "1.0.0" }], - permissions: ["shared.records.read"], + permissions: [ + { id: "shared.records.read", baseline: "restricted", description: "Read records." }, + ], }, packageJson: { name: "@test/duplicate", diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 3f22127..58d47e4 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -65,7 +65,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts new file mode 100644 index 0000000..8652c82 --- /dev/null +++ b/core/tests/module-permissions.test.ts @@ -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); + } + }); +}); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 71edd82..28b02d9 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -43,7 +43,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts index e7374df..1a760f9 100644 --- a/core/tests/module-schema-discovery.test.ts +++ b/core/tests/module-schema-discovery.test.ts @@ -41,7 +41,7 @@ function manifestFor( options: { dependencies?: Record; schema?: string | false } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 177eda3..8a1f3bd 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -31,10 +31,19 @@ permission registry, the bootstrap operation, and HTTP enforcement. ## Permission registry -`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable, -composition-time-built `PermissionRegistry` distinguishing a *registered declaration* -(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties -matter: +`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative, +strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`, +nothing more — shared by Core's authorization registry and a module manifest's `permissions` array. +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 `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 "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 | | --- | --- | --- | | `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 -for this slice (deferred to the Module SDK work). +## Module permission composition + +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 @@ -287,7 +334,19 @@ there are or whether the failure happened during startup. ## Tests - `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 per-domain database tests). - `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError` diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 6dcbddf..53947a7 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -48,7 +48,7 @@ without executing module code: ```json { - "manifestVersion": 2, + "manifestVersion": 3, "id": "calendar", "name": "Calendar", "version": "0.1.0", @@ -77,7 +77,13 @@ without executing module code: "version": "1.0.0" } ], - "permissions": ["calendar.events.read"], + "permissions": [ + { + "id": "calendar.events.read", + "baseline": "restricted", + "description": "Read calendar events." + } + ], "storage": { "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 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 `.`, 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 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 diff --git a/packages/contracts/src/authorization.ts b/packages/contracts/src/authorization.ts index 85aa1e3..a1d8e03 100644 --- a/packages/contracts/src/authorization.ts +++ b/packages/contracts/src/authorization.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { permissionIdSchema } from "./identifiers.js"; import type { EkklesiaUserId } 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; +/** + * 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; +export type PermissionDeclaration = z.output; + +export function parsePermissionDeclaration(input: unknown): PermissionDeclaration { + return permissionDeclarationSchema.parse(input); +} + +export function safeParsePermissionDeclaration( + input: unknown, +): z.ZodSafeParseResult { + return permissionDeclarationSchema.safeParse(input); +} + /** * 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 diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 322939b..24310ba 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -1,10 +1,10 @@ import { z } from "zod"; +import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; import { capabilityIdSchema, logicalBucketIdSchema, moduleIdSchema, packageRelativePathSchema, - permissionIdSchema, } from "./identifiers.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; @@ -89,7 +89,7 @@ function reportDuplicates( export const moduleManifestSchema = z .object({ - manifestVersion: z.literal(2), + manifestVersion: z.literal(3), id: moduleIdSchema, name: z.string().trim().min(1).max(120), description: z.string().trim().min(1).max(500).optional(), @@ -102,7 +102,7 @@ export const moduleManifestSchema = z .strict(), dependencies: dependenciesSchema, capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), - permissions: z.array(permissionIdSchema).default(() => []), + permissions: z.array(permissionDeclarationSchema).default(() => []), storage: moduleStorageSchema, entrypoints: entrypointsSchema, }) @@ -114,7 +114,12 @@ export const moduleManifestSchema = z context, "id", ); - reportDuplicates(manifest.permissions, ["permissions"], context); + reportDuplicates( + manifest.permissions.map((permission) => permission.id), + ["permissions"], + context, + "id", + ); reportDuplicates( manifest.storage.buckets.map((bucket) => bucket.id), ["storage", "buckets"], @@ -133,11 +138,11 @@ export const moduleManifestSchema = z }); manifest.permissions.forEach((permission, index) => { - if (!permission.startsWith(`${manifest.id}.`)) { + if (!permission.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", 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; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; export type ModuleCapabilityDeclaration = z.output; +/** 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; diff --git a/packages/contracts/tests/authorization.test.ts b/packages/contracts/tests/authorization.test.ts index cd605e8..65a5a16 100644 --- a/packages/contracts/tests/authorization.test.ts +++ b/packages/contracts/tests/authorization.test.ts @@ -10,10 +10,13 @@ import { isUserActor, parseAuthorizationScope, parseGrantId, + parsePermissionDeclaration, parsePermissionId, + type PermissionDeclaration, type PermissionId, safeParseAuthorizationScope, safeParseGrantId, + safeParsePermissionDeclaration, safeParsePermissionId, SYSTEM_ACTOR, } from "../src/index.js"; @@ -50,6 +53,87 @@ describe("PermissionId", () => { }); }); +describe("PermissionDeclaration", () => { + function validDeclaration(): Record { + return { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }; + } + + it.each([ + "public", + "authenticated", + "restricted", + ] as const)("parses a well-formed declaration with baseline %s", (baseline) => { + const declaration = parsePermissionDeclaration({ ...validDeclaration(), baseline }); + expect(declaration).toEqual({ + id: "calendar.events.read", + baseline, + description: "Read calendar events.", + }); + }); + + it("is exactly three fields — this is the one shape both a module manifest and Core's registry parse into", () => { + const declaration: PermissionDeclaration = parsePermissionDeclaration(validDeclaration()); + expect(Object.keys(declaration).sort()).toEqual(["baseline", "description", "id"]); + }); + + it("rejects an unknown declaration property", () => { + const result = safeParsePermissionDeclaration({ + ...validDeclaration(), + scope: "calendar-event:123", + }); + expect(result.success).toBe(false); + }); + + it("rejects a missing baseline", () => { + const { baseline: _baseline, ...withoutBaseline } = validDeclaration(); + expect(safeParsePermissionDeclaration(withoutBaseline).success).toBe(false); + }); + + it("rejects an invalid baseline", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), baseline: "superuser" }).success, + ).toBe(false); + }); + + it("rejects a missing, empty, or whitespace-only description", () => { + const { description: _description, ...withoutDescription } = validDeclaration(); + expect(safeParsePermissionDeclaration(withoutDescription).success).toBe(false); + expect(safeParsePermissionDeclaration({ ...validDeclaration(), description: "" }).success).toBe( + false, + ); + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: " " }).success, + ).toBe(false); + }); + + it("rejects a description beyond the fixed maximum length", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(240) }) + .success, + ).toBe(true); + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(241) }) + .success, + ).toBe(false); + }); + + it("rejects a malformed permission id", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), id: "not-namespaced" }).success, + ).toBe(false); + }); + + it("never registers or evaluates anything — parsing only proves the value is well-formed", () => { + // Nothing here consults a registry: `parsePermissionDeclaration` succeeding says nothing + // about whether `calendar.events.read` is actually registered anywhere. + expect(() => parsePermissionDeclaration(validDeclaration())).not.toThrow(); + }); +}); + describe("GrantId", () => { it("parses a non-empty opaque grant id and rejects empty/oversized input", () => { const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111"); diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index 5997339..07b5fb9 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -4,12 +4,13 @@ import { MODULE_LIFECYCLE_STAGES, packageRelativePathSchema, parseModuleManifest, + parsePermissionId, safeParseModuleManifest, } from "../src/index.js"; function validManifest(): Record { return { - manifestVersion: 2, + manifestVersion: 3, id: "calendar", name: "Calendar", version: "0.1.0", @@ -18,7 +19,13 @@ function validManifest(): Record { moduleSdk: "^0.1.0", }, capabilities: [{ id: "calendar.events", version: "2.3.0" }], - permissions: ["calendar.events.read"], + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }, + ], storage: { buckets: [{ id: "assets", description: "Calendar-owned event assets." }], }, @@ -45,16 +52,23 @@ describe("module manifest contract", () => { it("creates isolated default collections for every parsed manifest", () => { const input = validManifest(); delete input.storage; + delete input.permissions; const first = parseModuleManifest(input); const second = parseModuleManifest(input); first.dependencies.modules.required.people = "^0.1.0"; first.dependencies.capabilities.required["people.records"] = "^1.0.0"; first.storage.buckets.push({ id: "exports" }); + first.permissions.push({ + id: parsePermissionId("calendar.events.write"), + baseline: "restricted", + description: "Write calendar events.", + }); expect(second.dependencies.modules.required).toEqual({}); expect(second.dependencies.capabilities.required).toEqual({}); expect(second.storage.buckets).toEqual([]); + expect(second.permissions).toEqual([]); }); it("rejects unknown and presentation-oriented manifest properties", () => { @@ -68,14 +82,16 @@ describe("module manifest contract", () => { const manifest = { ...validManifest(), capabilities: [{ id: "media.items", version: "1.0.0" }], - permissions: ["media.items.read"], + permissions: [ + { id: "media.items.read", baseline: "restricted", description: "Read media items." }, + ], }; const result = safeParseModuleManifest(manifest); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual( - expect.arrayContaining(["capabilities.0.id", "permissions.0"]), + expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]), ); } }); @@ -107,6 +123,32 @@ describe("module manifest contract", () => { } }); + it("rejects a duplicate permission id at its exact issue path", () => { + const manifest = { + ...validManifest(), + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }, + { + id: "calendar.events.read", + baseline: "authenticated", + description: "Read calendar events, again.", + }, + ], + }; + const result = safeParseModuleManifest(manifest); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path.join("."))).toContain( + "permissions.1.id", + ); + } + }); + it("rejects module and capability self-dependencies", () => { const manifest = { ...validManifest(), @@ -151,6 +193,113 @@ describe("module manifest contract", () => { expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false); }); + it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => { + // manifestVersion 2's own permissions shape (a bare string array) is also what version 3 + // must reject — this manifest is invalid twice over, deliberately, since a real version 2 + // manifest is exactly this shape. + const version2Manifest = { + ...validManifest(), + manifestVersion: 2, + permissions: ["calendar.events.read"], + }; + + expect(safeParseModuleManifest(version2Manifest).success).toBe(false); + }); + + it("rejects a bare permission-id string array instead of complete declarations", () => { + const manifest = { ...validManifest(), permissions: ["calendar.events.read"] }; + + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("defaults to an empty permission list when omitted", () => { + const input = validManifest(); + delete input.permissions; + + const manifest = parseModuleManifest(input); + expect(manifest.permissions).toEqual([]); + }); + + it.each([ + "public", + "authenticated", + "restricted", + ] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => { + const manifest = parseModuleManifest({ + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }], + }); + expect(manifest.permissions).toEqual([ + { id: "calendar.events.read", baseline, description: "Read events." }, + ]); + }); + + it("rejects a permission declaration with a missing baseline", () => { + const manifest = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", description: "Read events." }], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with an invalid baseline", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "calendar.events.read", baseline: "superuser", description: "Read events." }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with a missing or blank description", () => { + const missingDescription = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline: "restricted" }], + }; + const blankDescription = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }], + }; + expect(safeParseModuleManifest(missingDescription).success).toBe(false); + expect(safeParseModuleManifest(blankDescription).success).toBe(false); + }); + + it("rejects a permission declaration with an oversized description", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with an unknown property", () => { + const manifest = { + ...validManifest(), + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read events.", + scope: "calendar-event:123", + }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a malformed permission id", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + it.each([ "../outside.ts", "/absolute.ts", diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index ccfe877..6ea316a 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 2, + "manifestVersion": 3, "id": "example", "name": "Example Module", "description": "A minimal backend-only Ekklesia module template.", @@ -14,7 +14,13 @@ "version": "1.0.0" } ], - "permissions": ["example.records.read"], + "permissions": [ + { + "id": "example.records.read", + "baseline": "restricted", + "description": "Read example records." + } + ], "storage": { "buckets": [ { diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 95ca89d..0b3b1e3 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -20,6 +20,13 @@ describe("module template", () => { expect(manifest.id).toBe("example"); 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.entrypoints.schema).toBe("./dist/schema/index.js"); expect(manifest.entrypoints.migrations).toBe("./drizzle");