Files
Ekklesia/docs/development/authorization.md
T
ekklesia c38b572778
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00

37 KiB
Raw Blame History

Authorization

Phase 4C establishes Core's authorization foundation and wires it into apps/api over HTTP. Phase 5A extends that same system so an installed module's declared permissions are enforced and grantable through the identical mechanism as Core's own. This page covers all of it: the schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the enforcement/endpoints apps/api exposes, and how module manifests contribute to the one platform permission registry. See Identity for the identity foundation this builds on, API for the HTTP transport it is mounted into, Module development for the manifest shape that declares module permissions, and ADR 0013 / ADR 0014 for the durable architectural decisions this page describes in more implementation detail.

What this is, and is not

An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone they delegate to) can create, list, and revoke explicit authorization grants over HTTP for any registered permission, Core-owned or module-owned; a module can register its own HTTP routes and have them mounted, enforced, and reachable in production (see API); and every enforced route fails closed on infrastructure trouble instead of silently allowing or denying. It is not possible to manage roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived grants (for example, automatic access from 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

Established as a prior checkpoint on this branch (ekklesia_core_platform_owner, ekklesia_core_grant, ekklesia_core_authorization_audit; AuthorizationEvaluator, AuthorizationStore; @ekklesia/contracts's PermissionId/GrantId/AuthorizationScope branded types). This page does not restate that design — read core/src/authorization/schema.ts, evaluator.ts, and store.ts directly for it. This page covers what pass 2 added on top: the permission registry, the bootstrap operation, and HTTP enforcement.

Permission registry

PermissionDeclaration (packages/contracts/src/authorization.ts) is the one authoritative, strict Zod-validated shape of a registered declaration — exactly {id, baseline, description}, nothing more — shared by Core's authorization registry and a module manifest's permissions array. Core does not declare its own separate interface for this; core/src/authorization/permissions.ts imports and re-exports the contracts type so existing @ekklesia/core/authorization consumers keep importing it from the same place. id is a branded PermissionId, baseline is public/authenticated/restricted, and description is required, trimmed, non-empty operator and audit-facing text up to 240 characters — informational only; nothing in authorization reads or interprets it.

core/src/authorization/permissions.ts defines the smallest possible registry on top of that shape: an immutable, composition-time-built PermissionRegistry distinguishing a registered declaration from a merely well-formed PermissionId string. Two properties matter:

  • Duplicate declarations fail at composition time. createPermissionRegistry throws PermissionRegistryError (PERMISSION_REGISTRY_DUPLICATE_DECLARATION) if the same permission id is declared twice — this can only happen while wiring the registry itself, not at request time.
  • Unknown permissions fail closed at lookup time. registry.get(permissionId) and registry.has(permissionId) both re-validate their input (safeParsePermissionId internally) and return undefined/false for anything malformed or simply not registered — there is no "trust this permission id because it looks right" path.

Permission matching is always exact string equality, both in the registry and in the evaluator's grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the required dot-namespacing in a PermissionId (core.authorization.grants.manage, calendar.events.read) is purely a naming convention for readability and collision-avoidance.

CORE_PERMISSION_REGISTRY is Core's own registry, currently declaring exactly one permission, and remains what Core-only code and existing tests use directly. CORE_PERMISSION_DECLARATIONS is the same declarations exported separately in deeply immutable form, for the module composition helper below to build on without reconstructing Core's own list:

Permission Baseline Description
core.authorization.grants.manage restricted Create, list, and revoke another user's explicit authorization grants.

Module permission composition

A module manifest's permissions array (manifestVersion: 3, Module development) 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, and starts the module runtime, from the exact same 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 exactly once (discoverModules).
  3. Compose the platform permission registry from that exact discovered list (composePlatformPermissionRegistry).
  4. Construct the shared PostgreSQL pool.
  5. Construct Identity, the authorization store, and the evaluator.
  6. Start the module runtime from that same discovered list (startDiscoveredModuleRuntime) — modules are never rediscovered for this step.
  7. Create the Hono application, injecting the composed registry and mounting the started module runtime's published routes (see API).
  8. Bind the HTTP server.

apps/api/src/platform-permissions.ts's prepareStartupModuleInventory performs steps 1–3, returning both the discovered module list and the composed registry so step 6 can start the module runtime from that identical list rather than rediscovering modules a second time. (composeStartupPermissionRegistry remains available as a registry-only convenience wrapper around it, for callers — Doctor, modules:validate, existing tests — that only need the registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module entrypoint import, 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.

A module's own mounted HTTP routes (/api/v1/modules/<module-id>/*) are enforced through this identical registry and requirePermission middleware — see API for the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated slice — this work only makes a module's declared permission usable through Core's existing, generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes.

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

createAuthorizationEvaluator (core/src/authorization/evaluator.ts) has no registry and is never given one — it independently re-validates permission, baseline, and scope as a pure shape check (safeParsePermissionId/permissionBaselineSchema/safeParseAuthorizationScope) and denies a malformed value under every baseline, including public and authenticated, before any baseline is allowed to return an allowed decision. This is defense in depth for a future caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking calendar.events.read that nothing has ever declared) from a genuinely registered one — it can only reject what is not even shaped like a PermissionId at all.

Registration — "does this permission actually exist" — is exclusively the registry's responsibility, consulted by every trusted caller before the evaluator ever runs: requirePermission (apps/api/src/authorization-middleware.ts) looks up the caller's own required permission (for example core.authorization.grants.manage, always a hardcoded route constant, never client-supplied) in the registry before resolving a principal or evaluating anything; POST /api/v1/grants (apps/api/src/grants-routes.ts) separately looks up the target permission being granted — the one named in the request body — in the same registry, after body shape-validation and before ever calling store.createGrant, so a caller cannot create a dormant grant for an invented permission id that might later become effective the moment someone actually registers it. A malformed target permission (fails PermissionId shape validation) still resolves to the body-level 400 {"code":"INVALID_REQUEST"} documented below, unchanged; a well-formed but unregistered target permission instead receives the identical sanitized 403 {"code":"AUTHORIZATION_DENIED"} a genuinely denied caller would, so that response never reveals whether the caller was denied or the permission was simply never registered.

Platform-owner bootstrap

There is no "first user becomes owner" inference anywhere in this repository, and no HTTP endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command:

pnpm migrations:apply   # the database must already be migrated
DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \
  --user-id <existing-ekklesia-user-id> \
  --confirm-user-id <same-id>

DATABASE_URL is read only when the command actually executes (never merely by importing the CLI module — proved by core/tests/cli-bootstrap-platform-owner.test.ts), and it is never printed, logged, or included in any error. --user-id and --confirm-user-id must match exactly, checked before the id is even parsed as a valid EkklesiaUserId — this command never infers a target from signup order or "the only user in the database"; the operator must name the exact, opaque id twice.

Bootstrap is permanently single-use

core/src/authorization/bootstrap.ts's bootstrapPlatformOwner(db, {userId}) is a dedicated, Hono-independent Core operation — deliberately not a composition of "check for an owner, then call the existing grant function." "Already consumed" means any row has ever existed in ekklesia_core_platform_owner, for any user, including one whose revoked_at is set. There is no owner-management flow in this slice that clears that state; a revoked owner staying revoked (and bootstrap staying consumed) is intentional here.

The single-use check, the target user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL transaction. The very first statement inside that transaction is a transaction-scoped advisory lock:

select pg_advisory_xact_lock(1500000001, 1500000002)

This lock key pair is distinct from the migration runner's own advisory lock (core/src/migrations/postgres-database.ts), so bootstrap serialization can never collide with or be blocked by unrelated migration locking. pg_advisory_xact_lock releases automatically on commit or rollback, so no manual unlock path is needed even when the transaction fails partway. Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live against real PostgreSQL in core/tests/authorization-bootstrap.integration.ts (Promise.allSettled on two simultaneous calls; asserts exactly one fulfilled, exactly one rejected with PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED, exactly one owner row, exactly one audit row).

A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back the entire transaction — there is no partial state where an owner row exists without its audit entry, or vice versa.

The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity — never derived from any caller input — so an auditor can tell a given platform_owner.grant row came specifically from this command rather than from a future owner-management flow:

Field Value
actor_type system
actor_id authorization-bootstrap-cli

Error codes

PlatformOwnerBootstrapError never carries the underlying database error, SQL text, host, port, path, or stack — only a stable code and a fixed message:

Code Meaning
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED A platform-owner row already exists (any user, revoked or not).
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND --user-id does not match an existing identity user.
PLATFORM_OWNER_BOOTSTRAP_FAILED Any other failure (database/infrastructure).

HTTP authorization enforcement

apps/api/src/authorization-middleware.ts exports requirePermission(options, permissionId), a Hono middleware factory reused by every protected route:

  1. Looks up permissionId in the trusted, server-side PermissionRegistry (never a client-supplied baseline) — an unregistered permission id fails closed with 403 before the principal is even resolved. In today's wiring only hard-coded registered constants are ever passed by route code, but the check stays live per-request as defense in depth rather than being trusted once at composition time.
  2. Resolves the principal exclusively through the same server-side identity runtime /api/v1/principal already uses (EkklesiaIdentityRuntime.resolvePrincipal) — nothing here ever trusts a client-supplied claim about identity.
  3. Calls the injected AuthorizationEvaluator with the registry's baseline and permission for that principal (platform owners pass through the evaluator's owner bypass here — this is how delegation to core.authorization.grants.manage recipients also works, without a second code path).
  4. On success, stores the resolved EkklesiaPrincipal (Core's narrow {kind, userId} — never a Better Auth session/user object) on the Hono context via c.set, readable by the route through getAuthorizedPrincipal(c). This is a deliberately narrow exception to the "no generalized session/user object in Hono context" concern ADR 0011 raised — only this already-public-safe principal shape is ever set.

A denied decision and an unregistered permission id both return a sanitized 403 {"code":"AUTHORIZATION_DENIED"}. A genuine infrastructure failure — principal resolution, evaluation, or the database underneath either — is never folded into that 403; it returns a sanitized 503 instead (PRINCIPAL_RESOLUTION_FAILED or AUTHORIZATION_EVALUATION_FAILED), mirroring /api/v1/principal's existing rule that "could not be determined" must never look like "no permission." Neither response ever reveals whether a grant or owner row exists for the caller.

Origin enforcement for mutations

requireTrustedOrigin(trustedOrigins) requires an exact match against the same EKKLESIA_IDENTITY_TRUSTED_ORIGINS set the API already uses for CORS and Better Auth's own checks (see API) — a missing or untrusted Origin header is rejected with 403 {"code":"UNTRUSTED_ORIGIN"} before the permission check or the route handler ever runs. This exists because CORS response headers alone are not CSRF protection: they only stop a browser from reading a cross-origin response, not from sending the state-changing request in the first place. Every grants mutation route (POST /api/v1/grants, POST /api/v1/grants/:grantId/revoke) requires this; the read-only list route does not. Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice and has no way to satisfy this check yet.

Explicit-grant HTTP API

Mounted at the API's root by apps/api/src/grants-routes.ts. Every route requires core.authorization.grants.manage. All three routes only ever act on explicit grants — the generic HTTP API structurally cannot create or revoke a relationship-derived grant.

Route Origin required Notes
GET /api/v1/grants?userId=<id> No Lists the subject's explicit grants only (sourceKind: "explicit" filter applied server-side).
POST /api/v1/grants Yes Creates a new explicit grant.
POST /api/v1/grants/:grantId/revoke Yes Revokes an explicit grant without deleting it.

POST /api/v1/grants request body

Only four keys are accepted; any other key present (actor, owner, isOwner, sourceKind, sourceRef, id, createdAt, revokedAt, createdByActorId, effectivePermissions, or anything else) makes the entire body invalid rather than being silently dropped, so a caller cannot smuggle a server-derived field through a request that otherwise looks well-formed:

Field Required Notes
subjectUserId Yes Must parse as a valid EkklesiaUserId.
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). 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.
expiresAt No An ISO date string if present.

