Merge pull request 'feat: integrate module authorization' (#11) from phase-5/module-authorization-integration into main
CI / verify (push) Successful in 25s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 26s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
The_River
2026-07-24 23:05:42 +00:00
40 changed files with 2286 additions and 126 deletions
+22 -4
View File
@@ -7,7 +7,8 @@ surfaces.
## Current status ## Current status
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
and Phase 4C (Core authorization) have landed. The repository includes Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
@@ -46,11 +47,26 @@ immutable permission registry; an explicit, single-use, concurrency-safe platfor
command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup
order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped
`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin`
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service
accounts/API tokens, and module-permission registration remain later work. See accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
[Authorization](docs/development/authorization.md) and
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
Phase 5A wires module-declared permissions into that same authorization system: a version-3 module
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare
string), and `apps/api` discovers every installed module and composes one immutable platform
permission registry — Core's own declarations plus every module's — before constructing its
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
enforceable and grantable through the exact same `requirePermission` middleware and
`/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
composition. Installing, removing, or editing a module currently requires an API restart; removing
one makes its permission unavailable to new checks and new grants without deleting or revoking any
historical grant or audit row. Module HTTP route registration and relationship-derived grant
creation remain later work. See
[Authorization](docs/development/authorization.md#module-permission-composition),
[Module development](docs/module-development/README.md), and
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -89,6 +105,8 @@ Modules
0 modules installed 0 modules installed
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed
1 permission registered
Data definitions Data definitions
✓ Schemas validated ✓ Schemas validated
+9 -5
View File
@@ -1,8 +1,8 @@
import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
import { import type {
type AuthorizationEvaluator, AuthorizationEvaluator,
type AuthorizationStore, AuthorizationStore,
CORE_PERMISSION_REGISTRY, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -12,6 +12,10 @@ import { createGrantsRoutes } from "./grants-routes.js";
export interface CreateApiAppAuthorizationOptions { export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator; readonly evaluator: AuthorizationEvaluator;
/** The complete platform permission registry (Core's own declarations plus every installed
* module's) — always the caller-composed registry `startApi` produces via
* `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */
readonly registry: PermissionRegistry;
readonly store: AuthorizationStore; readonly store: AuthorizationStore;
} }
@@ -80,7 +84,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
"/", "/",
createGrantsRoutes({ createGrantsRoutes({
evaluator: options.authorization.evaluator, evaluator: options.authorization.evaluator,
registry: CORE_PERMISSION_REGISTRY, registry: options.authorization.registry,
runtime, runtime,
store: options.authorization.store, store: options.authorization.store,
trustedOrigins: allowedOrigins, trustedOrigins: allowedOrigins,
+5 -3
View File
@@ -17,9 +17,11 @@ function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response
} }
/** /**
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own * Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected,
* trusted, server-side permission registry — a route never supplies its own baseline, and no * trusted, server-side `PermissionRegistry` — in production, the complete platform registry
* request input can influence which baseline is checked. The principal is resolved exclusively * `startApi` composes from Core's own declarations plus every installed module's, never a
* Core-only fallback. A route never supplies its own baseline, and no request input can influence
* which baseline is checked. The principal is resolved exclusively
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal` * through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or * already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
* grants. A permission id that is not registered fails closed (403) exactly like a denied * grants. A permission id that is not registered fails closed (403) exactly like a denied
+106
View File
@@ -0,0 +1,106 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { PermissionRegistry } from "@ekklesia/core/authorization";
import {
composePlatformPermissionRegistry,
discoverModules,
type DiscoveredModule,
} from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
* content, or any other machine detail — a broken repository layout is a startup-time
* configuration problem, not something to describe in detail to whatever eventually logs it. */
export class PlatformPermissionConfigurationError extends Error {
readonly code: PlatformPermissionConfigurationErrorCode;
constructor() {
super("Module discovery configuration could not be resolved.");
this.name = "PlatformPermissionConfigurationError";
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Resolves the repository root from this compiled/source file's own location, never
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
* `..` segments resolves correctly in both.
*/
function resolveRepositoryRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
}
interface PackageVersionFile {
readonly version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
let packageData: PackageVersionFile;
try {
packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile;
} catch {
throw new PlatformPermissionConfigurationError();
}
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
throw new PlatformPermissionConfigurationError();
}
return packageData.version;
}
export interface ComposeStartupPermissionRegistryOptions {
/** Test-only escape hatch: overrides the directory module discovery scans for installed
* modules. Never set by the executable entry point — production always discovers the real
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
* real repository's own `package.json` files regardless of this override. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
* on it. Exists because a genuine exact permission-id collision between two modules is
* unreachable through real discovery: module ids are unique (discovery itself rejects a
* duplicate) and every permission id is namespaced by its own module's id, so no two
* really-discovered modules can ever contribute the same permission id. Proving
* `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
* has created no pool before it runs, requires supplying deliberately conflicting declarations
* directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
* module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this
* case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the
* executable entry point.
*/
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
}
/**
* Discovers every installed module's manifest and composes the one immutable platform permission
* registry (Core's own declarations plus every module's) — the production source of truth
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK
* versions only when this function actually executes, never at import time.
*/
export async function composeStartupPermissionRegistry(
options: ComposeStartupPermissionRegistryOptions = {},
): Promise<PermissionRegistry> {
if (options.discoveredModulesOverride) {
return composePlatformPermissionRegistry(options.discoveredModulesOverride);
}
const repositoryRoot = resolveRepositoryRoot();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
platformVersion,
});
return composePlatformPermissionRegistry(modules);
}
+32 -8
View File
@@ -5,11 +5,13 @@ import {
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import type { DiscoveredModule } from "@ekklesia/core/modules";
import { serve, type ServerType } from "@hono/node-server"; import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { composeStartupPermissionRegistry } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000; const operationTimeoutMs = 10_000;
@@ -24,6 +26,13 @@ export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See /** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */ * `IdentityRateLimitOverride` for why production must leave this unset. */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
/** Test-only escape hatch: overrides the directory permission-composition module discovery
* scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly to permission
* composition, skipping filesystem discovery entirely. Never set by the executable entry
* point. See `composeStartupPermissionRegistry`. */
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
} }
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> { async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
@@ -54,18 +63,33 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
} }
/** /**
* Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and * Explicit runtime composition, in a fixed order: discover every installed module's manifest and
* authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity * compose the one immutable platform permission registry from Core's own declarations plus every
* runtime, the Core authorization store and evaluator, and the Hono application, then starts the * module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool
* Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry * (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the
* point and tests can stop the server and close the shared pool deterministically. Never applies * Core identity runtime, the Core authorization store and evaluator, and the Hono application;
* migrations or generates schema — the database must already be migrated through * finally start the Node HTTP server. Permission composition happens before any pool, Better
* `pnpm migrations:apply`. * Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
* directly, and startup never falls back to a Core-only registry. Returns a handle with an
* idempotent `shutdown()` so both the executable entry point and tests can stop the server and
* close the shared pool deterministically. Never applies migrations or generates schema — the
* database must already be migrated through `pnpm migrations:apply`.
*/ */
export async function startApi( export async function startApi(
config: ApiRuntimeConfig, config: ApiRuntimeConfig,
options: StartApiOptions = {}, options: StartApiOptions = {},
): Promise<RunningApi> { ): Promise<RunningApi> {
const permissionRegistry = await composeStartupPermissionRegistry({
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
...(options.discoveredModulesOverride === undefined
? {}
: { discoveredModulesOverride: options.discoveredModulesOverride }),
});
const databaseHandle = createApiDatabase(config.databaseUrl, { const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs, connectionTimeoutMs,
operationTimeoutMs, operationTimeoutMs,
@@ -91,7 +115,7 @@ export async function startApi(
}); });
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, store: authorizationStore }, authorization: { evaluator, registry: permissionRegistry, store: authorizationStore },
runtime, runtime,
trustedOrigins: config.identity.trustedOrigins, trustedOrigins: config.identity.trustedOrigins,
}); });
+265 -9
View File
@@ -8,12 +8,13 @@ import {
createAuthorizationDrizzleDatabase, createAuthorizationDrizzleDatabase,
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { diagnoseEkklesia } from "@ekklesia/core/diagnostics";
import { import {
applyModuleMigrations, applyModuleMigrations,
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
} from "@ekklesia/core/migrations"; } from "@ekklesia/core/migrations";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, describe, expect, it, vi } from "vitest";
@@ -198,10 +199,60 @@ async function applyCoreMigrations(url: string): Promise<void> {
} }
} }
const trackedModulesDirectories: string[] = [];
async function createFixtureModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
trackedModulesDirectories.push(directory);
return directory;
}
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and
* composition run against it, nothing hand-built. The server entrypoint throws immediately if
* ever imported/executed, so a scenario that completes without that throw firing is direct
* evidence permission composition and Doctor never ran module lifecycle code. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 3,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition and Doctor must never execute a module server entrypoint");',
),
]);
}
afterAll(async () => { afterAll(async () => {
for (const databaseName of [...trackedDatabases]) { for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined); await dropDatabase(databaseName).catch(() => undefined);
} }
for (const directory of trackedModulesDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
}
}); });
const identityBaseUrl = "https://identity.example.test"; const identityBaseUrl = "https://identity.example.test";
@@ -216,7 +267,10 @@ function apiOrigin(running: RunningApi): string {
return `http://127.0.0.1:${address.port}`; return `http://127.0.0.1:${address.port}`;
} }
async function startMigratedApi(url: string): Promise<RunningApi> { async function startMigratedApi(
url: string,
modulesDirectoryOverride?: string,
): Promise<RunningApi> {
const config = createApiRuntimeConfig( const config = createApiRuntimeConfig(
{ {
databaseUrl: url, databaseUrl: url,
@@ -230,10 +284,15 @@ async function startMigratedApi(url: string): Promise<RunningApi> {
}, },
{ environment: "production" }, { environment: "production" },
); );
// A narrow, explicit test-only override: production composition (server.ts's `main`) never // Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets
// sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would // either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise
// otherwise reject this suite's many sequential requests. // reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); // permission composition at a real, disposable module fixture instead of this repository's own
// (empty) `modules/` directory.
return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
});
} }
function setCookieValue(response: Response): string | undefined { function setCookieValue(response: Response): string | undefined {
@@ -832,9 +891,11 @@ describe.sequential("live API — platform owner and delegated grant management"
const ownerEmail = "[email protected]"; const ownerEmail = "[email protected]";
const managerEmail = "[email protected]"; const managerEmail = "[email protected]";
const password = "correct horse battery staple"; const password = "correct horse battery staple";
// Module permission registration does not exist yet, so `core.authorization.grants.manage` — // This scenario intentionally proves out the explicit-grant lifecycle (create, list, use,
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some // revoke) against Core's own grant-management permission rather than a module's — a module
// registered permission" throughout this scenario's explicit-grant lifecycle. // permission would exercise the exact same route and evaluator code, adding coverage breadth
// without coverage depth. The "live API — installed module permission end-to-end" scenario
// below is where a real installed module's declared permission is proven end-to-end instead.
const testPermission = "core.authorization.grants.manage"; const testPermission = "core.authorization.grants.manage";
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the // Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
// live route rejects it even for a caller who otherwise has full grant-management authority. // live route rejects it even for a caller who otherwise has full grant-management authority.
@@ -1147,3 +1208,198 @@ describe.sequential("live API — authorization infrastructure failure is never
await running.shutdown().catch(() => undefined); await running.shutdown().catch(() => undefined);
}); });
}); });
describe.sequential("live API — installed module permission end-to-end", () => {
let database: { name: string; url: string };
let modulesDirectory: string;
let running: RunningApi;
let origin: string;
let ownerUserId: string;
let ownerCookie: string;
let createdGrantId: string;
const ownerEmail = "[email protected]";
const password = "correct horse battery staple";
const modulePermission = "calendar.events.read";
const absentPermission = "scheduler.jobs.read";
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
modulesDirectory = await createFixtureModulesDirectory();
await createFixtureModule(modulesDirectory, "calendar", {
permissions: [
{
baseline: "restricted",
description: "Read calendar events.",
id: modulePermission,
},
],
});
const report = await diagnoseEkklesia({
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
},
runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node,
});
// Only the module-discovery and permission-composition checks are asserted here — this
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
// find, which is unrelated to what this scenario proves and would otherwise make the check
// fail for a reason that has nothing to do with permission composition.
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
expect(moduleCheck).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({
details: ["2 permissions registered"],
status: "pass",
});
});
it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => {
database = await createDisposableDatabase("authz_module");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url, modulesDirectory);
origin = apiOrigin(running);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
expect(ownerSignIn.cookiePair).toBeDefined();
ownerCookie = ownerSignIn.cookiePair as string;
// Existing identity HTTP behavior — sign-up, sign-in, principal resolution — is unaffected by
// module permission composition running before it.
const { body: principal } = await fetchPrincipal(origin, { cookie: ownerCookie });
expect(principal).toEqual({ kind: "authenticated", userId: ownerUserId });
});
it("lets an authorized platform owner create an explicit grant for the installed module's permission, with an atomic audit entry using its declared id", async () => {
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
} finally {
await close();
}
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as {
grant: { id: string; permission: string; sourceKind: string };
};
expect(response.status).toBe(201);
expect(body.grant.permission).toBe(modulePermission);
expect(body.grant.sourceKind).toBe("explicit");
createdGrantId = body.grant.id;
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string; permission_id: string }>(
admin,
`select action, permission_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
[createdGrantId],
);
expect(rows).toEqual([{ action: "grant.create", permission_id: modulePermission }]);
} finally {
await safeEnd(admin);
}
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const before = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[absentPermission],
);
expect(before[0]?.count).toBe("0");
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: absentPermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const after = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[absentPermission],
);
const audit = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[absentPermission],
);
expect(after[0]?.count).toBe("0");
expect(audit[0]?.count).toBe("0");
} finally {
await safeEnd(admin);
}
});
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
const emptyModulesDirectory = await createFixtureModulesDirectory();
running = await startMigratedApi(database.url, emptyModulesDirectory);
origin = apiOrigin(running);
// The owner's existing session survives the restart (sessions live in the database, not the
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: ownerCookie },
});
const listBody = (await listResponse.json()) as {
grants: Array<{ id: string; permission: string }>;
};
expect(listResponse.status).toBe(200);
expect(listBody.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ id: string }>(
admin,
`select id from "ekklesia_core_grant" where id = $1 and revoked_at is null`,
[createdGrantId],
);
expect(rows).toHaveLength(1);
} finally {
await safeEnd(admin);
}
await running.shutdown();
await dropDatabase(database.name);
});
});
+38 -3
View File
@@ -1,5 +1,10 @@
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -17,9 +22,13 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
} }
/** A fully stubbed authorization dependency, denying everything by default — tests that need a /** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. */ * specific decision or store behavior override the relevant method explicitly. The registry
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
* mounted grants routes require) rather than an app-composed one — these tests exercise
* `createApiApp`'s wiring, not permission composition itself. */
function fakeAuthorization(overrides: { function fakeAuthorization(overrides: {
readonly evaluator?: Partial<AuthorizationEvaluator>; readonly evaluator?: Partial<AuthorizationEvaluator>;
readonly registry?: PermissionRegistry;
readonly store?: Partial<AuthorizationStore>; readonly store?: Partial<AuthorizationStore>;
}): CreateApiAppAuthorizationOptions { }): CreateApiAppAuthorizationOptions {
const evaluator: AuthorizationEvaluator = { const evaluator: AuthorizationEvaluator = {
@@ -36,7 +45,7 @@ function fakeAuthorization(overrides: {
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
...overrides.store, ...overrides.store,
}; };
return { evaluator, store }; return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
} }
describe("Ekklesia API Hono application", () => { describe("Ekklesia API Hono application", () => {
@@ -253,6 +262,32 @@ describe("Ekklesia API Hono application", () => {
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
}); });
it("uses exactly the injected registry, never an internal Core-only fallback", async () => {
// An evaluator that would allow anything, paired with a registry that does not register
// core.authorization.grants.manage at all — if createApiApp silently fell back to some
// internal registry containing it, this request would reach the evaluator and succeed
// (empty grant list, 200). It must instead fail closed on the registry lookup itself.
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const emptyRegistry: PermissionRegistry = {
declarations: [],
get: () => undefined,
has: () => false,
};
const app = createApiApp({
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
registry: emptyRegistry,
}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123");
expect(response.status).toBe(403);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
});
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({ const app = createApiApp({
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
@@ -151,6 +151,153 @@ describe("requirePermission", () => {
}); });
}); });
describe("requirePermission with module-owned declarations", () => {
// requirePermission has no concept of "Core" versus "module" — it only ever consults whatever
// registry it is given. These declarations are shaped exactly like what
// composePlatformPermissionRegistry would contribute from a real installed module (an id
// namespaced under a module, not `core.*`), proving the shared enforcement mechanism works
// identically for module-owned permissions without any module-specific code path.
const modulePublicPermission = parsePermissionId("calendar.events.list");
const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe");
const moduleRestrictedPermission = parsePermissionId("calendar.events.read");
const moduleRegistry = createPermissionRegistry([
{
baseline: "public",
description: "List public calendar events.",
id: modulePublicPermission,
},
{
baseline: "authenticated",
description: "Subscribe to calendar event updates.",
id: moduleAuthenticatedPermission,
},
{
baseline: "restricted",
description: "Read calendar events.",
id: moduleRestrictedPermission,
},
]);
function buildModuleApp(
permissionId: typeof modulePublicPermission,
options: Partial<AuthorizationMiddlewareOptions> = {},
) {
const runtime = options.runtime ?? fakeRuntime();
const evaluator = options.evaluator ?? fakeEvaluator();
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator,
registry: moduleRegistry,
runtime,
};
const app = new Hono();
app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) =>
c.json({ ok: true }),
);
return { app, evaluator, runtime };
}
it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(modulePublicPermission, { evaluator });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "public", permission: modulePublicPermission }),
);
});
it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator });
await app.request("/module-protected");
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({
baseline: "authenticated",
permission: moduleAuthenticatedPermission,
}),
);
});
it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
await app.request("/module-protected");
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({
baseline: "restricted",
permission: moduleRestrictedPermission,
}),
);
});
it("allows an anonymous principal for a module permission the evaluator authorizes", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const { app } = buildModuleApp(modulePublicPermission, { runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
});
it("allows an authenticated principal for a module permission the evaluator authorizes", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const { app } = buildModuleApp(moduleRestrictedPermission, { runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
});
it("denies a module permission the evaluator does not authorize", async () => {
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
const response = await app.request("/module-protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => {
const runtime = fakeRuntime();
const evaluator = fakeEvaluator();
const absentModulePermission = parsePermissionId("scheduler.jobs.read");
const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("never lets a client-supplied baseline influence the decision for a module permission", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
// A hostile client cannot make a restricted module permission behave as public — there is no
// request field the middleware reads for a baseline override.
await app.request("/module-protected", {
headers: { "x-baseline": "public", "x-permission-baseline": "public" },
});
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted" }),
);
});
});
describe("getAuthorizedPrincipal", () => { describe("getAuthorizedPrincipal", () => {
it("throws when called on a route requirePermission never protected", async () => { it("throws when called on a route requirePermission never protected", async () => {
const { app } = buildTestApp(); const { app } = buildTestApp();
+7 -2
View File
@@ -25,8 +25,13 @@ const subjectUserId = parseEkklesiaUserId("subject_1");
// (or, for listing, still return) an unregistered permission id rather than assuming registration. // (or, for listing, still return) an unregistered permission id rather than assuming registration.
const permission = parsePermissionId("calendar.events.read"); const permission = parsePermissionId("calendar.events.read");
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach // A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission // `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
// registration, which does not exist yet. // isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
// ever consult whichever registry they are given. Coverage proving a module-declared permission
// works through the exact same route code lives in
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE; const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111"); const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
+234 -1
View File
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn()); const poolConstructor = vi.hoisted(() => vi.fn());
const betterAuthMock = vi.hoisted(() => vi.fn()); const betterAuthMock = vi.hoisted(() => vi.fn());
@@ -11,6 +14,13 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock }));
import { parseModuleManifest } from "@ekklesia/contracts";
import {
type DiscoveredModule,
ModuleDiscoveryError,
ModulePermissionCompositionError,
} from "@ekklesia/core/modules";
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js";
@@ -30,6 +40,84 @@ function validConfig() {
); );
} }
const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-modules-"));
temporaryDirectories.push(directory);
return directory;
}
/** Writes a minimal, structurally valid module fixture (package.json + ekklesia.module.json +
* server entrypoint) directly to disk — real discovery, not a hand-built double. The server
* entrypoint throws immediately if ever imported, so a test that reaches a successful assertion
* without that throw firing is direct evidence permission composition never executed it. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 3,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition must never execute a module server entrypoint");',
),
]);
}
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
* unreachable through real filesystem discovery (module ids are unique and every permission id is
* namespaced by its own module's id, so no two genuinely discovered modules can ever collide). */
function fakeDiscoveredModule(
id: string,
permissions: ReadonlyArray<{
id: string;
baseline: "public" | "authenticated" | "restricted";
description: string;
}>,
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 3,
name: `${id} module`,
permissions,
version: "0.1.0",
});
return {
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown` /** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts; * to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */ * `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
@@ -285,3 +373,148 @@ describe("Ekklesia API shutdown lifecycle", () => {
restore(); restore();
}); });
}); });
describe("Ekklesia API module permission composition at startup", () => {
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("discovers and composes permissions before constructing the database pool, Better Auth, or the HTTP server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// An incompatible platform version fails discovery itself (MODULE_PLATFORM_INCOMPATIBLE),
// before permission composition — and, since composition runs before any pool exists, before
// any pool, Better Auth instance, or HTTP server could ever be constructed.
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup when a module claims the reserved core.* permission namespace, creating no pool, Better Auth instance, or server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// 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.
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{ baseline: "restricted", description: "Not really Core's.", id: "core.rogue.permission" },
],
});
let captured: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModulePermissionCompositionError);
expect(captured).toMatchObject({ code: "MODULE_PERMISSION_RESERVED_NAMESPACE" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup on a duplicate permission declaration, creating no pool, Better Auth instance, or server", async () => {
const first = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const second = fakeDiscoveredModule("calendar", [
{ baseline: "public", description: "Also read events.", id: "calendar.events.read" },
]);
let captured: unknown;
try {
await startApi(validConfig(), { discoveredModulesOverride: [first, second] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PermissionRegistryError);
expect(captured).toMatchObject({ code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("keeps a reserved-namespace failure and a generic duplicate-declaration failure distinguishable", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{
baseline: "restricted",
description: "Attempting to shadow Core's own permission.",
id: "core.authorization.grants.manage",
},
],
});
let reservedNamespaceError: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
reservedNamespaceError = error;
}
const duplicateModule = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const duplicateModuleAgain = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events, again.", id: "calendar.events.read" },
]);
let duplicateError: unknown;
try {
await startApi(validConfig(), {
discoveredModulesOverride: [duplicateModule, duplicateModuleAgain],
});
} catch (error) {
duplicateError = error;
}
// Even though the first scenario's module attempted to exactly collide with an existing
// Core-owned permission id, it is reported as a reserved-namespace violation, never as a
// generic duplicate — the two failure families never overlap.
expect(reservedNamespaceError).toBeInstanceOf(ModulePermissionCompositionError);
expect(reservedNamespaceError).not.toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).not.toBeInstanceOf(ModulePermissionCompositionError);
});
it("still constructs exactly one pool and succeeds when zero modules are installed", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js");
expect(typeof module.composeStartupPermissionRegistry).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
});
});
+25 -23
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[];
@@ -53,9 +46,10 @@ export class PermissionRegistryError extends Error {
* Builds an immutable permission registry from a fixed list of declarations, rejecting any * Builds an immutable permission registry from a fixed list of declarations, rejecting any
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that * duplicate permission id outright at composition time. Deliberately the smallest mechanism that
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no * distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
* network or filesystem I/O — a future module registration mechanism can merge its own * network or filesystem I/O. The module integration layer's composer
* declarations into a combined list and still call this same constructor, inheriting duplicate * (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
* detection for free instead of reimplementing it. * declarations with every discovered module's into one combined list and calls this same
* constructor, inheriting duplicate detection for free rather than reimplementing it.
*/ */
export function createPermissionRegistry( export function createPermissionRegistry(
declarations: readonly PermissionDeclaration[], declarations: readonly PermissionDeclaration[],
@@ -89,7 +83,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.",
@@ -98,11 +99,12 @@ const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.fr
]); ]);
/** /**
* Core's own permission registry — the trusted source of truth `apps/api`'s authorization * Core's own permission registry, containing exactly the Core-owned permissions declared above.
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the * Production `apps/api` does not consult this constant directly: it enforces requests against the
* one Core-owned permission this slice needs; a future module registration mechanism is * composed platform registry (`composePlatformPermissionRegistry`,
* deliberately out of scope here and will compose its own declarations through * `core/src/modules/permissions.ts`), which merges these same declarations with every installed
* `createPermissionRegistry` rather than through this constant. * module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's
* permissions in isolation, without composing in any module.
*/ */
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry( export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_DECLARATIONS,
+13
View File
@@ -1,8 +1,11 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js";
import { import {
composePlatformPermissionRegistry,
discoverModuleSchemas, discoverModuleSchemas,
ModuleDiscoveryError, ModuleDiscoveryError,
ModulePermissionCompositionError,
ModuleSchemaDiscoveryError, ModuleSchemaDiscoveryError,
} from "../modules/index.js"; } from "../modules/index.js";
@@ -31,6 +34,12 @@ async function main(): Promise<void> {
}); });
const modules = schemaAggregate.discoveredModules; const modules = schemaAggregate.discoveredModules;
// Reuses the modules discoverModuleSchemas already discovered — never rediscovers modules
// solely for permission composition. A manifest that passes structural validation but claims
// the reserved core.* namespace, or duplicates another declaration, fails validation here
// before this module is considered installable.
composePlatformPermissionRegistry(modules);
if (modules.length === 0) { if (modules.length === 0) {
console.log("Module validation passed (0 modules installed)."); console.log("Module validation passed (0 modules installed).");
return; return;
@@ -57,6 +66,10 @@ main().catch((error: unknown) => {
const issuePath = issue.path ? ` (${issue.path})` : ""; const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`); console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
} }
} else if (error instanceof ModulePermissionCompositionError) {
console.error(`[${error.code}] ${error.message}`);
} else if (error instanceof PermissionRegistryError) {
console.error(`[${error.code}] ${error.message}`);
} else { } else {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
} }
+1 -1
View File
@@ -11,7 +11,7 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti
const sections: readonly DoctorSection[] = Object.freeze([ const sections: readonly DoctorSection[] = Object.freeze([
section("Platform", ["platform.runtime"]), section("Platform", ["platform.runtime"]),
section("Modules", ["modules.discovery", "modules.dependencies"]), section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration"]), section("Identity", ["identity.configuration"]),
+93 -1
View File
@@ -1,6 +1,12 @@
import path from "node:path"; import path from "node:path";
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts"; import {
isVersionCompatible,
moduleIdSchema,
permissionIdSchema,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm"; import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import { import {
type CreateIdentityConfigOptions, type CreateIdentityConfigOptions,
createIdentityConfig, createIdentityConfig,
@@ -18,9 +24,11 @@ import {
planModuleMigrations, planModuleMigrations,
} from "../migrations/index.js"; } from "../migrations/index.js";
import { import {
composePlatformPermissionRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
ModulePermissionCompositionError,
type ModuleDiscoveryOptions, type ModuleDiscoveryOptions,
type ModuleMigrationAggregate, type ModuleMigrationAggregate,
type ModuleSchemaAggregate, type ModuleSchemaAggregate,
@@ -30,6 +38,7 @@ export type DoctorCheckId =
| "platform.runtime" | "platform.runtime"
| "modules.discovery" | "modules.discovery"
| "modules.dependencies" | "modules.dependencies"
| "modules.permissions"
| "schemas.discovery" | "schemas.discovery"
| "migrations.discovery" | "migrations.discovery"
| "postgresql.connection" | "postgresql.connection"
@@ -68,6 +77,9 @@ export interface DoctorOptions {
} }
export interface DoctorDependencies { export interface DoctorDependencies {
/** Synchronous, matching the real implementation: pure in-memory composition over an
* already-discovered module list, never re-discovering modules itself. */
readonly composePermissions: (modules: readonly DiscoveredModule[]) => PermissionRegistry;
readonly createMigrationDatabase: ( readonly createMigrationDatabase: (
connectionString: string, connectionString: string,
options: PostgresMigrationDatabaseOptions, options: PostgresMigrationDatabaseOptions,
@@ -101,6 +113,7 @@ async function discoverAllMigrationsForRuntime(
} }
const productionDependencies: DoctorDependencies = Object.freeze({ const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase, createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime, discoverMigrations: discoverAllMigrationsForRuntime,
discoverModules, discoverModules,
@@ -163,6 +176,57 @@ function safeMigrationReference(identity: string, checksum: string): string {
return `${identity} · ${checksum.slice(0, 12)}`; return `${identity} · ${checksum.slice(0, 12)}`;
} }
function safeModuleId(value: string): string {
if (!moduleIdSchema.safeParse(value).success) {
throw new TypeError("Unsafe module diagnostic value.");
}
return value;
}
function safePermissionId(value: string): string {
if (!permissionIdSchema.safeParse(value).success) {
throw new TypeError("Unsafe permission diagnostic value.");
}
return value;
}
/** Distinguishes the two composition-error families rather than describing a reserved `core.*`
* declaration as a generic duplicate. Never includes a module directory, manifest path, caught
* cause, or stack — only the module id and permission id the structured errors already carry,
* each re-validated before being interpolated (defense in depth against a future drift bug,
* matching `safeModuleDetails`/`safeMigrationReference` elsewhere in this file); if that
* re-validation itself fails, this falls back to a fixed generic detail rather than throwing. */
function permissionCompositionFailure(error: unknown): DoctorCheck {
if (error instanceof ModulePermissionCompositionError) {
try {
const moduleId = safeModuleId(error.moduleId);
const permissionId = safePermissionId(error.permissionId);
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
`Module "${moduleId}" declared permission "${permissionId}" in the reserved core.* namespace.`,
]);
} catch {
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
"A module declared a permission in the reserved core.* namespace.",
]);
}
}
if (error instanceof PermissionRegistryError) {
try {
const permissionId = safePermissionId(error.permissionId);
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
`Permission "${permissionId}" is declared more than once.`,
]);
} catch {
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
"A permission is declared more than once.",
]);
}
}
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
"The installed permission declarations could not be composed safely.",
]);
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck { function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details = const details =
error instanceof Error && error.name === "ModuleDiscoveryError" error instanceof Error && error.name === "ModuleDiscoveryError"
@@ -386,6 +450,33 @@ export async function diagnoseEkklesia(
]); ]);
} }
// Reuses the already-discovered module list above — never re-discovers modules solely for
// permission composition. Independent of the schema/migration checks below: a permission
// composition failure here (a reserved-namespace or duplicate declaration) does not skip those,
// since neither depends on the permission registry, only on discovery having succeeded.
let permissionsCheck: DoctorCheck;
if (!discoveredModules) {
permissionsCheck = freezeCheck(
"modules.permissions",
"skipped",
"Permission composition skipped",
["Complete module discovery is required."],
);
} else {
try {
const registry = dependencies.composePermissions(discoveredModules);
const count = registry.declarations.length;
permissionsCheck = freezeCheck(
"modules.permissions",
"pass",
"Permission registry composed",
[`${count} ${plural(count, "permission")} registered`],
);
} catch (error) {
permissionsCheck = permissionCompositionFailure(error);
}
}
let schemaCheck: DoctorCheck; let schemaCheck: DoctorCheck;
let migrationCheck: DoctorCheck; let migrationCheck: DoctorCheck;
let migrations: ModuleMigrationAggregate | undefined; let migrations: ModuleMigrationAggregate | undefined;
@@ -466,6 +557,7 @@ export async function diagnoseEkklesia(
runtimeCheck, runtimeCheck,
moduleCheck, moduleCheck,
dependencyCheck, dependencyCheck,
permissionsCheck,
schemaCheck, schemaCheck,
migrationCheck, migrationCheck,
postgresConnectionCheck, postgresConnectionCheck,
+12 -6
View File
@@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints {
readonly jobs?: string; readonly jobs?: string;
} }
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown // Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
// which structurally satisfy `T extends object` — without this check first, the mapped-type
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
? T ? T
: T extends readonly (infer Item)[] : T extends (...arguments_: never[]) => unknown
? readonly DeepReadonly<Item>[] ? T
: T extends object : T extends readonly (infer Item)[]
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> } ? readonly DeepReadonly<Item>[]
: T; : T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>; export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
+1
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";
+82
View File
@@ -0,0 +1,82 @@
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 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. This
* check runs before a declaration is ever added to the list `createPermissionRegistry` sees, so it
* is also what actually catches a module attempting to collide with a Core-owned id specifically:
* every Core-owned id is itself `core.`-prefixed, module ids may never contain a dot (so only a
* module whose own id is literally `"core"` can produce a `core.`-prefixed permission at all), and
* a genuine module-vs-module collision is structurally unreachable through real discovery — module
* ids are unique (discovery itself rejects a duplicate) and every permission id is namespaced by
* its own module's id. `PermissionRegistryError` therefore never fires for a real, validly
* discovered installed-module set; `createPermissionRegistry`'s duplicate rejection remains here as
* a defensive, unconditional guarantee regardless, and is exercised directly with deliberately
* conflicting test data rather than through a scenario real discovery could ever produce.
*/
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",
+4
View File
@@ -6,6 +6,7 @@ import {
} from "../src/diagnostics/index.js"; } from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js"; import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js"; import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
const originalNoColor = process.env.NO_COLOR; const originalNoColor = process.env.NO_COLOR;
@@ -36,6 +37,7 @@ function zeroMigrationAggregate(): ModuleMigrationAggregate {
function noConfigurationDependencies(): DoctorDependencies { function noConfigurationDependencies(): DoctorDependencies {
return { return {
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: () => { createMigrationDatabase: () => {
throw new Error("database must not be constructed"); throw new Error("database must not be constructed");
}, },
@@ -75,6 +77,8 @@ Modules
0 modules installed 0 modules installed
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed
1 permission registered
Data definitions Data definitions
✓ Schemas validated ✓ Schemas validated
+123 -2
View File
@@ -1,5 +1,6 @@
import type { ModuleManifest } from "@ekklesia/contracts"; import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import { import {
type DoctorCheck, type DoctorCheck,
type DoctorDependencies, type DoctorDependencies,
@@ -21,6 +22,7 @@ import type {
ModuleMigrationAggregate, ModuleMigrationAggregate,
ModuleSchemaAggregate, ModuleSchemaAggregate,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
import { ModulePermissionCompositionError } from "../src/modules/permissions.js";
const checksumA = "a".repeat(64); const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64); const checksumB = "b".repeat(64);
@@ -39,7 +41,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: [] },
@@ -125,6 +127,11 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies { function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
return { return {
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
createPermissionRegistry(
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
),
),
createMigrationDatabase: vi.fn(() => emptyDatabase()), createMigrationDatabase: vi.fn(() => emptyDatabase()),
discoverMigrations: vi.fn(async () => migrationAggregate()), discoverMigrations: vi.fn(async () => migrationAggregate()),
discoverModules: vi.fn(async () => Object.freeze([])), discoverModules: vi.fn(async () => Object.freeze([])),
@@ -196,6 +203,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"platform.runtime", "platform.runtime",
"modules.discovery", "modules.discovery",
"modules.dependencies", "modules.dependencies",
"modules.permissions",
"schemas.discovery", "schemas.discovery",
"migrations.discovery", "migrations.discovery",
"postgresql.connection", "postgresql.connection",
@@ -205,6 +213,10 @@ describe("Ekklesia Doctor diagnostics", () => {
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"],
status: "pass",
});
expect(check(report, "migrations.discovery").details).toEqual([ expect(check(report, "migrations.discovery").details).toEqual([
"1 migration · checksums valid", "1 migration · checksums valid",
"people:0000_initial · aaaaaaaaaaaa", "people:0000_initial · aaaaaaaaaaaa",
@@ -369,6 +381,7 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(check(report, "modules.discovery").status).toBe("failure"); expect(check(report, "modules.discovery").status).toBe("failure");
for (const id of [ for (const id of [
"modules.dependencies", "modules.dependencies",
"modules.permissions",
"schemas.discovery", "schemas.discovery",
"migrations.discovery", "migrations.discovery",
"postgresql.connection", "postgresql.connection",
@@ -385,6 +398,114 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(JSON.stringify(report)).not.toContain("password"); expect(JSON.stringify(report)).not.toContain("password");
}); });
it("passes permission composition with a safe declaration count when modules are installed", async () => {
const calendar = discoveredModule("calendar");
const report = await diagnoseEkklesia(
options(),
dependencies({
composePermissions: vi.fn(() =>
createPermissionRegistry([
{
baseline: "restricted",
description: "Read calendar events.",
id: parsePermissionId("calendar.events.read"),
},
]),
),
discoverModules: vi.fn(async () => Object.freeze([calendar])),
}),
);
expect(check(report, "modules.permissions")).toMatchObject({
details: ["1 permission registered"],
status: "pass",
});
});
it("reuses the already-discovered module list rather than rediscovering modules for permission composition", async () => {
const calendar = discoveredModule("calendar");
const composePermissions = vi.fn(() => createPermissionRegistry([]));
const discoverModules = vi.fn(async () => Object.freeze([calendar]));
await diagnoseEkklesia(options(), dependencies({ composePermissions, discoverModules }));
expect(discoverModules).toHaveBeenCalledOnce();
expect(composePermissions).toHaveBeenCalledOnce();
expect(composePermissions).toHaveBeenCalledWith([calendar]);
});
it("fails permission composition when a module claims the reserved core.* namespace, distinctly from a generic duplicate", async () => {
const report = await diagnoseEkklesia(
options(),
dependencies({
composePermissions: vi.fn(() => {
throw new ModulePermissionCompositionError("core", "core.rogue.permission");
}),
}),
);
expect(check(report, "modules.permissions")).toMatchObject({
details: [
'Module "core" declared permission "core.rogue.permission" in the reserved core.* namespace.',
],
status: "failure",
});
expect(report.result).toBe("not-ready");
});
it("fails permission composition on a generic duplicate declaration, distinctly from a reserved-namespace violation", async () => {
const report = await diagnoseEkklesia(
options(),
dependencies({
composePermissions: vi.fn(() => {
throw new PermissionRegistryError("calendar.events.read");
}),
}),
);
expect(check(report, "modules.permissions")).toMatchObject({
details: ['Permission "calendar.events.read" is declared more than once.'],
status: "failure",
});
expect(report.result).toBe("not-ready");
});
it("sanitizes an unexpected permission-composition exception to a fixed generic detail", async () => {
const hostile = "postgresql://admin:[email protected]/db /home/operator/.env";
const report = await diagnoseEkklesia(
options(),
dependencies({
composePermissions: vi.fn(() => {
throw new Error(hostile);
}),
}),
);
expect(check(report, "modules.permissions")).toMatchObject({
details: ["The installed permission declarations could not be composed safely."],
status: "failure",
});
expect(JSON.stringify(report)).not.toContain(hostile);
});
it("continues independent schema, migration, and PostgreSQL checks after permission composition fails", async () => {
const planMigrations = vi.fn(async () => plan());
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
composePermissions: vi.fn(() => {
throw new PermissionRegistryError("calendar.events.read");
}),
planMigrations,
}),
);
expect(check(report, "modules.permissions").status).toBe("failure");
expect(check(report, "schemas.discovery").status).toBe("pass");
expect(check(report, "migrations.discovery").status).toBe("pass");
expect(check(report, "postgresql.connection").status).toBe("pass");
expect(planMigrations).toHaveBeenCalledOnce();
});
it("continues independent migration and PostgreSQL checks after schema failure", async () => { it("continues independent migration and PostgreSQL checks after schema failure", async () => {
const planMigrations = vi.fn(async () => plan()); const planMigrations = vi.fn(async () => plan());
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
+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",
@@ -19,6 +19,15 @@ registry, which conflicts with install-by-cloning discovery.
## Decision ## Decision
> **Partial correction — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).**
> `manifestVersion: 2` and the permission-string-array shape described immediately below (the
> `permissions` field of the static manifest) were superseded by ADR 0014's `manifestVersion: 3`,
> which represents each permission as a complete `{id, baseline, description}` declaration instead
> of a bare identifier string. Every other decision in this ADR — versioned capabilities,
> `defineModuleRuntime`, the compiled-runtime production loading boundary, and registration/lifecycle
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
> historical record of the manifest's prior shape.
`ekklesia.module.json` remains the one canonical static manifest and moves to `ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
semantic version. Required and optional capability dependencies map identifiers to semantic-version semantic version. Required and optional capability dependencies map identifiers to semantic-version
@@ -194,6 +194,13 @@ module permission discovery/registration (a mechanism for a module to contribute
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
remain later work. remain later work.
> **Update — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).** Module
> permission discovery/registration, deferred above, was implemented by ADR 0014: a module manifest
> declares permissions in the same `PermissionDeclaration` shape this ADR's registry uses, and
> `apps/api` composes Core's declarations with every installed module's into the one registry every
> request is enforced against. Roles/groups/teams/tenants, owner-management HTTP, and service
> accounts/API tokens remain deferred.
## Alternatives ## Alternatives
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a - Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
@@ -0,0 +1,233 @@
# 0014: Module permission declarations and platform permission registry
- Status: Accepted
- Date: 2026-07-24
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md)
- Supersedes (partial): [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — only
its `manifestVersion: 2` and bare-permission-string decision. Capabilities, the compiled-runtime
production loading boundary, and registration/lifecycle ordering established in ADR 0006 remain
accepted and unchanged.
## Context
ADR 0013 established Core's authorization foundation — schema, evaluator, store, permission
registry, platform-owner bootstrap, and HTTP enforcement — but explicitly deferred module
permission registration: a module manifest (ADR 0006, `manifestVersion: 2`) could declare a
`permissions` array, but only as bare namespaced strings. That was enough to validate a namespace
at manifest-parse time, but not enough to *register* anything: Core's authorization registry needs
a trusted baseline and an operator-facing description for every permission it enforces, and a bare
string carries neither. Phase 5A closes that gap in two passes on this branch: pass 1
(`feat: declare module permissions`) established the shared declaration contract, manifest version
3, and a pure composition function; pass 2 (`feat: integrate module authorization`) wired the
composed registry into `apps/api`'s production startup and its existing enforcement/explicit-grant
HTTP surface. This ADR records the durable architecture both passes established.
## Decision
### `PermissionDeclaration` is the one shared, contracts-layer definition
`packages/contracts/src/authorization.ts`'s `permissionDeclarationSchema` is the single
authoritative shape of a registered permission declaration — `id` (a branded `PermissionId`),
`baseline` (`public`/`authenticated`/`restricted`, the evaluator's trusted baseline), and
`description` (required, trimmed, non-empty, capped at 240 characters). `.strict()` rejects any
other property. Both Core's own authorization registry
(`core/src/authorization/permissions.ts`) and a module manifest's `permissions` array
(`packages/contracts/src/module-manifest.ts`) consume this exact type — Core does not declare a
second, independently maintained interface, and a module cannot smuggle extra fields into a
declaration Core treats as trusted. `description` is informational only: audit and operator UI
text, never read or interpreted by the evaluator, the registry, or enforcement — changing it can
never change what a permission allows.
### Module manifests are inert JSON and the sole permission-declaration source
A module's `ekklesia.module.json` remains inert, static data Core parses without executing any
module code — consistent with ADR 0006's discovery model. Its `permissions` array is the only
place a module declares permissions; nothing in `defineModuleRuntime` (Module SDK,
`packages/module-sdk/src/define-module-runtime.ts`) repeats, duplicates, or otherwise re-declares
permissions, baselines, or descriptions — exactly like it already does not repeat capabilities,
dependencies, storage requests, or entrypoints (ADR 0006). Core never imports or runs a module's
server entrypoint, job, or lifecycle hook to discover its permissions; only the manifest is read.
### Manifest version 3 replaces version 2's bare permission strings
Representing a permission as a complete declaration instead of a bare identifier string is an
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
compatibility adapter, no automatic conversion from a string array to declarations, and no dual
acceptance of both shapes. A module author must rewrite `permissions` explicitly. `permissions`
still defaults to an empty array when the field is omitted entirely, exactly as version 2 did.
### Each module owns exactly its own namespace; `core.*` is reserved
A permission id declared in a module's manifest must begin with `<module-id>.`, checked the same
way a capability id already is (ADR 0006). Module ids never contain a dot (`moduleIdSchema` is
plain kebab-case), so the only module whose manifest could ever declare a `core.`-prefixed
permission and still pass this per-manifest namespace check is one whose own id is literally
`"core"` — `core.*` itself remains reserved for Core's own permissions, enforced independently at
composition time (below). Duplicate permission ids within one manifest fail manifest validation at
the exact issue path `permissions[index].id`, mirroring the existing capability and storage-bucket
duplicate checks.
### Core and installed-module declarations compose into one immutable registry
`composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) builds the platform's one
permission registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every
already-discovered module's declarations. It reuses `createPermissionRegistry`
(`core/src/authorization/permissions.ts`) rather than reimplementing registry construction or
duplicate detection — there is no second, module-specific registry type. Order is deterministic:
Core's declarations first, then each module in the exact order it appears in the caller-supplied
list, then each module's own declarations in manifest-array order. The resulting registry, and
every declaration it exposes, is immutable (`Object.freeze`), matching `createPermissionRegistry`'s
existing guarantee. Composition is pure, synchronous, in-memory work over data the caller already
discovered: no filesystem, environment, database, network, or module-code operation happens inside
it.
`core/src/authorization` continues to know nothing about modules — it does not import module
discovery or runtime. `core/src/modules/permissions.ts` is the one-directional module-integration
layer that depends on `core/src/authorization`, never the reverse.
### The structural collision guarantee
A discovered module contributing any `core.`-prefixed permission id fails composition with
`ModulePermissionCompositionError` (`MODULE_PERMISSION_RESERVED_NAMESPACE`) — checked, and thrown,
before that declaration is ever added to the list `createPermissionRegistry` sees. This is a
deliberate architectural guarantee, not an incidental test limitation, and it follows from three
facts holding simultaneously: real module discovery rejects a duplicate module id
(`MODULE_ID_DUPLICATE`), so module ids are unique; a manifest's own namespace check requires every
permission id to begin with `<module-id>.`; and a manifest already rejects a duplicate permission
id within itself. Together, these mean a module can only ever produce a `core.`-prefixed permission
if its own id is literally `"core"` — and when it does, that declaration is caught by the
reserved-namespace check specifically, never by `createPermissionRegistry`'s generic duplicate
detection (`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`). The same three
facts also mean a genuine *module-vs-module* exact permission-id collision cannot arise from a
valid, really-discovered installed-module set at all — two different module ids can never produce
the identical permission string. `createPermissionRegistry`'s own duplicate rejection remains in
place regardless, as an unconditional, defense-in-depth guarantee of the underlying registry
primitive; both Core's test suite and this composer's own tests exercise it directly with
deliberately conflicting internal test data (not reachable through real discovery), specifically to
prove the guarantee holds even though production traffic cannot currently trigger it. Both error
families carry only a module id and/or permission id — never a module directory, manifest path,
caught cause, or stack.
### Module discovery and permission composition precede all database and HTTP construction
`apps/api`'s `startApi()` (`apps/api/src/server.ts`) composes the platform registry
(`composeStartupPermissionRegistry`, `apps/api/src/platform-permissions.ts`) as its first step,
strictly before constructing the shared PostgreSQL pool, Better Auth, the authorization store and
evaluator, the Hono application, or binding the HTTP server. A discovery or composition failure —
an invalid manifest, an incompatible dependency graph, a reserved-namespace claim, or a duplicate
declaration — therefore has nothing to close: it propagates directly out of `startApi()`, and
startup never falls back to a Core-only registry. Modules are discovered exactly once for this
composition; nothing re-discovers them a second time for permissions specifically.
Repository and package-version information is derived from the installed repository's own layout —
never `process.cwd()`, which a package-scoped test run does not set to the repository root. The
repository root is resolved from the composing module's own compiled/source file location (a fixed
number of directory levels up, identical whether running `apps/api/dist/*.js` or, under a test
runner, `apps/api/src/*.ts`); the platform and Module SDK versions are read from their real
`package.json` files, only when startup actually executes — never merely by importing the API's
server, config, or app modules, which remain side-effect-free at import time.
### API enforcement and explicit grants use one registry for Core and module permissions alike
`createApiApp` requires the composed platform registry as an injected authorization option;
`startApi` is the only production caller, and it always supplies the registry
`composeStartupPermissionRegistry` produced. There is no `CORE_PERMISSION_REGISTRY` fallback
anywhere in `apps/api` — not in `createApiApp`, not in `createGrantsRoutes`, not in the
`requirePermission` middleware. `requirePermission` has no notion of "Core" versus "module"
permissions; it resolves a route's declared `PermissionId` and its trusted baseline from whichever
registry it is given, denies before principal resolution or evaluator access when the permission is
unregistered, and never accepts a baseline, actor, or any other authorization-relevant field from
client input. A module's `public`, `authenticated`, or `restricted` declaration is therefore
enforced identically to Core's own `core.authorization.grants.manage`, through the identical
evaluator and grant path, and — because registration and evaluator shape validation remain the two
separate, independent fail-closed layers ADR 0013 established — a well-formed but unregistered
permission (module or Core) still receives a sanitized `403` with no store or audit write, whether
the request is a permission check or an explicit-grant creation attempt on `POST /api/v1/grants`.
### The registry is a static startup snapshot
There is no hot installation, removal, or dynamic mutation of the composed registry while `apps/api`
is running. Installing, removing, or editing a module's manifest takes effect only on the next API
restart. Removing a module from the next startup's discovered inventory makes its permission
unknown to new authorization checks and new explicit grants — it denies exactly like any other
unregistered permission — but never deletes, revokes, or otherwise modifies a historical grant or
audit row already recorded for that permission. A previously granted, still-unrevoked explicit
grant remains listed through `GET /api/v1/grants`, and revoking it explicitly remains possible,
since revocation is keyed to the grant's own id and source kind, not to whether its permission is
currently registered. What happens if the exact same permission id is later reinstalled or
reactivated in a future startup — whether a preserved, still-active grant becomes effective again
automatically — is **explicitly not decided by this ADR** and remains deferred; nothing here should
be read as a guarantee either way.
### Doctor and `modules:validate` compose the same inventory
`pnpm doctor`'s `modules.permissions` check and `pnpm modules:validate` both call
`composePlatformPermissionRegistry` over the module list their own already-completed discovery
produced — neither rediscovers modules solely to check permissions. A structurally valid manifest
that claims the `core.*` namespace, or that would produce a duplicate declaration, now fails before
that module is considered installable (`modules:validate`) or reported healthy (`doctor`).
### Test-only discovery overrides are not a production configuration surface
`composeStartupPermissionRegistry`'s `modulesDirectoryOverride` and `discoveredModulesOverride`
exist solely so tests can exercise discovery and composition against controlled fixtures or
deliberately conflicting data — the latter specifically because a genuine module-vs-module
duplicate is otherwise unreachable through real discovery, as established above. Neither is read
from an environment variable, an HTTP header, or any other client- or deployment-controlled input;
`main()`, the executable entry point, never sets either.
## Alternatives
- **Runtime, code-based permission registration** (a module registering permissions imperatively
from its server entrypoint or a registration hook) was rejected: it would require executing
module code to discover what a module can even offer, breaking the inert-manifest discovery
model ADR 0006 already established and that Doctor and `modules:validate` depend on for
offline, side-effect-free validation.
- **A second, module-specific registry implementation** was rejected in favor of reusing
`createPermissionRegistry` — a second implementation would need to reimplement duplicate
detection, immutability, and fail-closed lookup independently, with no guarantee the two stayed
behaviorally identical.
- **Falling back to `CORE_PERMISSION_REGISTRY` when discovery or composition fails** was rejected:
it would let the API start in a state where module-declared permissions silently do not exist,
which is indistinguishable from "no modules were ever installed" and would mask a real
configuration error as ordinary, healthy startup.
- **Dynamic, mutable, or hot-reloaded registration** (updating the registry while the process
runs, without a restart) was rejected for this phase: it introduces concurrent-mutation and
partial-update hazards this phase does not need to solve, and a static snapshot is sufficient
for the current deployment model.
- **Silently accepting version-2 manifests** (auto-converting a bare permission string into a
declaration with a synthesized baseline/description) was rejected: any synthesized baseline
would be a guess Core cannot trust, and the whole point of requiring a real declaration is that
the baseline and description come from the module author, not from Core inventing them.
- **Deleting or automatically revoking a module's grants when the module disappears** was
rejected: it would destroy an audit trail that must remain queryable regardless of a module's
current installation state, and revocation is a deliberate, actor-attributed action elsewhere in
this system (ADR 0013) — an uninstall is not that action.
- **Reading a permission's baseline from HTTP request input** (letting a caller assert which
baseline to check) was already rejected by ADR 0013 and remains rejected here: the baseline is
never anything but the registry's own trusted value for a request-supplied `PermissionId`.
## Consequences
A module gains a real, enforceable permission the moment its manifest is written correctly and the
module is discovered — no separate registration step, no code to write beyond the manifest itself.
Core's own permissions and every module's permissions share one enforcement mechanism end to end:
one registry, one middleware, one explicit-grant HTTP API. The reserved `core.*` namespace and the
structural collision guarantee mean a module can never accidentally or deliberately shadow a
Core-owned permission without that attempt being caught, sanitized, and reported as exactly what it
is. The cost is the one explicitly deferred question this ADR leaves open — reinstall/reactivation
semantics for a permission whose module was previously removed — and the operational fact that
every module inventory change requires an API restart to take effect, which is acceptable for the
current deployment model but not the final word on it.
## Deferred
Module HTTP route-registration contracts (a module mounting its own endpoints); a
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
reinstall/reactivation policy for a permission whose owning module was previously removed;
roles, groups, teams, and tenants; an owner-management HTTP endpoint; service accounts and API
tokens for non-browser automation; module installation/uninstallation state and hot reload; Surfaces
and UI; and workflow/event automation. None of these are implemented by, or required for, the
architecture this ADR records.
+1
View File
@@ -22,3 +22,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted - [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted - [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
+12
View File
@@ -206,6 +206,18 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
abuse-control design. abuse-control design.
## Module permission discovery at startup
Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers
every installed module's manifest and composes the one immutable platform permission registry
(Core's own declarations plus every module's) that the rest of startup — and every subsequent
request — enforces against. A discovery or composition failure (an invalid manifest, a module
claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails
startup outright, before there is anything to close; startup never falls back to a Core-only
registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
for the full flow, and [Module development](module-development/README.md) for the manifest shape
that produces these declarations.
## Database connection limits ## Database connection limits
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`, `apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
+196 -36
View File
@@ -1,24 +1,29 @@
# Authorization # Authorization
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into Phase 4C establishes Core's authorization foundation and wires it into `apps/api` over HTTP. Phase
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the 5A extends that same system so an installed module's declared permissions are enforced and
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it. grantable through the identical mechanism as Core's own. This page covers all of it: the
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the
HTTP transport it is mounted into, and [ADR enforcement/endpoints `apps/api` exposes, and how module manifests contribute to the one platform
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable permission registry. See [Identity](identity.md) for the identity foundation this builds on,
architectural decisions this page describes in more implementation detail. [API](api.md) for the HTTP transport it is mounted into, [Module development](../module-development/README.md)
for the manifest shape that declares module permissions, and
[ADR 0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) /
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md)
for the durable architectural decisions this page describes in more implementation detail.
## What this is, and is not ## What this is, and is not
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of registered permission, Core-owned or module-owned; and every enforced route fails closed on
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants; infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
exposed at all in this slice); there are no service accounts or API tokens, so only re-granting platform-owner status is not exposed at all); there are no service accounts or API
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
grants (for example, automatic access from a roster assignment) cannot yet be created by anything yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
in this repository — the generic grants API structurally cannot create or revoke them either. a roster assignment) cannot yet be created by anything in this repository — the generic grants API
structurally cannot create or revoke them either.
## Core authorization schema and store ## Core authorization schema and store
@@ -31,10 +36,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 +58,121 @@ 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).
## API startup: discovery, composition, and enforcement
`apps/api` builds its production permission registry from the complete installed-module inventory
every time it starts, in a fixed order, before anything else:
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
platform/Module SDK versions from their own `package.json` files).
2. Discover installed modules (`discoverModules`).
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
4. Construct the shared PostgreSQL pool.
5. Construct Identity and Authorization.
6. Create the Hono application, injecting the composed registry.
7. Bind the HTTP server.
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY`
or any Core-only registry. The repository root is resolved from this compiled/source file's own
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working
directory, which would otherwise point discovery at the wrong `modules/` directory.
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
installation, removal, or dynamic mutation of installed modules or their permissions while the
process is running — an installed-module change (add, remove, or edit a manifest) takes effect
only on the next API restart. `createApiApp` requires this registry as part of its injected
authorization options; `requirePermission` and the explicit-grant routes
(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same
registry Core's own `core.authorization.grants.manage` permission is registered in, so a
module-owned permission and Core's own are enforced, and grantable, through the identical
mechanism — there is no module-specific enforcement path. Removing a module from the next
startup's discovered inventory makes its permission unknown to new authorization checks and new
grants (a well-formed but unregistered permission denies exactly as before, including on
`POST /api/v1/grants`, which checks the registry explicitly before calling the store); it never
deletes, revokes, or otherwise modifies a historical grant or audit row already recorded for that
permission. A previously granted, still-unrevoked explicit grant for a now-uninstalled module's
permission remains visible through `GET /api/v1/grants` and simply stops being something a
`restricted`-baseline authorization check can newly succeed against. Listing and explicit
revocation (`POST /api/v1/grants/:grantId/revoke`) are unaffected either way: neither route
consults the registry for a grant that already exists — revocation is keyed to the grant's own id
and source kind, not to whether its permission is currently registered — so a manager can still
revoke a historical grant for a now-unregistered permission.
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
middleware.
## Doctor and modules:validate
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
it never re-discovers modules itself. A successful composition reports a pass with a safe
declaration count (for example `1 permission registered` with zero installed modules, since Core's
own declaration is always present); zero installed modules still composes successfully. If module
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
pattern; if discovery succeeded but composition failed, schema and migration checks still run
normally — they are independent diagnostics that do not depend on the permission registry. A
reserved-namespace violation (`ModulePermissionCompositionError`,
`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration
(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct
failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even
though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as
the reserved-namespace failure specifically. Composition requires no database and never imports or
executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its
own already-discovered modules for the identical reason: a manifest that is otherwise structurally
valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before
that module is considered installable.
### Two layers, not one: shape versus registration ### Two layers, not one: shape versus registration
@@ -218,7 +339,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel
| Field | Required | Notes | | Field | Required | Notes |
| --- | --- | --- | | --- | --- | --- |
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | | `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | | `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
| `expiresAt` | No | An ISO date string if present. | | `expiresAt` | No | An ISO date string if present. |
@@ -287,7 +408,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`
@@ -298,13 +431,27 @@ there are or whether the failure happened during startup.
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
still blocking reuse, and the concurrency scenario described above. still blocking reuse, and the concurrency scenario described above.
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
`restricted` declarations identically to Core's own, that the evaluator receives the registry's
exact id/baseline, and that an absent module permission denies before principal resolution or
evaluator access — the shared mechanism, not a module-specific code path.
- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions`
check: passing count (including zero installed modules), reuse of the already-discovered module
list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
independently after a composition failure, and the exact documented terminal report shape.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, a well-formed but unregistered target permission rejected with and malformed-field rejection, a well-formed but unregistered target permission rejected with
`403` and `store.createGrant` never called, exact server-derived-argument assertions, `403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping. store-error mapping.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. - `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
plus module-permission composition at startup: discovery/composition run before any pool,
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
instances/servers and remain distinguishable from one another; zero installed modules still
succeeds; and import safety for `platform-permissions.ts`.
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under - `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
consulting the grant or platform-owner ports for `public`/`authenticated`. consulting the grant or platform-owner ports for `public`/`authenticated`.
@@ -314,11 +461,21 @@ there are or whether the failure happened during startup.
account then managing grants as a non-owner, a well-formed but unregistered target permission account then managing grants as a non-owner, a well-formed but unregistered target permission
rejected with `403` (with no grant or audit row written) even for a caller with full rejected with `403` (with no grant or audit row written) even for a caller with full
grant-management authority, a create/list/revoke cycle with audit-trail verification (using grant-management authority, a create/list/revoke cycle with audit-trail verification (using
`core.authorization.grants.manage` itself as the granted permission, since module permission `core.authorization.grants.manage` itself as the granted permission), disallowed-field
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection, rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
infrastructure-failure scenario (database dropped out from under a live session) asserting a dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
`503` rather than a false `403` or `200`. - `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint
that throws if ever imported) proves the complete chain against a real API process and real
Doctor run: Doctor recognizes the installed module and passes permission composition without
executing its entrypoint; API startup discovers the fixture and makes its declared permission
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's
permission with an atomic audit row recorded under its exact declared id; a well-formed
permission absent from the composed registry is rejected with no grant or audit write; and,
after a clean shutdown and restart with the module absent, the same permission can no longer be
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
Run the focused suites: Run the focused suites:
@@ -330,7 +487,10 @@ pnpm test:migrations:integration # requires Docker
## Next phase ## Next phase
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission Deliberately deferred, unrelated slices — see
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service [ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself for the complete list: module HTTP route-registration contracts (a module mounting its own
(a roster or similar module operation, not the generic grants API). endpoints), a module runtime-context authorization service, relationship-derived grant creation
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
+2
View File
@@ -101,6 +101,8 @@ Modules
0 modules installed 0 modules installed
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed
1 permission registered
Data definitions Data definitions
✓ Schemas validated ✓ Schemas validated
+35 -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,33 @@ 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.
`apps/api` composes this registry once, at startup, from every module installed at that time (see
[Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement));
installing, removing, or editing a module's manifest takes effect only on the next API restart, not
while it is running. Once installed and discovered, a module's declared permission is enforceable
and grantable through exactly the same mechanism as any Core-owned permission — the same
`requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform
owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a
module's own HTTP routes, and creating relationship-derived grants automatically from a module's
own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface.
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");