Files
Ekklesia/docs/development/authorization.md
T
ekklesiaandClaude Sonnet 5 dfaf324a65 feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 19:16:20 -05:00

539 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](identity.md) for the identity foundation this builds on,
[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
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](api.md#module-http-routes)); 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](../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, 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](api.md#module-http-routes)).
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](api.md#module-http-routes) 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:
```bash
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:
```sql
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](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) 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](api.md#origin-cors-and-cookies)) — 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](#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. |
| `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:
```json
{
"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 two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` 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](api.md#graceful-shutdown)).
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 both identity and authorization
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:
```bash
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](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
[Module development](module-development/README.md#http-routes). 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.