feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one shared, validated PostgreSQL pool; adds an explicit, single-use, concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible immutable permission registry with one Core-owned restricted permission (core.authorization.grants.manage); and adds reusable Hono authorization enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
+30
-19
@@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg
|
||||
production-equivalent origin/CSRF protection under the test runner, bounded identity database
|
||||
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
|
||||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
@@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| --- | --- | --- |
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w
|
||||
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
|
||||
saved content, preferences) — none of those features are implemented in this PR.
|
||||
|
||||
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit,
|
||||
auditable operator command that grants an *existing* identity platform-owner authority — signup
|
||||
order is never part of that decision. Future **relationship-derived access** (for example, a
|
||||
person added to a volunteer roster automatically gaining narrow, reversible read access to that
|
||||
roster) belongs to a later roster slice, not this one.
|
||||
Explicit **platform-owner bootstrap** is now available as a local operator command — see
|
||||
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
|
||||
platform-owner authority; signup order is never part of that decision. Future
|
||||
**relationship-derived access** (for example, a person added to a volunteer roster automatically
|
||||
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
|
||||
one.
|
||||
|
||||
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
|
||||
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
|
||||
@@ -204,15 +208,19 @@ abuse-control design.
|
||||
|
||||
## Database connection limits
|
||||
|
||||
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
|
||||
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
|
||||
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
|
||||
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
|
||||
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
|
||||
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
|
||||
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
|
||||
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any
|
||||
pool is constructed, sanitized the same way an invalid connection string already is.
|
||||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||||
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
|
||||
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
|
||||
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
|
||||
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
|
||||
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
|
||||
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
|
||||
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
|
||||
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
|
||||
constructed, sanitized the same way an invalid connection string already is. Both the identity and
|
||||
authorization domains read from this one pool through independent typed Drizzle views — see
|
||||
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
|
||||
not two.
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
@@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()
|
||||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||||
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
|
||||
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
|
||||
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's
|
||||
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
|
||||
authorization both read from it — see
|
||||
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
|
||||
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
|
||||
same function production composition uses, but independently testable with a fake `running`/`exit`
|
||||
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
|
||||
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
|
||||
## Next phase
|
||||
|
||||
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
|
||||
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
|
||||
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
|
||||
that does not exist yet.
|
||||
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
|
||||
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
|
||||
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
|
||||
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
|
||||
Dashboard Surface, which still depends on an authorization model that is only now getting started.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# Authorization
|
||||
|
||||
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
|
||||
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
|
||||
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
|
||||
See [Identity](identity.md) for the identity foundation this builds on and [API](api.md) for the
|
||||
HTTP transport it is mounted into.
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
After this slice, 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, 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 in this slice); there are no service accounts or API tokens, so only
|
||||
cookie-authenticated browser callers can reach the mutation routes; 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
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
||||
matter:
|
||||
|
||||
- **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.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
|
||||
|
||||
| Permission | Baseline | Description |
|
||||
| --- | --- | --- |
|
||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||
|
||||
Module-owned permission declarations and a discovery/composition path for them are out of scope
|
||||
for this slice (deferred to the Module SDK work).
|
||||
|
||||
## 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`. |
|
||||
| `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`, or the permission id was unregistered. |
|
||||
| `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` contents.
|
||||
- `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.
|
||||
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
|
||||
and malformed-field rejection, exact server-derived-argument assertions, sourceKind-filtered
|
||||
revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping.
|
||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
|
||||
- `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 create/list/revoke cycle with audit-trail
|
||||
verification, 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`.
|
||||
|
||||
Run the focused suites:
|
||||
|
||||
```bash
|
||||
pnpm --filter @ekklesia/core test
|
||||
pnpm --filter @ekklesia/api test
|
||||
pnpm test:migrations:integration # requires Docker
|
||||
```
|
||||
|
||||
## Next phase
|
||||
|
||||
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
||||
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
||||
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
||||
(a roster or similar module operation, not the generic grants API).
|
||||
Reference in New Issue
Block a user