The server always derives, and the request body can never supply: the grant id, sourceKind (always "explicit"), the actor (always the authenticated caller, via createUserActor(principal.userId)), and all audit/timestamp fields.

Response shapes

A grant, wherever returned:

{
  "id": "...",
  "userId": "...",
  "permission": "core.authorization.grants.manage",
  "scope": null,
  "sourceKind": "explicit",
  "createdAt": "2026-07-23T00:00:00.000Z",
  "expiresAt": null,
  "revokedAt": null
}

GET /api/v1/grants → 200 {"grants": [...]}. POST /api/v1/grants → 201 {"grant": {...}}. POST /api/v1/grants/:grantId/revoke → 200 {"success": true}.

Status-code mapping

Status When
200 / 201 Success.
400 {"code":"INVALID_REQUEST"} Malformed query/body/param (including a disallowed extra field).
403 {"code":"AUTHORIZATION_DENIED"} Caller lacks core.authorization.grants.manage; the caller's own required permission id was unregistered; or, for POST /api/v1/grants, the well-formed target permission named in the body is not registered.
403 {"code":"UNTRUSTED_ORIGIN"} Missing or untrusted Origin on a mutation route.
404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"} The targeted grant id does not exist as an explicit grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably.
503 Principal resolution, evaluation, or store failure (PRINCIPAL_RESOLUTION_FAILED, AUTHORIZATION_EVALUATION_FAILED, or an AuthorizationStoreError code).

Every response sets Cache-Control: no-store.

Shared database composition

apps/api/src/database.ts's createApiDatabase(databaseUrl, options) is the one place apps/api touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded pg.Pool (createValidatedPostgresPool, @ekklesia/core/database) and wraps it with three independent typed Drizzle views — createIdentityDrizzleView(pool), createAuthorizationDrizzleView(pool), and createModuleDatabaseView(pool) (the ModuleDatabase facade module code queries through context.services.database; see API and Module development) — rather than three separate pools. close() on the returned handle is the only thing that ever calls pool.end(); no domain view owns or closes the pool itself. apps/api/src/server.ts calls createApiDatabase once during startApi(), builds the identity runtime from .identity, the AuthorizationStore/AuthorizationEvaluator from .authorization, and the frozen ModuleRuntimeServices from .module off that same handle, and closes the handle exactly once — on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring the existing identity-only failure-cleanup path from API).

The existing per-domain factories (createIdentityDrizzleDatabase, createAuthorizationDrizzleDatabase), which each construct and own a private pool, are unaffected and remain available for standalone/testable use — the CLI bootstrap command uses createAuthorizationDrizzleDatabase directly for exactly that reason, since it only ever needs the authorization schema and has no reason to share a pool with anything else.

apps/api/tests/lifecycle.test.ts proves this composition explicitly: exactly one new Pool(...) call for a full startApi() lifecycle (covering identity, authorization, and module database usage), and exactly one pool.end() call regardless of how many concurrent shutdown() callers there are or whether the failure happened during startup.

Tests

  • core/tests/authorization-permissions.test.ts — registry construction, duplicate-declaration rejection, fail-closed lookup, CORE_PERMISSION_REGISTRY/CORE_PERMISSION_DECLARATIONS contents and immutability.
  • packages/contracts/tests/authorization.test.ts — PermissionDeclaration shape validation: every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description rejection, malformed permission id rejection.
  • packages/contracts/tests/module-manifest.test.ts — manifestVersion: 3 acceptance and version 2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above), module-namespace enforcement, duplicate permission id at its exact permissions[index].id issue path, and default-empty-list isolation across separate parses.
  • core/tests/module-permissions.test.ts — composePlatformPermissionRegistry: Core-only and Core-plus-modules composition, deterministic ordering, exact baseline/description preservation, duplicate rejection, reserved core.* namespace rejection, unknown-permission denial once a module is absent from the supplied list, and input/output immutability with no I/O.
  • core/tests/database-pool.test.ts — shared pool validation/construction (mirrors the existing per-domain database tests).
  • core/tests/authorization-bootstrap.test.ts — import safety, PlatformOwnerBootstrapError behavior.
  • core/tests/cli-bootstrap-platform-owner.test.ts — the CLI never constructs a pool merely by being imported.
  • core/tests/authorization-bootstrap.integration.ts (live PostgreSQL) — successful bootstrap and its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner still blocking reuse, and the concurrency scenario described above.
  • apps/api/tests/authorization-middleware.test.ts — allow/deny/unregistered/infrastructure- 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 and malformed-field rejection, a well-formed but unregistered target permission rejected with 403 and store.createGrant never called, exact server-derived-argument assertions, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping.
  • apps/api/tests/module-http-routes.test.ts — the production route-mounting contract: exact prefix/path joining (including the non-trailing-slash root case), every supported method, independent same-path routes across two modules, deterministic static-versus-parameterized resolution by published order, request/handler adaptation (raw Request, frozen context, decoded and frozen path parameters, empty frozen params for static/root routes), the registry's exact baseline for public/authenticated/restricted module permissions, denial/infrastructure- failure paths never invoking the handler, ModuleRouteConfigurationError for an inconsistent route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised methods, no reflection for an untrusted origin, no authorization or handler invocation on preflight), fixed sanitized 500 for a throwing/rejecting/non-Response-returning handler with no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced Cache-Control/Vary with a handler's own Vary preserved, stripped module-supplied CORS headers, HEAD traversing the same permission middleware as GET with no body, and unmatched paths/zero-route/no-handler-during-construction behavior.
  • 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. A dedicated block covers module runtime startup and shutdown against real, working (non-throwing) fixture modules: permission composition and module runtime startup share the exact same discovered inventory, route mounting order matches discovery order, a load/register/start failure each close the pool exactly once and bind no server (preserving Core's own rollback for a start failure), an app-construction failure and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup before the shutdown rejection surfaces, and a direct discoveredModulesOverride drives both composition and runtime startup identically.
  • core/tests/authorization-evaluator.test.ts — includes a malformed permission id denying under every baseline (public, authenticated, restricted), never just restricted, and never consulting the grant or platform-owner ports for public/authenticated.
  • apps/api/tests/api-http.integration.ts (live PostgreSQL) — end-to-end: anonymous/non-owner denial, real bootstrap against the running API's own database, owner authority over the HTTP grants API, the owner delegating core.authorization.grants.manage to another account and that 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 grant-management authority, a create/list/revoke cycle with audit-trail verification (using core.authorization.grants.manage itself as the granted permission), disallowed-field rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database dropped out from under a live session) asserting a 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 real, working server entrypoint recording register/start/stop markers instead of throwing, and declaring a restricted GET /events/:eventId route plus a restricted, state-changing POST /events route against its own manifest permission) 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 (an empty marker set); a successful startApi() discovers the same fixture, imports/registers/starts its runtime exactly once, 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; the module's own mounted route denies an anonymous caller and then serves the newly granted owner with the decoded path parameter and authenticated principal; the module's state-changing route requires a trusted Origin exactly like the platform's own mutation routes; a well-formed permission absent from the composed registry is rejected with no grant or audit write; and, after a clean shutdown (module stop invoked exactly once) and restart with the module absent, its former route returns the ordinary 404, the same permission can no longer be newly granted, and the earlier, still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.

Run the focused suites:

pnpm --filter @ekklesia/core test
pnpm --filter @ekklesia/api test
pnpm test:migrations:integration   # requires Docker

Next phase

Module HTTP route registration and production mounting (see ADR 0014 for the deferral this closes) is now implemented — see API and Module development. Deliberately deferred, unrelated slices remain: 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.